Skip to content

hgn/machine-code-analyzer

Repository files navigation

Machine Code Analyzer

A machine code analyzer for x86 and x86_64 ELF binaries. The tool disassembles a binary via capstone, reads the ELF structure via pyelftools and answers questions like: where does the code size come from, what does the compiler actually generate, how much error handling does the binary carry, and did that compiler flag change anything?

A symbol table is required for full analyze capabilities. Distro binaries usually carry at least the dynamic symbol table (.dynsym), which is sufficient for the exported functions; without any symbols the executable sections are analyzed as a whole and a warning explains the reduced precision.

Requirements

  • python3 with capstone and pyelftools (pip install capstone pyelftools)
  • optional: matplotlib to generate PNG graphs

Usage

./machine-code-analyzer.py <module> [<module-options>] <binary>

Every module answers one class of questions:

Module Question it answers
function How big are the functions, how are they distributed?
instruction Which instructions and categories does the code consist of?
idiom Which instruction follows which - the compiler fingerprint
branch How far and in which direction do local jumps go?
call Who calls whom, how often, directly or through pointers?
stack How much stack do the functions allocate?
atomic How does the binary synchronize - locks, fences, spinning?
layout How much code lies outside the hot path, cold at the end?
hardening Which mitigations (CET, stack protector) are compiled in?
register Which registers and addressing forms does the code use?
info ELF metadata and security features (PIE, RELRO, NX, ...)
diff What changed between two builds of the same program?

./machine-code-analyzer.py <module> --help prints the full option list including the available graphs of the module.

Common module options:

  • -v, --verbose show verbose output
  • -x, --no-exclude do not exclude glibc/gcc runtime helper functions like _start or __do_global_dtors_aux
  • -g, --graphs render all graphs of the module (matplotlib required)
  • --graph-<name> render one dedicated graph, e.g. --graph-histogram, --graph-<name>-filename <file> overrides the PNG output name and implies the graph option itself
  • --graph-style light|dark color style, e.g. for dark slides
  • --json-out <file> write the analysis results as JSON, the same data the text output shows (often more complete: the idiom module exports all pairs, not just the top list); parent directories are created

Graphs are PNG at 300 dpi. Every graph is accompanied by a <name>.png.txt sidecar with a short explanation of the picture and its axes, so the images stay understandable when they end up in slides or a wiki. Written files are reported on stderr, the text output on stdout is never affected by graph generation.

The JSON export is made for automation: run a module per binary with --json-out, collect the files and analyze the whole corpus with pandas or plain python - the document carries the module name and the absolute binary path next to the data.

make full runs all modules and renders all graphs: BINARY=<binary> make full selects the analyzed binary, GRAPH_STYLE=dark switches the colors. The diff module only runs when a compare partner is set explicitly via BINARY2=<binary>. make graphs is the quiet variant.

All examples below come from tests/test-program.c, the small fixture that make test compiles - a handful of functions with loops, buffers, atomics and one deliberate error path.

Function Module

Function Module

This is /usr/bin/python3. The twenty largest of its 1540 functions, led by _PyEval_EvalFrameDefault at 57 KB, the bytecode interpreter loop. A handful of giant functions dominate the code size while the rest of the binary is small helpers.

Measures function anatomy: sizes as recorded in the ELF symbol table, a size histogram, duplicated function names, prologue similarity and start address alignment. The intention is to see where the code size lives: a few huge functions or many small ones?

$ ./machine-code-analyzer.py function tests/test-program
dynamic_stack_user:  124 byte  [start: 0x120e, end: 0x128a]
              main:  115 byte  [start: 0x128a, end: 0x12f1]
       branchy_sum:   80 byte  [start: 0x1176, end: 0x11c6]

Read from it: the cumulative graph tells you sentences like "50% of the code lives in 5% of the functions" - the more top heavy, the more a handful of functions dominates the binary size.

Instruction Module

Instruction Module

This is /usr/bin/python3. 44% of its instructions move data, 29% are control transfer and 11% arithmetic. The unusually heavy control-transfer share is the interpreter dispatching one bytecode after another.

Counts every decoded instruction: mnemonic frequency, instruction categories (data transfer, control transfer, arithmetic, SSE, ...) and encoding lengths. Mnemonics are reported in AT&T syntax including the size suffix (movq, addl). The category mapping is loaded from data/instruction-db.json, unknown mnemonics fall back to a heuristic.

