Binary Analysis Tools from Scratch
Read ELF and Mach-O, decode x86-64, build the CFG, lift to C and prove it. Python stdlib only.
- Concepts and terms
- Tasks and commands
- Annotated code
- Diagrams
- Comparisons
- Troubleshooting
- Questions and tips
From bytes to checked C#
Read an object in Python#
from main import ElfFile, MachOFile, FIXTURESelf = ElfFile.from_path(FIXTURES / "ledger-x86_64-O1.o")elf.header.e_shoff, elf.header.e_shnum0x428 and 11 headerself.function_span("percent_change")16 bytes at 0x40elf.function_bytes("percent_change")85 ff 74 09 ...elf.relocations_for(".text")3 R_X86_64_PC32 recordself.file_offset_map()3 gaps of alignmenttiny = ElfFile.from_path(FIXTURES / "tiny-x86_64")tiny.address_to_file_offset(0x4000b0)0xb0 through PT_LOADmacho = MachOFile.from_path(FIXTURES / "ledger-arm64-O1.o")macho.function_span("_percent_change")24 bytes at 648Failures and fixes#
| Symptom | Cause | Fix |
|---|---|---|
"percent_change is at 0" | st_value used as a file offset | Use sh_offset + st_value - sh_addr, here 0x40 |
"grade is 48 bytes" | Padding 66 90 counted as code | st_size is 46. Padding belongs to no symbol |
"the table starts at 0x10" | Addend read as the target offset | Add the 4 bytes to the instruction end: 0x14 |
| A plausible listing over a data table | Linear sweep decodes data as code | Descent from the symbol claims no data byte |
| "jmp rax" and dead blocks | Indirect jump with no table | Read targets from .rela.rodata and the bounds check |
| Decompiler reports a third parameter | idivl writes %rdx and the return width is a guess | Read before write on an argument register is a parameter |
The two projects#
| Factor | binary-inspector | mini-decompiler |
|---|---|---|
| Builds | Static inspector for ELF, PE/COFF, Mach-O | Decompiler for an x86-64 subset |
| Input | ELF64 object, COFF object, PE32+ image, Mach-O 64 | ELF64 ET_REL object built by clang |
| Output | JSON report, HTML byte map, build diff | Structured C and report.json |
| Run | python3 cli.py inspect <file> --json report.json | python3 -m minidec verify <object> --source <source> |
| Check | Function starts scored with precision and recall | Masked bytes and a behaviour grid |
| Exit | 2 with error: <message> on a bad file | 0 on pass or behaviour-only, else 1 |
Project commands#
| Command | What it does |
|---|---|
python3 scripts/ | Makes the workspace. Stage 1 fails on purpose |
python3 scripts/ | Runs the cumulative grader |
python3 cli.py inspect <stripped> --truth <unstripped> --html report.html | Scores function starts against the unstripped build |
python3 cli.py diff <old> <new> --json diff.json | Section growth, moved symbols, changed imports |
python3 -m minidec decompile <object> --ir --no-division-idioms | Shows IR and SSA, keeps the magic multiply |
python3 -m minidec verify <object> --source <source> --opt O1 --out <dir> | Recompiles, compares bytes, runs the grid |
Run the lessons#
| Command | What it does |
|---|---|
python3 code/main.py (32) | Header, sections, symbols, relocations, map |
python3 code/main.py <object> (32) | Reads one file. Picks ELF or Mach-O |
python3 code/main.py (33) | One JSON report for every fixture |
python3 code/ | Listing with relocation targets |
python3 code/ | Analysis of one function |
python3 code/main.py (34) | IR, SSA, pseudocode next to Ghidra |
python3 -m unittest discover -s code/ | Test suite of lessons 32 and 34 |
python3 code/ | Test suite of lesson 33 |
ELF64 record formats#
| Task | Code |
|---|---|
| Header after e_ident, offset 16 | "<HHIQQQIHHHHHH" |
| Section header, 64 bytes | <IIQQQQIIQQ |
| Symbol, 24 bytes | <IBBHQQ |
| Rela record, 24 bytes | <QQq |
| Program header, 56 bytes | <IIQQQQQQ |
| Symbol index of r_info | ELF64_R_SYM(i) is i >> 32 |
| Type of r_info | ELF64_R_TYPE(i) is i & 0xffffffff |
| Mach-O symbol, 16 bytes | nlist_64 has no size field |
Address arithmetic#
- Function bytes
- file offset is
sh_offset + st_value - sh_addr. Length isst_size. - R_X86_64_PC32
- the linker writes
S + A - Pinto the field. - R_X86_64_64
S + A. R_X86_64_PLT32:L + A - P.- RIP target
- addend plus 4.
.rodata+0x10on aleameans.rodata+0x14. - PT_LOAD
- file offset is
p_offset + (address - p_vaddr). - Mach-O size
- next symbol in the same section minus this one.
- Divide by 10
0x66666667with shift 34. The error 6 is at most 8.
Sweep or descent: grade at -O0#
| Factor | Linear sweep | Recursive descent | Descent with table |
|---|---|---|---|
| Instructions | 34 | 25 | 33 |
| Blocks | 9 | 3 | 8 |
| Edges | 7 | 2 | 11 |
| Failure | data decoded as code | stops at jmpq *%rax | nothing |
| Use it for | oracle agreement | which bytes run | the full CFG |
Analyze a new object#
Instruction to IR#
| Command | What it does |
|---|---|
testl %edi, | flags = |
je 0xd | if je(flags) goto 0xd else goto 0x4 |
xorl %eax, | eax = |
cltd | edx = |
movslq %edi, | rax = |
sarq $0x22, | rcx = |
pushq %rbp and frame moves | nothing, frame bookkeeping |
retq | return rax |
Questions#
- Why does the oracle agree with the sweep on the trap?
llvm-objdumpis a linear sweep too.- When is an edge a back edge?
- Its target dominates its source. It marks a loop.
- Why
do whileintotalat -O1? - The header is also the block with the back edge.
- Why no jump table in
gradeat -O1? - Every case returns a constant. A value table replaced the jumps.
- Does a behaviour pass prove equivalence?
- No. It covers the declared grid on one host.