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__
:
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:
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:
[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:
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:
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
:
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:
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:
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
:
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:
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:
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:
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.