Typical picture: 40-50% data transfer, 20-30% control transfer. A visible SIMD share means vectorized code. The encoding length distribution shows how compact the code is - x86 instructions vary between 1 and 15 byte.

Idiom Module

Idiom Module

This is /usr/bin/python3. Each row is an instruction, its bar split into what follows it, widest first: after test almost always a conditional jump (je 47%, jne 25%), after pop another pop (62%) then ret. Reads left to right as the grammar of the code.

Looks at instruction sequences instead of single instructions: which instruction follows which, as pairs and triples. Compilers emit very stereotypical code, and the pair profile is their fingerprint:

$ ./machine-code-analyzer.py idiom tests/test-program
Preferred Successors
mov      -> mov 50.0%, call 12.2%, sub 5.6%
call     -> mov 41.7%, add 33.3%, sub 8.3%

Read from it: cmp -> je pairs are comparison branches, test -> je are null checks, pop -> pop -> ret is the function epilogue. The transition matrix graph shows the whole picture at once - bright cells are instruction couples the compiler almost always emits together.

Branch Module

Branch Module

This is /usr/bin/python3. The distance of its 13k local jumps as percentile curves, split into forward and backward. Half the jumps stay within about a hundred byte, but the tail reaches tens of kilobytes because of the huge interpreter function.

Analyzes local jumps, i.e. jumps whose target stays within the same function: direction (forward skips code, backward closes loops), conditional versus unconditional, and the distance distribution measured in byte. Function calls and tail calls are not counted here, that is the call module's job.

Read from it: the percentile curves read like a latency plot (p50/p90/p99 of the jump distances). Long distances are not a bug: optimizing compilers move error handling far behind the hot path, and giant functions like interpreter loops jump tens of kilobytes locally.

Call Module

Call Module

This is /usr/bin/python3. Of its 4596 call sites 15% are indirect through function pointers, and only 17% of the functions are leaves. The indirect share is CPython's slot and type dispatch.

Analyzes the call structure: how many call sites there are, whether they are direct internal (target inside the binary), direct external (through the PLT into a shared library) or indirect (through a register or memory pointer - function pointers, vtables). Plus fan-in (most called functions), fan-out (functions with the most call sites), leaf functions that call nothing, tail calls and direct recursion.

$ ./machine-code-analyzer.py call tests/test-program
Call sites: 12
    direct, internal target:       6  [ 50.00% ]
    direct, external target:       6  [ 50.00% ]
    indirect (pointer):            0  [  0.00% ]
Leaf functions: 2 of 7  [ 28.57% ]

Read from it: a high indirect share means dynamic dispatch (C++ virtual calls, callback heavy C) - harder for branch predictors and for static analysis. Many leaf functions mean a flat call graph.

Stack Module

Stack Module

This is /usr/bin/python3. How many functions allocate which exact stack size, largest first. The cluster at 4096 byte are PATH_MAX buffers on the stack, and 30% of all functions use a stack frame at all.

Detects the stack frame setup of every function: static allocations (sub $0x210,%rsp), dynamic ones through a register (VLAs, alloca) and multi level allocations. Reports per function usage, a histogram and the share of functions without any stack frame.

$ ./machine-code-analyzer.py stack tests/test-program
big_stack_user                             528      -
dynamic_stack_user                          40      Dynamic

Read from it: the 528 byte are the 512 byte buffer of the fixture plus alignment; "Dynamic" marks the VLA. In real binaries the allocation size spectrum graph typically shows a wall of identical 4096 byte functions - PATH_MAX buffers on the stack.

Atomic Module

Atomic Module

This is /usr/bin/python3. 104 locked atomic operations, 69 of them compare-and-swap, and no spin loops. The CAS-heavy profile is CPython's reference counting and its free-threading atomics.

Finds synchronization instructions: locked read-modify-write operations split into compare-and-swap, fetch-and-add and atomic swap, memory fences including the GCC lock or $0x0,(%rsp) fence idiom, pause based spin wait loops, CAS retry loops and interrupt toggling (cli/sti).

Read from it: the mix reveals the synchronization style. CAS heavy code implements lock free structures, plain locked RMW dominates in reference counting, spin loops show busy waiting. The per function breakdown points at the synchronization hot spots. The fixture's atomic_user shows one CAS, one fetch-and-add, one fence and one spin loop - exactly the four GCC builtins in its source.

Layout Module

Layout Module

This is /usr/bin/python3. Each row is a large function, orange for the hot head up to the first ret, purple for the cold tail of error paths behind it, ticks for jumps into the tail. Overall half of the analyzed bytes are cold.

