9.1 KiB
Static Analysis Depth Research
The M3.5 depth features that lift rveng from a hex-and-disasm viewer to a real
static reverse-engineering workbench: import resolution through the PLT, cross
references with RIP-relative operand math, control-flow-graph reconstruction,
and function discovery in stripped binaries. Every offset, opcode, and address
below is traced to the sample gate binary (gcc -no-pie -fno-stack-protector -O0) and its stripped copy gate_stripped (strip gate), captured with
readelf and objdump -M intel. Nothing here executes a binary: PLT, GOT, and
relocation tables are inspected as data, exactly like the header and sections.
Import resolution through the PLT
A dynamically linked call does not jump straight to libc. It jumps to a small
stub in .plt that reads a pointer from .got.plt, which the dynamic linker
fills in. To name a call the way objdump prints call 401030 <puts@plt>, the
engine resolves the stub back to the imported symbol.
Sections that carry the mapping, from readelf -SW gate:
.pltPROGBITS at0x401020, entry size0x10(16 bytes)..got.pltPROGBITS at0x403fe8, entry size 8..rela.pltRELA at0x400520, entry size0x18(24 bytes), 3 entries..dynsymDYNSYM at0x4003d0, entry size0x18, its linked string table is.dynstr.
The .plt layout from objdump -d -j .plt:
401020 <puts@plt-0x10>: push/jmp/nop the PLT0 resolver stub (not an import)
401030 <puts@plt>: ff 25 ca 2f 00 00 jmp QWORD PTR [rip+0x2fca] -> 404000
401040 <printf@plt>: ff 25 c2 2f 00 00 jmp QWORD PTR [rip+0x2fc2] -> 404008
401050 <atoi@plt>: ff 25 ba 2f 00 00 jmp QWORD PTR [rip+0x2fba] -> 404010
The first .plt slot (0x401020) is the resolver trampoline (PLT0), not an
import. Real entries begin at 0x401030 and step by 16.
Resolution algorithm, robust across PIE and non-PIE:
-
For a PLT entry, decode its leading
jmp QWORD PTR [rip+disp32](opcodeff 25). The GOT slot it reads isentry_addr + 6 + disp32. For0x401030:0x401036 + 0x2fca = 0x404000. -
.rela.pltmaps each GOT slot to a dynamic symbol. EachElf64_Relais{ r_offset: u64, r_info: u64, r_addend: i64 }. The symbol index isr_info >> 32; the type isr_info & 0xffffffff(7 =R_X86_64_JUMP_SLOT). Fromreadelf -rW:r_offset 0x404000 sym 2 JUMP_SLOT puts r_offset 0x404008 sym 3 JUMP_SLOT printf r_offset 0x404010 sym 5 JUMP_SLOT atoi -
Look the symbol index up in
.dynsym(same 24-byteElf64_Symlayout as.symtab, names via.dynstr) to getputs,printf,atoi.
So 0x401030 -> 0x404000 -> dynsym[2] -> puts. A call whose target is a .plt
entry is then annotated with that name.
KAT (call-site to import, from main in objdump -d):
401199: call 401050resolves toatoi.4011a6: call 401146ischeck, a local function, not a PLT entry.4011c5: call 401040resolves toprintf.4011d6: call 401030resolves toputs.
VERIFIED, and load-bearing for the stripped module: .plt, .got.plt,
.rela.plt, and .dynsym all SURVIVE strip. readelf -SW gate_stripped
still shows them; readelf -rW gate_stripped still lists the three JUMP_SLOT
relocations to puts, printf, atoi. Stripping removes .symtab and
.strtab (local names like check, main), never the dynamic-linking tables.
A stripped binary can still name its library calls. This is a real teaching
point, not an accident.
Robustness note an adversarial check flagged: binaries built with
-fcf-protection (default on many modern toolchains) grow an endbr64 and a
.plt.sec section, so the jmp [rip+x] may live in .plt.sec rather than
.plt. Resolving by decoding the stub's jmp [rip+disp] wherever it sits (and
matching the GOT slot against .rela.plt) handles both layouts; resolving by
the PLT entry's pushed relocation index does not. The engine decodes the jmp.
Our curated -no-pie -O0 samples use the classic single .plt.
RIP-relative cross references
x86-64 addresses data PC-relative. lea rax,[rip+disp] computes an absolute
address as address_of_next_instruction + disp, where the next-instruction
address is insn.address + insn.size. This is how a function points at a string
without a relocation in a non-PIE binary.
KAT (from main, objdump -d -j .text):
401173: lea rax,[rip+0xe8a]with size 7 resolves to0x40117a + 0xe8a = 0x402004, the.rodatastringthe_flag_is_here.4011b6: lea rax,[rip+0xe58](size 7) resolves to0x4011bd + 0xe58 = 0x402015, theunlocked: %sformat string.4011cc: lea rax,[rip+0xe50](size 7) resolves to0x4011d3 + 0xe50 = 0x402023, thewrong numberstring.
capstone exposes the displacement on the memory operand whose base register is
RIP. The engine computes insn.address + insn.size + disp and, when that
address falls inside a known section, labels the reference (a .rodata hit is a
string; a .text hit is code).
Cross references are the inverse index: for a target address, the list of instructions that reach it. Two reference kinds cover these challenges:
- control-flow refs, from
call/jmp/jccbranch targets. Example:checkat0x401146is referenced by4011a6: call 401146inmain. - data refs, from resolved RIP-relative operands. Example: the flag string at
0x402004is referenced by401173: leainmain.
"What calls this function" and "what reads this string" are the two questions a learner asks constantly; both are this same reverse map.
Control-flow graph reconstruction
A basic block is a straight run of instructions with one entry and one exit: no branch lands in the middle, no branch leaves except at the end. The standard leader algorithm:
- The first instruction of the function is a leader.
- Any branch target inside the function is a leader.
- The instruction following any conditional or unconditional jump is a leader.
A block runs from a leader up to the instruction before the next leader. call
does NOT end a block: it returns, so control falls through. Blocks end at
conditional jumps, unconditional jumps, and ret. This matches how IDA and
Ghidra draw function graphs.
KAT: check (0x401146..0x401163), from objdump -d:
401146 push rbp | B0 (entry)
401147 mov rbp,rsp |
40114a mov [rbp-4],edi |
40114d cmp [rbp-4],0x539 |
401154 jne 40115d | -> ends B0
401156 mov eax,1 | B1 (fallthrough of jne)
40115b jmp 401162 | -> ends B1
40115d mov eax,0 | B2 (jne taken target)
401162 pop rbp | B3 (jmp target, also B2 fallthrough)
401163 ret | -> ends B3
Leaders: 0x401146 (start), 0x401156 (after the jne), 0x40115d (the jne
target), 0x401162 (the jmp target). Four basic blocks. Edges:
- B0 ends in
jne: to B2 (taken,0x40115d) and to B1 (fallthrough,0x401156). - B1 ends in
jmp: to B3 (0x401162). - B2 ends by fallthrough (its successor
0x401162is a leader): to B3. - B3 ends in
ret: no successors.
A clean diamond, which is exactly the shape that teaches "the gate splits the flow and both sides rejoin". Edge kinds worth labeling for the learner: taken, fallthrough, unconditional.
Function discovery in stripped binaries
strip removes .symtab, so check and main have no names and the symbol
table is empty. Disassembly must start from something other than a symbol.
For the curated -O0 challenges, every C function opens with the frame-pointer
prologue push rbp; mov rbp,rsp, bytes 55 48 89 e5. Scanning executable
sections for that pattern recovers function entry points. Verified in
gate_stripped: 0x401146 (was check) and 0x401164 (was main) both begin
55 48 89 e5; _start at 0x401060 does not (it has a different prologue), so
the scan cleanly separates user functions from the runtime start-up code.
Honest limits an adversarial check must record: this heuristic only finds
functions that keep a frame pointer. Anything built with -O2
(frame-pointer-omitted), or hand-written assembly, will be missed; real tools
add call-target recovery, symbol hints, and unwind-table (.eh_frame) parsing.
For rveng's curated, frame-pointer-preserving samples the prologue scan is
sufficient and honest, and it is the right first lesson in "how do you even find
a function when the names are gone". The stripped challenge disassembles a
discovered region by address, never by name, and grades a found-value answer, so
no symbol is ever leaked.
Facts pulled forward for the KATs
- PLT entries:
0x401030 -> puts,0x401040 -> printf,0x401050 -> atoi; GOT slots0x404000/8/10;.rela.pltsymbol index isr_info >> 32. - RIP-relative:
target = insn.address + insn.size + disp;0x401173 lea -> 0x402004(the flag string). checkCFG: 4 basic blocks, leaders0x401146/0x401156/0x40115d/0x401162, diamond edges as above.- Stripped discovery: prologue
55 48 89 e5findscheckandmain, not_start;.dynsym/.rela.plt/.pltsurvivestripso imports stay named.