9.8 KiB
Implementation
A walk through the engine, module by module, against the sample gate binary.
Every address and offset below is real and reproducible: gate is compiled with
gcc -no-pie -fno-stack-protector -O0, so nothing moves. Code is referenced by
function name, never line number, because line numbers rot.
elf.py: reading the file format from raw bytes
elf.py hand-rolls the ELF64 parser with struct. It does not use a library to
read the format, on purpose: the point is to learn where every field lives. The
tests then cross-check the hand-rolled result against pyelftools
(test_matches_pyelftools), so pyelftools is a correctness oracle in the test
suite, not a dependency the engine leans on at runtime.
parse_header is the entry point. It rejects anything that is not ELF64:
- The first four bytes must be
\x7fELF(ELF_MAGIC). - Byte 4 (
EI_CLASS) must beELFCLASS64. - Byte 5 (
EI_DATA) selects endianness, turned into astructprefix (<for little-endian, which is what x86-64 uses).
Then it reads fixed offsets straight out of the header: e_entry at 0x18,
e_shoff at 0x28, e_shnum at 0x3C, and so on. For gate this yields
e_entry = 0x401060, e_shoff = 0x36a0 (13984), e_shnum = 30. _validate_ section_table then bounds-checks the section table so a malformed file raises
NotAnElf instead of reading out of range.
parse_sections reads all e_shnum section headers, then resolves each
section's name. Names are not stored inline; each header holds a name offset
into a dedicated string section, and the header's index is e_shstrndx. The
parser reads that section's bytes once and pulls each name as a C string. That is
how a nameless index becomes .text, .rodata, .symtab.
parse_symbols finds the .symtab section, follows its link field to the
matching string table, and reads each 24-byte symbol entry: name offset, value
(the symbol's address), size, and a packed info byte whose low nibble is the
type and high nibble is the binding. For gate, this recovers check at
0x401146 with size 30, and main at 0x401164. ElfImage wraps all of this
and offers section(name), symbol(name), and functions() (symbols whose type
is STT_FUNC).
The one design note worth internalizing: a section can be SHT_NOBITS (.bss),
meaning it occupies memory but no file bytes. Section.file_bytes returns empty
for those, so nothing tries to read file content that is not there.
hex.py: the canonical dump
hex.py renders the same layout as xxd: an 8-hex-digit offset, sixteen bytes
of hex split into two groups of eight, then the ASCII gutter. HexLine.ascii_ gutter prints a byte as its character only when it is in the printable range
0x20 to 0x7E, and a . otherwise. That gutter is the whole skill of
01-hex-reading: the secret string the_flag_is_here is invisible in the hex
columns but obvious in the gutter, sitting in .rodata at file offset 0x2004.
strings.py: printable runs
extract walks the bytes, accumulating a run whenever it sees a printable byte
and flushing the run as a FoundString when it breaks, if the run met the
minimum length. extract_in_section scopes that to one section's bytes while
keeping file-relative offsets, so you can ask specifically for the strings in
.rodata. This is the cheap win of reverse engineering: before disassembling
anything, read the strings, and a password or a format string often falls out.
patch.py: editing behavior without running it
Three functions, all pure:
apply(original, offset, new_bytes)returns a new buffer with the edit written in, bounds-checked, and length-preserving. A patch never changes the file size.diff(a, b)returns the per-byte differences between two equal-length buffers.verify_patch(original, offset, submitted, known_good)applies the submission and returns whether the result equals the known-good target.
This is the mechanism that grades a patch challenge with no execution. For the
gate, the jne at file offset 0x1154 is the bytes 75 07. Overwriting them
with 90 90 produces exactly the known-good patched target, so verify_patch
returns true. Nothing is run; two buffers are compared.
disasm.py: decoding x86-64 with capstone
_new_engine configures capstone for 64-bit x86 in Intel syntax with detail on.
Intel syntax is the one where the constant prints as 0x539; the setting is
CS_OPT_SYNTAX_INTEL. Detail mode is required because the engine inspects each
instruction's operands, not just its text.
Every decoded instruction becomes an Instruction dataclass carrying its
address, mnemonic, operand string, raw bytes, and three annotations that the
higher layers depend on:
immediate: the immediate operand of a non-flow instruction, socmp [rbp-4], 0x539exposes0x539. This is pulled by_immediate, which reads theX86_OP_IMMoperand.branch_target: for a control-flow instruction (a conditional jump,call, orjmp), the immediate is the destination address instead of a data value. The engine routes the immediate tobranch_targetfor flow ops and toimmediatefor everything else, so the two never get confused.rip_target: for a RIP-relative memory operand, the absolute address it points at, computed by_rip_targetasinstruction address + instruction size + displacement. This is howlea rax, [rip+X]loading the secret string resolves to0x402004.
There are three ways to disassemble:
disassemble_symboluses a symbol's section index, value, and size to carve exactly that function's bytes. This is the clean path when symbols exist.disassemble_atdisassembles from a raw virtual address until the firstret. This is the path for a stripped binary, where you have an address (from discovery) but no size.disassemble_textdecodes the whole.textsection for whole-binary analysis like cross-referencing.
find_gate returns the first comparison instruction that carries an immediate,
which for gate is the cmp ..., 0x539. That is the machine reading the gate
the same way a learner does.
plt.py: giving imported calls their names back
A stripped or dynamically linked binary calls libc functions through PLT stubs.
plt.py reconstructs the stub-to-name mapping the way the loader would:
_dynamic_namesreads.dynsymand resolves each dynamic symbol's name from.dynstr._got_to_namereads.rela.plt. Each relocation has a target GOT address (r_offset) and a packedr_infowhose top 32 bits are the dynamic-symbol index. It maps each GOT slot to the imported name._stub_got_slotfinds thejmp [rip+disp]inside a 16-byte PLT entry (the opcode isff 25) and computes which GOT slot it jumps through, asentry address + jump offset + 6 + displacement.plt_mapties it together: for every PLT stub, find its GOT slot, look up the name bound to that slot.
For gate this produces exactly 0x401030 -> puts, 0x401040 -> printf,
0x401050 -> atoi. That is why the disassembly of main can show call atoi
instead of call 0x401050.
xref.py and cfg.py: structure over the instruction stream
xref.py turns decoded instructions into references. Every branch_target
becomes a call or branch reference, every rip_target becomes a data reference.
build_xrefs groups them by the address they point at, so you can ask "who
references check?" and xrefs_to filters to one target. Running it over the
sample finds main calling check.
cfg.py builds a control-flow graph for a single function using the classic
leader algorithm:
_leadersmarks basic-block boundaries. A block starts at the first instruction, at any instruction immediately after a terminator (a conditional branch,jmp, orret), and at any branch target that lands inside the function.build_cfgslices the instruction stream at those leaders intoBasicBlocks.- It connects them: a conditional branch emits a
takenedge to its target and afallthroughedge to the next block; ajmpemits ajumpedge; a plain block falls through; aretemits nothing.
For check, this produces the diamond you would expect: an entry block with the
cmp/jne, a taken edge to the fail path and a fallthrough edge to the success
path.
discover.py: finding functions with no symbols
When a binary is stripped, functions() returns nothing, so navigation needs
another anchor. discover_functions scans every executable section for the
standard function prologue 55 48 89 e5, which is push rbp; mov rbp, rsp, the
frame setup that begins most -O0 functions. Each hit becomes a Discovered Function labeled sub_<address>. On gate_stripped this rediscovers the entry
of check at 0x401146 even though its name is gone, and the disassembly-by-
address path takes over from there. This is exactly how you work a real stripped
binary: no names, so you find code by its shape.
challenge.py: the grader and the reveal
challenge.py holds the three answer specs (FoundValue, IdentifiedSymbol,
PatchedBytes) and the grade function that dispatches on which one a challenge
uses:
- A
FoundValuenumeric answer is graded throughnormalize_int, which accepts0x539,539h, and1337and compares as integers. A stringFoundValueis compared after trimming and lowercasing. - An
IdentifiedSymbolis compared case-insensitively against the known name. - A
PatchedBytessubmission is graded byverify_patchagainst the known-good target.
The load-bearing behavior is at the end of grade: it returns the challenge's
source only when the answer is correct. A wrong answer returns revealed_source = None. That single conditional is the solve-then-reveal pedagogy, and it works
because every answer is checkable before the reveal. The API layer never has to
know how grading works; it calls grade, and hands back whatever source (if any)
comes out.