This page has been machine-translated from the original page.
I participated in UIUCTF 2023, which started on 7/1, as part of 0nePadding, and we placed 124th out of 818 teams.
This time I solved Rev and OSINT, but the contest left me with a strong sense of how much room I still have to improve.
For now, I will write up two of the Rev challenges.
vmwhere1(Rev)
The challenge provides an ELF file named vm and a binary file named program.
Analyzing it in Ghidra shows that vm reads the byte data from program and performs operations according to each byte value.
The actual decompilation looked like this.
undefined8 check(byte *param_1,int param_2)
{
byte *pbVar1;
byte bVar2;
byte bVar3;
int iVar4;
byte *pbVar5;
uint local_24;
byte *local_20;
byte *local_18;
pbVar5 = (byte *)malloc(0x1000);
local_20 = param_1;
local_18 = pbVar5;
while( true ) {
if ((local_20 < param_1) || (param_1 + param_2 <= local_20)) {
printf("Program terminated unexpectedly. Last instruction: 0x%04lx\n",
(long)local_20 - (long)param_1);
return 1;
}
pbVar1 = local_20 + 1;
switch(*local_20) {
case 0:
return 0;
case 1:
local_18[-2] = local_18[-2] + local_18[-1];
local_18 = local_18 + -1;
local_20 = pbVar1;
break;
case 2:
local_18[-2] = local_18[-2] - local_18[-1];
local_18 = local_18 + -1;
local_20 = pbVar1;
break;
case 3:
local_18[-2] = local_18[-2] & local_18[-1];
local_18 = local_18 + -1;
local_20 = pbVar1;
break;
case 4:
local_18[-2] = local_18[-2] | local_18[-1];
local_18 = local_18 + -1;
local_20 = pbVar1;
break;
case 5:
local_18[-2] = local_18[-2] ^ local_18[-1];
local_18 = local_18 + -1;
local_20 = pbVar1;
break;
case 6:
local_18[-2] = local_18[-2] << (local_18[-1] & 0x1f);
local_18 = local_18 + -1;
local_20 = pbVar1;
break;
case 7:
local_18[-2] = (byte)((int)(uint)local_18[-2] >> (local_18[-1] & 0x1f));
local_18 = local_18 + -1;
local_20 = pbVar1;
break;
case 8:
iVar4 = getchar();
*local_18 = (byte)iVar4;
local_18 = local_18 + 1;
local_20 = pbVar1;
break;
case 9:
local_18 = local_18 + -1;
putchar((uint)*local_18);
local_20 = pbVar1;
break;
case 10:
*local_18 = *pbVar1;
local_18 = local_18 + 1;
local_20 = local_20 + 2;
break;
case 0xb:
if ((char)local_18[-1] < '\0') {
pbVar1 = pbVar1 + CONCAT11(*pbVar1,local_20[2]);
}
local_20 = pbVar1;
local_20 = local_20 + 2;
break;
case 0xc:
if (local_18[-1] == 0) {
pbVar1 = pbVar1 + CONCAT11(*pbVar1,local_20[2]);
}
local_20 = pbVar1;
local_20 = local_20 + 2;
break;
case 0xd:
local_20 = pbVar1 + (long)CONCAT11(*pbVar1,local_20[2]) + 2;
break;
case 0xe:
local_18 = local_18 + -1;
local_20 = pbVar1;
break;
case 0xf:
*local_18 = local_18[-1];
local_18 = local_18 + 1;
local_20 = pbVar1;
break;
case 0x10:
local_20 = local_20 + 2;
bVar2 = *pbVar1;
if ((long)local_18 - (long)pbVar5 < (long)(ulong)bVar2) {
printf("Stack underflow in reverse at 0x%04lx\n",(long)local_20 - (long)param_1);
}
for (local_24 = 0; (int)local_24 < (int)(uint)(bVar2 >> 1); local_24 = local_24 + 1) {
bVar3 = local_18[(int)(local_24 - bVar2)];
local_18[(int)(local_24 - bVar2)] = local_18[(int)~local_24];
local_18[(int)~local_24] = bVar3;
}
break;
default:
printf("Unknown opcode: 0x%02x at 0x%04lx\n",(ulong)*local_20,(long)local_20 - (long)param_1);
return 1;
case 0x28:
FUN_00101370(param_1,pbVar5,local_18,(long)pbVar1 - (long)param_1);
local_20 = pbVar1;
}
if (local_18 < pbVar5) break;
if (pbVar5 + 0x1000 < local_18) {
printf("Stack overflow at 0x%04lx\n",(long)local_20 - (long)param_1);
return 1;
}
}
printf("Stack underflow at 0x%04lx\n",(long)local_20 - (long)param_1);
return 1;
}Looking at this code, you can see that when the opcode is 0x08, the getchar function is called and the input is read one character at a time.
So I extracted the 0x08 instructions from program, and found the following periodic pattern.
Tracing the following byte sequence shows that after the input value is saved by the 0xf handler, it is transformed by the 0x7 and 0x5 handlers, XORed with a hardcoded byte value, and then verified one character at a time by the 0xc handler.
Once I had narrowed it down that far, I could brute-force it with dynamic analysis.
Using the following script, I identified characters that satisfy the 0xc condition one by one from the beginning, which let me recover the flag.
# gdb -x solver.py
import gdb
from pprint import pprint
# pprint(dir(gdb))
BINDIR = "/home/ubuntu/Hacking/CTF/2023/UIUCTF/Rev/vmwhere1/"
BIN = "chal"
gdb.execute('file {}/{}'.format(BINDIR, BIN))
# gdb.execute('b *{}'.format(0x555555555587))
gdb.execute('b *{}'.format(0x55555555569f))
flag = "uiuctf{" + "A"*150
for i in range(150):
for j in range(0x21,0x126):
flag = flag[:i] + chr(j) + "A"*(30-i)
with open("in.txt", "w") as f:
f.write(flag)
gdb.execute("run program < in.txt")
gdb.execute("continue {}".format(51+i))
res = int(gdb.parse_and_eval("$al"))
if res == 0:
print(flag)
print(chr(j),flag)
break
else:
continue
print(flag)
gdb.execute('quit')Solving it properly with static analysis
For vmwhere1, it was enough to identify that 0xc checks the input one character at a time, and that alone was enough to recover the flag, but if you move on without understanding more than that, you get stuck on vmwhere2.
So at the vmwhere1 stage, I dug a little deeper to better understand how the binary works.
To understand the behavior of the VM, the following function called by the 0x28 handler is a useful clue.
void FUN_00101370(undefined8 param_1,long param_2,long param_3,undefined8 param_4)
{
int local_c;
param_3 = param_3 + -1;
printf("Program counter: 0x%04lx\n",param_4);
printf("Stack pointer: 0x%04lx\n",param_3 - param_2);
puts("Stack:");
for (local_c = 0; local_c < 0x10; local_c = local_c + 1) {
if (-1 < (param_3 - param_2) - (long)local_c) {
printf("0x%04lx: 0x%04x\n",(param_3 - param_2) - (long)local_c,
(ulong)*(byte *)(param_3 - local_c));
}
}
return;
}If you make a binary that pushes some arbitrary values onto the stack and then invokes the 0x28 handler, you get output like this.
This shows that the variable passed as param_3 is the stack pointer.
echo -n -e "\x0a\x21\x0a\x22\x0a\x23\x0a\x24\x28" > test
./chal test
>
Program counter: 0x0009
Stack pointer: 0x0003
Stack:
0x0003: 0x0024
0x0002: 0x0023
0x0001: 0x0022
0x0000: 0x0021
Program terminated unexpectedly. Last instruction: 0x0009Once I had identified the stack pointer and program counter, the rest was to work through the necessary operations.
After 0x8 reads one character of the flag, the following sequence runs.
08 0f 0a 04 07 05 05 0f 0a 72 05 0c 00 03 0d 04 0d 0eAs a starting point, I rewrote the processing up to 0xc in Python like this.
op = prog[pic]
if op == 1:
a = stack.pop()
b = stack.pop()
stack.append(a+b)
pic += 1
if op == 5:
a = stack.pop()
b = stack.pop()
n = a ^ b
stack.append(n)
pic += 1
if op == 7:
a = stack.pop()
b = stack.pop()
n = b >> (a & 0x1f)
stack.append(n)
pic += 1
if op == 8:
c = flag[seek]
seek += 1
stack.append(c)
pic += 1
if op == 0xa:
data = prog[pic+1]
stack.append(data)
pic += 2
if op == 0xf:
stack.append(stack[-1])
pic += 1Rephrased in order, it does the following.
- It reads one input character and pushes it onto the stack
- It pushes the same value onto the stack once more
- It pushes
0x4onto the stack - It right-shifts the input value by
0x4 & 0x1fand stores the result on the stack (extracting only the high bits of the input character) - It XORs that value with the input value and stores the result on the stack (the high bits stay as-is and only the low bits are XORed)
- It XORs the value that had been on the stack before the input was read (initially
0x00) with the transformed value above - It pushes another copy of that result onto the stack
- It pushes
0x72onto the stack (this value changes every time and is also used in the next verification) - It XORs
0x72with the previous result and stores it on the stack - It checks whether the previous result is 0, and treats 0 as correct
If you actually evaluate this sequence, you can see that when the first character is u, it returns the correct value 0 as shown below.
Apart from the value pushed by 0a 72, the rest of this processing is shared for every character.
In other words, if we extract the hardcoded verification value for each character, we should be able to reconstruct the correct flag.
The following script can be used to extract those keys.
with open("program", "rb") as f:
prog = f.read()
pattern = b"\x05\x0f\x0a"
key = []
for i in range(3, len(prog)):
if prog[i-3:i] == pattern:
key.append(prog[i])
print(key)Then, based on the analysis, I ran the following solver and obtained the correct flag.
The reason chr((a>>4)^a) recovers the flag is that the correct input keeps its upper bits unchanged, while its lower bits are XORed with the upper bits.
with open("program", "rb") as f:
prog = f.read()
pattern = b"\x05\x0f\x0a"
key = []
for i in range(3, len(prog)):
if prog[i-3:i] == pattern:
key.append(prog[i])
# print([hex(i) for i in key])
flag = ""
key = key[::-1]
for i in range(len(key)-1):
k = key[i]
a = k ^ key[i+1]
flag += chr((a>>4)^a)
print("c" + flag[::-1])
# ciuctf{ar3_y0u_4_r3al_vm_wh3r3_(gpt_g3n3r4t3d_th1s_f14g)}vmwhere2(Rev)
This challenge gives you a binary almost identical to vmwhere1 along with a new program file.
Unlike vmwhere1, this ELF added the following two handlers.
case 0x11:
local_30 = sp[-1];
for (j = 0; j < 8; j = j + 1) {
(sp + -1)[j] = local_30 & 1;
local_30 = local_30 >> 1;
}
sp = sp + 7;
current_pic = next_pic;
break;
case 0x12:
local_2f = 0;
for (k = 7; -1 < k; k = k + -1) {
local_2f = local_2f << 1 | (sp + -8)[k] & 1;
}
sp[-8] = local_2f;
sp = sp + -7;
current_pic = next_pic;
break;The 0x11 handler splits the input character into individual bits and pushes them onto the stack.
Meanwhile, 0x12 appears to reorder the bit sequence stored by 0x11 in reverse and pack it back again.
The rest looks the same as vmwhere1, so next I looked at the program itself.
In vmwhere2, unlike vmwhere1, it seems to read all of the input first and only then verify the flag.
First, just like before, let’s look at the processing after 0x08 reads a character.
The handling of the first input character looked like this.
There is a lot more here than before.
08 11 0a ff 10 09 10 08 0a 00 10 02 0f 0a ff 05 0c 00 04 0e 0d 00 04 0e 0d 00 16 10 02 10 02 0c 00 07 0e 0a 01 01 0d 00 01 0e 0f 0f 01 01 0d ff d9 0eTo make each step easier to follow, I arranged it by opcode and operand.
08
11
0a ff
10 09
10 08
0a 00
10 02
0f
0a ff
05
0c 00 04
0e
0d 00 04
0e 0d 00 16
10 02
10 02
0c 00 07 0e 0a 01 01 0d 00 01
0e
0f
0f
01
01
0d ff d9
0e- The received input value is stored on the stack as a bit sequence by the 0x11 handler
0xffis pushed onto the stack- The 0x10 handler manipulates the stack with
0x9as its argument - Then 0x10 manipulates the stack again with
0x8as its argument 0x0is pushed onto the stack- 0x10 is called with
0x2as its argument - The top stack value is duplicated onto the stack
0xffis pushed onto the stack- The top two stack values are XORed
- Execution advances by two steps and proceeds to the 0xd handler
- The 0x10 handler is executed twice with
0x2as its argument - Execution continues until the 0xe handler
- After pushing several values onto the stack, the 0xd handler moves the program counter back by
-0x25((0x55555555a535+0xffffffffffffffd9) & 0xffffffffffffffff)
I got this far, but the remaining amount of processing was so huge that I gave up trying to trace it manually.
So I decided to reimplement the binary and observe its behavior with print debugging.
My Python version was not good enough to reproduce the behavior completely, so I wrote it in C instead.
The code I wrote is as follows.
#include <stdio.h>
#include <stdlib.h>
void main()
{
char bVar1;
char bVar2;
int iVar3;
char *stack;
char local_30;
char local_2f;
uint i;
int j;
int k;
char *current_pic;
char *sp;
char *next_pic;
FILE *f;
void *prog;
long f_size;
// Read file
f = fopen("program","r");
if (f == (FILE *)0x0) {
prog = (void *)0x0;
}
else {
fseek(f,0,2);
f_size = ftell(f);
rewind(f);
prog = malloc(f_size);
if (prog == (void *)0x0) {
prog = (void *)0x0;
}
else {
fread(prog,1,f_size,f);
fclose(f);
}
}
// Run program
stack = (char *)malloc(0x1000);
current_pic = prog;
int start = (int)current_pic;
sp = stack;
while(1) {
if ((current_pic < prog) || (prog + f_size <= current_pic)) {
printf("Program terminated unexpectedly. Last instruction: 0x%04lx\n",
(long)current_pic - (long)prog);
return 1;
}
printf("[0x%x] OP:0x%x ", current_pic - start, *current_pic);
next_pic = current_pic + 1;
switch(*current_pic) {
case 0:
return 0;
case 1:
printf("ADD %x, %x = %x\n", sp[-2], sp[-1], sp[-2] + sp[-1]);
sp[-2] = sp[-2] + sp[-1];
sp = sp + -1;
current_pic = next_pic;
break;
case 2:
printf("SUB %x, %x = %x\n", sp[-2], sp[-1], sp[-2] - sp[-1]);
sp[-2] = sp[-2] - sp[-1];
sp = sp + -1;
current_pic = next_pic;
break;
case 3:
printf("AND %x, %x = %x\n", sp[-2], sp[-1], sp[-2] & sp[-1]);
sp[-2] = sp[-2] & sp[-1];
sp = sp + -1;
current_pic = next_pic;
break;
case 4:
printf("OR %x, %x = %x\n", sp[-2], sp[-1], sp[-2] | sp[-1]);
sp[-2] = sp[-2] | sp[-1];
sp = sp + -1;
current_pic = next_pic;
break;
case 5:
printf("XOR %x, %x = %x\n", sp[-2], sp[-1], sp[-2] ^ sp[-1]);
sp[-2] = sp[-2] ^ sp[-1];
sp = sp + -1;
current_pic = next_pic;
break;
case 6:
printf("LSH %x, %x = %x\n", sp[-2], (sp[-1] & 0x1f), sp[-2] >> (sp[-1] & 0x1f));
sp[-2] = sp[-2] << (sp[-1] & 0x1f);
sp = sp + -1;
current_pic = next_pic;
break;
case 7:
printf("RSH %x, %x = %x\n", sp[-2], (sp[-1] & 0x1f), sp[-2] << (sp[-1] & 0x1f));
sp[-2] = (char)((int)(uint)sp[-2] >> (sp[-1] & 0x1f));
sp = sp + -1;
current_pic = next_pic;
break;
case 8:
iVar3 = getchar();
*sp = (char)iVar3;
printf("READ %c\n", *sp);
sp = sp + 1;
current_pic = next_pic;
break;
case 9:
sp = sp + -1;
printf("PUTCHAR %c\n", *sp);
// putchar((uint)*sp);
current_pic = next_pic;
break;
case 10:
printf("PUSH %x\n", *next_pic);
*sp = *next_pic;
sp = sp + 1;
current_pic = current_pic + 2;
break;
case 0xb:
if ((char)sp[-1] < '\0') {
next_pic = next_pic + (*next_pic << 8 | current_pic[2]);
}
current_pic = next_pic;
current_pic = current_pic + 2;
printf("JPM %x\n", current_pic);
break;
case 0xc:
if (sp[-1] == 0) {
next_pic = next_pic + (*next_pic << 8 | current_pic[2]);
}
current_pic = next_pic;
current_pic = current_pic + 2;
printf("JPM %x\n", current_pic);
break;
case 0xd:
current_pic = next_pic + (*next_pic << 8 | current_pic[2]) + 2;
printf("JPM %x\n", current_pic);
break;
case 0xe:
printf("POP\n");
sp = sp + -1;
current_pic = next_pic;
break;
case 0xf:
printf("DUP %x\n", sp[-1]);
*sp = sp[-1];
sp = sp + 1;
current_pic = next_pic;
break;
case 0x10:
printf("REVERSE TOP %x\n", *next_pic);
current_pic = current_pic + 2;
bVar1 = *next_pic;
if ((long)sp - (long)stack < (long)(ulong)bVar1) {
printf("Stack underflow in reverse at 0x%04lx\n",(long)current_pic - (long)prog);
}
for (i = 0; (int)i < (int)(uint)(bVar1 >> 1); i = i + 1) {
bVar2 = sp[(int)(i - bVar1)];
sp[(int)(i - bVar1)] = sp[(int)~i];
sp[(int)~i] = bVar2;
}
break;
case 0x11:
printf("SPLIT BYTE TO BITS\n");
local_30 = sp[-1];
for (j = 0; j < 8; j = j + 1) {
(sp + -1)[j] = local_30 & 1;
local_30 = local_30 >> 1;
}
sp = sp + 7;
current_pic = next_pic;
break;
case 0x12:
printf("POP 8 VALUES, NEW VALUE = LSB OF LAST 8\n");
local_2f = 0;
for (k = 7; -1 < k; k = k + -1) {
local_2f = local_2f << 1 | (sp + -8)[k] & 1;
}
sp[-8] = local_2f;
sp = sp + -7;
current_pic = next_pic;
break;
default:
printf("Unknown opcode: 0x%02x at 0x%04lx\n",(ulong)*current_pic,
(long)current_pic - (long)prog);
return 1;
}
if (sp < stack) break;
if (stack + 0x1000 < sp) {
printf("Stack overflow at 0x%04lx\n",(long)current_pic - (long)prog);
return 1;
}
}
printf("Stack underflow at 0x%04lx\n",(long)current_pic - (long)prog);
return 1;
}When I compiled and ran this, it eventually executed up to the instruction at offset 0xbed and printed the string Incorrect password!.
With this, it looked like I could finally trace the behavior of the actual challenge program.
Extracting just the part after reading one character from the print-debug output gives the following.
[0x74] OP:0xa PUSH 0
[0x76] OP:0x8 READ u
[0x77] OP:0x11 SPLIT BYTE TO BITS
[0x78] OP:0xa PUSH ffffffff
[0x7a] OP:0x10 REVERSE TOP 9
[0x7c] OP:0x10 REVERSE TOP 8
[0x7e] OP:0xa PUSH 0
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 0
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 0, ffffffff = ffffffff
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f52f
[0x9f] OP:0xe POP
[0xa0] OP:0xf DUP 0
[0xa1] OP:0xf DUP 0
[0xa2] OP:0x1 ADD 0, 0 = 0
[0xa3] OP:0x1 ADD 0, 0 = 0
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 1
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 1, ffffffff = fffffffe
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f528
[0x98] OP:0xe POP
[0x99] OP:0xa PUSH 1
[0x9b] OP:0x1 ADD 0, 1 = 1
[0x9c] OP:0xd JPM f117f530
[0xa0] OP:0xf DUP 1
[0xa1] OP:0xf DUP 1
[0xa2] OP:0x1 ADD 1, 1 = 2
[0xa3] OP:0x1 ADD 1, 2 = 3
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 1
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 1, ffffffff = fffffffe
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f528
[0x98] OP:0xe POP
[0x99] OP:0xa PUSH 1
[0x9b] OP:0x1 ADD 3, 1 = 4
[0x9c] OP:0xd JPM f117f530
[0xa0] OP:0xf DUP 4
[0xa1] OP:0xf DUP 4
[0xa2] OP:0x1 ADD 4, 4 = 8
[0xa3] OP:0x1 ADD 4, 8 = c
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 1
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 1, ffffffff = fffffffe
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f528
[0x98] OP:0xe POP
[0x99] OP:0xa PUSH 1
[0x9b] OP:0x1 ADD c, 1 = d
[0x9c] OP:0xd JPM f117f530
[0xa0] OP:0xf DUP d
[0xa1] OP:0xf DUP d
[0xa2] OP:0x1 ADD d, d = 1a
[0xa3] OP:0x1 ADD d, 1a = 27
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 0
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 0, ffffffff = ffffffff
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f52f
[0x9f] OP:0xe POP
[0xa0] OP:0xf DUP 27
[0xa1] OP:0xf DUP 27
[0xa2] OP:0x1 ADD 27, 27 = 4e
[0xa3] OP:0x1 ADD 27, 4e = 75
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 1
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 1, ffffffff = fffffffe
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f528
[0x98] OP:0xe POP
[0x99] OP:0xa PUSH 1
[0x9b] OP:0x1 ADD 75, 1 = 76
[0x9c] OP:0xd JPM f117f530
[0xa0] OP:0xf DUP 76
[0xa1] OP:0xf DUP 76
[0xa2] OP:0x1 ADD 76, 76 = ec
[0xa3] OP:0x1 ADD 76, ffffffec = 62
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 0
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 0, ffffffff = ffffffff
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f52f
[0x9f] OP:0xe POP
[0xa0] OP:0xf DUP 62
[0xa1] OP:0xf DUP 62
[0xa2] OP:0x1 ADD 62, 62 = c4
[0xa3] OP:0x1 ADD 62, ffffffc4 = 26
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 1
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 1, ffffffff = fffffffe
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f528
[0x98] OP:0xe POP
[0x99] OP:0xa PUSH 1
[0x9b] OP:0x1 ADD 26, 1 = 27
[0x9c] OP:0xd JPM f117f530
[0xa0] OP:0xf DUP 27
[0xa1] OP:0xf DUP 27
[0xa2] OP:0x1 ADD 27, 27 = 4e
[0xa3] OP:0x1 ADD 27, 4e = 75
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP ffffffff
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR ffffffff, ffffffff = 0
[0x86] OP:0xc JPM f117f51d
[0x8d] OP:0xe POP
[0x8e] OP:0xd JPM f117f537
[0xa7] OP:0xe POP
[0xa8] OP:0x8 READ iIt is quite long.
I was completely stuck at this point, but after referring to ctf-writeups/uiuctf23/rev/vmwhere2, it seems this can be rewritten as a Python script that performs the following base-3 encoding.
result = 0
for i in range(7, -1, -1):
if (x >> i) & 1 == 1:
result = (result + 1) * 3
else:
result = result * 3
result = result % 256
return resultSure, if you spot the loop structure from 0xa0 to 0x9c, I guess it kind of does look like that … maybe? (Not really.)
[0xa0] OP:0xf DUP 1
[0xa1] OP:0xf DUP 1
[0xa2] OP:0x1 ADD 1, 1 = 2
[0xa3] OP:0x1 ADD 1, 2 = 3
[0xa4] OP:0xd JPM f117f510
[0x80] OP:0x10 REVERSE TOP 2
[0x82] OP:0xf DUP 1
[0x83] OP:0xa PUSH ffffffff
[0x85] OP:0x5 XOR 1, ffffffff = fffffffe
[0x86] OP:0xc JPM f117f519
[0x89] OP:0xe POP
[0x8a] OP:0xd JPM f117f521
[0x91] OP:0x10 REVERSE TOP 2
[0x93] OP:0x10 REVERSE TOP 2
[0x95] OP:0xc JPM f117f528
[0x98] OP:0xe POP
[0x99] OP:0xa PUSH 1
[0x9b] OP:0x1 ADD 3, 1 = 4
[0x9c] OP:0xd JPM f117f530I stared at the output for quite a while, but unfortunately I still could not form a clean understanding of the logic.
Still, it was reasonable to expect that the transformed input values would eventually be compared against some constant.
And in fact, I found the place where it compares against 0x75, which is the computed result when the first character is "u".
If you change the input as shown below, the XOR result changes as well, which suggests that the flag is ultimately checked around this point.
So I looked for hardcoded keys around 0xb90 in the binary.
As the code shows, the flag characters are embedded in reverse order, so the bottom 0x75 is the key used to compare against the first character.
Once I understood that much, I fed in an arbitrary string as shown below and was able to identify every location that seemed to be a key. (Every value that is being compared with 0x67 is a key.)
From there, I could extract the keys, build a table in Python mapping each character to its transformed value, and use that to recover the flag.
I wrote the following solver.
import subprocess
key = [0xc6,0x8b,0xd9,0xcf,0x63,0x60,0xd8,0x7b,0xd8,0x60,0xf6,0xd3,0x7b,0xf6,0xd8,0xc1,0xcf,0xd0,0xf6,0x72,0x63,0x75,0xbe,0xf6,0x7f,0xd8,0x63,0xe7,0x6d,0xf6,0x63,0xcf,0xf6,0xd8,0xf6,0xd8,0x63,0xe7,0x6d,0xb4,0x88,0x72,0x70,0x75,0xb8,0x75]
key = key[::-1]
table = {}
for i in range(0x21, 0xfd):
cmd = 'echo "{}" | ./a.out | grep 0xb90 | cut -d " " -f 5'.format(chr(i))
result = subprocess.run(cmd, capture_output=True, shell=True)
table[result.stdout.decode().replace(",\n", "")] = chr(i)
for k in key:
print(table[hex(k).replace("0x", "")], end="")Running this recovers the flag.
Summary
This time I really felt how lacking my pure reversing skills are.
Just like in the recent Google CTF, I have been running into more and more problems lately that I still cannot solve even after reading writeups.
To solve the kinds of problems I still cannot solve now and aim for a higher placement, I strongly feel that I need to build the raw ability to read binaries and understand their behavior, rather than relying on tool usage alone.