Quantifies what optimizing compilers do to the code layout: error handling and unlikely branches are moved behind the function epilogue so the hot path stays dense in the instruction cache. The linear path from the function entry to the first ret is the hot head, everything behind it the cold tail. Also classifies the frame pointer usage (push %rbp; mov %rsp,%rbp prologue versus omission).

$ ./machine-code-analyzer.py layout tests/test-program-o2
checked_div:      35 byte cold of      45  [ 77.78% ]  tail jumps: 1

Read from it: checked_div has a deliberate __builtin_expect(...) abort path - GCC moved it behind the ret, so 78% of the function is cold error handling. The layout map graph renders this like a genome browser: orange hot heads, purple cold tails, one strip per function. A static heuristic by design: early returns blur the split, the analysis shows the compiler's intent, not runtime behavior.

Hardening Module

Hardening Module

This is /usr/bin/python3. 97% of functions start with endbr64, so CET is on, while only 0.3% load a stack canary because few functions have buffers. A typical modern distribution build.

Checks which mitigations were compiled in, both the ones visible in the instruction stream and the ones in the ELF headers, for a full checksec-style picture of one binary: PIE/ASLR, a non-executable stack (NX), RELRO (full, partial or none), the stack protector (functions loading the canary from %fs:0x28), CET/IBT (functions starting with endbr64, a jump target barrier against ROP/JOP), FORTIFY (_chk wrapper calls) and a RUNPATH if one is set. The --graph-card graph renders this as a status card, one row per layer of defense.

$ ./machine-code-analyzer.py hardening tests/test-program-hard
CET / IBT (-fcf-protection): partial
    functions starting with endbr64: 1 of 3  [ 33.33% ]
    functions loading the canary (%fs:0x28): 2 of 3  [ 66.67% ]

Read from it: distro binaries typically show near 100% endbr64 coverage. Below 100% is not automatically bad: static functions that are only called directly need no endbr64, and only functions with stack buffers get a canary. The "largest functions without a canary" graph is the review starting point.

Register Module

Register Module

This is /usr/bin/python3. rax alone is 29% of all register mentions, followed by the argument registers, and the upper half r8-r15 drops off sharply. The compiler leans on the low registers to avoid the REX prefix.

Counts register usage across all operands, sub-registers folded into their family (eax, ax, al all count as rax), and classifies the operand forms: register, immediate, memory, RIP-relative (position independent access to globals) and SIB (base + index * scale, typically array accesses).

Read from it: rax always dominates (return value and scratch), then the argument registers rdi, rsi, rdx, rcx. The drop towards r8-r15 shows how much the compiler avoids the REX prefixed upper half. A high callee-saved share means functions juggling a lot of live state.

Info Module

The odd one out: instead of disassembling it wraps the standard tools eu-readelf and file to report ELF metadata and security features - class, PIE, interpreter, build id, stripped state, RELRO, BIND_NOW, NX stack, RUNPATH and the shared library dependencies. It complements the instruction level hardening module with the linker and loader side of the picture, checksec style. Unlike the other modules it fails hard when a required external tool is missing.

$ ./machine-code-analyzer.py info /usr/bin/date
kind:         PIE executable
RELRO:        full
NX stack:     yes

Diff Module

Diff Module

Compared against /usr/bin/python3.12: the diverging bars show which functions grew (warm) or shrank (teal) between the two builds, which differ by 18% in total code size. The single biggest change is the interpreter core _PyEval_EvalFrameDefault, 11 KB smaller in the other build.

Compares two builds of the same program: function count, code size and instruction count deltas, added and removed functions, per function size changes, instruction category shifts and stack usage changes. The self diff of a binary is exactly zero, which the test suite uses as an invariant.

$ cc -O1 -o tp-o1 tests/test-program.c && cc -O3 -o tp-o3 tests/test-program.c
$ ./machine-code-analyzer.py diff tp-o1 tp-o3
                            old          new   delta
code size [byte]            399          384   -15 ( -3.8% )
Function Size Changes
main: 264 -> 256 byte (-8)

Read from it: what a compiler flag really did. O1 to O3 on the tiny fixture only shaves a few byte; on real projects the diff shows inlining (functions disappear, their callers grow), vectorization (SIMD category jumps) and unrolling (code size grows).

Mass Analysis

The mass-analysis/ directory turns the single binary analyzer into a corpus tool:

