Last May, I had the opportunity to participate in the prestigious LakeCTF Finals!
This CTF has been around for a relatively long time, and it had always been my dream to get to compete in it. Well, the Iran War almost ruined my plan TWICE, but I managed to pull through nonetheless, though with a small dent in my wallet and a 1 hour penalty, causing me to end up just barely missing the solve for one of the challenges.
NUS Greyhats did not perform as well as expected partly because of that, but it’s okay, we had tons of fun, and that was the most important part :)
Broken Golf
Category: rev
Solves: 9/10
This is a bit of the more on the esoteric side in terms of reversing challenges, and was a good teaser / appetizer to start off the CTF.
Essentially, we are given a suspiciously named chal_missing_24_bytes binary file, as well as checker.py logic for explaining the setup:
with open("chal", "rb") as f:
challenge_data = f.read()
stripped_data = challenge_data[24:]
print(base64.b64encode(stripped_data).decode())
sys.stdout.flush()
decoded = base64.b64decode(sys.stdin.readline().strip())
print("Received data length:", len(decoded))
if len(decoded) != 24:
print("Expected 24 bytes")
sys.exit(1)
if decoded[4] != 2:
print("Expected 64-bit executable")
sys.exit(1)
elf_path = "/tmp/final_chal"
with open(elf_path, "wb") as f:
f.write(decoded + stripped_data)
The challenge starts off with a regular binary living on the server; before we get to run it, we need to pass in our own 24 bytes replacing the head of the binary. The chal_missing_24_bytes is provided to us as the truncated version of the binary.
os.chmod(elf_path, 0o755)
for _ in range(100):
args = [random.choice(["32", "64"]) for _ in range(random.randint(1, 30))]
res = subprocess.run(
[elf_path] + args,
env={},
timeout=2,
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
preexec_fn=preexec
)
if res.returncode != get_expected(args):
print("Testcase failed")
sys.exit(1)
print(f"All tests passed! Flag: {os.environ.get('FLAG', 'flag{default_flag}')}")
The checker understandably does some hardening to prevent unintended solutions; I will be largely ignoring them. The checker attempts to run our modified binary multiple times against a random sample of inputs to ensure that its behaviour is “as intended”:
def get_expected(args):
ebx = 1
for arg in args:
ebx = (ebx * 2 if arg == "32" else ebx + 2) & 0xffffffff
return ebx & 0xff
Interesting that ebx + 2 is used instead of maybe (ebx + 1) * 2. But well I’m not here to solve Math equations; all we need to know is that it computes a checksum based on the sequence of (a variable number of) input arguments (either 32 or 64). We probably intend to emulate this behaviour via our 24-byte modification. But what can we do from just changing the header?
Before that, we shall at least see what was provided in the truncated binary:
$ objdump -D -Mintel,x86-64 -b binary -m i386 chal_missing_24_bytes
chal_missing_24_bytes: file format binary
Disassembly of section .data:
00000000 <.data>:
0: 8d 00 lea eax,[rax]
2: 40 00 00 rex add BYTE PTR [rax],al
5: 00 00 add BYTE PTR [rax],al
7: 00 38 add BYTE PTR [rax],bh
...
19: 00 00 add BYTE PTR [rax],al
1b: 00 40 00 add BYTE PTR [rax+0x0],al
1e: 38 00 cmp BYTE PTR [rax],al
20: 01 00 add DWORD PTR [rax],eax
22: 00 00 add BYTE PTR [rax],al
24: 07 (bad)
...
31: 00 40 00 add BYTE PTR [rax+0x0],al
34: 00 00 add BYTE PTR [rax],al
36: 00 00 add BYTE PTR [rax],al
38: 00 00 add BYTE PTR [rax],al
3a: 40 00 00 rex add BYTE PTR [rax],al
3d: 00 00 add BYTE PTR [rax],al
3f: 00 d7 add bh,dl
41: 00 00 add BYTE PTR [rax],al
43: 00 00 add BYTE PTR [rax],al
45: 00 00 add BYTE PTR [rax],al
47: 00 d7 add bh,dl
...
55: 00 00 add BYTE PTR [rax],al
57: 00 e8 add al,ch
59: 07 (bad)
5a: 00 00 add BYTE PTR [rax],al
5c: 00 ea add dl,ch
5e: 17 (bad)
5f: 00 40 00 add BYTE PTR [rax+0x0],al
62: 33 00 xor eax,DWORD PTR [rax]
64: 48 b8 00 00 00 00 eb movabs rax,0x6eb00000000
6b: 06 00 00
6e: 83 c3 02 add ebx,0x2
71: c3 ret
72: 01 db add ebx,ebx
74: c3 ret
75: 59 pop rcx
76: 48 ff c9 dec rcx
79: 5e pop rsi
7a: bb 01 00 00 00 mov ebx,0x1
7f: 48 85 c9 test rcx,rcx
82: 74 32 je 0xb6
84: 5e pop rsi
85: 8a 06 mov al,BYTE PTR [rsi]
87: 3c 33 cmp al,0x33
89: 74 06 je 0x91
8b: 3c 36 cmp al,0x36
8d: 74 0b je 0x9a
8f: eb 20 jmp 0xb1
91: b8 00 08 40 00 mov eax,0x400800
96: b0 20 mov al,0x20
98: eb 07 jmp 0xa1
9a: b8 00 08 40 00 mov eax,0x400800
9f: b0 40 mov al,0x40
a1: 48 89 e5 mov rbp,rsp
a4: bc 00 08 40 00 mov esp,0x400800
a9: e8 3f ff ff ff call 0xffffffed
ae: 48 89 ec mov rsp,rbp
b1: 48 ff c9 dec rcx
b4: eb c9 jmp 0x7f
b6: b8 3c 00 00 00 mov eax,0x3c
bb: 89 df mov edi,ebx
bd: 0f 05 syscall
The file starts off with non-disassemblable junk, probably what we would expect to hold additional ELF information. We do start seeing valid instructions at offset 0x6e:
6e: 83 c3 02 add ebx,0x2
71: c3 ret
72: 01 db add ebx,ebx
74: c3 ret
Well, if this is not a joy to uncover! These two “gadgets” perfectly align with our required branches in get_expected above. For example, assuming rax holds either 32 or 64, we would want to have some sort of functionality in the binary as below:
[0] cmp rax, 32
[1] jne [4]
[2] call 0x72
[3] jmp [5]
[4] call 0x6e
In terms of argument parsing, this is handled by 0x85:0xa1:
- If the current element in
argvstarts with3, setrax = 0x400820 - If the current element in
argvstarts with6, setrax = 0x400840
Then the function sets up stack at 0x400800 and… calls 0xffffffed (-19). What?
Oh yea, we are missing the initial 24 bytes. If we include them, we are jumping to… offset 5?
On one hand that’s convenient, we directly know that we have some control over execution. On the other hand, what freedoms are we working with inside an ELF header?
For this, we will need to dive into the ELF specification itself! I found this and this to be relatively comprehensive resources for our purposes.
Remember, we can only affect the first 24 bytes of the header, which is surprisingly limited. Looking at the first few lines we discover
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT];
That is already two-thirds of our controllable bytes gone! Then, what does e_ident contain, other than the \x7fELF that everyone loves? From figure 4-4:
| Name | Value | Purpose |
|---|---|---|
| EI_MAG0 | 0 | File identification |
| EI_MAG1 | 1 | File identification |
| EI_MAG2 | 2 | File identification |
| EI_MAG3 | 3 | File identification |
| EI_CLASS | 4 | File class |
| EI_DATA | 5 | Data encoding |
| EI_VERSION | 6 | File version |
| EI_OSABI | 7 | Operating system/ABI identification |
| EI_ABIVERSION | 8 | ABI version |
| EI_PAD | 9 | Start of padding bytes |
| EI_NIDENT | 16 | Size of e_ident[] |
Immediately we see something promising: Padding bytes!? To get here though, we must bypass the rest of the used bytes.
Recall first that our execution starts at byte [5]. Byte [4] seems to be important (32-bit vs 64-bit), and is enforced by the checker:
if decoded[4] != 2:
print("Expected 64-bit executable")
sys.exit(1)
What about the rest?
Naturally, one might think that given that the challenge routes us to [5], there are only two scenarios:
- We have to golf our way through the constraints set by the specification.
- The validity of the bytes don’t actually matter for execution?
No guessing needed, the most straightforward way is to test it out ourselves. First we construct a valid ELF binary (which inevitably crashes due to bad opcode at [5] and beyond) —
from pwn import read, write
write('test.elf', (
# e_ident
b'\x7fELF' # EI_MAG
b'\x02' # EI_CLASS (enforced)
b'\x01' # EI_DATA: little endian
b'\x01' # EI_VERSION: current version
b'\0' # EI_OSABI: no extensions
b'\0' # EI_ABIVERSION: (irrelevant)
b'\0\0\0\0\0\0\0' # padding bytes
# e_type (2 bytes)
b'\x02\0' # executable
# e_machine (2 bytes)
b'\x3e\0' # amd64
# e_version (4 bytes)
b'\x01\0\0\0' # current version (again?)
) + read('chal_missing_24_bytes'))
$ ./test.elf 32
Segmentation fault (core dumped)
$ echo $?
139
$ ./test.elf 40
$ echo $?
1
Behaves as expected. Now lets try to inject some bad bytes into e_ident…
from pwn import read, write
x = bytearray(read('test.elf'))
x[18:19] = b'\x67'
write('test2.elf', bytes(x))
$ ./test2.elf 32
Segmentation fault (core dumped)
$ echo $?
139
$ ./test2.elf 40
$ echo $?
1
Perfect!
Through trial-and-error, we can also discover that we cannot realistically modify e_type and e_machine. e_version however is under our control, but we will probably need to jump across the 4 bytes of moat, at which point we might as well not use it. (Though it remains an option.)
from pwn import read, write
for i in range(16, 24):
x = bytearray(read('test.elf'))
x[i:i+1] = b'\x67'
write(f'test-{i}.elf', bytes(x))
for i in {16..23}; do \
echo; \
echo $i; \
./test-$i.elf 32; \
echo $?; \
./test-$i.elf 40; \
echo $?; \
rm ./test-$i.elf; \
done
16
-bash: ./test-16.elf: cannot execute binary file: Exec format error
126
-bash: ./test-16.elf: cannot execute binary file: Exec format error
126
17
-bash: ./test-17.elf: cannot execute binary file: Exec format error
126
-bash: ./test-17.elf: cannot execute binary file: Exec format error
126
18
-bash: ./test-18.elf: cannot execute binary file: Exec format error
126
-bash: ./test-18.elf: cannot execute binary file: Exec format error
126
19
-bash: ./test-19.elf: cannot execute binary file: Exec format error
126
-bash: ./test-19.elf: cannot execute binary file: Exec format error
126
20
Segmentation fault (core dumped)
139
1
21
Segmentation fault (core dumped)
139
1
22
Segmentation fault (core dumped)
139
1
23
Segmentation fault (core dumped)
139
1
So now our updated problem statement:
Write an 11-byte payload that does the following:
- If
rax = 0x400820, jump to0x8a(+24 bytes accounted)- If
rax = 0x400840, jump to0x86
Well not exactly as well, we need to take note of the virtual address mapping. Using our previously crafted dummy:
$ readelf -h test.elf
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x40008d
Start of program headers: 56 (bytes into file)
Start of section headers: 0 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 1
Size of section headers: 0 (bytes)
Number of section headers: 7
Section header string table index: 0
From here I treated this as a pure golfing challenge and tried all sorts of payloads to stay within the 11 byte limit. Most of my earlier discovered payloads were ironically 12 bytes long, but I did eventually stumble upon one after like maybe an hour:
; 0x400820 -> 0x40008a
; 0x400840 -> 0x400086
shr al, 3 ; c0 e8 03
neg al ; f6 d8
sub ax, 0x872 ; 66 2d 72 08
jmp rax ; ff e0
This is a pretty mathematical golf 😅 Essentially, we abuse the fact that the rax available at the start of the payload is quite similar to the target address. Not to mention,
>>> 0x8a-0x86
4
>>> 0x40-0x20
32
Exactly 8x difference!
So we consider how we want to transform, for now just the last byte of rax:
f1 f2 f3
[32] 20 ---- ?? ---- ?? ---- 8a
Δ 20 4 -4 -4
[64] 40 ---- ?? ---- ?? ---- 86
Hopefully it’s clear to follow that
Surely, we achieve:
>>> hex(f(0x20))
'0x8a'
>>> hex(f(0x40))
'0x86'
With f as simple as
def f(x):
return (-(x//8)+0x8e) % 0x100
But remember, we still needed to get rid of the 8 in 0x400820 somehow. Not to fret, we simply extend our
def f(x):
return (-(x//8)-0x72) % 0x100
So from sub al, 0x72, we simply extend this to sub ax, 0x872. Et voila, 11 bytes!
As I was making this writeup I vaguely remembered that the LakeCTF team did release their challenge repository, so it would be good to also reference the intended solution.
An explicitly writeup apparently wasn’t released for this challenge, but we can still find solution traces on the official reconstructed binary —
Well, I guess the intended solution was a bit longer and more involved than I expected 😓 As someone who wrote challenges before, it kind of sucked knowing I actually took the shortcut around the intended challenge design. But well, at least there wasn’t an immediately golfable solution AFAIK (so the +1h spent was still somewhat rewarding in the spirit of the CTF…)
Vault Matrix
Category: rev
Solves: 2/10
I did not manage to solve this challenge on-site :( but I was REALLY close. More on that at the end.
The setup for this is closer to a traditional reversing challenge. That is, one binary provided. For starters, it is a stripped binary, so it’s probably going to take some baseline effort before we start understanding the program mechanics. Reminder that this is a no AI challenge XD
Anyway, main isn’t directly available as expected, so we find it through IDA’s labelled start.
__int64 sub_401760()
{
int v0; // edx
int v1; // ecx
int v2; // r8d
int v3; // r9d
__int64 result; // rax
char *v5; // rdx
_QWORD *v6; // rbp
__int64 i; // rbx
__int64 v8; // rdi
unsigned int v9; // [rsp+14h] [rbp-44h] BYREF
int v10; // [rsp+18h] [rbp-40h]
char v11; // [rsp+1Ch] [rbp-3Ch]
_QWORD v12[3]; // [rsp+20h] [rbp-38h] BYREF
unsigned __int64 v13; // [rsp+38h] [rbp-20h]
v13 = __readfsqword(0x28u);
dword_4E54C0 = sub_44DDE0(0, 0, 1, 0);
sub_44FD20(1, (unsigned int)"Give me your key quickly or bad things will happen to you:\n> ", v0, v1, v2, v3);
if ( sub_40CD90(byte_4E54E0, 100, off_4E38B8) )
{
byte_4E54E0[j_ifunc_4208E0(byte_4E54E0, 4921194)] = 0;
if ( j_ifunc_4209C0(byte_4E54E0) == 81 )
{
v5 = byte_4E54E0;
while ( (unsigned __int8)(*v5 - 49) <= 8u )
{
if ( ++v5 == (char *)&unk_4E5531 )
{
v6 = v12;
for ( i = 0; i != 3; ++i )
sub_416180(v6++, 0, sub_401A60, i);
sub_4171E0(v12[0], 0);
sub_4171E0(v12[1], 0);
sub_4171E0(v12[2], 0);
v11 = 0;
LOWORD(v9) = 0;
v10 = 0;
sub_401AC0(&v9);
if ( !v10 )
{
sub_40D2F0("Validation Failed.");
break;
}
v8 = -(int)sub_44C630(30) & 0x4AF740;
if ( (unsigned int)sub_44DF40(v8, &byte_4AF8A9[-v8], 7) )
{
sub_40C170("mprotect");
result = 1;
}
else
{
sub_401FF0(sub_4AF740, byte_4AF8A9 - (char *)sub_4AF740, byte_4E54E0, 81);
sub_4AF740(byte_4E54E0, byte_4AF8A9 - (char *)sub_4AF740);
result = v9;
}
goto LABEL_4;
}
}
}
}
result = 1;
LABEL_4:
if ( v13 != __readfsqword(0x28u) )
sub_44FE10();
return result;
}
Personally I find that in terms of recovering symbols, the best way is really just to have as many best guesses as possible. It’s okay to label something with a bit of uncertainty (with some marking I guess), because more often than not it’s going to be correct.
Going through them one by one:
__int64 main() /* reached from start */
{
// ...
/* sys_ptrace mentioned in function */
dword_4E54C0 = _ptrace(0, 0, 1, 0);
/* this was printed out, with 1 representing stdout fp */
_fprintf(1, (unsigned int)"Give me your key quickly or bad things will happen to you:\n> ", v0, v1, v2, v3);
/* off_4E38B8 is stdin */
if ( _fgets(inp, 100, off_4E38B8) )
{
/* j_ifunc_xxx don't actually take arguments */
inp[j_ifunc_4208E0()] = 0;
if ( j_ifunc_4209C0() == 81 )
{
ptr = inp;
while ( (unsigned __int8)(*ptr - 49) <= 8u ) /* 1 to 9 */
{
/* inp_end is guessed, but this is a standard pattern */
if ( ++ptr == (char *)&inp_end )
{
v6 = v12;
for ( i = 0; i != 3; ++i )
/* pthread_create mentioned in function
also the arguments for it makes sense */
_pthread_create(v6++, 0, sub_401A60, i);
/* less evidence for this one
but essentially note that v12 is our pthread_t array */
_pthread_join(v12[0], 0);
_pthread_join(v12[1], 0);
_pthread_join(v12[2], 0);
v11 = 0;
LOWORD(v9) = 0;
v10 = 0;
sub_401AC0(&v9);
if ( !v10 )
{
/* weak evidence but looks like a library function
and the string was printed out */
_puts("Validation Failed.");
break;
}
// [OMITTED]
}
}
}
}
result = 1;
// ...
return result;
}
Can I just say I instantly clocked Sudoku mechanics just from this main function?
While solving, before I continued on with the [OMITTED] section, we could already tell that the main validation logic happens before that at sub_401AC0. We can explore that section if we need to, but it looked kind of messy and I felt that the ROI wasn’t very high at this current stage.
Also I have to say, I still haven’t figured out what the j_ifunc_xxx do. Maybe after I publish this.
Let’s explore the children functions, starting with sub_401A60:
/* a1 = 0, 1, 2 */
__int64 __fastcall sub_401A60(int a1)
{
int v1; // edx
char v2; // cl
char *v3; // rax
v1 = a1;
if ( a1 )
{
if ( a1 == 1 )
{
v2 = 59;
}
else
{
if ( a1 > 8599 )
return 0;
v2 = -100;
}
}
else
{
v2 = dword_4E54C0 + 122;
}
v3 = &byte_4E1120[a1];
do
{
v1 += 3;
*v3 ^= v2;
v3 += 3;
}
while ( v1 <= 8599 );
return 0;
}
Huh?
A quick scan tells us that this (multithreaded) function modifies byte_4E1120. But I didn’t really dig deeper beyond this — we will figure out what we need to figure out, as we go along. Instead I went straight to the bigger fish, sub_401AC0:
__int64 __fastcall sub_401AC0(unsigned __int16 *a1, __int64 a2, __int64 a3, __int64 a4, __int64 a5, __int64 a6)
{
int v6; // r12d
unsigned __int16 *v7; // r13
int v8; // ebx
bool v9; // bp
int v10; // eax
unsigned __int16 v12; // ax
unsigned int *v13; // r15
__int64 v14; // r14
__int64 v15; // rax
__int16 v16; // di
__int64 v17; // rcx
__int16 v18; // si
__int64 v19; // rax
__int64 v20; // rax
__int64 v21; // rax
__int64 v22; // rax
__int64 v23; // rax
__int64 v24; // rax
int v25; // eax
int v26; // eax
int v27; // eax
_OWORD v28[3]; // [rsp+0h] [rbp-128h] BYREF
_DWORD v29[21]; // [rsp+30h] [rbp-F8h] BYREF
_DWORD v30[25]; // [rsp+84h] [rbp-A4h] BYREF
unsigned __int64 v31; // [rsp+E8h] [rbp-40h]
v6 = *a1;
v31 = __readfsqword(0x28u);
*((_DWORD *)a1 + 1) = 1;
memset(v28, 0, sizeof(v28));
*(_OWORD *)v29 = 0;
if ( (unsigned __int16)v6 > 0x2197u )
goto LABEL_5;
v7 = a1;
v8 = 0;
v9 = 0;
do
{
a3 = (unsigned __int16)v6;
v10 = (unsigned __int8)(*((_BYTE *)v7 + 8) ^ byte_4E1120[(unsigned __int16)v6]);
switch ( (char)v10 )
{
case 1:
v6 += 4;
a4 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
v27 = a3 + 2;
a3 = (unsigned __int8)byte_4E1120[(int)a3 + 3];
*((_DWORD *)v28 + a4) = a3 | ((unsigned __int8)byte_4E1120[v27] << 8);
continue;
case 2:
v26 = (unsigned __int16)v6 + 1;
v6 += 3;
a3 = (int)a3 + 2;
a4 = (unsigned __int8)byte_4E1120[v26];
*((_DWORD *)v28 + a4) = byte_4E54E0[(unsigned __int8)byte_4E1120[a3]] - 48;
continue;
case 3:
v25 = (unsigned __int16)v6 + 2;
v6 += 3;
a3 = (int)a3 + 1;
a4 = *((unsigned int *)v28 + (unsigned __int8)byte_4E1120[v25]);
*((_DWORD *)v28 + (unsigned __int8)byte_4E1120[a3]) = a4;
continue;
case 4:
v6 += 3;
v24 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
a3 = *((unsigned int *)v28 + (unsigned __int8)byte_4E1120[(int)a3 + 2]);
*((_DWORD *)v28 + v24) += a3;
continue;
case 5:
v6 += 3;
a4 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
a3 = (unsigned __int8)byte_4E1120[(int)a3 + 2];
*((_DWORD *)v28 + a4) -= *((_DWORD *)v28 + a3);
continue;
case 6:
v6 += 3;
v23 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
a3 = *((unsigned int *)v28 + (unsigned __int8)byte_4E1120[(int)a3 + 2]);
*((_DWORD *)v28 + v23) ^= a3;
continue;
case 7:
v6 += 3;
v22 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
a3 = *((unsigned int *)v28 + (unsigned __int8)byte_4E1120[(int)a3 + 2]);
*((_DWORD *)v28 + v22) &= a3;
continue;
case 11:
v6 += 3;
v21 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
a3 = *((unsigned int *)v28 + (unsigned __int8)byte_4E1120[(int)a3 + 2]);
*((_DWORD *)v28 + v21) |= a3;
continue;
case 12:
v6 += 3;
v20 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
a3 = (unsigned __int8)byte_4E1120[(int)a3 + 2];
a4 = *((unsigned int *)v28 + a3);
*((_DWORD *)v28 + v20) <<= a4;
continue;
case 13:
v6 += 3;
v19 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
a3 = (unsigned __int8)byte_4E1120[(int)a3 + 2];
a4 = *((unsigned int *)v28 + a3);
*((_DWORD *)v28 + v19) = __ROR4__(*((_DWORD *)v28 + v19), a4);
continue;
case 14:
a3 = (unsigned __int16)v6 + 2;
a4 = (unsigned __int8)byte_4E1120[(unsigned __int16)v6 + 1];
v9 = *((_DWORD *)v28 + a4) == *((_DWORD *)v28 + (unsigned __int8)byte_4E1120[a3]);
v6 += 3;
continue;
case 16:
v6 += 2;
if ( v9 )
{
a3 = (int)a3 + 1;
LOWORD(v10) = byte_4E1120[a3];
v6 += v10;
}
continue;
case 20:
goto LABEL_5;
case 22:
v15 = v8;
v6 += 4;
++v8;
v16 = (unsigned __int8)byte_4E1120[(int)a3 + 1];
v17 = 3 * v15;
v30[v17] = 1;
v18 = (v16 << 8) | (unsigned __int8)byte_4E1120[(int)a3 + 2];
LOBYTE(v30[v17 + 1]) = byte_4E1120[(int)a3 + 3];
a1 = (unsigned __int16 *)&v29[2 * v15 + 4];
LOWORD(v29[v17 + 20]) = v18;
a2 = 0;
sub_416180(a1, 0, sub_401AC0, &v29[3 * v15 + 20]);
continue;
case 23:
if ( !v8 )
goto LABEL_16;
v13 = v30;
v14 = 1;
break;
case 29:
a4 = (unsigned __int8)byte_4E1120[(unsigned __int16)v6 + 1] << 8;
v12 = ((unsigned __int8)byte_4E1120[(unsigned __int16)v6 + 1] << 8)
| (unsigned __int8)byte_4E1120[(unsigned __int16)v6 + 2];
if ( v12 <= 0x2197u )
{
a3 = (unsigned __int8)byte_4E1120[(unsigned __int16)v6 + 3];
byte_4E1120[v12] ^= a3;
}
v6 += 4;
continue;
default:
*((_DWORD *)v7 + 1) = 0;
goto LABEL_5;
}
while ( 1 )
{
a1 = *(unsigned __int16 **)&v29[2 * v14 + 2];
a2 = 0;
sub_4171E0(a1, 0);
a3 = *v13;
if ( (_DWORD)a3 )
break;
*((_DWORD *)v7 + 1) = 0;
if ( v8 <= (int)v14 )
goto LABEL_5;
LABEL_13:
++v14;
v13 += 3;
}
if ( v8 > (int)v14 )
goto LABEL_13;
LABEL_16:
if ( !*((_DWORD *)v7 + 1) )
break;
++v6;
}
while ( (unsigned __int16)v6 <= 0x2197u );
LABEL_5:
if ( v31 != __readfsqword(0x28u) )
sub_44FE10(a1, a2, a3, a4, a5, a6, *(_QWORD *)&v28[0], *((_QWORD *)&v28[0] + 1));
return 0;
}
VM!
Again from experience, the head (and occasionally tail) of the function tells you 80% of the VM infrastructure; the rest are just opcode minutiae. So let’s go through this patiently:
/* recall: argument was zeroed before being passed in as a pointer */
__int64 __fastcall run(unsigned __int16 *state)
{
// ...
v6 = *state;
/* canary setup */
*((_DWORD *)state + 1) = 1;
memset(v28, 0, sizeof(v28));
*(_OWORD *)v29 = 0;
if ( (unsigned __int16)v6 > 0x2197u )
goto _out;
v7 = state;
v8 = 0;
v9 = 0;
do
{
a3 = (unsigned __int16)v6;
v10 = (unsigned __int8)(*((_BYTE *)v7 + 8) ^ vm[(unsigned __int16)v6]);
switch ( (char)v10 )
{
case 1:
v6 += 4;
a4 = (unsigned __int8)vm[(int)a3 + 1];
v27 = a3 + 2;
a3 = (unsigned __int8)vm[(int)a3 + 3];
*((_DWORD *)v28 + a4) = a3 | ((unsigned __int8)vm[v27] << 8);
continue;
// [OMITTED]
default:
*((_DWORD *)v7 + 1) = 0;
goto _out;
}
// [OMITTED]
/* rationale: all "regular" opcode branches end with `continue`,
skipping directly to the `while` check */
}
while ( (unsigned __int16)v6 <= 0x2197u );
_out:
/* canary check */
return 0;
}
Quite quickly we can find the pc taking the form of v6 and a3, with opcode in v10; this is also a good time to derive the state struct, using this function and the callsite:
unsigned int v9; // [rsp+14h] [rbp-44h] BYREF
int v10; // [rsp+18h] [rbp-40h]
char v11; // [rsp+1Ch] [rbp-3Ch]
// ...
v11 = 0;
LOWORD(v9) = 0;
v10 = 0;
sub_401AC0(&v9);
if ( !v10 )
Giving us
[0:2] pc
[2:4]
[4:8] success
[8:9] xorkey?
No idea what state[8] does; it seems to behave as an xor key from the
v10 = (unsigned __int8)(*((_BYTE *)v7 + 8) ^ vm[(unsigned __int16)v6]);
line, but it is initialized to 0 and doesn’t seem to change.
With most variables figured out, the bulk of the remaining work is to work through the each and every opcode… due to verbosity, I will just include the walkthrough for the first few.
case 1:
pc += 4;
a4 = (unsigned __int8)vm[(int)saved_pc + 1];
v27 = saved_pc + 2;
a3 = (unsigned __int8)vm[(int)saved_pc + 3];
*((_DWORD *)v28 + a4) = a3 | ((unsigned __int8)vm[v27] << 8);
continue;
where v28, if you recall, is the memzeroed stack buffer. So for an instruction 01 xx yy zz, we will have v28[xx] = 0000yyzz. For the next opcode
case 2:
v26 = (unsigned __int16)pc + 1;
pc += 3;
saved_pc = (int)saved_pc + 2;
a4 = (unsigned __int8)vm[v26];
*((_DWORD *)v28 + a4) = inp[(unsigned __int8)vm[saved_pc]] - 48;
continue;
of the structure 02 xx yy, we will have v28[xx] = int(inp[yy]) (since values of inp are 1-9).
The bulk of the remaining opcodes perform your standard binary operations of the form ## xx yy corresponding to v28[xx] ?= v28[yy] — as we parse them one by one we gradually confirm that v28 is most likely a register file. (There might be possibility of some funny OOB access? But I guess not if we don’t have control over the VM.)
However, there are a couple of “interesting” / “unorthodox” opcodes near the bottom:
case 22:
/* first mention of v8 in the function,
other than initialization to 0 */
v15 = v8;
pc += 4;
++v8;
v16 = (unsigned __int8)vm[(int)saved_pc + 1]; /* xx */
v17 = 3 * v15;
/* v30 is a DWORD array. so presumably it
actually holds structs spanning 3 DWORDs each */
v30[v17] = 1;
v18 = (v16 << 8) | (unsigned __int8)vm[(int)saved_pc + 2];
LOBYTE(v30[v17 + 1]) = vm[(int)saved_pc + 3];
/* v29 now used properly. looks like struct of 2 DWORDs */
a1 = (unsigned __int16 *)&v29[2 * v15 + 4];
/* v29 is a DWORD[21] array right before v30
so you can see it as v30[v17 - 1] */
LOWORD(v29[v17 + 20]) = v18;
a2 = 0;
/* sub_401AC0 is the current function itself */
_pthread_create(a1, 0, sub_401AC0, &v29[3 * v15 + 20]);
continue;
This is quite messy — cleaned up a little:
pthread_t threads[8];
struct {
short a;
int b;
char c;
} data[8];
// ...
char xx = vm[pc+1];
char yy = vm[pc+2];
char zz = vm[pc+3];
pc += 4;
int cur_idx = count++; /* v15 = v8; */
short xxyy = (xx << 8) | yy;
data[cur_idx] = {
.a = xxyy,
.b = 1,
.c = zz,
};
_pthread_create(&threads[cur_idx], 0, sub_401AC0, &data[cur_idx]);
Which is when we realize, wait — the data that we’re passing in is exactly state which we identified at the start of the function! This is quite literally multithreading WITHIN the VM! :mind_blown:
Note that the xorkey field (state[8]) that we identified earlier finally has an effect. Most likely this means the corresponding sections of the VM are encrypted relative to each thread. We will dig more later when we analyze the bytecode.
Continuing,
case 23:
if ( !v8 )
goto LABEL_16;
v13 = v30;
v14 = 1;
break;
// ...
}
while ( 1 )
{
a1 = *(unsigned __int16 **)&v29[2 * v14 + 2];
a2 = 0;
_pthread_join(a1, 0);
a3 = *v13;
if ( (_DWORD)a3 )
break;
*((_DWORD *)v7 + 1) = 0;
if ( v8 <= (int)v14 )
goto LABEL_5;
LABEL_13:
++v14;
v13 += 3;
}
if ( v8 > (int)v14 )
goto LABEL_13;
LABEL_16:
if ( !*((_DWORD *)v7 + 1) )
break;
++v6;
} /* VM execution do-while body ends here */
Control flow is a bit ugly due to decompilation, but still relatively sound. We can surely write better code:
case 23:
if (count) {
v13 = v30;
cur_idx = 1;
while (1) {
a1 = *(unsigned __int16 **)&v29[2 * cur_idx + 2];
a2 = 0;
_pthread_join(a1, 0);
a3 = *v13;
if ( !(_DWORD)a3 ) {
state.success = 0;
if (cur_idx >= count)
goto _out;
} else if (cur_idx >= count) break;
++cur_idx;
v13 += 3;
}
}
if ( !state.success )
goto _out;
++pc;
continue;
which is effectively
case 23:
if (count) {
for (int cur_idx = 0; cur_idx < count; ++cur_idx) {
_pthread_join(threads[cur_idx], 0);
if (!data[cur_idx].success) {
state.success = 0;
}
}
}
if (!state.success) { /* initialized to 1 at function start */
break;
}
++pc;
continue;
So this is the join counterpart for 22, short-circuiting on child failure. And for our final boss:
case 29:
a4 = (unsigned __int8)vm[(unsigned __int16)pc + 1] << 8;
v12 = ((unsigned __int8)vm[(unsigned __int16)pc + 1] << 8)
| (unsigned __int8)vm[(unsigned __int16)pc + 2];
if ( v12 <= 0x2197u )
{
a3 = (unsigned __int8)vm[(unsigned __int16)pc + 3];
vm[v12] ^= a3;
}
pc += 4;
continue;
This one is actually relatively chill, albeit odd and annoying to model: for 29 xx yy zz, we have vm[xxyy] ^= zz, i.e. another self-modifying layer.
With everything in place, time to disassemble the bytecode! In order to disentangle the effects of self-modification from 22 and 29, I wanted to see how far I could go without modifying the bytecode in-place for our first pass.
from pwn import read
RAW = read('./challenge')
off = RAW.find(b'\x6c\x3b\x8e\x7b\x2d')
PROGRAM = RAW[off:off+0x2197]
pc = 0
# helper functions to directly parse current bytecode values
def next8():
global pc
res = PROGRAM[pc]
pc += 1
return res
def next16():
global pc
res = PROGRAM[pc]<<8 | PROGRAM[pc+1]
pc += 2
return res
xorkey = 0
def exec():
global pc
print(f'{pc:04x} ||', end=' ')
op = next8() ^ xorkey
if op == 1:
print(f'mov r{next8()}, {next16():x}')
elif op == 2:
print(f'read r{next8()}, <<{next8()}>>')
elif op == 3:
print(f'mov r{next8()}, r{next8()}')
elif op == 4:
print(f'add r{next8()}, r{next8()}')
elif op == 5:
print(f'sub r{next8()}, r{next8()}')
elif op == 6:
print(f'xor r{next8()}, r{next8()}')
elif op == 7:
print(f'and r{next8()}, r{next8()}')
elif op == 11:
print(f'or r{next8()}, r{next8()}')
elif op == 12:
print(f'shl r{next8()}, r{next8()}')
elif op == 13:
print(f'ror r{next8()}, r{next8()}')
elif op == 14:
print(f'cmp r{next8()}, r{next8()}')
elif op == 16:
print(f'je {next8()}')
elif op == 20:
print('hlt')
return False
elif op == 22:
print(f'dispatch pc={next16()} xorkey={next8()}')
elif op == 23:
print('join')
elif op == 29:
print(f'CHNG [{next16():04x}], {hex(next8())}')
else:
print(f'=== INVALID INSTRUCTION @ PC={pc-1} OP={op}')
return True
# run the vm
while exec():
pass
$ python3 parse.py | head
0000 || === INVALID INSTRUCTION @ PC=0 OP=108
0001 || === INVALID INSTRUCTION @ PC=1 OP=59
0002 || === INVALID INSTRUCTION @ PC=2 OP=142
0003 || === INVALID INSTRUCTION @ PC=3 OP=123
0004 || === INVALID INSTRUCTION @ PC=4 OP=45
0005 || === INVALID INSTRUCTION @ PC=5 OP=150
0006 || === INVALID INSTRUCTION @ PC=6 OP=60
0007 || === INVALID INSTRUCTION @ PC=7 OP=57
0008 || === INVALID INSTRUCTION @ PC=8 OP=138
0009 || === INVALID INSTRUCTION @ PC=9 OP=104
Umm…
Oh, recall the other function that we only quickly scanned through? The one that also modifies byte_4E1120, our VM? I guess it’s time to look at that now…
But actually after been through so much, reversing that function isn’t as daunting anymore:
/* pos = 0, 1, 2 */
int sub_401A60(int pos)
{
char key;
if (pos == 0) {
key = dword_4E54C0 + 122;
} else if (pos == 1) {
key = 59;
} else if (pos <= 8599) {
key = 156; /* -100 */
} else {
return 0;
}
for (; pos <= 8599; pos += 3) {
vm[pos] ^= key;
}
return 0;
}
where dword_4E54C0 is written back at the start of main:
dword_4E54C0 = _pthread_create(0, 0, 1, 0);
i.e. normally 0. I suppose this is some antidebugging protection. Anyway, this small modification is relatively easy to integrate into our disassembler:
@@ -4,6 +4,9 @@
off = RAW.find(b'\x6c\x3b\x8e\x7b\x2d')
PROGRAM = RAW[off:off+0x2197]
+KEY = [0x7a, 0x3b, 0x9c]
+PROGRAM = [x ^ KEY[i%3] for i, x in enumerate(PROGRAM)]
+
pc = 0
# helper functions to directly parse current bytecode values
def next8():
Now it should run properly:
$ python3 parse.py | head
0000 || dispatch pc=18 xorkey=1
0004 || dispatch pc=2630 xorkey=2
0008 || dispatch pc=4620 xorkey=3
000c || dispatch pc=6610 xorkey=4
0010 || join
0011 || hlt
Oh… well at least it did run properly XD
This isn’t too bad, it’s equivalent to delegating the task to 4 sub-agents. Praying that executions don’t cross each other (how much trouble would that be to craft this challenge!?), we can just emulate the sub-execution (though with a single-threaded workqueue):
@@ -20,6 +20,7 @@
pc += 2
return res
+saved = []
xorkey = 0
def exec():
global pc
@@ -53,7 +54,10 @@
print('hlt')
return False
elif op == 22:
- print(f'dispatch pc={next16()} xorkey={next8()}')
+ fn = next16()
+ _xorkey = next8()
+ print(f'dispatch pc={fn} xorkey={_xorkey}')
+ saved.append((fn, _xorkey))
elif op == 23:
print('join')
elif op == 29:
@@ -65,3 +69,11 @@
# run the vm
while exec():
pass
+
+print(len(saved))
+for x, y in tuple(saved):
+ print('==========')
+ pc = x
+ xorkey = y
+ while exec():
+ pass
$ python3 parse.py | head -n 20
0000 || dispatch pc=18 xorkey=1
0004 || dispatch pc=2630 xorkey=2
0008 || dispatch pc=4620 xorkey=3
000c || dispatch pc=6610 xorkey=4
0010 || join
0011 || hlt
4
==========
0012 || mov r0, 0
0016 || read r1, <<0>>
0019 || mov r2, r1
001c || mov r3, 0
0020 || xor r2, r3
0023 || mov r4, ff
0027 || and r2, r4
002a || add r0, r2
002d || CHNG [0037], 0xa5
0031 || CHNG [0038], 0x5a
0035 || mov r1, a55c
0039 || cmp r0, r1
Perfect! Well except just 2 lines down we have…
003c || je 1
003e || === INVALID INSTRUCTION @ PC=62 OP=21
Interestingly, searching through all INVALID INSTRUCTIONs present within the dump they ALL follow the same two pattern (with no exceptions):
002d || CHNG [0037], 0xa5
0031 || CHNG [0038], 0x5a
0035 || mov r1, <value>
0039 || cmp r0, r1
003c || je 1
003e || === INVALID INSTRUCTION @ PC=62 OP=21
and
0b19 || mov r1, 3fe
0b1d || cmp r0, r1
0b20 || je 1
0b22 || === INVALID INSTRUCTION @ PC=2850 OP=21
In fact, looking more closely, the bytecode seems extremely regular! At this point I’m already craving to start lifting the bytecode, so do that we shall:
0012 || mov r0, 0
0016 || read r1, <<0>>
0019 || mov r2, r1
001c || mov r3, 0
0020 || xor r2, r3
0023 || mov r4, ff
0027 || and r2, r4
002a || add r0, r2
002d || CHNG [0037], 0xa5
0031 || CHNG [0038], 0x5a
0035 || mov r1, a55c
0039 || cmp r0, r1
003c || je 1
003e || === INVALID INSTRUCTION @ PC=62 OP=21
We note that the VM does a whole lot of “nothing”:
r0 = 0 # okay, valid, sure
r1 = inp[0]
r2 = r1 # r1 is reset later; might as well just use r1?
r3 = 0
r2 ^= r3 # no-op
r4 = 0xff
r2 &= r4 # no-op (given inp values are 1-9)
r0 += r2 # accumulate
That is, the whole chunk of instructions essentially just do
r0 = 0
r0 += inp[0]
The only reason why I left in r0 = 0 is because the exact same pattern between read and add is repeated for each different <<x>> value of inp access. Just to show an example to see for yourself:
003f || mov r0, 0
0043 || read r1, <<1>>
0046 || mov r2, r1
0049 || mov r3, 0
004d || xor r2, r3
0050 || mov r4, ff
0054 || and r2, r4
0057 || add r0, r2
005a || read r1, <<9>>
005d || mov r2, r1
0060 || mov r3, 0
0064 || xor r2, r3
0067 || mov r4, ff
006b || and r2, r4
006e || add r0, r2
0071 || read r1, <<10>>
0074 || mov r2, r1
0077 || mov r3, 0
007b || xor r2, r3
007e || mov r4, ff
0082 || and r2, r4
0085 || add r0, r2
0088 || CHNG [0092], 0xa5
008c || CHNG [0093], 0x5a
0090 || mov r1, a548
0094 || cmp r0, r1
0097 || je 1
0099 || === INVALID INSTRUCTION @ PC=153 OP=21
Understandably this therefore does r0 = inp[1] + inp[9] + inp[10].
Okay, then what about that last chunk with the invalid instruction and the self-modification? Well, neither are particularly scary in this case:
- Notice that bytes
0092and0093in the above example correspond to the 16-bit immediate inmov r1, a548. Thus performing^= a55awe getmov r1, 0x12, which is then compared against our accumulated value. - Upon a match,
jeskips over the invalid instruction, continuing execution; otherwise, we realize the invalid instruction simply makes VM halt execution with.success = 0. This is perfectly logical for a checker-based VM.
That is to say, the first half of the VM is simply doing
assert inp[0] == 0x6
assert inp[1]+inp[9]+inp[10] == 0x12
assert inp[2]+inp[3]+inp[4] == 0xa
assert inp[5]+inp[14] == 0xb
# ...
Okay, now onto the other half!
0a46 || mov r0, 0
0a4a || read r1, <<0>>
0a4d || mov r2, r1
0a50 || mov r3, 1
0a54 || mov r4, 0
0a58 || xor r2, r4
0a5b || shl r3, r2
0a5e || or r0, r3
0a61 || read r1, <<1>>
0a64 || mov r2, r1
0a67 || mov r3, 1
0a6b || mov r4, 0
0a6f || xor r2, r4
0a72 || shl r3, r2
0a75 || or r0, r3
0a78 || read r1, <<2>>
0a7b || mov r2, r1
0a7e || mov r3, 1
0a82 || mov r4, 0
0a86 || xor r2, r4
0a89 || shl r3, r2
0a8c || or r0, r3
0a8f || read r1, <<3>>
0a92 || mov r2, r1
0a95 || mov r3, 1
0a99 || mov r4, 0
0a9d || xor r2, r4
0aa0 || shl r3, r2
0aa3 || or r0, r3
0aa6 || read r1, <<4>>
0aa9 || mov r2, r1
0aac || mov r3, 1
0ab0 || mov r4, 0
0ab4 || xor r2, r4
0ab7 || shl r3, r2
0aba || or r0, r3
0abd || read r1, <<5>>
0ac0 || mov r2, r1
0ac3 || mov r3, 1
0ac7 || mov r4, 0
0acb || xor r2, r4
0ace || shl r3, r2
0ad1 || or r0, r3
0ad4 || read r1, <<6>>
0ad7 || mov r2, r1
0ada || mov r3, 1
0ade || mov r4, 0
0ae2 || xor r2, r4
0ae5 || shl r3, r2
0ae8 || or r0, r3
0aeb || read r1, <<7>>
0aee || mov r2, r1
0af1 || mov r3, 1
0af5 || mov r4, 0
0af9 || xor r2, r4
0afc || shl r3, r2
0aff || or r0, r3
0b02 || read r1, <<8>>
0b05 || mov r2, r1
0b08 || mov r3, 1
0b0c || mov r4, 0
0b10 || xor r2, r4
0b13 || shl r3, r2
0b16 || or r0, r3
0b19 || mov r1, 3fe
0b1d || cmp r0, r1
0b20 || je 1
0b22 || === INVALID INSTRUCTION @ PC=2850 OP=21
Well…
r0 = 0
r1 = inp[0]
r2 = r1 # again redundant
r3 = 1
r4 = 0
r2 ^= r4 # no-op again
r3 <<= r2 # set (x+1)-th least significant bit
r0 |= r3 # transfer onto r0
If you’re less familiar with bit operations:
r0 = [False for _ in range(16)]
r0[inp[0]] = True
And the tail chunk simply checks if r0 == 0x3fe, or 0b1111111110, which in our context means
assert all(r0[i] for i in range(1, 10))
Okay the cat is out of the bag and long gone at this point. It is basically a Sudoku with extra steps. This portion makes sure that across the inp[0]...inp[8] being checked this round, there must be at least one inp[x] having a value of 1, of 2, of 3, and so on. Given that exactly 9 inp cells are checked at each round, each inp cell has to uniquely correspond to one number.
The rest basically follow the same format: the next set of checks are inp[9]...inp[17], then inp[18]...inp[26], and so on. In fact, remember the delegation to 4 different threads? Turns out each thread maps very cleanly to one type of checek:
0000 || dispatch pc=18 xorkey=1 ; sum check
0004 || dispatch pc=2630 xorkey=2 ; each row is unique
0008 || dispatch pc=4620 xorkey=3 ; each column is unique
000c || dispatch pc=6610 xorkey=4 ; each grid is unique
The last 3 checks make sense in a Sudoku context, but what about the first? Is it even solvable? I did try to just z3 it at first, but of course it did not work very well. Staring at the checks, that’s when it registered:
import re
with open('./dump.txt') as f:
RAW = f.read().strip().splitlines()[10:]
cur = []
for line in RAW:
if (m := re.search(r'<<(\d+)>>', line)) is not None:
cur.append(int(m.group(1)))
elif 'mov r1' in line:
target = int(line.split(', ')[1], 16) ^ 0xa55a
print(' + '.join(map(lambda x: f'[{x}]', cur)), '==', target)
cur = []
elif line == '==========':
break
[0] == 6
[1] + [9] + [10] == 18
[2] + [3] + [4] == 10
[5] + [14] == 11
[6] == 7
[7] + [8] == 10
[11] + [20] + [29] + [30] == 21
[12] + [13] == 14
[15] + [16] == 6
[17] + [26] == 8
[18] + [27] + [36] == 10
[19] + [28] == 11
[21] + [22] + [23] == 13
[24] + [25] == 14
[31] + [32] == 8
[33] + [41] + [42] == 18
[34] + [35] == 10
[37] + [45] + [46] == 16
[38] + [47] == 6
[39] + [40] + [48] == 16
[49] + [50] == 11
[51] + [52] + [53] + [62] == 16
[43] + [44] == 14
[54] + [55] == 7
[56] + [65] == 15
[57] + [66] == 15
[58] + [59] + [67] == 12
[60] + [68] + [69] + [77] == 15
[61] + [70] == 12
[63] + [64] == 7
[72] + [73] == 15
[74] + [75] + [76] == 8
[78] + [79] == 8
[71] + [80] == 17
Not going to lie, I’m so glad I’ve played (or at least heard of) this game before. Since it’s relatively established, there’s bound to be a solver online:

Solution:
683254791
915687243
427913865
598162437
132479586
764538912
256891374
349726158
871345629
Stringing the numbers together and sending to the binary as input yields us
$ ./challenge
Give me your key quickly or bad things will happen to you:
> 683254791915687243427913865598162437132479586764538912256891374349726158871345629
Good job! Here is the flag: af71644eb345468223e196bee35f61ac:87a8bceff6b7c062b0d75c2d07fc2fea78caefd0adb1167e982c79f76379265527d3d4c9ab05bc3206438b2e96179c4456a65d54a92339cca74933424b76540
At which point I had 20 minutes left in the CTF, so I spiritually gave up. Anyway, it’s time to dig into the last mechanic, the block of code that I omitted in main at the very start of our analysis:
sub_401AC0(&v9); /* vm run function */
if ( !v10 )
{
_puts("Validation Failed.");
break;
}
/* no idea at all what this does but
that name is mentioned in the function */
v8 = -(int)_get_pthread_stack_min(30) & 0x4AF740;
/* makes sense with the idiom below,
also arguments make sense */
if ( (unsigned int)_mprotect(v8, &byte_4AF8A9[-v8], 7) )
{
/* common c idiom */
_perror("mprotect");
result = 1;
}
else
{
sub_401FF0(sub_4AF740, byte_4AF8A9 - (char *)sub_4AF740, inp, 81);
sub_4AF740(inp, byte_4AF8A9 - (char *)sub_4AF740);
result = v9;
}
goto LABEL_4;
Now it comes to the two mysterious functions in the else branch. What’s even more mysterious, if you’ve noticed, is that sub_4AF740 seems to get modified (cue from mprotect + passed as buffer to sub_401FF0) before getting called!
For sure, the current state of sub_4AF740 looks corrupted:
// positive sp value has been detected, the output may be wrong!
__int64 __fastcall sub_4AF740(__int64 a1, _BYTE *_RSI, _BYTE *a3, __int64 a4)
{
unsigned __int8 v5; // bp
_BYTE retaddr[16]; // [rsp+0h] [rbp+0h]
__asm { rep sahf }
*a3 *= 2;
__asm { outsd }
__outbyte((unsigned __int16)a3, v5);
BYTE1(a4) &= *_RSI;
return MK_FP(*(_WORD *)retaddr, *(_QWORD *)retaddr)(a1, _RSI, a3, a4);
}
I took a preliminary look at sub_401FF0 and was frankly quite bamboozled. It almost looks like a memcpy, except
- The buffer sizes don’t match (361 vs 81)
memcpydoesn’t accept a destination size- There are mentions of
mallocin the function somehow
But this looks like something dynamic analysis would easily resolve, that is, simply enter the correct Sudoku input and stop execution before the sub_4AF740 call (remembering to bypass the antidebugging):
$ gdb challenge
# ...
pwndbg> b *0x401794 # after _pthread_create()
Breakpoint 1 at 0x401794
pwndbg> b *0x4018ef # before calling sub_4AF740
Breakpoint 2 at 0x4018ef
pwndbg> r
# ...
Breakpoint 1, 0x0000000000401794 in ?? ()
# ...
RAX 0xffffffffffffffff
# ...
pwndbg> set $rax=0 # neutralize antidebugging from _pthread_create()
pwndbg> c
Continuing.
Give me your key quickly or bad things will happen to you:
> 683254791915687243427913865598162437132479586764538912256891374349726158871345629
# ...
Thread 1 "challenge" hit Breakpoint 2, 0x00000000004018ef in ?? ()
# ...
pwndbg> dump binary memory patch.bin 0x4af740 0x4af8a9
from pwn import read, write
a = bytearray(read('./challenge'))
i = a.find(bytes.fromhex('F3 9E 36 D0 32 6F 65 95'))
a[i:i+(0x4af8a9-0x4af740)] = read('./patch.bin')
write('./challenge_patched', bytes(a))
It now looks much better!
unsigned __int64 __fastcall sub_4AF740(__int64 a1)
{
int v1; // r8d
int v2; // r9d
__int64 v3; // rax
int i; // edx
__int64 v5; // rcx
int v6; // ecx
int v7; // r8d
int v8; // r9d
__int8 *v9; // rbx
int j; // edx
char *v11; // rbx
int v12; // ecx
int v13; // r8d
int v14; // r9d
int k; // edx
unsigned __int64 result; // rax
_OWORD v17[4]; // [rsp+0h] [rbp-D8h]
_BYTE v18[32]; // [rsp+40h] [rbp-98h] BYREF
__m128i si128; // [rsp+60h] [rbp-78h] BYREF
_OWORD v20[4]; // [rsp+70h] [rbp-68h] BYREF
char v21; // [rsp+B0h] [rbp-28h] BYREF
unsigned __int64 v22; // [rsp+B8h] [rbp-20h]
v22 = __readfsqword(0x28u);
v17[0] = _mm_load_si128((const __m128i *)&xmmword_4B1190);
v17[1] = _mm_load_si128((const __m128i *)&xmmword_4B11A0);
si128 = _mm_load_si128((const __m128i *)&xmmword_4B11B0);
v20[0] = _mm_load_si128((const __m128i *)&xmmword_4B11C0);
v20[1] = _mm_load_si128((const __m128i *)&xmmword_4B11D0);
v20[2] = _mm_load_si128((const __m128i *)&xmmword_4B11E0);
v20[3] = _mm_load_si128((const __m128i *)&xmmword_4B11F0);
sub_402110(a1, v18);
v3 = 0;
for ( i = 71; ; i = *((unsigned __int8 *)v17 + v3) )
{
LOBYTE(i) = v18[v3] ^ i;
v5 = (int)v3++;
*((_BYTE *)&v17[2] + v5) = i;
if ( v3 == 32 )
break;
}
_fprintf(1, (unsigned int)"\nGood job! Here is the flag: ", i, v5, v1, v2, v17[0]);
v9 = &si128.m128i_i8[1];
for ( j = 175; ; j = (unsigned __int8)*v9++ )
{
_fprintf(1, (unsigned int)"%02x", j, v6, v7, v8, v17[0]);
if ( v9 == (__int8 *)v20 )
break;
}
v11 = (char *)v20 + 1;
/* 58 is ASCII for ':' */
_putchar(58);
for ( k = 135; ; k = (unsigned __int8)*v11++ )
{
_fprintf(1, (unsigned int)"%02x", k, v12, v13, v14, v17[0]);
if ( v11 == &v21 )
break;
}
_putchar(10);
result = v22 - __readfsqword(0x28u);
if ( result )
sub_44FE10();
return result;
}
Still a bit messy though, with the AVX stuff and byte-level manipulation. Just some cleanup:
void derive_flag(char *inp) {
char x[32];
char y[32];
char z[32];
char a[16];
char b[64];
/* canary setup */
memcpy(x, xmmword_4B1190, 32);
memcpy(a, xmmword_4B11B0, 16);
memcpy(b, xmmword_4B11C0, 64);
sub_402110(inp, z);
for (int i = 0; i < 32; ++i) {
y[i] = z[i] ^ x[i];
}
_fprintf(1, (unsigned int)"\nGood job! Here is the flag: ");
for (int i = 0; i < 16; ++i) {
_fprintf(1, (unsigned int)"%02x", a[i]);
}
_putchar(':');
for (int i = 0; i < 64; ++i) {
_fprintf(1, (unsigned int)"%02x", b[i]);
}
_putchar('\n');
/* canary check */
}
And sub_402110:
__int64 __fastcall sub_402110(__int64 a1, _BYTE *a2)
{
_BYTE *v3; // rdx
int v5; // eax
int v6; // r11d
int v7; // r9d
int v8; // r8d
__int64 result; // rax
char v10; // dl
char v11; // bl
char v12; // si
char v13; // cl
__int64 v14; // rsi
v3 = a2;
v5 = 49;
do
{
*v3 = v5;
v5 += 11;
++v3;
}
while ( (_BYTE)v5 != 0x91 );
v6 = 0;
v7 = 11;
v8 = 3;
result = 0;
do
{
v10 = *(_BYTE *)(a1 + result);
v11 = v6 ^ v10;
v6 += 17;
a2[result & 0x1F] = v11 + (v10 ^ a2[result & 0x1F]);
v12 = v8;
v8 += 7;
a2[v12 & 0x1F] = (v10 + result) ^ __ROL1__(a2[v12 & 0x1F], 1);
v13 = result++;
v14 = v7 & 0x1F;
v7 += 13;
a2[v14] += __ROL1__(v10, v13 & 7);
}
while ( result != 81 );
return result;
}
Just some cleanup:
void scramble(char *inp, char *out) {
/* pow(11, -1, 256) * (0x91-49) % 256 = 32 */
char x = 49;
for (int i = 0; i < 32; ++i) {
out[i] = x;
x += 11;
}
char tmp = 0;
char a = 0, b = 3, c = 11;
for (int i = 0; i < 81; ++i) {
char cur = inp[i];
out[a] = (tmp ^ cur) + (cur ^ out[a]);
out[b] = (cur + i) ^ rol(out[b], 1);
out[c] = out[c] + rol(cur, i);
tmp += 17;
a = (a+1) % 0x20;
b = (b+7) % 0x20;
c = (c+13) % 0x20;
}
}
Honestly, I don’t immediately clock a meaningful algorithm other than random deterministic scrambling that we can either run ourselves or retrieve the result dynamically.
Fitting back to the original “win” function, we could suppose this prevents directly retrieving y (or v17[2]) without solving the Sudoku.
However, perhaps perplexingly, y (or even z) is never actually printed to stdout. Let’s just see how it looks like, continuing from our earlier debugging session:
pwndbg> b *0x4af7f9
Breakpoint 3 at 0x4af7f9
pwndbg> c
# ...
Thread 1 "challenge" hit Breakpoint 3, 0x00000000004af7f9 in ?? ()
# ...
► 0x4af7f9 mov esi, 0x4b1103 ESI => 0x4b1103 ◂— '\nGood job! Here is the flag: '
0x4af7fe mov edi, 1 EDI => 1
0x4af803 xor eax, eax EAX => 0
0x4af805 call 0x44fd20 <0x44fd20>
# ...
pwndbg> tele $rsp (4+2+1+4)*2
00:0000│ rsp 0x7fffffffdc50 ◂— 0xb6df00b86eba2547 # x
01:0008│-110 0x7fffffffdc58 ◂— 0x30d0e10d5ab3330d
02:0010│-108 0x7fffffffdc60 ◂— 0x11510877e6dee367
03:0018│-100 0x7fffffffdc68 ◂— 0xffd2bbeb5d4e0d71
04:0020│-0f8 0x7fffffffdc70 ◂— 0xfad4359b89c173fa # y
05:0028│-0f0 0x7fffffffdc78 ◂— 0xee434e8acce10d8c
06:0030│-0e8 0x7fffffffdc80 ◂— 0x98f4866b1012ad21
07:0038│-0e0 0x7fffffffdc88 ◂— 0x99c6fe5177360745
08:0040│ rdi 0x7fffffffdc90 ◂— 0x4c0b3523e77b56bd # z
09:0048│-0d0 0x7fffffffdc98 ◂— 0xde93af8796523e81
0a:0050│-0c8 0x7fffffffdca0 ◂— 0x89a58e1cf6cc4e46
0b:0058│-0c0 0x7fffffffdca8 ◂— 0x661445ba2a780a34
0c:0060│-0b8 0x7fffffffdcb0 ◂— 0x824645b34e6471af # a
0d:0068│-0b0 0x7fffffffdcb8 ◂— 0xac615fe3be96e123
0e:0070│-0a8 0x7fffffffdcc0 ◂— 0x62c0b7f6efbca887 # b
0f:0078│-0a0 0x7fffffffdcc8 ◂— 0xea2ffc072d5cd7b0
10:0080│-098 0x7fffffffdcd0 ◂— 0x7e16b1add0efca78
11:0088│-090 0x7fffffffdcd8 ◂— 0x55267963f7792c98
12:0090│-088 0x7fffffffdce0 ◂— 0x32bc05abc9d4d327
13:0098│-080 0x7fffffffdce8 ◂— 0x449c17962e8b4306
14:00a0│-078 0x7fffffffdcf0 ◂— 0xcc3923a9545da656
15:00a8│-070 0x7fffffffdcf8 ◂— 0xb54764b423349a7
Still nothing meaningful :(
Anyway, I couldn’t solve this challenge in time (managed to just barely clean up derive_flag). After the CTF, I was discussing with r3kapig, where I learned that the final hidden y was an AES encryption key, with a and b being the IV and ciphertext respectively:
from Crypto.Cipher import AES
KEY = bytes.fromhex('fa73c1899b35d4fa8c0de1cc8a4e43ee21ad12106b86f4984507367751fec699')
IV, CT = tuple(map(bytes.fromhex, 'af71644eb345468223e196bee35f61ac:87a8bceff6b7c062b0d75c2d07fc2fea78caefd0adb1167e982c79f76379265527d3d4c9ab05bc3206438b2e96179c4456a65d54a92339cca74933424b76540b'.split(':')))
print(AES.new(KEY, mode=AES.MODE_CBC, iv=IV).decrypt(CT).decode())
EPFL{v4ult_unl0ck3d_n0w_r3v3rs3_th3_m4tr1x_4_43s!}
I honestly can’t tell if I would have figured this out by myself, even with additional 1 hour of time lost due to my flight arrangements. So I just tell myself that it would have been so, instead of potentially malding harder at being bad at guessing XD
That aside, I still really enjoyed both challenges. I think the reversing challenges set by the LakeCTF team were superb (would have been even better if Vault Matrix just handed over the flag after the Sudoku).
I didn’t really touch pwn much during the CTF, as I felt relatively “rusty” and I knew my trusty teammates Lucas and Gerrard were working hard at it. From my quick glances, they all did seem pretty challenging and rewarding as well.
The CTF landscape has been dominated by LLMs these days. I’m not joining in on the ten million blogposts discussing the current state of CTFs, but personally as someone who enjoys solving challenges more for the artisan qualities of it, this year’s LakeCTF rendition was a really refreshing break from the chaos, from mentally stimulating problems which felt almost like putting an old muscle to work, to simply more physically involved ones allowing us to “touch grass”.
In fact, we liked it so much that the format for GreyCTF took a little inspiration from it. At the very least, the style of my authored challenges did. Another post coming soon?