Cybersecurity-Projects/PROJECTS/advanced/rveng/learn/03-IMPLEMENTATION.md

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 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 BasicBlocks.
  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.