196 lines
9.8 KiB
Markdown
196 lines
9.8 KiB
Markdown
<!-- ©AngelaMos | 2026 -->
|
|
<!-- 03-IMPLEMENTATION.md -->
|
|
|
|
# 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 be `ELFCLASS64`.
|
|
- Byte 5 (`EI_DATA`) selects endianness, turned into a `struct` prefix (`<` 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, so `cmp [rbp-4],
|
|
0x539` exposes `0x539`. This is pulled by `_immediate`, which reads the
|
|
`X86_OP_IMM` operand.
|
|
- `branch_target`: for a control-flow instruction (a conditional jump, `call`,
|
|
or `jmp`), the immediate is the destination address instead of a data value.
|
|
The engine routes the immediate to `branch_target` for flow ops and to
|
|
`immediate` for 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_target` as `instruction address + instruction
|
|
size + displacement`. This is how `lea rax, [rip+X]` loading the secret string
|
|
resolves to `0x402004`.
|
|
|
|
There are three ways to disassemble:
|
|
|
|
- `disassemble_symbol` uses a symbol's section index, value, and size to carve
|
|
exactly that function's bytes. This is the clean path when symbols exist.
|
|
- `disassemble_at` disassembles from a raw virtual address until the first `ret`.
|
|
This is the path for a stripped binary, where you have an address (from
|
|
discovery) but no size.
|
|
- `disassemble_text` decodes the whole `.text` section 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_names` reads `.dynsym` and resolves each dynamic symbol's name from
|
|
`.dynstr`.
|
|
- `_got_to_name` reads `.rela.plt`. Each relocation has a target GOT address
|
|
(`r_offset`) and a packed `r_info` whose top 32 bits are the dynamic-symbol
|
|
index. It maps each GOT slot to the imported name.
|
|
- `_stub_got_slot` finds the `jmp [rip+disp]` inside a 16-byte PLT entry (the
|
|
opcode is `ff 25`) and computes which GOT slot it jumps through, as `entry
|
|
address + jump offset + 6 + displacement`.
|
|
- `plt_map` ties 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:
|
|
|
|
1. `_leaders` marks basic-block boundaries. A block starts at the first
|
|
instruction, at any instruction immediately after a terminator (a conditional
|
|
branch, `jmp`, or `ret`), and at any branch target that lands inside the
|
|
function.
|
|
2. `build_cfg` slices the instruction stream at those leaders into `BasicBlock`s.
|
|
3. It connects them: a conditional branch emits a `taken` edge to its target and
|
|
a `fallthrough` edge to the next block; a `jmp` emits a `jump` edge; a plain
|
|
block falls through; a `ret` emits 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 `FoundValue` numeric answer is graded through `normalize_int`, which accepts
|
|
`0x539`, `539h`, and `1337` and compares as integers. A string `FoundValue` is
|
|
compared after trimming and lowercasing.
|
|
- An `IdentifiedSymbol` is compared case-insensitively against the known name.
|
|
- A `PatchedBytes` submission is graded by `verify_patch` against 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.
|