Blkhurst

January 31 2025

Reverse Reverse Reverse

Stage 3 Area 2

This challenge contains three reverse-engineering steps: a Java file, an obfuscated Python check, and a compiled ELF executable, each one unlocking the next.

Walkthrough

00:19 - Unpacking the Java archive
02:56 - Deobfuscating the Python stage
07:21 - Patching the ELF executable

Patching next.o

The video walkthrough covers all three steps; this section focuses on patching the final ELF executable.

Conditions

In order to successfully patch the binary, we need to do two things: bypass the comparison checks so execution continues, and ensure cpu_func/time_func still produce the expected values so the final flag is generated correctly.

def u32_le(b: bytes) -> int:
    return struct.unpack("<I", b)[0]
 
# cpu_func expected XOR
vendor = b"NereusTechPC"
w0, w1, w2 = struct.unpack("<III", vendor) # 3x uint32 (little-endian)
cpu_expected = (w0 ^ w2) ^ w1
print(hex(cpu_expected))  # 0x43767e58
 
# time_func expected value
name = b"system_ok_f"
year_offset = 0x102  # tm_year (2158 - 1900)
expected_value = (u32_le(name[:4]) + year_offset) & 0xFFFFFFFF
print(hex(expected_value))  # 0x74737a75

Finding the dynamic function offsets

Since the binary uses an indirect call (call rdx) for both functions, radare2 can't resolve the address in static analysis. To patch it, we need the function's offset/address. During debugging, once you have analysed and named the functions, find the offset using one of:

dmj | jq
dmi @ func_name
iPh | grep baddr
 
# OFFSET = FUNC_ADDR - BASE_ADDR
?v FUNC_ADDR - BASE_ADDR

cpu_func

The CPU check compares the cpuid vendor string against the hardcoded value NereusTechPC via memcmp. To bypass this, I patched the stored vendor string to my machine’s vendor (GenuineIntel).

However, this function also XORs the vendor bytes and stores the result for later use (the value written at mov dword [0x00202074], eax). So I also patched the code just before that store to load the expected XOR value directly into eax, padding with nops to keep the instruction count the same.

r2 -w ./next_patched
af cpu_func 0x9B5
 
# Patch expected vendor string so memcmp passes
s 0xe93
wz GenuineIntel
 
# Patch the computed XOR result (used later for flag generation)
s 0x00000a5b
pa mov eax, 0x43767e58  # b8587e7643
wx b8587e7643909090

time_func

time_func calls gmtime_r and then performs a year check. The value in var_cch is tm_year (years since 1900).

To bypass this check, I patched the two mov eax, dword [var_cch] loads to instead load the expected value into eax. This forces the subsequent comparison/path to behave as intended.

r2 -w ./next_patched
af time_func 0xA89
 
# Replace: mov eax, dword [var_cch]
# With:    mov eax, 0x102 ; nop
# (pad with NOP to match original instruction length)
 
s 0x00000b49
wx b80201000090
 
s 0x00000b73
wx b80201000090

main

For convenience, I hardcoded the two required CLI arguments in main by overwriting what value is stored.

# Always assume "argc > 2"
s 0x000008fd      # jg 0x915
pa jmp 0x915      # eb16
wx eb16
 
# Hardcode argv[1] -> 2158
s 0x00000934      # call sym.imp.atoi
pa mov eax, 0x86e
wx b86e080000
 
# Hardcode argv[2] -> 50661104
s 0x00000946      # call sym.imp.atoi
pa mov eax, 0x30506f0
wx b8f0060503

Result

With both checks patched (and the expected values preserved for seed_func), the binary prints the flag!

./next_patched 2158 50661104
 
Executing subsystem 3
Passed: Central processing unit check
Passed: System file
 
Printing diagnostics:
 Subsystem 1......... Active
 Subsystem 2......... Active
 Subsystem 3......... Active
 Environment checks.. Passed
 Flag................ <redacted>
 Fuel pumps.......... Frozen (ERROR)
 
 
 Incoming transmission.....
 <<
   I'm not going to congratulate you Pithy human.
   You'll thaw your fuel pumps by a reboot of your
   SuperSpace 3.0 and ASRM. You'll find what's
   seemingly unrelated is almost always connected.
 >>

Video walkthrough: Two quick clarifications - var_cch (rbp-0xcc) is writable during debugging (stack local variable), but not something you can "set" via static patching; and the tm_year addition interprets the first 4 bytes of "system_ok_f" as little-endian uint32.