mca-mass-harvest.py walks the standard distribution executable directories (/usr/bin, /usr/sbin, /usr/libexec, /usr/lib/systemd, ... - deliberately no /usr/local or user paths), verifies via the ELF header that a file really is an x86 executable, and runs every analyzer module with --json-out. The results land below --output (default /tmp/mca) mirroring the binary path: /tmp/mca/usr/bin/date.stack.json. Re-runs skip binaries whose JSON is already newer than the executable, --force overrides, --jobs controls the parallelism.

./mass-analysis/mca-mass-harvest.py --output /tmp/mca

mca-mass-analysis.py loads the whole corpus and runs cross binary reports: a hardening report card (CET and stack protector coverage over the whole distribution), a frame pointer census, the size league, how much of the distribution is cold error handling code, instruction idiom fingerprints that group binaries by their code generator, the stack allocation wall, synchronization and dynamic dispatch rankings and the vectorization league.

./mass-analysis/mca-mass-analysis.py --input /tmp/mca
./mass-analysis/mca-mass-analysis.py --report security --top 30
./mass-analysis/mca-mass-analysis.py --graph-dir /tmp/mca-graphs

With --graph-dir the reports also render as PNG graphs (same house style as the analyzer, --graph-style light|dark): a security report card (hardening coverage over the whole system), the language population and a language versus hardening heatmap that answers whether e.g. Rust binaries are built more defensively than C ones. Sample findings from a desktop system: Go binaries are never PIE, C++ binaries have markedly weaker RELRO than C, and 99.96% of all binaries are stripped.

The cluster report goes one step further: it builds an instruction pair fingerprint per binary (the distribution over the 40 most common bigrams), projects it to two dimensions with PCA and runs k-means, all label free. The scatter, colored afterwards by the detected language, shows that the raw instruction mix alone separates the toolchains: C++ and C land on opposite ends of the first component (mean -3.0 versus +1.8 on a desktop corpus), with Rust sitting next to C++ in LLVM territory. numpy only, no scikit-learn.

The toolchain report turns that fingerprint into a classifier: it trains a k nearest neighbor model on the confidently labeled C and C++ binaries and cross validates it leave-one-out. On a desktop corpus the instruction pair mix alone tells C from C++ with about 92% accuracy, no symbols read. It then resolves the large stripped bucket by fingerprint, splitting it into a GCC-like (C) and an LLVM-like (C++/Rust) family, and renders the confusion matrix and the resolved distribution.

The anomaly report hunts the weird binaries. It scores every sizable binary by how far it sits from the corpus median across a dozen metrics (code size, instruction mix, cold tail share, indirect call share, atomic density, register classes, ...), using a robust median/MAD z-score capped per metric so that being unusual along many axes at once outranks a single extreme. The heatmap shows the top outliers as rows and the metrics as columns, red high and blue low, so it is obvious at a glance which binary is strange in which way. On a desktop system the Rust tools light up the whole board - far more indirect calls, distinct mnemonics and code size than anything else - while a few GNOME helpers are anomalous in the opposite direction.

The profile report draws the typical metric profile of each language as parallel coordinates, distinct silhouettes where Rust rides high on indirect calls and SIMD while C peaks on control transfer.

See mass-analysis/README.md for the full report catalog and the design notes.

FAQ

Are alignment NOPs between functions accounted?

No. Function boundaries are taken from the ELF symbol table (st_size), the reported sizes therefore match readelf -s exactly. Consider the following end of a function:

405021:       5d                      pop    %rbp
405022:       c3                      retq
405023:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
40502a:       00 00 00
40502d:       0f 1f 00                nopl   (%rax)

The NOP instructions after the retq (13 byte) are alignment padding added by the assembler so that the next function starts at 0x405030, better for cachelines etc. This padding belongs to no function and is not accounted, neither by the function nor by the instruction module.

ToDo

Function Gap Analysis

Size in bytes from function end to next function start. Function alignment characteristics and the resulting effect of "wasted" memory is of interest. Especially for small and tiny functions

Cross Distribution Analysis

Mount the images of other distributions (Fedora, RHEL, SUSE, Arch, Alpine) and point mca-mass-harvest.py at their executable directories. Comparing the corpora answers questions no single distribution can: who ships the most hardened binaries, which compiler defaults dominate, how do the instruction mixes and idiom fingerprints differ between GCC and Clang built distributions.

About

Machine Code Analyzer for X86(_64) ISA

Topics

Resources

License

Stars

2 stars

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages