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:
mmaps 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].
mmaps 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.
calls 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 named failure_flag & an endless empty loop. The program awaits the SIGALRM handler that was set up in main.
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 <span class="katex math inline">rdi</span>rdi+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 sets plop_state to a non-zero value, as the flag has to start with HackTM{.
Once set, plop_state remains fixed. This abolishes the else 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}
Commentaires