Skip to content

Level 6B: 4D (RE, Pwn)

We are provided with a website and the binary responsible for handling HTTP requests to the challenge website. Inspecting the Server response header reveals that it is a VLang VWeb server.

The website makes a GET request to /get_4d_number, which responds with an event stream of "4D numbers" which is displayed by the website. There is also a cookie id that is set to a UUID to identify our session.

Examining the binary in IDA, we find a few methods prefixed with main__:

image-20231005204800490

Since this is a reverse engineering challenge, the compare and decrypt functions are particularly interesting.

The compare function compares the integer at a particular position in the input array with a fixed integer:

c
memmove(&v141, &source, 0x20uLL);
// The 31th element must be 118
if ( *(_BYTE *)array_get(31LL, (__int64)&source, v8, v9, v10, v11, v141, v142, v143, v144) != 118 )
return 0xFFFFFFFFLL;
dest = &v141;

// and so on

This pattern repeats for each of the 32 integers in the array. With some quick parsing in Python, we can extract the target array:

python
[106, 1, 100, 45, 94, 96, 23, 46, 117, 40, 17, 104, 43, 19, 84, 110, 37, 104, 40, 0, 63, 87, 59, 126, 91, 20, 120, 28, 87, 22, 110, 118]

If all elements in the array compare correctly, the decrypt function is called:

c
return (unsigned int)main__decrypt((int)&v141, (int)&source, v136, v137, v138, v139, v141);

The decrypt function initializes an AES cipher object another 32-byte long constant array (probably the ciphertext). This ciphertext is then decrypted with the key checked in the compare function and the plaintext is returned.

I wrote a quick script to decrypt the ciphertext in Python:

python
from Crypto.Cipher import AES

key = bytes([106, 1, 100, 45, 94, 96, 23, 46, 117, 40, 17, 104, 43, 19, 84, 110, 37, 104, 40, 0, 63, 87, 59, 126, 91, 20, 120, 28, 87, 22, 110, 118])
ct = bytes([140, 136, 11, 10, 13, 180, 116, 122, 196, 218, 228, 85, 20, 56, 22, 87, 183, 137, 122, 149, 106, 19, 137, 95, 26, 33, 235, 91, 179, 114, 136, 75])
cipher = AES.new(key, AES.MODE_ECB)
p1 = cipher.decrypt(ct)
print(p1)

Unfortunately, the output is not the flag: TISC{THIS_IS_NOT_THE_FLAG_00000}. We can guess that the constant array in the decrypt function on the server contains some other values that will decrypt to the real flag.

Searching for references to main__compare, we find that it is called in main__App_get_4d_number, which seems to be the handler for the /get_4d_number route.

There seemed to be some manipulation performed on the source variable before it is passed to main__compare:

c
for ( k = 0; k < v161; ++k )
{
  v205 = &source;
  memmove(&source, v160, 0x20uLL);
  v13 = (_BYTE *)array_get(k, (unsigned int)v160, v9, v10, v11, v12, source, v58, v59, (_DWORD)v60);
  *v13 += 5;
  v205 = &source;
  memmove(&source, v160, 0x20uLL);
  v18 = (_BYTE *)array_get(k, (unsigned int)v160, v14, v15, v16, v17, source, v58, v59, (_DWORD)v60);
  *v18 ^= (_BYTE)k + 1;
  if ( k > 0 )
  {
    v205 = &source;
    memmove(&source, v160, 0x20uLL);
    v205 = (void *)array_get(k, (unsigned int)v160, v19, v20, v21, v22, source, v58, v59, (_DWORD)v60);
    v154 = k - 1;
    v148 = &source;
    memmove(&source, v160, 0x20uLL);
    v27 = (_BYTE *)array_get(v154, (unsigned int)v160, v23, v24, v25, v26, source, v58, v59, (_DWORD)v60);
    *(_BYTE *)v205 ^= *v27;
  }
}

Here's a simplified Python implementation:

python
def forward(data):
    out = []
    for k in range(0x20):
        out.append(data[k] + 5)
        out[k] ^= k + 1
        if k > 0:
            out[k] ^= out[k-1]
    return out

Tracing data flow for the source array leads us to v170, which is set after a map_get_check call:

c
memmove(v174, v182, 0x10uLL);
memset(v172, 0, sizeof(v172));
memmove(v172, &pass, 0x78uLL);
memset(v171, 0, sizeof(v171));
memmove(v171, v174, 0x10uLL);

v173 = (void *)map_get_check(v172, v171);
memset(v168, 0, 0x38uLL);
if ( v173 )
{
  v205 = v173;
  memmove(v170, v173, 0x10uLL);
}

With some debugging, we find that v172 is a pointer to the global pass variable, while v171 is a pointer to our session identifier UUID. It seems that it's checking if the pass(word?) for our session has been set.

Searching for references to pass leads us to main__App_handle_inpt:

c
if ( !(unsigned __int8)string__eq(v37[0], v37[1], &L_5642, 0x100000001LL) )
{
    memset(v22, 0, sizeof(v22));
    v22[0] = (__int64)&L_5644;
    v22[1] = 0x100000001LL;
    if ( !(unsigned __int8)string__eq(v24[0], v24[1], &L_5644, 0x100000001LL) )
    {
      memset(v21, 0, sizeof(v21));
      memmove(v21, v37, 0x10uLL);
      memset(v20, 0, sizeof(v20));
      memmove(v20, v48, 0x10uLL);
      map_set((__int64)&pass, (__int64)v21, (__int64)v20, a4);
    }
}

main__App_handle_inpt seems to be called in vweb__handle_route_T_main__App which probably handles routing for the app. Surrounding the call are several string comparisons. Luckily, these strings gives us lots of information about the handle_inpt route:

image-20231005211601473

With even more debugging, we can validate this, as a post request to /blah results in the handle_inpt route being called.

Now that we know how to set the password, all that's left is to recover the password using z3:

python
from z3 import *


def forward(data):
    out = []
    for k in range(0x20):
        out.append(data[k] + 5)
        out[k] ^= k + 1
        if k > 0:
            out[k] ^= out[k-1]
    return out

target = [106, 1, 100, 45, 94, 96, 23, 46, 117, 40, 17, 104, 43, 19, 84, 110, 37, 104, 40, 0, 63, 87, 59, 126, 91, 20, 120, 28, 87, 22, 110, 118]
sim = [BitVec(f"x{i}", 8) for i in range(0x20)]

out = forward(sim)

s = Solver()
for i,x in enumerate(out):
    s.add(x == target[i])


print(s.check())
m = s.model()
result = []
for char in sim:
    result.append(m.eval(char).as_long())

print("".join(chr(x) for x in result))

That yields fdaHq3k,MR-pI1C%UZN7%yvX7PrsQZb3.

Now we just need to submit the password to the server for the flag:

python
import requests

s = requests.Session()

host = "http://chals.tisc23.ctf.sg:48471"
s.get(host)
res = s.post(host+"/fdaHq3k%2CMR-pI1C%25UZN7%25yvX7PrsQZb3")
res = s.get(host+"/get_4d_number")
print(res.content)

Flag: TISC{Vlang_R3v3rs3_3ng1n333r1ng}

Note: I tried debugging the binary in GDB but it didn't work too well because the binary somehow segfaults immediately and catches it, then spawns a bunch of threads. Luckily IDA's debugger worked really well.