PLOP is a reversing task composed of a single x64 ELF executable.
By Inspecting main
we find that it:
- Copies the program’s input into a global array (100 bytes at the most).
- Registers a signal handler for SIGALRM.
- Calls
alarm(1)
(which will invoke the aforementioned handler after a second). - Returns
All the alarm handler does is to check a global flag, henceforth referred to as success_flag
, and print a success / failure message accordingly.
The lion’s share of the task lies within two finalizer functions, referenced in the ELF’s .fini_array section. These run after main
returns to libc — and, in our case, before the alarm timer hits.
These finalizers (presumably) act on our input and set success_flag
accordingly. Let’s inspect them.
The first one performs three actions:
mmap
s a single page at a fixed address – 0x1337000.- Copies our input (from the global array, see above) into 0x1337000-0x1337063.
- Registers another signal handler, this time for SIGSEGV.
The second finalizer intentionally triggers a segmentation fault by executing mov rax, ds:51C7h
(an absolute read of an unmapped address).
Control is transferred to the SIGSEGV handler, which:
- Extracts the address bytes (0x51 & 0xC7) from the offending
mov
instruction. - This is done via the
ucontext
parameter that is passed to the signal handler:context->uc_mcontext.gregs[REG_RIP]
. mmap
s an RWX page (at an arbitrary address)- Unpacks the 0x51 bytes that follow the offending
mov
. - The packing scheme is unknown, but it’s irrelevant, as you’ll see below.
call
s the unpacked code.- Jumps to the instruction that’s 0x51+0xC7 bytes after the offending
mov
. - Which happens to be yet another faulty
mov
. This is essentially an unpack+execute loop that’s driven by segmentation faults. - This sequence ends with settings
success_flag
to!(*0x1337064)
, which we namedfailure_flag
& an endless empty loop. The program awaits the SIGALRM handler that was set up inmain
.
Instead of deciphering the packing scheme, we placed a breakpoint on the call
to each unpacked chunk and dumped them all:
b <call_unpacked_chunk>
# Rinse and repeat
dump binary memory plop_routine_i rdirdi+0x50
c
# Stitch together afterwards
cat plop_routine_* > plop_routines_all
We then opened the amalgamated blob in IDA & mapped an empty segment at 0x1337000.
(The emitted instructions all use absolute addressing, so we don’t have to worry about rebasing)
Annotated decompiled checks:
void plop_check_0() { unsigned __int64 v0; // rax __int16 v1; // bx v0 = __ROL8__(input_qword_0, 14) ^ 0xDC3126BD558BB7A5ui64; if ( plop_state ) { v1 = failure_flag; if ( v0 != plop_state ) v1 = 1; failure_flag = v1; } else { plop_state = v0; } } void plop_check_1() { __int64 v0; // r9 v0 = __ROR8__(plop_state ^ 0x76085304E4B4CCD5i64, 40); if ( plop_state ) { if ( input_qword_1 != v0 ) LOBYTE(failure_flag) = 1; } else { plop_state = __ROL8__(input_qword_1, 40) ^ 0x76085304E4B4CCD5i64; } } void plop_check_2() { __int64 rol_xor_comp; // rax __int16 v1; // bx rol_xor_comp = __ROL8__(input_qword_2, 62) ^ 0x1CB8213F560270A0i64; if ( plop_state ) { v1 = failure_flag; if ( rol_xor_comp != plop_state ) v1 = 1; failure_flag = v1; } else { plop_state = rol_xor_comp; } } void plop_check_3() { __int64 v0; // rax __int16 v1; // bx v0 = __ROL8__(input_qword_3, 2) ^ 0x4EF5A9B4344C0672i64; if ( plop_state ) { v1 = failure_flag; if ( v0 != plop_state ) v1 = 1; failure_flag = v1; } else { plop_state = v0; } } void plop_check_4() { __int64 v0; // r9 v0 = __ROR8__(plop_state ^ 0xE28A714820758DF7ui64, 45); if ( plop_state ) { if ( input_qword_4 != v0 ) LOBYTE(failure_flag) = 1; } else { plop_state = __ROL8__(input_qword_4, 45) ^ 0xE28A714820758DF7ui64; } } void plop_check_5() { unsigned __int64 v0; // rax __int16 v1; // bx v0 = __ROL8__(input_qword_5, 39) ^ 0xA0D78B57BAE31402ui64; if ( plop_state ) { v1 = failure_flag; if ( v0 != plop_state ) v1 = 1; failure_flag = v1; } else { plop_state = v0; } } void plop_check_6() { __int64 v0; // rax v0 = __ROL8__(0x4474F2ED7223940i64, 53) ^ input_qword_6; if ( plop_state ) { if ( __ROR8__(v0, 53) != plop_state ) LOBYTE(failure_flag) = 1; } else { plop_state = __ROL8__(v0, 11); } } void plop_check_7() { __int64 v0; // r9 v0 = __ROR8__(plop_state ^ 0xB18CEEB56B236B4Bui64, 25); if ( plop_state ) { if ( input_qword_7 != v0 ) LOBYTE(failure_flag) = 1; } else { plop_state = __ROL8__(input_qword_7, 25) ^ 0xB18CEEB56B236B4Bui64; } }
The 0x1337000
page looks like this:
# Placed by the main binary 0x1337000 | input_qword_0 0x1337008 | input_qword_1 0x1337010 | input_qword_2 0x1337018 | input_qword_3 0x1337020 | input_qword_4 0x1337028 | input_qword_5 0x1337030 | input_qword_6 0x1337038 | input_qword_7 # Read by the main binary after executing all check routines 0x1337064 | failure_flag # Used by the check routines in some fashion. Details below. 0x1337080 | plop_state
A couple of observations:
- Each check routine handles a single distinct input QWORD. This means that the flag is 64-byte long (at most).
failure_flag
must never be turned on; all eight check routines must succeed.plop_check_0
setsplop_state
to a non-zero value, as the flag has to start with HackTM{.- Once set,
plop_state
remains fixed. This abolishes theelse
clause in routines 1 through 7, significantly simplifying the solution. - All operations are trivially reversible. Rotate Left and Rotate Right inverse each other. XOR is an Involution.
The key corollaries here are that the check routines are all independent (well, sans plop_check_0
executing first) and are all very easy to fulfill.
Flag synthesis revolves around satisfying these constraints:
- All eight check routines must succeed.
- The flag must start with HackTM{.
- Each byte in the flag must be printable
Let’s plug what we know into an SMT solver:
import z3 class Plop(object): def __init__(self): self.flag = z3.BitVec("flag", 64 * 8) self.plop_state = None # Initialized by plop0 self.solver = z3.Solver() # Flag must strart with HackTM{ for i, c in enumerate("HackTM{"): self.solver.add(self._flag_byte(i) == ord(c)) # The rest of it must be printable ASCII for i in range(len("HackTM{"), 64): self._assert_printable(self._flag_byte(i)) def _flag_qword(self, idx): low = (idx * 64) high = (idx * 64) + 63 return z3.Extract(high, low, self.flag) def _flag_byte(self, idx): low = (idx * 8) high = (idx * 8) + 7 return z3.Extract(high, low, self.flag) def _assert_printable(self, byte): self.solver.add(z3.And(byte >= 0x21, byte <= 0x7e)) def plop0(self): iq0 = self._flag_qword(0) self.plop_state = z3.RotateLeft(iq0, 14) ^ 0x0DC3126BD558BB7A5 def plop1(self): iq1 = self._flag_qword(1) self.solver.add(z3.RotateRight(self.plop_state ^ 0x76085304E4B4CCD5, 40) == iq1) def plop2(self): iq2 = self._flag_qword(2) self.solver.add(z3.RotateLeft(iq2, 62) ^ 0x1CB8213F560270A0 == self.plop_state) def plop3(self): iq3 = self._flag_qword(3) self.solver.add(z3.RotateLeft(iq3, 2) ^ 0x4EF5A9B4344C0672 == self.plop_state) def plop4(self): iq4 = self._flag_qword(4) self.solver.add(z3.RotateRight(self.plop_state ^ 0xE28A714820758DF7, 45) == iq4) def plop5(self): iq5 = self._flag_qword(5) self.solver.add((z3.RotateLeft(iq5, 39) ^ 0xA0D78B57BAE31402) == self.plop_state) def plop6(self): iq6 = self._flag_qword(6) iq6_mix = z3.RotateLeft(z3.BitVecVal(0x4474F2ED7223940, 64), 53) ^ iq6 self.solver.add(z3.RotateRight(iq6_mix, 53) == self.plop_state) def plop7(self): iq7 = self._flag_qword(7) self.solver.add(z3.RotateRight(self.plop_state ^ 0xB18CEEB56B236B4B, 25) == iq7) plop = Plop() plop.plop0() plop.plop1() plop.plop2() plop.plop3() plop.plop4() plop.plop5() plop.plop6() plop.plop7() assert plop.solver.check() == z3.sat m = plop.solver.model() flag_int = m[plop.flag].as_long() flag_str = ("%x" % flag_int).decode('hex')[::-1] print flag_str
Running this yields the flag: HackTM{PolynomialLookupOrientedProgramming_sounds_kinda_shit_xd}