All Articles

UIUCTF 2023 Writeup

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.

image-20230705210351400

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.

image-20230703183714428

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: 0x0009

Once 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 0e

As 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 += 1

Rephrased 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 0x4 onto the stack
  • It right-shifts the input value by 0x4 & 0x1f and 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 0x72 onto the stack (this value changes every time and is also used in the next verification)
  • It XORs 0x72 with 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.

image-20230703220020748

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.

image-20230703220312435

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.

image-20230703233629823

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 0e

To 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
  • 0xff is pushed onto the stack
  • The 0x10 handler manipulates the stack with 0x9 as its argument
  • Then 0x10 manipulates the stack again with 0x8 as its argument
  • 0x0 is pushed onto the stack
  • 0x10 is called with 0x2 as its argument
  • The top stack value is duplicated onto the stack
  • 0xff is 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 0x2 as 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!.

image-20230705184531728

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 i

It 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 result

Sure, 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 f117f530

I 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.

image-20230705203219584

So I looked for hardcoded keys around 0xb90 in the binary.

image-20230705204000304

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.)

image-20230705204415010

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.

image-20230705210305482

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.