Introduction
hereticus compares two versions of a binary or firmware. It finds the functions that changed, and puts the security fix near the top.
$ hereticus before.bin after.bin
Note
Coding agents wrote much of this code.
Why hereticus
A vendor ships a security update with one line of advisory: “a memory corruption issue was fixed”. To know what the bug was, you must find the function that the update changed. This is patch diffing. Three problems make it slow:
- The binaries are stripped. Firmware and kernels ship with no symbols, and often with no ELF header at all.
- Everything moves. A new release moves each function and each global, so a byte diff marks almost all the code as changed.
- The usual tools are heavy. BinDiff and Diaphora need IDA, and Ghidra Version Tracking needs a project, a GUI and an analysis of each program. None fits in a shell script or a CI job.
hereticus is one static binary. It reads the two files, pairs their
functions, and prints a short list, the most likely fix first.
It finds the fix, on the first rows
On published security fixes, the fixed function is on the first rows of the report:
| Fix | Binary | Row of the fix | Rows in the report |
|---|---|---|---|
| CVE-2026-30874 | procd, OpenWrt, stripped | 1 | 24 |
| CVE-2026-35385 | dropbear scp, OpenWrt, stripped | 1 | 19 |
| CVE-2024-23265 | AppleDiskImages2, iOS kernelcache | 1 | 33 |
| CVE-2021-30883 | AppleCLCD, iOS kernelcache | 1 | 333 |
See Real-Life Examples for all of them, each with the commands to do it again.
It shows less noise than other tools
On 35 stripped libraries, each built before and after one upstream fix:
| Tool | Fixes found | Fix on row 1 | Rows the fix did not touch |
|---|---|---|---|
| hereticus | 35 | 20 | 0 |
| radiff2 | 20 | 9 | 2418 |
| Ghidra | 33 | 18 | 629 |
This is a narrow test, which hereticus was developed with. See
Comparison With Other Tools for how it scores each
tool, and for its limits.
It finds the functions of stripped code
Before a tool can compare functions, it must find them. In one raw OpenWrt
kernel, with no symbols and no sections, hereticus finds
19277 of the
20988 real function
starts, and Ghidra finds
13210. The
Ghidra extension adds the functions that Ghidra misses.
It is made for coding agents too
Ghidra, IDA and BinDiff are made for a person at a GUI. An agent can use
them only through a server or a plugin. hereticus is one command that
gives one answer, so an agent runs it as it runs grep:
- The answer comes first. The most likely fix is on the first rows,
so
| headis enough, and the agent reads a few rows, not a project. - The output is short.
--format compactgives one line for each pair, and--format jsongives one object for each pair. - Each name works as input. The report names a stripped function
sub_7630, and--diff sub_7630shows its diff. - The diff reads alike on each architecture.
--view ilshows a compare, a call or a text in one form, asif (w0 == 0) goto ...andcall strncmp, so the agent does not need to know each instruction set. - An error tells what to do next. With no
--base, the error gives the base that the calls of the image show, where they show one. A failure exits with 2. - No setup. One static binary, with no IDA, Ghidra, Python, server or
project.
--exit-codelets a CI job or a script stop on a change.
It reads what firmware ships
- Architectures: A32, Thumb-2, AArch64, MIPS32, MIPS16e, x86-64 and 32-bit x86.
- Files: ELF (also with no section headers, as
sstripleaves it), Mach-O and iOS kernelcaches, and raw flash dumps. - Firmware images: zImage, FIT, uImage, sysupgrade, lzma-loader and
TP-Link containers, with
--unpack.
Who It Is For
- Security researchers who find the bug that an update fixed.
- Vendors and integrators who check that a backport of a fix is in a build.
- Malware analysts who compare two builds of one family. See Malware.
Where to Go Next
- Installation: get the binary.
- Usage: read the report, and the options.
- Examples: a raw firmware stub, and the diff of one function.
- Real-Life Examples: published fixes, step by step.
- Methods: how it finds and pairs the functions, and why each method is there.
Installation
GitHub Releases
The fastest way: download a built binary from GitHub releases. Each release has these assets:
hereticus-<tag>-x86_64-unknown-linux-musl.tar.gz: a static binary that runs on any x86-64 Linux system, with nothing else to install.hereticus-<tag>-ghidra_<version>.zip: the Ghidra extension, for that Ghidra release only.
$ tar xf hereticus-<tag>-x86_64-unknown-linux-musl.tar.gz
$ ./hereticus --version
From crates.io
On another system, as macOS or AArch64 Linux, build from the published source. This needs Rust 1.90 or later, and a C compiler for the mimalloc allocator. CI tests it only on x86-64 Linux:
$ cargo install hereticus-cli --locked
From the Repository
To build the newest code:
$ git clone https://github.com/wc0x2b/hereticus
$ cd hereticus
$ cargo install --path hereticus-cli --locked
The repository builds a static musl binary by default, and
rust-toolchain.toml installs the musl target. See
Development to run the tests.
As a Library
The hereticus crate reads binaries and compares them, with no command
line. Each of its parts is also a crate of its own. See
Crates.
Usage
Compare Two Binaries
Give the earlier binary first, then the later one:
$ hereticus fw-v1.elf fw-v2.elf
╭──────────────┬─────────┬───────┬─────────┬──────────────────────────────────────────┬────────────╮
│ FUNCTION │ CHANGE │ SCORE │ +/-/~ │ TOUCHED │ MATCHED BY │
├──────────────┼─────────┼───────┼─────────┼──────────────────────────────────────────┼────────────┤
│ dispatch │ changed │ 0.62 │ 21/11/5 │ 4 branch, 1 call, 6 jump, 6 mem, 1 const │ same name │
│ check_header │ changed │ 0.31 │ 8/1/0 │ 1 branch, 1 call, 1 mem │ same name │
│ handle │ changed │ 0.82 │ 8/4/2 │ 1 branch, 1 call, 1 jump, 1 mem, 1 const │ same name │
╰──────────────┴─────────┴───────┴─────────┴──────────────────────────────────────────┴────────────╯
5 matched (1 unchanged, 4 changed), 0 gone, 0 new
1 changed only a constant, hidden. Use --show-retouched to list them.
The report lists only the functions that changed, the most added code
first. --all lists every function.
Read the Report
| Column | What it shows |
|---|---|
FUNCTION | The name of the function. A stripped function is sub_<address>, and a -> b gives its address in each binary. |
CHANGE | The kind of change. See the table below. |
SCORE | How alike the two functions are, from 0.00 to 1.00. See The Similarity Score. |
+/-/~ | The instructions that the edit added, removed and rewrote. |
TOUCHED | The kind of code that the edit changed. See the list below. |
MATCHED BY | Why the two functions are a pair. See Pairing Functions. |
The CHANGE column has these words:
| Word | The pair | Shown |
|---|---|---|
changed | Has different instructions | Yes |
reordered | Has the same instructions in a different order | Yes |
changed unread | Differs in bytes that the decoder cannot read | Yes |
changed values | Has the same instructions, with other constants or texts | Only with --show-retouched, or where a text changed |
changed data | Looks like data, not code, and its bytes differ | Only with --show-data |
same | Has no change that a reader would see | Only with --all |
A fix can change only a format string, as "%s" to "%.*s". Thus a row
of changed values stays visible where a text changed.
The TOUCHED column counts the edits of each kind:
branch: a conditional branch.call: a call.callee: a call that stayed in place, but now goes to a different function, asstrcpytostrlcpy.jump: a jump that is not a call.mem: a load or a store.const: a constant that changed.text: a text that the code points to, and that changed. A text counts when it holds four or more printable bytes after a NUL, because a shorter one can be a pointer or a number by chance.unread: bytes that the decoder cannot read.
Plain data instructions are left out, because they are the bulk of any
rewrite and name nothing to act on. The column also stays quiet where it
would only repeat the CHANGE word beside it, so a row of
changed values shows a dash rather than 1 const.
Gone and New Functions
A function with no pair is listed after the table as gone or new. It names
its closest candidate on the other side, with the score, where the score is
--near-miss or more. It also names the unpaired function that sits in the
same place, between the same two pairs. A fix can rewrite a function so much
that no score pairs it, or split it in two; its place still shows where it
went. Zlib built for ARM at -O1 and then -O2, stripped:
inflateInit_: gone, same place as inflateInit_
inflate_table: gone, closest inflate_table at 0.57, same place as inflate_table
A place is a hint and not a pair: over the backports, 64% of them are right.
Look Inside a Function
--diff <FUNCTION> shows the diff of one function, and --diff-all shows
the diff of each changed function. --view il shows each instruction as
an IL statement that reads the same on each architecture. A stripped
function has the name that the report gives it, as sub_7630. See
The Diff of One Function.
Terminal Interface
--tui lets you browse the report, and open the diff of each function,
in the terminal. See Terminal Interface.
Stripped Binaries
A stripped binary needs no option. Where a binary has no symbols,
hereticus finds its functions from the code, and pairs them from the
code. A shared library keeps the names of the functions that it exports,
so those keep their names.
--ignore-names pairs from the code alone, also where both binaries have
symbols. Use it to see how a stripped build will pair.
Raw Images and Firmware
A flash dump or a kernel image has no ELF header. Give --raw, the
architecture with --arch, and the address the image loads at with
--base:
$ hereticus --raw --arch mips32 --base 0x80000000 v1.bin v2.bin
When you do not know the base, leave out --base. hereticus then stops,
and gives the base that the calls of the image show, where they show one:
$ hereticus --functions --raw --arch mips32 kernel.bin
hereticus: --raw needs --base, because a raw image does not say where it loads. Its calls show that it loads at --base 0x80000000
hereticus does not use that base by itself. A wrong base does not
fail: it gives a report against the wrong addresses, with no other sign.
Where the base that you give looks wrong, hereticus warns.
A firmware image holds the kernel compressed, often after a small
decompressor. --unpack kernel takes out the kernel, and --unpack stub
takes out the decompressor. A uImage gives the address its kernel loads
at, and an lzma-loader gives the address of its stub. There, --base is
not needed. For the other containers, give --base:
$ hereticus --raw --arch mips32-be --unpack kernel uImage-v1.bin uImage-v2.bin
$ hereticus --raw --arch arm32 --base 0x80208000 --unpack stub zImage-v1 zImage-v2
Output Formats
--format changes how the report prints:
text: the table above, for a person. The default.markdown: the same table in Markdown, to paste into a document or an issue.compact: one line for each pair, with tabs between the columns and no header, forgrep,cutandawk.json: one JSON object, for another tool.
The columns of compact are the change, the name before, the name after,
the score, and the counts of added, removed and rewritten instructions:
$ hereticus --format compact fw-v1.elf fw-v2.elf
changed dispatch dispatch 0.62 21 11 5
changed check_header check_header 0.31 8 1 0
changed handle handle 0.82 8 4 2
A gone or new function prints as gone or new, with its closest
candidate and its score in the same columns.
The JSON object holds pairs, gone, new and totals. Each address is
a number, so a tool can compare it:
$ hereticus --format json basic-v1.elf basic-v2.elf
{"pairs":[{"before":{"name":"array_max","address":144},"after":{"name":"array_min","address":144},"change":"changed","score":0.8378,"added":0,"removed":0,"edited":3,"touched":{"other":3},"matched_by":"similar"}],"gone":[],"new":[],"totals":{"matched":8,"unchanged":4,"changed":4,"gone":0,"new":0}}
Use With a Coding Agent
A coding agent can find a fix with four commands. Each one prints little, and gives the name that the next one takes:
$ hereticus --format markdown before.bin after.bin | head -20 # the most likely fixes
$ hereticus --view il --diff sub_7630 before.bin after.bin # what one function changed
$ hereticus --view il --diff-all before.bin after.bin | grep -E '^(sub_|[-+].*PATH)' # find a text
$ hereticus --functions --format json before.bin # each function, for a script
Use --format markdown where the agent needs the TOUCHED and
MATCHED BY columns, and --format compact where it needs only the
names, the scores and the counts. compact is the shortest. The text
table is the longest, because it draws a box around each cell.
To teach an agent the tool, put this in its instructions file, as
AGENTS.md or CLAUDE.md:
## Binary diffing with hereticus
To find what changed between two builds of a binary, use `hereticus`:
- `hereticus --format markdown OLD NEW | head -20` lists the changed
functions, the most likely fix first.
- `hereticus --view il --diff NAME OLD NEW` shows the diff of one function.
Use the name the report gives, as `sub_7630`.
- `hereticus --view il --diff-all OLD NEW | grep TEXT` finds the function
that uses a text an advisory names.
- For a raw image, add `--raw --arch ARCH --base ADDRESS`. Without
`--base`, the error gives the base the code shows; check it before you
use it.
- Exit status 2 means an input could not be read.
Exit Status
| Status | When |
|---|---|
| 0 | Nothing changed, or something changed and --exit-code is not given |
| 1 | Something changed, and --exit-code is given |
| 2 | An input could not be read |
--exit-code lets a CI job fail when a build changed:
$ hereticus --exit-code release.elf candidate.elf || echo "the code changed"
Listing Functions
--functions reads one binary, lists the functions it finds, and compares
nothing. Each row gives the address, the size in bytes, the instruction set,
and whether the function holds code or data. --raw, --base, --arch and
--unpack read the binary as they do for a comparison.
╭─────────┬──────┬───────┬───────┬─────────────────╮
│ ADDRESS │ SIZE │ ISA │ HOLDS │ FUNCTION │
├─────────┼──────┼───────┼───────┼─────────────────┤
│ 0x0 │ 8 │ arm32 │ code │ add │
│ 0x8 │ 12 │ arm32 │ code │ mul_by_constant │
│ 0x14 │ 40 │ arm32 │ code │ sum_to │
│ 0x3c │ 40 │ arm32 │ code │ classify │
│ 0x64 │ 28 │ arm32 │ code │ switch_dense │
│ 0x80 │ 16 │ arm32 │ code │ call_chain │
│ 0x90 │ 144 │ arm32 │ code │ array_max │
│ 0x120 │ 144 │ arm32 │ code │ factorial │
╰─────────┴──────┴───────┴───────┴─────────────────╯
--format json gives the same list to another tool, as a Ghidra script that
makes a function at each address Ghidra missed. Each address is the one the
binary is linked at. A tool that loads the binary at another base must move
each address by the difference.
Tune the Report
--thresholdis the lowest score that still pairs two functions. A lower value pairs more functions, and more of them wrongly.--near-missis the lowest score at which a gone or new function names its closest candidate.--sortchanges the order of the changed functions.added, the default, puts the most added code first, because a fix usually adds a check.raregives more weight to code that few other functions add.removedandmodifiedsort by the other two counts, andconfidencekeeps the order of the pairing.
All Options
Command line tool to compare functions between two binaries
Usage: hereticus [OPTIONS] <BEFORE> [AFTER]
Arguments:
<BEFORE> The earlier binary, or with `--functions` the one binary to list
[AFTER] The later binary
Options:
-a, --all Report every function, not only the ones that changed
--diff <FUNCTION> Show the instruction-level diff of one function, then stop
--diff-all Show the instruction-level diff of every changed function, then stop
--tui Browse the report in the terminal, and change its order, its filters and the diff it shows without a second run
--functions List the functions of one binary, then stop
--view <VIEW> How `--diff`, `--diff-all` and `--tui` show each instruction [default: asm] [possible values: asm, il]
--ignore-names Ignore symbol names, as though both binaries were stripped
--show-retouched List functions that differ in nothing but a constant
--show-data List regions that look like data rather than code
--threshold <THRESHOLD> Lowest similarity that still counts as a match, from 0.0 to 1.0 [default: 0.65]
--exit-code Exit with a failure status when anything changed
--near-miss <NEAR_MISS> Lowest score at which an unmatched function names its closest candidate, from 0.0 to 1.0 [default: 0.25]
--raw Read the inputs as raw code with no container format
--base <BASE> Address a raw image loads at, for example `0x08000000`
--unpack <UNPACK> Take one part out of each firmware image before reading it raw [possible values: stub, kernel]
--arch <ARCH> Architecture of a raw image [default: arm32] [possible values: arm32, thumb2, aarch64, mips32, mips32-be, mips16, mips16-be, x86-64, x86]
--format <FORMAT> How to print the result [default: text] [possible values: text, compact, markdown, json]
--sort <SORT> How to order the changed functions. [default: added] [possible values: confidence, added, removed, modified, rare]
--progress <PROGRESS> When to show a progress bar. [default: auto] [possible values: auto, always, never]
--color <COLOR> When to color the report. [default: auto] [possible values: auto, always, never]
-h, --help Print help (see more with '--help')
-V, --version Print version
Limits
- Little-endian A32 (ARMv5TE and later, soft-float or hard-float, with VFP and NEON), Thumb-2 (Cortex-M too) and AArch64, MIPS32 and MIPS16e in either byte order, and x86-64 and 32-bit x86. A library that mixes A32 and Thumb-2, or MIPS32 and MIPS16e, is read function by function where its symbols say which set each is in. Stripped or raw, it is read in one set, so its minority reads as nonsense.
- ELF, raw images, and AArch64 Mach-O. An ELF file that
sstripleft with no section headers, as OpenWrt ships each binary, is read through its segments. A Mach-O file is read as macOS and iOS ship it, arm64 or arm64e, stripped or not, and a universal binary as its AArch64 slice. A kernelcache of iOS 15 and later is read kext by kext. The dyld shared cache and 32-bit Mach-O are not read. - Unoptimized against optimized is out of scope. At
-O2the compiler changes the algorithm, which normalizing cannot bridge. Shipped firmware is built optimized.
Terminal Interface
--tui compares the two binaries once, then lets you browse the report in
the terminal. You can sort it, filter it, and open the diff of each
function, with no second run:
$ hereticus --tui fw-v1.elf fw-v2.elf
The Functions
The first screen is the report, with the changed functions first. The functions that are gone or new come after the pairs.
schanges the order, andSreverses it.tlists only the pairs that touched one kind of code, ascall./filters on the names. The letters must come in order, but need not be next to each other.ashows every function,rthe pairs that changed only a constant, anddthe data regions.Enter, or a second click, opens the diff of the selected function.
The Diff
The diff shows the two bodies side by side, with the address of each instruction. Red lines were removed, green lines were added, and yellow lines changed only a value:
v shows the same diff as IL statements, which read alike on each
architecture:
]and[go to the next and the previous change.vchanges between assembly and IL.lchanges between two columns and one.nandpgo to the next and the previous function.Escgoes back to the list.
All Keys
? shows each key, on each screen.
| Key | In the list | In the diff |
|---|---|---|
j / k | down / up | down / up |
PgDn / PgUp | a page down / up | a page down / up |
g / G | first / last | top / bottom |
Enter | open the diff | |
s / S | next order / reverse the order | |
t | next kind touched | |
a / r / d | every function / retouched pairs / data regions | |
/ | filter the names | |
] / [ | next / previous change | |
v | assembly or IL | |
l | side by side or one column | |
n / p | next / previous function | |
Esc | back to the list | |
? | this help | this help |
q | quit, after a question | quit, after a question |
The mouse wheel scrolls, and a click selects a row.
The screenshots come from book/tapes/tui.tape. To make them again, run
just tui-screenshots. The tests of hereticus-cli/src/tui/ lock the
text of each screen.
Examples
The commands you use most:
$ hereticus before.elf after.elf # what changed
$ hereticus --all before.elf after.elf # every function
$ hereticus --diff array_max before.elf after.elf # the diff of one function
$ hereticus --diff-all before.elf after.elf # the diff of every changed function
$ hereticus --view il --diff array_max a.elf b.elf # the diff as IL statements
$ hereticus --tui before.elf after.elf # browse, sort and open diffs in the terminal
$ hereticus --ignore-names before.elf after.elf # match from the code alone
$ hereticus --format markdown before.elf after.elf # a table to paste into a document
$ hereticus --format json before.elf after.elf # for another tool to read
$ hereticus --raw --base 0x08000000 v1.bin v2.bin # a flash dump, no ELF header
$ hereticus --raw --arch mips32 --unpack stub a.bin b.bin # the decompressor in two firmware images
The sections below run some of them on the test fixtures, and show the output. For published security fixes, see Real-Life Examples.
A Decompressor Stub
Two OpenWrt releases for the same device, 22.03.2 and 22.03.5 for the Netgear EX6100v2. The ARM decompressor stub is 15 KB of shipped code with no symbols, and 43 of its 15,351 bytes changed:
$ hereticus --format markdown --raw --base 0x80208000 stub-22.03.2.bin stub-22.03.5.bin
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_80208000 | changed | 0.98 | 1/1/1 | - | similar |
sub_8020a338 | changed | 0.94 | 0/0/1 | - | similar |
sub_80208af8 | changed | 0.95 | 0/0/1 | - | similar |
sub_80208a18 | changed | 0.96 | 0/0/1 | - | similar |
sub_80209154 | changed | 0.96 | 0/0/1 | 1 mem | similar |
sub_8020b1f8 | changed | 0.98 | 0/0/1 | - | similar |
sub_80208e08 | changed unread | 0.98 | 0/0/1 | - | similar |
sub_8020a3bc | changed | 1.00 | 0/0/1 | - | similar |
67 matched (57 unchanged, 10 changed), 0 gone, 0 new
1 changed only a constant, hidden. Use –show-retouched to list them.
1 data region changed, hidden. Use –show-data to list them.
Two Firmware Releases
Two versions of one firmware, built the same way, one release apart:
$ hereticus --format markdown --show-retouched fw-v1.elf fw-v2.elf
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
dispatch | changed | 0.62 | 21/11/5 | 4 branch, 1 call, 6 jump, 6 mem, 1 const | same name |
check_header | changed | 0.31 | 8/1/0 | 1 branch, 1 call, 1 mem | same name |
handle | changed | 0.82 | 8/4/2 | 1 branch, 1 call, 1 jump, 1 mem, 1 const | same name |
validate_length | changed values | 1.00 | - | - | identical shape |
The 1 call on check_header is an added call to an authentication routine.
The 1 const on handle is a bound that moved from 1024 to 512, arriving
through inlining: that function was never edited. Both would otherwise read as
plain changed, and the constant is invisible to every other column, because
the edit around it is far too large to read as changed values.
The Diff of One Function
To see what changed inside one function:
$ hereticus --diff mul_by_constant before.elf after.elf
mul_by_constant
- mov r1, 0x25
+ mov r1, 0x29
mul r0, r0, r1
bx lr
--view il shows each instruction as the IL reads it, the same on every
architecture, and a branch with the compare it tests, as
if (w0 <= 0) goto $-0x40:
$ hereticus --view il --diff mul_by_constant before.elf after.elf
mul_by_constant
- r1 = 0x25
+ r1 = 0x29
r0 = r0 * r1
return
Troubleshooting
The Fix Is Not on the First Rows
-
Search the diffs. An advisory often names a text, a format string or a function of the C library. Search the diff of each changed function for it:
$ hereticus --view il --diff-all before.bin after.bin | grep -E '^(sub_|[-+].*PATH)'See CVE-2026-30874 for this search on a real fix.
-
Show the hidden rows. A fix that changes only a constant, as a limit from 1024 to 512, is
changed values, and is hidden. Use--show-retouched. -
Change the order.
--sort rareputs first the code that few other functions add. It helps where a new compiler or new flags changed many functions in the same way. -
Look at the gone and new functions. A fix can rewrite a function so much that no score pairs it. The list after the table names the closest candidate and the function in the same place.
Almost Every Function Changed
- The two builds use different compilers or flags.
hereticusmakes-O1,-O2and-Osbuilds alike, but not an-O0build and an optimized one. A release that changes the compiler changes much of the code, and the report shows that. - A raw image has the wrong
--arch. A Cortex-M image is Thumb-2, so give--arch thumb2, notarm32. A MIPS image is big-endian or little-endian:mips32-beormips32. - A raw image has the wrong
--base. A wrong base gives a report against the wrong addresses. See below.
Error and Warning Messages
--raw needs --base, because a raw image does not say where it loads
Give the load address with --base. Where the calls of the image show
the base, the message ends with
Its calls show that it loads at --base <address>. Check that address
against the datasheet or the boot log of the device before you use it.
<file> may not load at <address>: ...
The base that you gave looks wrong. The rest of the message tells why:
<count> words look like addresses, but none point into the image at this base: the image holds a table of pointers, and no pointer fits this base.<count> of <count> calls land on a function start at this base, but <count> do at <address>: another base fits the calls better.
a raw image has no exception index, so function boundaries are recovered from the code and are approximate
This is a note, not an error. A raw image has no symbols, so hereticus
finds its functions from the code. See
Finding Functions.
cannot read <file>: cannot parse object: ...
The file is not an ELF or Mach-O file. For a flash dump or a kernel
image, use --raw. For a firmware image, use --unpack too. See
Raw Images and Firmware.
cannot read <file>: unsupported architecture: ...
The file is for an architecture that hereticus does not decode. See
Limits.
cannot read <file>: object has no executable code
The file holds no code section or segment, as a file of data only.
cannot read <file>: object has code, but no function could be found in it
The code holds nothing that starts a function. For a raw image, check
--arch and --base, and that the image holds code from its first byte.
A Function Is Missing
--functionslists each function thathereticusfinds in one binary. Check that the function is there.- A region that looks like data, not code, is hidden. Use
--show-data. - In a stripped binary, a function that nothing calls and that starts with no prologue can join the function before it.
Two Functions Are Paired Wrongly
The MATCHED BY column tells why the two paired. identical bytes and
identical shape are almost always right. similar and same place are
the weakest reasons. A higher --threshold pairs fewer functions by
similarity.
When a pair is wrong, please open an issue with the two binaries, or with the commands to get them.
Ghidra Extension
The Ghidra extension in ghidra/ runs hereticus from inside Ghidra. It
adds two things:
- Hereticus Function Finder, an analyzer. It makes a function at each
start that
hereticusfinds and Ghidra does not. - Hereticus Function Match, a Version Tracking correlator. It proposes
each pair of functions that
hereticusfinds between two programs.
The extension holds no analysis of its own. It writes the imported file of
each program to a temporary file, runs the hereticus tool on it, and reads
the JSON that the tool prints. Thus it finds and pairs functions exactly as
the command line does.
Installation
-
Install
hereticuson thePATH. See Installation. -
Get the extension zip for your Ghidra. Ghidra installs only a zip built for its exact release. If a GitHub release has
hereticus-<tag>-ghidra_<version>.zipfor your Ghidra, download it. If not, build the zip. Ghidra ships the Gradle that the build needs:$ cd ghidra $ <GhidraInstallDir>/support/gradle/gradlew -PGHIDRA_INSTALL_DIR=<GhidraInstallDir> -p .The build runs the unit tests, then writes the extension zip to
ghidra/dist/. -
In Ghidra, select File → Install Extensions, then the green
+, then the zip. Restart Ghidra.
If hereticus is not on the PATH, give its path in the
Hereticus Executable option of the analyzer and of the correlator.
Function Finder
The analyzer runs during auto-analysis of each ARM, AArch64 or MIPS
program, after the function analysis of Ghidra. It reads ELF and Mach-O
files, and raw images that Ghidra loaded with the Raw Binary loader. A
raw image goes to hereticus at the address of its first byte, in the
instruction set of its language. A Cortex language gives Thumb-2.
The analyzer changes nothing that Ghidra decided. It does not make a function at these starts:
- A start inside a function that Ghidra made.
- A start on data that Ghidra defined.
- A start whose body, as
hereticussees it, holds the start of a function that Ghidra made. A scan reads the PLT of a stripped library as one function, where Ghidra marks up each entry. - A region that
hereticusthinks is a table or fill, not code.
The analysis log shows what became of each start, as Of <count> starts hereticus found in <program>: <count> made, <count> already a function, with
a count for each reason above.
just ghidra-extension runs the analyzer in headless Ghidra 12.1.4. On the
raw zImage stub of the Netgear EX6100v2, Ghidra finds
19 functions alone and
66 with the analyzer. That is
each of the 66 starts
that hereticus finds. On a stripped library, Ghidra already finds almost
every function. On the zlib backport for Thumb-2, Ghidra finds
188 functions alone and
189 with the analyzer.
Function Match
In the Version Tracking window, add Hereticus Function Match to the correlators of a session. Each match has these values:
- Similarity: the score of
hereticus, from 0.0 to 1.0. Ghidra keeps three decimals. - Confidence: how sure the pairing is, from highest for a pair of
identical bytes to lowest for a pair that is only similar. The
MATCHED BYcolumn of the report names each stage. - Tag: what changed, as
hereticus: sameorhereticus: changed. Filter the match table on a tag to see only the functions that changed.
The correlator has these options:
Lowest Similarity, the same as--threshold.Ignore Symbol Names, the same as--ignore-names.
A pair is proposed only if both programs have a function at its addresses.
Thus, run the Function Finder on both programs first. On the zlib backport
for ARM, the correlator proposes
123 of the
124 pairs of
hereticus. The one it does not propose is the PLT, which Ghidra holds as
one function for each entry.
Limits
- Both programs of a session must be read alike. Both are ELF or Mach-O, or both are raw images at the same base in the same instruction set.
- An unlinked object puts its sections where Ghidra chooses. The
addresses of
hereticusagree with those of Ghidra when the first section thathereticusreads is the first that Ghidra loads. - Only ELF and raw images are tested in Ghidra. A Mach-O file goes to
hereticusas it does on the command line, but no test runs it in Ghidra.
Real-Life Examples
Each page uses hereticus on a published security fix, in the binaries
that the vendor shipped. It compares the binary before the fix with the
binary after the fix, and shows where the fixed function is in the report.
Each page gives the commands to get the binaries and to do it again.
The binaries are stripped, and each update holds more changes than the fix. Thus the report also lists functions that the fix did not touch. The row of the fix is the row that a reader gets to before the fix is found. Lower is better.
| Fix | Binary, release | Build | Row of the fix | Rows |
|---|---|---|---|---|
| CVE-2019-19945 | uhttpd, OpenWrt 18.06.5 to 18.06.6 | AArch64 | 3 | 5 |
| CVE-2020-7248 | libubox, OpenWrt 19.07.0 to 19.07.1 | AArch64 | 1 | 3 |
| CVE-2021-30883 | AppleCLCD, iOS 15.0.1 to 15.0.2 | kext | 1 | 333 |
| CVE-2024-23265 | AppleDiskImages2, iOS 17.3.1 to 17.4 | kext | 1 | 33 |
| CVE-2025-14282 | dropbear, OpenWrt 24.10.4 to 24.10.5 | AArch64 | 2 | 14 |
| CVE-2025-24257 | IOGPUFamily, iOS 18.3.2 to 18.4 | kext | 19 | 403 |
| CVE-2025-62525 | the ltq-ptm kernel module, OpenWrt 24.10.3 to 24.10.4 | MIPS32 BE | 1 | 2 |
| CVE-2025-62526 | ubusd, OpenWrt 24.10.3 to 24.10.4 | AArch64 | 5 | 8 |
| CVE-2026-28825 | smbfs, macOS 26.3.1 to 26.4 | kext | 9 | 462 |
| CVE-2026-30873 | jsonfilter, OpenWrt 24.10.5 to 24.10.6 | AArch64 | 1 | 1 |
| CVE-2026-30874 | procd, OpenWrt 24.10.5 to 24.10.6 | AArch64 | 1 | 24 |
| CVE-2026-35385 | dropbear scp, OpenWrt 24.10.7 to 24.10.8 | AArch64 | 1 | 19 |
| CVE-2026-53921 | odhcpd, OpenWrt 24.10.7 to 24.10.8 | AArch64 | 5 | 18 |
| CVE-2026-55614 | uhttpd, OpenWrt 24.10.7 to 24.10.8 | AArch64 | 5 | 13 |
Most pages also give the rows of other builds, as A32 and MIPS. The
OpenWrt binaries are all stripped with sstrip. The Apple binaries are
kernel extensions (kexts), taken out of the kernelcache with ipsw.
hereticus-cli/tests/truth_cves.rs locks each row, so a change that moves
a fix down fails the tests.
CVE-2019-19945: uhttpd, OpenWrt 18.06.5 to 18.06.6
OpenWrt 18.06.6 updates uhttpd to 2019-12-22, with three upstream
commits
(OpenWrt commit b901563):
5f9ae57, “client: fix invalid data access through invalid content-length values”: the fix. Jan-Niklas Sohn found that a POST to a CGI script withTransfer-Encoding: chunkedand aContent-Lengthabove 2^31 reads as a negative length:client_parse_headerstores it, andclient_poll_post_datapasses it on toustream_consume(advisory 2020-01-13-1).6b03f96, “ubus: increase maximum ubus request size to 64KB”.91fcac3, “uhttpd: Fix multiple format string problems”.
OpenWrt runs sstrip on what it ships, so only the segments of the file
are left.
Get the binaries
armvirt before 21.02 ships an empty rootfs.cpio.gz, so the files come
from the tar of the root file system:
$ for v in 18.06.5 18.06.6; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armvirt/64/openwrt-$v-armvirt-64-default-rootfs.tar.gz \
| tar -xz -C $v ./usr/sbin/uhttpd
done
Compare
$ hereticus --format markdown 18.06.5/usr/sbin/uhttpd 18.06.6/usr/sbin/uhttpd
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_4076f8 -> sub_4076fc | changed | 0.91 | 6/1/0 | 1 branch, 1 jump | similar |
sub_4040fc | changed | 0.97 | 3/2/0 | 1 branch, 2 mem | similar |
sub_404288 -> sub_404290 | changed | 0.99 | 3/1/0 | 1 branch | similar |
sub_404bfc -> sub_404c08 | changed | 0.97 | 1/2/0 | 2 mem | similar |
sub_404ca4 -> sub_404cac | changed | 0.98 | 1/2/0 | 2 mem | similar |
228 matched (222 unchanged, 6 changed), 0 gone, 0 new
sub_4040fc is client_poll_post_data, which calls ustream_consume,
and sub_404288 is client_parse_header, which reads "chunked": the
fix is on rows 2 and 3. The other rows hold the two other commits, or
code that moved with them; this report does not tell them apart. The
build for ARMv7 reads the same, with the fix on rows 2 and
3 of 5. The
MIPS16e build for malta, from the initramfs of its kernel, has the fix
on rows 1 and 2 of
6. Its code is not PIC, so the literal pool after each
function holds the absolute addresses it loads, which move with each
release; the matching reads them as data.
Read the fix
$ hereticus --view il --diff sub_404288 18.06.5/usr/sbin/uhttpd 18.06.6/usr/sbin/uhttpd
...
+ w1 = load.8 [x1]
+ if (w1 != 0) goto $+0x8
+ if ((w0 & 0x80000000) == 0) goto $-0x80
x2 = &page
w1 = 0x190
x2 = &"Bad Request"
...
The new test of bit 31 refuses a length that reads as negative: the code after it answers 400 Bad Request.
CVE-2020-7248: libubox, OpenWrt 19.07.0 to 19.07.1
OpenWrt 19.07.1 updates libubox, and with it libblobmsg_json.so, the
library that turns a blob message into JSON. Petr Štetiar and Jo-Philipp
Wich found that a large enough double overflows the buffer on the stack
that blobmsg_format_json writes each number into
(advisory 2020-01-31-2).
The update pulls in ten commits of libubox
(OpenWrt commit 5c73bb1),
of which these touch the library:
1ffa41535369, “blobmsg_json: prefer snprintf usage”: the fix. Each value is written withsnprintfand the size of the buffer, in place ofsprintf.a36ee96618a9, “blobmsg: blobmsg_add_json_element() 64-bit values”.f0da3a4283b7, “blobmsg_json: fix int16 serialization”.
19.07.2 ships the same bytes. OpenWrt runs sstrip on what it ships,
so only the segments of the file and its exports are left.
Get the binaries
armvirt before 21.02 ships an empty rootfs.cpio.gz, so the files come
from the tar of the root file system:
$ for v in 19.07.0 19.07.1; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armvirt/64/openwrt-$v-armvirt-64-default-rootfs.tar.gz \
| tar -xz -C $v ./lib/libblobmsg_json.so
done
Compare
$ hereticus --format markdown 19.07.0/lib/libblobmsg_json.so 19.07.1/lib/libblobmsg_json.so
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_1184 -> sub_1188 | changed | 0.95 | 10/5/3 | 2 call, 2 callee, 3 mem | similar |
sub_e70 | changed | 0.99 | 1/0/1 | 1 callee, 1 text | similar |
blobmsg_add_json_element | changed | 0.89 | 17/3/3 | 1 branch, 1 call, 1 callee, 1 jump, 6 mem, 2 const, 1 text | same name |
19 matched (16 unchanged, 3 changed), 0 gone, 0 new
The first row is the fix: sub_1184 is blobmsg_format_element, the
static function that blobmsg_format_json calls for each value. The
third row is the change to 64-bit values. The build for ARMv7 reads the
same, with the fix on row 1 of
3 too, and so does the MIPS16e build for
malta, on row 1 of
5. OpenWrt ships no root file system for malta
before 21.02, but its vmlinux-initramfs.elf holds one, as a newc
cpio archive in .init.data.
Read the fix
$ hereticus --view il --diff sub_1184 19.07.0/lib/libblobmsg_json.so 19.07.1/lib/libblobmsg_json.so
...
- x1 = &"%d"
- x3 = load.64 [@sprintf]
- call sprintf
+ call snprintf
...
- x1 = &"%ld"
- x0 = x19
- x2 = rev(x2)
- call sprintf
+ x2 = &"%ld"
...
Each format moves from the second argument to the third, as the size of
the buffer comes before it, and each call of sprintf turns into one of
snprintf. The default order ranks a call turned into its bounded form
first; by the count of instructions added alone, the fix is on row 2,
below blobmsg_add_json_element.
CVE-2021-30883: AppleCLCD, iOS 15.0.1 to 15.0.2
This repeats the patch diff in
Saar Amar: Bindiff and POC for the IOMFB vulnerability.
IOMFB::TableCompensator::BilerpGainTable::new_from_data computed the size
of a table in 32 bits, as 12 * v15 * v14 + 4 * (v14 + a3), and allocated
that much. The fix computes it in 64 bits, puts upper limits on the counts,
and checks the allocation for NULL.
An iPhone 11 is used: the A14 and later run this code on the DCP instead.
Get the binaries
$ ipsw download ipsw --device iPhone12,1 --build 19A348 --kernel --output 15.0.1
$ ipsw download ipsw --device iPhone12,1 --build 19A404 --kernel --output 15.0.2
The kernelcache of iOS 15 on an A13 is no fileset, but one image with every kext linked in, so it is compared whole.
Compare
$ hereticus --format markdown 15.0.1/*/kernelcache.release.iPhone12,1 15.0.2/*/kernelcache.release.iPhone12,1
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_fffffff009c0b13c -> sub_fffffff009c0b178 | changed | 0.72 | 28/16/5 | 3 branch, 4 call, 3 jump, 8 mem | similar |
sub_fffffff009c0a39c | changed | 0.40 | 10/14/2 | 1 branch, 2 call, 1 jump, 4 mem | same callers |
sub_fffffff009c0aedc -> sub_fffffff009c0aef8 | changed | 0.89 | 14/5/4 | 2 branch, 1 call, 1 mem | similar |
| … |
114843 matched (114507 unchanged, 336 changed), 1 gone, 2 new
6 changed only a constant, hidden. Use –show-retouched to list them.
The first row is the function from the report, out of 336 changed in the whole kernel. It adds the most instructions.
Read the fix
$ hereticus --diff sub_fffffff009c0b13c 15.0.1/*/kernelcache.release.iPhone12,1 15.0.2/*/kernelcache.release.iPhone12,1
...
- cmp w25, #0x0
- csinc w9, w25, wzr, ne
- mul w9, w8, w9
- mov w22, #0xc
- mul w9, w9, w22
- add w8, w8, w25
- add w0, w9, w8, lsl #2
bl $-0xdcc
+ cbz w0, $+0x54
...
ldr w8, [x19, #0x20]
+ cmp w8, #0x64
+ b.hi $+0x10
ldr w9, [x19, #0x10]
+ cmp w9, #0x3e9
+ b.lo $+0x70
...
The size 12 * v15 * v14 + 4 * (v14 + a3) in 32-bit registers is gone. A
call computes it now, a zero result leaves through the new error path, and
the two counts are limited to 100 and 1000: the upper limits the report
names.
CVE-2024-23265: AppleDiskImages2, iOS 17.3.1 to 17.4
This repeats the patch diff in
8ksec: Patch Diffing CVE-2024-23265.
The fix adds a check for the sentinel -1 in FUN_fffffff028a156c8.
Get the binaries
$ ipsw download ipsw --device iPhone15,4 --build 21D61 --kernel --output 17.3.1
$ ipsw download ipsw --device iPhone15,4 --build 21E219 --kernel --output 17.4
$ ipsw kernel extract 17.3.1/*/kernelcache.release.iPhone15,4 com.apple.driver.AppleDiskImages2 --output 17.3.1
$ ipsw kernel extract 17.4/*/kernelcache.release.iPhone15,4 com.apple.driver.AppleDiskImages2 --output 17.4
Compare
$ hereticus --format markdown 17.3.1/com.apple.driver.AppleDiskImages2 17.4/com.apple.driver.AppleDiskImages2
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_fffffff028a156c8 -> sub_fffffff008a3341c | changed | 0.97 | 4/0/0 | 1 branch | similar |
sub_fffffff028a14798 -> sub_fffffff008a324f4 | changed | 0.97 | 0/2/1 | 1 mem, 2 const | similar |
sub_fffffff028a0e780 -> sub_fffffff008a2c4e0 | changed | 0.98 | 0/0/1 | 1 const | similar |
sub_fffffff028a12400 -> sub_fffffff008a30160 | changed | 1.00 | 0/1/0 | 2 const, 5 text | similar |
sub_fffffff028a0ba10 -> sub_fffffff008a29770 | changed values | 1.00 | - | 8 const, 1 text | identical shape |
| … |
251 matched (178 unchanged, 73 changed), 0 gone, 0 new
40 changed only a constant, hidden. Use –show-retouched to list them.
The first row is the function from the report. It adds the most instructions, and it is the only function that gets new branches.
Read the fix
$ hereticus --diff sub_fffffff028a156c8 17.3.1/com.apple.driver.AppleDiskImages2 17.4/com.apple.driver.AppleDiskImages2
...
- cbnz x8, $+0x1c
+ cbz x8, $+0xc
+ cmn x8, #0x1
+ b.ne $+0x24
...
+ cmn x8, #0x1
+ csel x8, xzr, x8, eq
if (x != 0) became if (x != 0 && x != -1), as the report says.
CVE-2025-14282: dropbear, OpenWrt 24.10.4 to 24.10.5
This repeats the fix of
OpenWrt security advisory 2025-12-16-1.
With Unix domain socket forwarding, a user who logged in to Dropbear could
make the server, still running as root, open a socket as root. OpenWrt
24.10.5 takes the upstream fix
(OpenWrt commit 0f52a05):
the server now drops to the privileges of the user as soon as the user
logs in, in a new svr_switch_user, and no longer when it starts the
shell, in execchild. The same update keeps the utmp group to record
the logout, offers only the first host key when it rekeys, and fixes
CVE-2019-6111 in scp.
OpenWrt runs sstrip on what it ships, so only the segments of the file
are left. OpenWrt builds Dropbear as plain MIPS32, not MIPS16e as most of
its MIPS user space, so this case and the ubusd case of CVE-2025-62526
read different instruction sets on MIPS.
Get the binaries
$ for v in 24.10.4 24.10.5; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armsr/armv8/openwrt-$v-armsr-armv8-rootfs.cpio.gz \
| gunzip | (cd $v && cpio -id usr/sbin/dropbear)
done
Compare
$ hereticus --format markdown 24.10.4/usr/sbin/dropbear 24.10.5/usr/sbin/dropbear
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_15fd0 -> sub_1650c | changed | 0.95 | 74/37/0 | 8 branch, 16 call, 2 jump, 22 mem, 1 text | similar |
sub_15048 -> sub_1513c | changed | 0.21 | 64/2/0 | 8 branch, 8 call, 2 jump, 17 mem, 4 text | same place |
sub_19acc -> sub_19c14 | changed | 0.91 | 42/20/0 | 5 branch, 10 call, 2 jump, 16 mem, 1 const | similar |
sub_3c48 -> sub_3d08 | changed | 0.99 | 25/18/7 | 5 branch, 2 call, 1 jump, 8 mem, 6 const, 1 text | similar |
sub_13f8c -> sub_14078 | changed | 0.92 | 5/6/2 | 3 call, 6 mem | similar |
sub_17e94 -> sub_18078 | changed | 0.98 | 5/6/2 | 3 call, 5 mem | similar |
sub_18488 -> sub_18674 | changed | 0.94 | 4/39/4 | 4 branch, 10 call, 3 jump, 7 mem, 2 text | similar |
sub_17b48 -> sub_17dc0 | changed | 0.81 | 2/45/1 | 7 branch, 7 call, 2 jump, 10 mem, 3 text | similar |
sub_13e0c -> sub_13f90 | changed | 0.58 | 0/38/0 | 4 branch, 7 call, 1 jump, 8 mem, 2 text | same references |
sub_2293c -> sub_22ad4 | changed | 0.99 | 0/0/2 | - | similar |
sub_c328 -> sub_c40c | changed values | 1.00 | - | 1 text | identical shape |
sub_1c2a8 -> sub_1c440 | changed values | 1.00 | - | 1 text | identical shape |
sub_13070: newsub_130c0: new
417 matched (404 unchanged, 13 changed), 0 gone, 2 new
The texts each function points at name the rows:
| Row | Function | What changed |
|---|---|---|
sub_15fd0 | sink of scp | CVE-2019-6111: names from the server must match the wildcard |
sub_15048 | send_msg_userauth_success | the fix: svr_switch_user, inlined, drops to the user |
sub_19acc | recv_msg_kexdh_init | a rekey offers only the host key in use |
sub_3c48 | the options of the server | not read further |
sub_13f8c | the owner of the pty | the utmp group is raised around it |
sub_17b48 | execchild | no longer changes the user |
sub_13e0c | svr_agentcleanup | no longer takes the user’s IDs to remove the agent socket: the server runs as the user by then |
sub_13070, sub_130c0 | svr_raise_gid_utmp, svr_restore_gid | new |
The fix is on row 2. GCC inlined the new svr_switch_user into
send_msg_userauth_success, which grew from 20 instructions to 77, so
the code scores only 0.21. The pair holds because the function stays in
its place between pairs on both sides.
Read the fix
$ hereticus --view il --diff sub_15048 24.10.4/usr/sbin/dropbear 24.10.5/usr/sbin/dropbear
...
w0 = 1
store.32 [@0x50ac8] = w0
- if (w1 != 0) goto $+0x8
+ call getuid
+ if (w0 != 0) goto $+0xb0
+ w0 = load.32 [@0x50aec]
+ if (w0 == 0) goto $+0x38
+ x1 = &page
+ x1 = load.64 [@setgid]
+ call setgid
+ if ((w0 & 0x80000000) == 0) goto $+0x10
+ x0 = &page
+ x0 = &"Error changing user group"
...
+ x0 = &"utmp"
+ call getgrnam
...
+ call setresgid
...
+ call setuid
...
ses.authstate.authdone = 1 is the store of 1; after it, the server now
calls setgid, initgroups, setresgid and setuid while it is still
in the authentication handler.
The other architectures
The same comparison on the other builds of the release:
| Build | Rows | Row of the fix | Score |
|---|---|---|---|
armsr/armv8, AArch64 | 14 | 2 | 0.21 |
armsr/armv7, A32 | 33 | 1 | 0.25 |
malta/be, MIPS32 big endian | 22 | 2 | 0.28 |
malta/le, MIPS32 little endian | 19 | 2 | 0.28 |
hereticus-cli/tests/truth_cves.rs locks these ranks, and
fixtures/cve_binaries.py fetches and pins the binaries.
CVE-2025-24257: IOGPUFamily, iOS 18.3.2 to 18.4
This repeats the finding of the
public PoC of CVE-2025-24257,
discovered by Wang Yu of Cyberserval. When a resource group is created with
a capacity under 64, bitmap_mask = (capacity >> 6) - 1 underflows to
0xFFFFFFFF, and the bitmap is read and written out of bounds. iOS 18.4 adds
a check that the capacity is 64 or more.
Get the binaries
$ ipsw download ipsw --device iPhone15,2 --build 22D82 --kernel --output 18.3.2
$ ipsw download ipsw --device iPhone15,2 --build 22E240 --kernel --output 18.4
$ ipsw kernel extract 18.3.2/*/kernelcache.release.iPhone15,2 com.apple.iokit.IOGPUFamily --output 18.3.2
$ ipsw kernel extract 18.4/*/kernelcache.release.iPhone15,2 com.apple.iokit.IOGPUFamily --output 18.4
Compare
$ hereticus --format markdown 18.3.2/com.apple.iokit.IOGPUFamily 18.4/com.apple.iokit.IOGPUFamily
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_fffffff009a95038 -> sub_fffffff009bb22c0 | changed | 0.93 | 96/79/15 | 33 branch, 14 call, 17 jump, 27 mem, 34 const | similar |
| … | |||||
sub_fffffff009a765c0 -> sub_fffffff009b92fa8 | changed | 0.76 | 20/6/3 | 2 branch, 1 call, 1 jump, 4 mem, 3 const, 2 text | similar |
| … |
1728 matched (1304 unchanged, 424 changed), 14 gone, 8 new
43 changed only a constant, hidden. Use –show-retouched to list them.
iOS 18.4 rewrote much of the driver, so 424 functions changed. The fix is row 19.
Find the fix
--diff-all prints every diff in one run, so a search finds the new check:
a compare of a count with 63 or 64.
$ hereticus --diff-all 18.3.2/com.apple.iokit.IOGPUFamily 18.4/com.apple.iokit.IOGPUFamily > diffs.txt
$ awk '/^sub_/{name=$0} /^\+ cmp [wx][0-9]+, #0x(3f|40)$/{print name": "$0}' diffs.txt
sub_fffffff009a765c0 -> sub_fffffff009b92fa8: + cmp w2, #0x3f
...
Read the fix
$ hereticus --diff sub_fffffff009a765c0 18.3.2/com.apple.iokit.IOGPUFamily 18.4/com.apple.iokit.IOGPUFamily
...
- cmp w2, #0x0
...
+ mov x21, x2
+ cmp w2, #0x3f
+ b.hi $+0x40
mov w8, #0x40
- csel w21, w8, w2, eq
...
+ mov w2, #0x11
+ bl $-0x16727c4
+ mov x19, #0x0
+ b $+0x180
...
Before, a capacity of 0 became 64 and any other was taken as given, so a
capacity of 1 to 63 went on to the bitmap. Now a capacity of 63 or less
logs an error and the function returns NULL. The bitmap search that the
mask bounds, sub_fffffff009a83cb0, compares it in 64 bits now too.
--view il shows the two choices plainly: the csel that took 64 for a
capacity of 0, and the branch that now leaves for one of 63 or less.
$ hereticus --view il --diff sub_fffffff009a765c0 18.3.2/com.apple.iokit.IOGPUFamily 18.4/com.apple.iokit.IOGPUFamily
...
- flags = compare(w2, 0)
...
+ x21 = x2
+ flags = compare(w2, 0x3f)
+ if (w2 u> 0x3f) goto $+0x40
w8 = 0x40
- w21 = eq ? w8 : w2
...
+ w2 = 0x11
+ call $-0x16727c4
+ x19 = 0
+ goto $+0x180
...
CVE-2025-62525: the ltq-ptm kernel module, OpenWrt 24.10.3 to 24.10.4
This repeats the fix of
OpenWrt security advisory 2025-10-22-2.
The ioctl handler of the PTM driver of the Lantiq xRX200 (VR9) wrote to
and read from the address a user gave, with no check, so any local user
could read and write kernel memory. The fix
(OpenWrt commit e001b31)
asks for CAP_NET_ADMIN first, and copies through copy_from_user and
copy_to_user.
The module is MIPS32 big endian. OpenWrt’s strip-kmod.sh keeps only the
symbols that relocations need, and renames the local ones to numbers, as
_7: 8 symbols name 1.5 KiB of the 12 KiB of code, and none of them is
the handler. So hereticus reads the module as stripped code: it places
each code section after the one before, scans it, takes the starts that
tables of callbacks in the data point at, and names each call by the
relocation at it.
Get the binaries
$ for v in 24.10.3 24.10.4; do
base=https://downloads.openwrt.org/releases/$v/targets/lantiq/xrx200/kmods
kernel=$(curl -s $base/ | grep -o 'href="6[^"]*"' | cut -d'"' -f2)
ipk=$(curl -s $base/$kernel | grep -o 'href="kmod-ltq-ptm-vr9_[^"]*"' | cut -d'"' -f2)
mkdir -p $v && curl -s $base/$kernel$ipk | tar xzO ./data.tar.gz | tar xz -C $v
done
Compare
$ hereticus --format markdown 24.10.3/lib/modules/*/ltq_ptm_vr9.ko 24.10.4/lib/modules/*/ltq_ptm_vr9.ko
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_267c | changed | 0.70 | 69/37/9 | 11 branch, 7 call, 3 jump, 34 mem, 1 const | similar |
sub_2b0c: gone
20 matched (10 unchanged, 10 changed), 1 gone, 0 new
The fix is the only changed row. The kernel moved from 6.6.104 to 6.6.110 between the two releases, and the 9 functions that changed only a constant are hidden.
Read the fix
$ hereticus --view il --diff sub_267c 24.10.3/lib/modules/*/ltq_ptm_vr9.ko 24.10.4/lib/modules/*/ltq_ptm_vr9.ko
...
if (v0 != a0) goto $+0x57c
a2 = 0
+ call capable
+ a0 = 0xc
+ if (v0 == 0) goto $+0x3c
+ v0 = -0x1
...
+ a0 = s0
+ a1 = sp + 0x24
+ a2 = 0x14
+ at = 0
+ at = at
+ call __raw_copy_to_user
+ nop
+ if (a2 == 0) goto $-0x118
v0 = 0
+ v0 = -0xe
...
capable(12) asks for CAP_NET_ADMIN, and its failure returns -1,
-EPERM. Each answer is now built on the stack and copied out with
__raw_copy_to_user, which fails with -14, -EFAULT.
hereticus-cli/tests/truth_cves.rs locks the rank, and
fixtures/cve_binaries.py fetches the package and pins the module.
CVE-2025-62526: ubusd, OpenWrt 24.10.3 to 24.10.4
This repeats the fix of
OpenWrt security advisory 2025-10-22-1.
ubusd_alloc_event_pattern took a pattern to be at least one byte long,
so an empty one wrote out of bounds on the heap, before any ACL check. The
fix rejects it, adds the same check in three more places, and checks the
ACL of a wildcard event too. 24.10.4 also takes two more changes to the
ACLs of ubus: a user and group are compared by number, and a client’s
extra groups count.
OpenWrt runs sstrip on what it ships, which removes the section headers
as well as the symbols, so only the segments of the file are left. The
report diffs AArch64, and the same holds for MIPS16e, which OpenWrt builds
its MIPS user space as.
Get the binaries
The package feed of a release moves on with its fixes, so the binary of 24.10.3 comes from the root file system of that release.
$ for v in 24.10.3 24.10.4; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armsr/armv8/openwrt-$v-armsr-armv8-rootfs.cpio.gz \
| gunzip | (cd $v && cpio -id sbin/ubusd)
done
Compare
$ hereticus --format markdown 24.10.3/sbin/ubusd 24.10.4/sbin/ubusd
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_4d8c -> sub_4d0c | changed | 0.88 | 36/7/4 | 9 branch, 6 call, 3 jump, 11 mem, 1 const | similar |
sub_33c4 -> sub_32ec | changed | 0.81 | 14/9/6 | 2 branch, 1 call, 3 jump, 10 mem | similar |
sub_4ca0 -> sub_4c04 | changed | 0.86 | 8/0/0 | 1 branch, 1 jump, 4 mem, 1 const | similar |
sub_374c -> sub_3698 | changed | 0.96 | 7/2/0 | 1 branch, 1 jump, 2 const | similar |
sub_4a3c -> sub_49a0 | changed | 0.98 | 4/5/0 | 2 branch, 1 mem | similar |
sub_2b58 -> sub_5878 | changed | 0.97 | 3/1/1 | 1 call, 2 mem, 3 const | similar |
sub_3f4c -> sub_3ea8 | changed | 0.99 | 2/0/0 | 1 call, 1 mem, 5 const | similar |
sub_5710: new
67 matched (53 unchanged, 14 changed), 0 gone, 1 new
7 changed only a constant, hidden. Use –show-retouched to list them.
Each row is a part of the change:
| Row | Function | What changed |
|---|---|---|
sub_4d8c | the ACL file loader | user and group by number; the four new if (!o) return; |
sub_33c4 | ubusd_acl_check | compares the user and the groups by number |
sub_4ca0 | ubusd_acl_alloc_obj | the new if (!len) return NULL; after strlen |
sub_374c | __ubusd_handle_lookup | the new if (!len), which returns UBUS_STATUS_INVALID_ARGUMENT |
sub_4a3c | ubusd_alloc_event_pattern | the fix |
sub_2b58, sub_3f4c | client set-up | the client holds the groups now, so its fields moved |
Read the fix
$ hereticus --diff sub_4a3c 24.10.3/sbin/ubusd 24.10.4/sbin/ubusd
...
ldr w0, [x0]
rev w0, w0
cmp w0, #0x3ff
b.hi $+0xc
mov w19, #0x6
...
blr x1
+ mov w23, w0
+ cmp w0, #0x0
+ b.le $-0x40
sxtw x1, w0
sub x1, x1, #0x1
- mov w20, w0
- ldrb w2, [x21, x1]
+ ldrb w2, [x22, x1]
cmp w2, #0x2a
...
- ldrb w0, [x21]
- cbz w0, $+0x1c
...
blr x1 calls strlen on the pattern. Before, the code went on at once to
read the byte before its end, pattern[len - 1], which for an empty
pattern lies before the buffer. Now b.le goes back 16 instructions to
mov w19, #0x6 when the length is 0 or less: the function returns
UBUS_STATUS_PERMISSION_DENIED. The ldrb and cbz that are gone tested
pattern[0] before the ACL check, which the third commit removed so that
a wildcard is checked too.
--view il shows the same diff as IL statements, where the new check
reads as one test, the error path as the status it sets, and the call
by the function it reaches:
$ hereticus --view il --diff sub_4a3c 24.10.3/sbin/ubusd 24.10.4/sbin/ubusd
...
flags = compare(w0, 0x3ff)
if (w0 u> 0x3ff) goto $+0xc
w19 = 6
...
call strlen
+ w23 = w0
+ flags = compare(w0, 0)
+ if (w0 <= 0) goto $-0x40
...
MIPS16e
The MIPS build of OpenWrt, as for the Malta board, holds its user space as
MIPS16e. Read so, ubusd of the big-endian Malta board reports nine
functions changed:
$ hereticus 24.10.3/sbin/ubusd 24.10.4/sbin/ubusd
...
123 matched (104 unchanged, 19 changed), 0 gone, 8 new
10 changed only a constant, hidden. Use --show-retouched to list them.
The lookup is among them, with its new ori v0, zero, 0x2, the
UBUS_STATUS_INVALID_ARGUMENT it returns, and the fix is on row
5. Of
the eight new functions, seven are the lazy binding stubs of the imports
24.10.4 adds to compare a user and a group by number: getpwnam,
getgrnam, fopen, fgets, fclose, snprintf and strtoul. The
stubs are MIPS32 among MIPS16e code, and each is named for the symbol it
resolves. The eighth opens with the global pointer preamble of MIPS16e
and has no match in 24.10.3.
CVE-2026-28825: smbfs, macOS 26.3.1 to 26.4
This repeats the patch diff in
MAD Bugs: An Apple Kernel Bug, Brought to You by Microsoft.
smb2_rq_decompress_read copied compress_len bytes of a compressed SMB 2
reply without comparing it with the buffer it had allocated. The fix adds
that comparison, with an error path that logs
compress_len %u > originalCompressedSegmentSize %u.
The report diffs 26.3.2. The IPSW feed holds no 26.3.2 for the VirtualMac2,1, so this compares 26.3.1, which has the bug too, and more changes besides.
Get the binaries
$ ipsw download ipsw --macos --device VirtualMac2,1 --build 25D2128 --kernel --output 26.3.1
$ ipsw download ipsw --macos --device VirtualMac2,1 --build 25E246 --kernel --output 26.4
$ ipsw kernel extract 26.3.1/*/kernelcache.release.VirtualMac2,1 com.apple.filesystems.smbfs --output 26.3.1
$ ipsw kernel extract 26.4/*/kernelcache.release.VirtualMac2,1 com.apple.filesystems.smbfs --output 26.4
Compare
$ hereticus --format markdown 26.3.1/com.apple.filesystems.smbfs 26.4/com.apple.filesystems.smbfs
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
_smb_iod_thread | changed | 0.89 | 285/386/154 | 44 branch, 50 call, 24 jump, 151 mem, 41 const, 15 text | same name |
| … | |||||
_smb2_rq_decompress_read | changed | 0.97 | 81/68/3 | 16 branch, 16 call, 2 jump, 35 mem, 31 const, 2 text | same name |
| … |
762 matched (194 unchanged, 568 changed), 1 gone, 3 new
110 changed only a constant, hidden. Use –show-retouched to list them.
The fix is row 9 of 568: the kext changed a great deal over these two releases.
Read the fix
$ hereticus --diff _smb2_rq_decompress_read 26.3.1/com.apple.filesystems.smbfs 26.4/com.apple.filesystems.smbfs
...
+ sub w9, w8, #0x10
+ str w9, [sp, #0x44]
+ ldr w8, [sp, #0x4c]
+ str w8, [sp, #0x3c]
+ cmp w9, w8
+ b.hi $+0x24c
+ mov w2, w9
add x0, sp, #0x80
mov x1, x20
mov w3, #0x0
bl $+0x13164
...
cmp w9, w8 with b.hi is the new check the report names: compress_len
against OriginalCompressedSegmentSize, before the copy.
CVE-2026-30873: jsonfilter, OpenWrt 24.10.5 to 24.10.6
OpenWrt 24.10.6 adds one patch to jsonfilter, the fix of CVE-2026-30873,
which Trail of Bits found (TOB-OWRT-3)
(OpenWrt commit 77d54cc):
jp_get_tokenreads one token of a JSONPath expression into an operation on its stack.match_tokencopies the text of a string, a label or a regular expression into it withstrdup, andjp_alloc_opcopies it again, so each such token leaked its first copy. The fix frees it (jsonpath commit e086664).
The version of the package stays 2025.04.18~8a86fb78. OpenWrt runs
sstrip on what it ships, so only the segments of the file are left.
Get the binaries
$ for v in 24.10.5 24.10.6; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armsr/armv8/openwrt-$v-armsr-armv8-rootfs.cpio.gz \
| gunzip | (cd $v && cpio -id usr/bin/jsonfilter)
done
Compare
$ hereticus --format markdown 24.10.5/usr/bin/jsonfilter 24.10.6/usr/bin/jsonfilter
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_402c48 | changed | 0.92 | 5/0/0 | 1 branch, 1 call | similar |
86 matched (85 unchanged, 1 changed), 0 gone, 0 new
The one row is the fix. GCC inlined jp_get_token into the function
that parses an expression.
Read the fix
$ hereticus --view il --diff sub_402c48 24.10.5/usr/bin/jsonfilter 24.10.6/usr/bin/jsonfilter
...
w2 = load.32 [sp + 0x68]
x4 = 0
call sub_402274
+ x19 = x0
+ x0 = load.64 [sp + 0x60]
+ if (x0 == 0) goto $-0x88
+ call free
...
sub_402274 is jp_alloc_op, and [sp + 0x60] is the str field of
the operation on the stack. The lines read as the C of the fix:
- return jp_alloc_op(s, op.type, op.num, op.str, NULL);
+ newop = jp_alloc_op(s, op.type, op.num, op.str, NULL);
+ /* match_token might alloced str using strdup() */
+ if (op.str)
+ free(op.str);
+
+ return newop;
The other architectures
| Build | Rows | Row of the fix |
|---|---|---|
armsr/armv8, AArch64 | 1 | 1 |
armsr/armv7, A32 | 4 | 1 |
malta/be, MIPS16e big endian | 1 | 1 |
malta/le, MIPS16e little endian | 1 | 1 |
On malta, jsonfilter is a MIPS16e executable that is not PIC, and
calls each import through a MIPS16e PLT stub that GNU ld puts after
.fini. hereticus names each stub by the slot it jumps through, so the
call of the fix reads call free.
hereticus-cli/tests/truth_cves.rs locks these ranks, and
fixtures/cve_binaries.py fetches and pins the binaries.
CVE-2026-30874: procd hotplug-dispatch, OpenWrt 24.10.5 to 24.10.6
OpenWrt 24.10.6 takes procd from 2024-12-22 to 2026-03-14 (OpenWrt commit 3e77f8c), which is 24 commits. One of them is the fix of CVE-2026-30874, which Trail of Bits found (TOB-OWRT-4):
hotplug_callcopies the environment that a caller of thehotplugobject of ubus gives, and must dropPATH. It compared each variable, asPATH=/tmp, to"PATH"withstrcmp, so no variable ever matched, and a caller could set thePATHof the scripts it runs. The fix compares the first 5 bytes to"PATH="withstrncmp(procd commit 8581313).
The other commits fix an out-of-bounds write in cgroups_apply, a
use-after-free in the update of a service, and more, so much of procd
changed. OpenWrt runs sstrip on what it ships, so only the segments of
the file are left.
Get the binaries
$ for v in 24.10.5 24.10.6; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armsr/armv8/openwrt-$v-armsr-armv8-rootfs.cpio.gz \
| gunzip | (cd $v && cpio -id sbin/procd)
done
Compare
$ hereticus --format markdown 24.10.5/sbin/procd 24.10.6/sbin/procd
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_7630 -> sub_75c0 | changed | 1.00 | 1/0/0 | 1 callee, 1 text | similar |
sub_f99c -> sub_fb64 | changed | 0.85 | 31/57/5 | 11 branch, 10 call, 5 jump, 27 mem, 14 const | similar |
sub_8ab4 -> sub_8bb0 | changed | 0.89 | 28/8/2 | 3 branch, 3 call, 5 mem, 1 text | similar |
sub_ae68 -> sub_afb8 | changed | 0.69 | 14/10/6 | 3 branch, 1 call, 2 callee, 4 jump, 5 mem, 2 const | similar |
sub_12994 -> sub_12b20 | changed | 0.92 | 19/0/1 | 2 branch, 2 call, 1 jump, 3 mem, 1 text | similar |
sub_f604 -> sub_f790 | changed | 0.93 | 16/0/0 | 1 branch, 2 call, 4 mem, 3 const, 1 text | similar |
sub_e920 -> sub_ea94 | changed | 0.94 | 7/0/0 | 2 branch, 2 mem | similar |
sub_70b0 -> sub_8220 | changed | 0.86 | 10/0/0 | 2 call, 1 mem, 2 text | similar |
sub_12e14 -> sub_1300c | changed | 0.62 | 9/4/6 | 7 branch, 1 jump, 3 const, 1 text | same references |
sub_10938 -> sub_10aac | changed | 0.94 | 9/0/19 | 18 mem, 7 const | similar |
sub_df54 -> sub_e0a8 | changed | 0.96 | 9/1/8 | 10 mem | similar |
sub_11edc -> sub_1204c | changed | 0.99 | 7/0/0 | 1 call, 1 mem, 1 const, 1 text | similar |
sub_7b08 -> sub_7a9c | changed | 0.92 | 1/2/3 | 1 branch, 2 mem | similar |
sub_115f0 -> sub_11788 | changed | 0.97 | 6/16/1 | 4 call, 1 callee, 1 mem, 7 const, 2 text | similar |
sub_fef4 -> sub_1005c | changed | 0.98 | 6/3/1 | 3 call, 3 jump, 2 mem, 2 const | similar |
sub_73f0 -> sub_73c8 | changed | 0.79 | 4/21/4 | 1 branch, 23 mem, 24 const | similar |
sub_fe74 -> sub_ffd0 | changed | 0.89 | 3/0/0 | 1 call, 2 mem, 1 const | similar |
sub_8044 -> sub_8378 | changed | 0.43 | 2/86/2 | 9 branch, 10 call, 2 jump, 15 mem, 5 text | same references |
sub_8d70 -> sub_8edc | changed | 0.82 | 1/0/1 | 2 mem | similar |
sub_6a94 -> sub_6b14 | changed | 0.89 | 0/0/1 | 7 const | similar |
sub_a34c -> sub_a4bc | changed | 0.98 | 0/8/2 | 9 mem, 7 const | similar |
sub_6ca8: newsub_806c: newsub_8eb0: new
177 matched (144 unchanged, 33 changed), 0 gone, 3 new
12 changed only a constant, hidden. Use –show-retouched to list them.
The fix is on row 1 of
24, counting the functions new.
The report puts first a function that turns a call into its bounded
form, as the fix turns strcmp into strncmp, though the fix adds only
one instruction: the length that strncmp takes.
Find the fix
When the advisory names a text or a function of the C library, search the diff of every changed function for it:
$ hereticus --view il --diff-all 24.10.5/sbin/procd 24.10.6/sbin/procd | grep -E '^(sub_|[-+].*PATH)'
...
sub_7630 -> sub_75c0
- x1 = &"DEVPATH"[3]
+ x1 = &"PATH="
...
Read the fix
$ hereticus --view il --diff sub_7630 24.10.5/sbin/procd 24.10.6/sbin/procd
...
x1 = &"LD_"
- call x3
+ call strncmp
if (w0 == 0) goto $+0x11c
x1 = &page
x0 = x20
- x1 = &"DEVPATH"[3]
- call strcmp
+ x1 = &"PATH="
+ x2 = 5
+ call strncmp
if (w0 == 0) goto $+0x104
...
The linker keeps one copy of "DEVPATH" for both texts, and a pointer
3 bytes into it for "PATH". The LD_ test calls strncmp in both
builds: 24.10.5 keeps the address of strncmp on the stack, and 24.10.6
in x26. The other lines read as the C of the fix:
- if (!strcmp(enve, "PATH"))
+ if (!strncmp(enve, "PATH=", 5))
continue;
The other architectures
| Build | Rows | Row of the fix |
|---|---|---|
armsr/armv8, AArch64 | 24 | 1 |
armsr/armv7, A32 | 29 | 1 |
malta/be, MIPS32 big endian | 25 | 1 |
malta/le, MIPS32 little endian | 25 | 1 |
This case showed four faults of --view il, all fixed (RESEARCH.md,
“Published fixes, again”). Before, the AArch64 diff named the
strncmp of the fix avl_delete, as a load on another path came first
in the code; the A32 diff showed the text as unchanged, and the words of
the literal pool after the function as ten changed instructions.
hereticus-cli/tests/truth_cves.rs locks these ranks, and
fixtures/cve_binaries.py fetches and pins the binaries.
CVE-2026-35385: dropbear scp, OpenWrt 24.10.7 to 24.10.8
OpenWrt 24.10.8 takes six fixes of Dropbear 2026.90
(OpenWrt commit 262c093).
Four are in scp, which OpenWrt builds into the same binary as the
server:
- CVE-2026-35385:
scpkept the setuid and setgid bits of the files it received, so a server could plant a setuid program. The fix, from OpenSSH, clears them. - The rest of the CVE-2019-6111 fix: names a server sends must match the pattern asked for, with braces expanded.
-rinto a directory that exists is refused.
The other two are in the server: an authenticated user could bypass the
command= option of authorized_keys where the server runs with -t,
and authorized_keys is now opened without blocking. Neither has a CVE.
OpenWrt runs sstrip on what it ships, so only the segments of the file
are left. On MIPS, Dropbear is MIPS32.
Get the binaries
$ for v in 24.10.7 24.10.8; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armsr/armv8/openwrt-$v-armsr-armv8-rootfs.cpio.gz \
| gunzip | (cd $v && cpio -id usr/sbin/dropbear)
done
Compare
$ hereticus --format markdown 24.10.7/usr/sbin/dropbear 24.10.8/usr/sbin/dropbear
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_1650c -> sub_1623c | changed | 0.69 | 369/64/14 | 63 branch, 40 call, 1 callee, 31 jump, 63 mem, 4 text | similar |
sub_1a5cc -> sub_1a83c | changed | 0.94 | 75/58/19 | 12 branch, 16 call, 1 callee, 6 jump, 43 mem, 2 const | similar |
sub_3d08 -> sub_3d88 | changed | 0.99 | 21/4/4 | 2 branch, 2 call, 1 jump, 9 mem, 2 const, 1 text | similar |
sub_105a4 -> sub_10128 | changed | 0.99 | 6/9/0 | 2 mem, 1 const | similar |
sub_13ec8 -> sub_13530 | changed | 0.80 | 5/1/0 | 2 call, 2 mem | similar |
sub_12144 -> sub_11b90 | changed | 0.94 | 4/5/1 | 1 call, 4 mem | similar |
sub_13dfc -> sub_134a8 | changed | 0.46 | 3/22/3 | 2 branch, 1 call, 2 jump, 11 mem | same callers |
sub_11bec -> sub_13594 | changed | 0.97 | 3/4/2 | 1 mem | similar |
sub_17dc0 -> sub_18028 | changed | 0.98 | 3/0/0 | 1 branch, 1 mem | similar |
sub_1d2a8 -> sub_1d83c | changed | 0.99 | 3/3/3 | 2 const | similar |
sub_23f3c -> sub_245c8 | changed | 1.00 | 3/3/1 | 2 mem | similar |
sub_1cea8 -> sub_1d440 | changed | 1.00 | 3/4/3 | 2 const | similar |
sub_1cbb0 -> sub_1cb48 | changed | 0.96 | 1/0/0 | - | similar |
sub_10c70 -> sub_107e8 | changed | 0.97 | 1/2/0 | 2 mem | similar |
sub_22ad4 -> sub_23160 | changed | 0.99 | 1/1/1 | - | similar |
sub_8200 -> sub_82d0 | changed | 0.78 | 0/0/1 | - | similar |
sub_1d8e4 -> sub_1de78 | changed | 0.98 | 0/0/1 | - | similar |
sub_c40c -> sub_c4dc | changed values | 1.00 | - | 1 text | identical shape |
sub_10bc8: new
419 matched (399 unchanged, 20 changed), 0 gone, 1 new
The texts each function points at name the first rows:
| Row | Function | Texts |
|---|---|---|
sub_1650c | sink of scp | "mtime.sec not delimited", and new "filename does not match request" |
sub_1a5cc | the authentication of the server | "unknown service in auth" |
sub_3d08 | the options of the server | "Bad maxauthtries '%s'" |
sub_105a4 | the progress meter of scp | " %3d%% " |
The fix is on row 1: GCC inlined the new pattern matching and brace
expansion into sink, which took 369 instructions.
Read the fix
$ hereticus --view il --diff sub_1650c 24.10.7/usr/sbin/dropbear 24.10.8/usr/sbin/dropbear
...
x21 = load.64 [@umask]
...
call exit
+ w0 = w0 | 0xe00
+ store.32 [sp + 0x90] = w0
+ call x21
...
x21 holds the address of umask. 0xe00 is 07000, the setuid,
setgid and sticky bits: sink now adds them to the mask of the process,
so no file it creates keeps them.
The other architectures
| Build | Rows | Row of the fix |
|---|---|---|
armsr/armv8, AArch64 | 19 | 1 |
armsr/armv7, A32 | 21 | 1 |
malta/be, MIPS32 big endian | 16 | 1 |
malta/le, MIPS32 little endian | 28 | 1 |
In 24.10.8 the linker gave some static objects a slot of the global
offset table of their own, where 24.10.7 loaded the slot of the page and
added the low half. That added half no longer reads as an edit
(RESEARCH.md, “Two ways MIPS loads an address”); before, it put 18
more rows in the report for MIPS32 little endian, and the fix on row 2.
hereticus-cli/tests/truth_cves.rs locks these ranks, and
fixtures/cve_binaries.py fetches and pins the binaries.
CVE-2026-53921: odhcpd, OpenWrt 24.10.7 to 24.10.8
OpenWrt 24.10.8 takes 34 patches to odhcpd, its DHCP and router
advertisement server
(OpenWrt commit 67c5b71).
Four are for CVEs, and three of those are in the code that answers the
IA_NA and IA_PD options of a DHCPv6 client:
- CVE-2026-53921:
build_iawrote an address past the end of the reply buffer. - CVE-2026-53918:
dhcpv6_ia_handle_IAskept a pointer to a lease it had freed. - CVE-2026-53922:
dhcpv6_ia_handle_IAstook a length less than the header of an IA, and the size underflowed. - CVE-2026-53920: a cut-short IA option disclosed stack memory.
GCC inlines build_ia into dhcpv6_ia_handle_IAs, so one function holds
the first three. The other patches check realloc, bound the nesting of
relayed messages, check the lengths of options, and escape host names in
the lease file.
OpenWrt runs sstrip on what it ships, so only the segments of the file
are left. On MIPS the binary is MIPS16e and exports dhcpv6_ia_handle_IAs,
so the report names it there.
Get the binaries
$ for v in 24.10.7 24.10.8; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armsr/armv8/openwrt-$v-armsr-armv8-rootfs.cpio.gz \
| gunzip | (cd $v && cpio -id usr/sbin/odhcpd)
done
Compare
$ hereticus --format markdown 24.10.7/usr/sbin/odhcpd 24.10.8/usr/sbin/odhcpd
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_13498 -> sub_11ff0 | changed | 0.60 | 65/129/8 | 41 branch, 5 call, 11 jump, 50 mem, 1 const | same references |
sub_11e3c -> sub_123a0 | changed | 0.98 | 53/46/8 | 8 branch, 6 call, 10 jump, 31 mem, 1 const, 14 text | similar |
sub_9d34 -> sub_9dd8 | changed | 0.98 | 23/11/9 | 4 branch, 18 mem, 1 const | similar |
sub_fadc -> sub_fd30 | changed | 0.93 | 21/0/1 | 2 branch, 2 call, 1 jump, 4 mem | similar |
sub_e12c -> sub_e338 | changed | 0.97 | 21/15/11 | 9 branch, 1 jump, 10 mem, 1 text | similar |
sub_d70c -> sub_d8ec | changed | 0.98 | 21/10/4 | 2 call, 20 mem, 6 text | similar |
sub_7230 -> sub_72c4 | changed | 0.99 | 19/14/9 | 2 branch, 2 call, 1 jump, 19 mem, 2 text | similar |
sub_b868 -> sub_bc6c | changed | 0.83 | 12/4/0 | 1 branch, 6 mem, 1 const | similar |
sub_6ee4 -> sub_6f74 | changed | 0.99 | 2/0/0 | 1 branch | similar |
sub_f6a8 -> sub_f8e8 | changed | 0.97 | 6/0/0 | 2 branch, 2 mem, 1 const | similar |
sub_c300 -> sub_c4cc | changed | 0.97 | 5/4/0 | 8 mem, 2 const | similar |
sub_f02c -> sub_f25c | changed | 0.91 | 4/0/0 | 1 call, 2 mem | similar |
sub_c78c -> sub_c95c | changed | 0.99 | 4/1/1 | 1 branch, 1 mem, 1 const | similar |
sub_ac2c -> sub_ad00 | changed | 0.99 | 3/1/0 | 1 branch, 2 mem, 1 const | similar |
sub_93fc -> sub_94a0 | changed values | 1.00 | - | 2 text | identical shape |
sub_d358 -> sub_d538 | changed values | 1.00 | - | 1 text | identical shape |
sub_be84: newsub_13a18: new
168 matched (152 unchanged, 16 changed), 0 gone, 2 new
The texts each function points at name the rows:
| Row | Function | Texts |
|---|---|---|
sub_13498 | the relay of router advertisements | "Forward a RA on %s" |
sub_11e3c | the neighbour and route tables | "hop_limit", "rfc9096: ..." |
sub_9d34 | handle_client_request | "Got a DHCPv6-request on %s" |
sub_fadc | ndp_setup_interface | "/proc/sys/net/ipv6/conf/%s/proxy_ndp" |
sub_e12c | dhcpv6_ia_handle_IAs, with build_ia | "clamping requested PD ...", "IA_PD" |
sub_d70c | dhcpv6_ia_write_statefile | "broken\x20", the lease line |
sub_7230 | config_parse_interface | "leasetime" |
sub_f6a8 | handle_solicit | "Got a NS for %s on %s" |
The fix is on row 5. The four rows above it are larger patches of the same update: the relay of router advertisements alone takes 65 instructions and drops 129.
The other architectures
| Build | Rows | Row of the fix |
|---|---|---|
armsr/armv8, AArch64 | 18 | 5 |
armsr/armv7, A32 | 20 | 1 |
malta/be, MIPS16e big endian | 21 | 1 |
malta/le, MIPS16e little endian | 21 | 1 |
hereticus-cli/tests/truth_cves.rs locks these ranks, and
fixtures/cve_binaries.py fetches and pins the binaries.
What this case found
The first reports of this update held two kinds of noise, both fixed:
- On A32, 18 PLT stubs read as changed. Two new imports moved every slot of the global offset table, and each stub loads its slot at an offset from itself. Two stubs of one import now read as unchanged.
- On MIPS16e, the lazy binding stubs of MIPS32, which
sstripleaves no section to find, decoded as MIPS16e and ran together: 14 read as gone and 18 as new. They are now read as MIPS32, 16 bytes each, and named for the dynamic symbol each resolves, asstrcmp@plt.
CVE-2026-55614: uhttpd, OpenWrt 24.10.7 to 24.10.8
OpenWrt 24.10.8 takes 17 patches to uhttpd, its web server
(OpenWrt commit f68fa73),
three of them for CVEs:
- CVE-2026-55614:
client_parse_headercomparedTransfer-Encodingwith case, soChunkedread as no encoding, and a proxy in front could read the body another way: request smuggling. The fix rejects every encoding it does not handle. - CVE-2026-55612:
client_poll_post_datakept the state of a chunk after an invalid chunk length. The fix closes the connection. - CVE-2026-55613: the ubus plugin,
uhttpd_ubus.so, kept reading after a POST body it could not parse. The fix closes the connection.
The others parse Content-Length and chunk sizes safely, fix a one-byte
overflow in uh_urldecode and an out-of-bounds read in uh_b64decode,
compare passwords in constant time, and keep the status of a CGI per
client. This report follows CVE-2026-55614, the first fix in
client_parse_header.
OpenWrt runs sstrip on what it ships, so only the segments of the file
are left. On MIPS the binary is MIPS16e, and it exports the functions
the plugins call, so some rows there have names.
Get the binaries
$ for v in 24.10.7 24.10.8; do
mkdir -p $v
curl -s https://downloads.openwrt.org/releases/$v/targets/armsr/armv8/openwrt-$v-armsr-armv8-rootfs.cpio.gz \
| gunzip | (cd $v && cpio -id usr/sbin/uhttpd)
done
Compare
$ hereticus --format markdown 24.10.7/usr/sbin/uhttpd 24.10.8/usr/sbin/uhttpd
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
sub_8c68 -> sub_8da0 | changed | 0.73 | 32/0/2 | 2 branch, 2 call, 2 jump, 7 mem, 3 text | similar |
sub_5b9c -> sub_5ba0 | changed | 0.81 | 24/7/6 | 3 branch, 4 call, 1 callee, 14 mem | similar |
sub_6c84 -> sub_6cfc | changed | 0.85 | 19/12/13 | 3 branch, 7 jump, 3 mem | similar |
sub_8dcc -> sub_8f80 | changed | 0.89 | 17/11/6 | 7 branch, 3 call, 2 jump, 5 mem | similar |
sub_5d78 -> sub_5dbc | changed | 0.96 | 16/3/3 | 2 branch, 1 call, 3 callee, 1 jump, 6 mem, 1 text | similar |
sub_7394 -> sub_7428 | changed | 0.97 | 6/7/1 | 2 branch, 2 jump, 3 mem | similar |
sub_9f50 -> sub_a10c | changed | 0.96 | 5/2/1 | 1 call, 3 mem, 1 const | similar |
sub_6ea4 -> sub_6f38 | changed | 0.96 | 0/0/1 | 1 branch | similar |
sub_9b08 -> sub_9cd0 | changed | 0.93 | 2/5/2 | 6 mem | similar |
sub_3dd0 | changed | 1.00 | 1/0/0 | - | similar |
sub_9c3c -> sub_9df8 | changed | 0.97 | 0/0/1 | - | similar |
sub_9d50 -> sub_9f0c | changed | 0.98 | 0/0/1 | - | similar |
sub_8cec: new
120 matched (106 unchanged, 14 changed), 0 gone, 1 new
The texts each function points at name most rows. Where a function
points at none, the row is named by the MIPS16e build, which names
client_poll_post_data, uh_auth_add, uh_auth_check and
uh_urldecode, and by the size of the edit there:
| Row | Function | What changed |
|---|---|---|
sub_8c68 | uh_auth_add, by "$p$" | a $p$ user is looked up by the state of the account |
sub_5b9c | client_poll_post_data, by the MIPS16e build | CVE-2026-55612, and chunk sizes parsed safely |
sub_6c84 | uh_urldecode, by the MIPS16e build | the one-byte overflow |
sub_8dcc | uh_auth_check, by "Authorization Required" | no stored hash as a password, and a constant-time compare |
sub_5d78 | client_parse_header, by "Transfer-Encoding" | CVE-2026-55614, and Content-Length parsed safely |
sub_3dd0 | main, by its option string | |
sub_8cec | new, by size | likely the constant-time compare |
The fix of CVE-2026-55614 is on row 5. Every patched function sits
above it or near it, so the fix is among the first rows a reader sees,
but --sort added ranks the larger edits first.
Read the fix
$ hereticus --view il --diff sub_5d78 24.10.7/usr/sbin/uhttpd 24.10.8/usr/sbin/uhttpd
...
x1 = &"transfer-encoding"
call strcmp
if (w0 != 0) goto $+0x3c
+ x2 = &page
+ x2 = load.64 [@strcasecmp]
x1 = &page
x0 = x21
x1 = &"chunked"
- call strcmp
+ call strcasecmp
if (w0 != 0) goto $+0x10
w0 = 1
store.8 [x19 + 0x2e7] = w0
goto $-0xfc
+ x2 = &page
+ w1 = 0x1f5
+ x2 = &"Not Implemented"
+ goto $-0x4c0
...
The value of Transfer-Encoding is now compared without case, and any
value but chunked answers 501 Not Implemented, where before it was
ignored. Just above, Content-Length is read by strtol in base 10,
with errno and the top of an int checked, where strtoul in base 0
read 010 as eight.
The other architectures
| Build | Rows | Row of the fix |
|---|---|---|
armsr/armv8, AArch64 | 13 | 5 |
armsr/armv7, A32 | 13 | 5 |
malta/be, MIPS16e big endian | 13 | 5 |
malta/le, MIPS16e little endian | 14 | 5 |
hereticus-cli/tests/truth_cves.rs locks these ranks, and
fixtures/cve_binaries.py fetches and pins the binaries.
Malware
Each page uses hereticus on two builds of one malware family, to find
what changed between them. The samples are live malware: hereticus only
reads them, and so should you.
Two builds of Mirai for ARM
theZoo keeps two builds of one Mirai
variant for ARM as Linux.Mirai.B. Both are static executables with their
symbols, so the symbols give the truth for a comparison by code alone.
Both fetch their next stage from the same host, so one operator built
them. This page calls the smaller build “old” and the larger one “new”:
the larger one holds more exploits, but nothing in the files dates them.
Warning
These files are live malware. Do not run them.
hereticusonly reads them.
| SHA-256 | Size | |
|---|---|---|
| Old | 03254e6240c35f7d787ca5175ffc36818185e62bdfc4d88d5b342451a747156d | 179383 bytes |
| New | f60b29cfb7eab3aeb391f46e94d4d8efadde5498583a2f5c71bd8212d8ae92da | 283000 bytes |
| Zip of theZoo | b50681094c040883f3da8fe697bd3a9e35e62d1672f59d9f14e0db494f9d41c2 | 182620 bytes |
Get the samples
$ curl -sLO https://github.com/ytisf/theZoo/raw/master/malware/Binaries/Linux.Mirai.B/Linux.Mirai.B.zip
$ sha256sum Linux.Mirai.B.zip
b50681094c040883f3da8fe697bd3a9e35e62d1672f59d9f14e0db494f9d41c2 Linux.Mirai.B.zip
$ unzip -P infected Linux.Mirai.B.zip && chmod a-x 03254e62* f60b29cf*
$ ln -s 03254e6240c35f7d787ca5175ffc36818185e62bdfc4d88d5b342451a747156d old
$ ln -s f60b29cfb7eab3aeb391f46e94d4d8efadde5498583a2f5c71bd8212d8ae92da new
Compare
$ hereticus --format markdown old new
The report pairs 531 functions: 492 unchanged and 39 changed, with 115 new. The first rows are the parts of the bot that grew:
| FUNCTION | CHANGE | SCORE | +/-/~ | TOUCHED | MATCHED BY |
|---|---|---|---|---|---|
killer_init | changed | 0.17 | 2224/39/52 | 304 branch, 242 call, 77 jump, 438 mem, 62 const, 1 unread | same name |
table_init | changed | 0.20 | 1046/8/27 | 209 call, 1 jump, 337 mem, 49 const | same name |
exploit_worker | changed | 0.19 | 225/4/1 | 111 call, 1 mem | same name |
attack_method_udpgeneric -> attack_method_ovh | changed | 0.65 | 129/31/13 | 4 branch, 10 call, 40 mem, 7 const | same place |
attack_method_udpdns -> attack_method_tcpfrag | changed | 0.62 | 125/119/38 | 21 branch, 15 call, 5 jump, 68 mem, 17 const | same place |
killer_init, which kills other bots, andtable_init, which holds the texts the bot hides, grew most.exploit_workerstarts each exploit in turn: about 20 more of them, asexploit_socket_hnap,_tr064,_huawei,_realtekand_gpon8080, each with its*_IPGenthat picks the addresses to try.- Six attacks, as
attack_method_udpgeneric, are gone, and six new ones, asattack_method_ovh, came. The report pairs each gone attack with a new one at the same place, as it pairs a renamed function, such asrand_alpha_strwithrand_alphastr. Here the names say that the attacks are different ones.
An exploit that changed its target
exploit_socket_edimax changed by only a few instructions:
$ hereticus --view il --diff exploit_socket_edimax old new
...
- r1 = 0x1b8
- r1 = r1 + 3
r0 = r4
+ r1 = 0x50
call socket_connect_tcp
- r1 = &"GET /tmui/login.jsp/..;/tmui/locallb/wor..."
+ r1 = &"POST /goform/mp HTTP/1.1\r\nUser-Agent: Mo..."
r4 = r0
- r2 = 0xf5
r0 = sp
- call memcpy
+ call sprintf
...
In the old build, the function named for Edimax sends the F5 BIG-IP
request of CVE-2020-5902 to port 443. The new build sends a request for
Edimax devices to port 80. Before this comparison, the IL view showed
the load of the payload as r1 = load.32 [pc + 0x4c] on both sides, and
the swap read as no change (RESEARCH.md, “Two builds of Mirai”).
By code alone
--ignore-names pairs the functions as if the builds had no symbols,
and --format json keeps the names, so each pair can be checked:
$ hereticus --ignore-names --all --format json old new
Of the 520 names in both builds, 514 pair with their own name and none pairs with another. 6 stay unpaired:
exploit_socket_edimax,exploit_socket_drayandattack_method_tcpackchanged.EDIMAX_IPGenandDRAY_IPGendid not change, but each*_IPGenis one template that differs from the others only in the exploit it calls, so it pairs only when its callee pairs (TODO.md, “Tell twins apart that no instruction separates”).
Three functions that only the old build has pair with three that only the new one has:
rand_alpha_strwithrand_alphastr, a rename.attack_method_udpvsewithattack_method_udpgame: the code is the same but for its constants, so the attack is likely the same, renamed.exploit_socket_arubawithexploit_socket_tp: two copies of one template, for different devices.
Indicators
The zip also holds two download scripts, which fetch builds for ten architectures and set up cron jobs. The addresses are defanged.
| Indicator | Where |
|---|---|
79.124.8[.]24 | the host of fetch.sh in the exploits of both builds, and of the sora.* builds in one script |
78.142.18[.]20 | a text in both builds |
dotheneedfull[.]xyz | the host of the fetch.* builds in the other script |
46.239.223[.]80 | the DNS server the second script writes to /etc/resolv.conf |
Methods
This page tells how hereticus finds the functions that changed. Each
step is a known method of binary diffing, or of a near field. Each section
names the code that does the step, and its entry in
RESEARCH.md.
That file gives the source of each method, and the measurements that
kept it.
The Steps
before.bin after.bin
│ │
▼ ▼
unpack ─▶ read ─▶ find functions ─▶ normalize (the same steps)
│ │
└──────┬────────┘
▼
pair the functions
▼
compare each pair
▼
sort the changed pairs
- Unpack each firmware image, with
--unpack. - Read each binary, and find its functions.
- Normalize each instruction, and lift it to an IL.
- Pair the functions of the two binaries.
- Compare each pair, and say what kind of change it has.
- Sort the changed pairs, so that a fix comes first.
Terms
- Pair: a function of the earlier binary and the function of the later
binary that
hereticusthinks is the same function. - Normalized body: the instructions of a function, with the details that a rebuild can change removed. See Normalizing.
- Twins: two or more functions of one binary with the same normalized body, as small wrappers that differ only in the function they call.
- Stub: a short piece of code that the linker writes for each function
that the binary imports. A call to
memcpygoes to the stub ofmemcpy, and the stub jumps through a slot of the GOT (global offset table) to the library. On ELF the stubs are in the PLT (procedure linkage table). - Text: a string in the binary that the code points to, as a format string or a message.
Unpacking Firmware
A vendor ships a kernel compressed, behind a header, and often with a
small decompressor before it. --unpack stub takes out the decompressor,
and --unpack kernel takes out the kernel it holds. hereticus reads
these containers: zImage, FIT, uImage, the sysupgrade tar of OpenWrt,
lzma-loader and TP-Link v2. Where a container gives the address its part
loads at, hereticus keeps it as the base of the raw image. binwalk finds
such containers by their signatures in the same way.
Code: unpack. See RESEARCH.md, A18.
Reading a Binary
hereticus reads ELF, Mach-O and raw images. An ELF file with no section
headers, as sstrip leaves it, is read through its program headers. The
dynamic segment still gives the dynamic symbols and the relocations.
The Load Address of a Raw Image
A raw image has no header, so --base gives the address it loads at. A
wrong base does not fail: the code still decodes, and each relative branch
still lands in the image. Thus hereticus checks the base on words that
hold absolute addresses, as a vector table or a table of pointers does.
Such words fall in the image only at the right base. On MIPS, a jal
holds its target, so at the right base most targets land on a prologue.
hereticus warns when the base looks wrong. With no --base, it stops,
and the error gives the base that the calls show, where they show one.
It does not use that base by itself, because a wrong base gives a wrong
report with no other sign. rbasefind and basefind use the same idea.
Limits: small position-independent code holds no absolute addresses, so nothing shows whether its base is right. A base that is wrong by megabytes looks the same as no evidence, not as a wrong base.
Code: check_base and suggest_base. See RESEARCH.md, A17.
Finding Functions
A binary with symbols names its functions. A stripped binary does not, so
hereticus finds them from the code and from what the linker left. It
uses these sources, the strongest first:
- The dynamic symbols. A shared library keeps them after stripping, because the loader links through them.
- The unwind tables.
.ARM.exidxand.eh_framegive the start of each function, and.eh_framegives its end too. They stay after stripping, because the unwinder needs them at run time. - Call targets. A call goes to the start of a function. A slot of the GOT that a call goes through holds the start of one.
- The code after a return, and prologues. Code after the end of a function usually starts a new one. This source is the weakest, so it fills only the gaps that the others leave.
Not every return ends a function, because most functions return from more than one place. A return that a branch jumps across is in the function. A prologue starts a function, whatever comes before it.
--functions lists what this step finds. The Ghidra extension uses it to
make the functions that Ghidra misses.
Limits: where the first two sources are missing, the result is a guess. A function that nothing calls, and that starts with no prologue, can join the function before it. A stripped binary that mixes A32 and Thumb-2, or MIPS32 and MIPS16e, is read in one instruction set.
Code: recover_functions. See RESEARCH.md, A16. Papers: Nucleus
(EuroS&P 2017) and FETCH (DSN 2021).
Normalizing
Two builds of one function differ in what the compiler can choose: which register holds a value, where a spill goes in the frame, and to which address a branch goes. These differences do not change what the code does. Normalizing removes them, and keeps the operation and the kind of each operand:
| Kept | Removed |
|---|---|
The operation, as add or load | The register numbers |
| The kind of each operand | The branch targets |
| The kind of control flow | The constants that are addresses |
| The size of a constant: zero, small, wide | The instructions of the stack frame |
Spellings of one operation become one: add, adds and addu are all
an add. The instructions that only make and remove the stack frame go,
because they are the largest difference between two builds.
A constant is an address when it falls in the memory of the binary. An address changes each time the linker moves what it points to, so it is removed, as a branch target is. The other constants stay with each instruction. The pairing does not compare them, but the report does.
For example, classify of fixtures/src/basic.c, built at -O2:
$ hereticus --diff classify O2/basic.o O1/basic.o
classify
cmp r0, 0x0
mvnmi r0, 0x0
bxmi lr
moveq r0, 0x0
bxeq lr
mov r1, r0
mov r0, 0x2
cmp r1, 0x64
movlt r0, 0x1
bx lr
And built at -O1:
$ hereticus --diff classify O1/basic.o O2/basic.o
classify
cmp r0, 0x0
mvnmi r0, 0x0
bxmi lr
moveq r0, 0x0
bxeq lr
mov r1, r0
mov r0, 0x2
cmp r1, 0x64
movlo r0, 0x1
bx lr
The bytes differ in one place: the -O1 build tests r1 < 100 with no
sign (movlo), and the -O2 build with a sign (movlt). The code before
it returns when r0 is negative, so the two do the same. Normalizing does
not keep the condition of a conditional move, so the two bodies are equal,
and the pair is identical shape:
| `classify` | same | 1.00 | - | - | identical shape |
Limits: normalizing makes -O1, -O2 and -Os builds alike. It does not
make an -O0 build like an optimized one, because at -O2 the compiler
uses a different algorithm. And, as above, a fix that changes only the
condition of a conditional move shows as no change.
Code: normalize_body_in. See RESEARCH.md, A2.
Lifting to an IL
Each instruction lifts to a small IL: what it reads, writes and tests,
the same on each architecture. Thus cbnz x8, L, and cmp x8, #0 with
b.ne L, read as one guard: “branch where x8 is not zero”. A
branch keeps its condition, either way round, because a compiler can
branch on the opposite condition and swap the code after it. --view il
shows the IL; see The Diff of One Function.
The IL also gives the data-flow features of a function. Each value is hashed with the operation that makes it and with the values that the operation reads, for two rounds. This is the main idea of Ghidra BSim, and it is a Weisfeiler-Lehman hash of the data-flow graph. When the compiler writes one operation with different instructions, these features can still agree.
Limits: the IL has no full semantics. It does not say what value an instruction computes. That keeps it fast: a whole kernel lifts in seconds.
Code: Lifted, find_guards and flow_features. See RESEARCH.md,
A9 and A15.
Pairing Functions
The pairing is a cascade of stages. Each stage pairs the functions it is sure of, and leaves the others to the next stage. The stages go from the most reliable to the least, so a weak stage cannot change a pair that a strong stage made. BinDiff and Ghidra Version Tracking work in the same way. The first stages are hashes, and their time is linear. Only the functions that they leave go to the similarity stage, which compares each pair.
The MATCHED BY column of the report names the stage that made each
pair. The stages, in the order they run:
| Stage | Pairs two functions where | MATCHED BY | Also in |
|---|---|---|---|
| Stubs by import | Two stubs jump to the same import | same import | |
| Identical code | Their bytes are the same | identical bytes | BinDiff hash matching |
| Identical shape | Their normalized bodies are the same | identical shape | Ghidra Exact Function Instructions Match |
| Names | Their symbols have the same name, unless --ignore-names | same name | BinDiff name hash matching |
| Twins in order | Equal groups of twins sit in the same order | twin, in order | BinDiff address sequence |
| Stubs in order | The stubs align as two ordered lists, with the calls of the pairs as votes | same callers | |
| Twins by what they reach | One constant, callee or text tells a twin from the others | identical shape | Diaphora Same constants |
| Similarity | Their score is the best, and no rival comes near it | similar | BinDiff prime signature matching |
| Tables | They have the same slot in two tables of function pointers, as two vtables | same slot of a table | |
| Unique references | Each is the only function of its binary that holds one text, constant or call to an import | same references | BinDiff call graph propagation |
| Callers | The calls of the pairs vote that they are one function | same callers | BinDiff call graph expansion |
| Context | Their callers, callees and texts agree, and their code is close enough | same callers, or similar where a pair by similarity moves | |
| Layout | They sit between the same two pairs, in the same order | same place | BinDiff address sequence; synteny in genomics |
| Moved code | One is a wrapper that kept the place of the other, and the wrapper with its new callee matches better than the callee alone, or the wrapper is at most 3 instructions and calls only that callee; or the reverse, for a callee inlined into its caller | the stage that paired the callee | O2NMatcher and N2NMatcher, 1-to-n matching |
Some stages run again, after a later stage pairs more functions.
A release often moves the body of a function into a new helper, and leaves a wrapper with the old name. The code of the old function then matches the helper, not the wrapper. The moved-code stage reads the wrapper and the helper as one function, so the report shows the old function as changed and the helper as new. That is what the source did. Where the whole body moved, the wrapper adds too little to show. A wrapper of at most 3 instructions that calls only the helper then takes the place of the old function, if it shares a paired caller with it, or if neither has a caller.
At the end, hereticus undoes a weak pair when no caller or callee of it
agrees, or when the other pairs join its two source files elsewhere. Such
a pair is often a deleted function that paired with a new function of
similar code. A stripped binary has no source files, so hereticus
guesses them: a compiler emits the functions of one file together, and
they call each other and share texts. BCD splits a binary into its files
by the same edges. With these checks, over the OpenWrt kernels,
0.69% of the pairs are wrong
against the debug symbols, and
1.77% across the
largest kernel upgrade.
The Similarity Score
The SCORE of two functions is the Jaccard similarity of their normalized
bodies, as multisets: the count of shared instructions divided by the
count of all. The order of the instructions does not count, because a
compiler can change it. A body of add, load, load and a body of
add, load, store share two instructions of four in all, so they score
0.5. A pair pairs where its score is --threshold or more.
The pairs go best first, in two passes. The first pass takes only close pairs. Their callees then help the second pass to choose. Three things change the order of the candidates of a function, but not its score:
- References. The callees and the texts that the two functions share.
- Constants. The constants that the two functions share. A constant comes from the source, so another build keeps it.
- Data flow. A pair under the threshold still pairs where its data-flow features agree.
A pair does not go where a free rival scores almost as high. The code cannot choose between the two, so a later stage chooses by place, references or callers. This is the ratio test of image matching.
Limits: a small function has many twins, and its code alone cannot tell them apart. Thus the stages after the similarity stage use what is around a function: its callers, its callees, its texts and its place.
Code: cascade.rs and score.rs. See RESEARCH.md,
A1 to A10, and entries 1, 7, 13, 14, 15, 16 and 17. The moved code and the
source files are in moved.rs and files.rs.
Gone and New Functions
A function with no pair is gone or new. It names its closest
candidate, where the score is --near-miss or more, and the unpaired
function in the same place. A place is a hint, not a pair. See
Gone and New Functions.
See RESEARCH.md, A14.
What Changed
A pair that matched can still differ. hereticus gives each pair one of
these kinds, which the CHANGE column shows:
| Kind | The pair |
|---|---|
| No change | Has the same code, or differs only in what normalizing removes |
| Reordered | Has the same instructions in a different order |
| Data | Looks like data, not code, and its bytes differ |
| Changed values | Has the same instructions with different constants or texts |
| Unreadable bytes | Differs in bytes that the decoder cannot read |
| Changed code | Has different instructions |
The two bodies align as a longest common subsequence, as diff aligns
lines. The alignment gives the counts of the +/-/~ column, and each
edit is a branch, a call, a jump, a memory access or other work, as the
TOUCHED column shows.
Normalizing hides each call target, so it also hides a call to a
different function. That is the most usual shape of a fix: strcpy
becomes strlcpy. Thus hereticus finds the callee of each call through
the PLT, the GOT, Mach-O stubs or relocations. When all the pairs are
known, it compares the callees of each pair. It compares the texts of
each pair in the same way. A path that differs only in a random build
directory is the same text.
Two shapes of a fix get their own mark:
- A call changed to its bounded form, as
sprintftosnprintf. This fix adds only the instruction that loads the limit. - A check before an early exit. The edit adds a conditional branch, and one way out of it leaves the function in a few instructions. SPIDER (IEEE S&P 2020) calls such a patch safe.
Code: change.rs, bounded.rs, exits.rs,
and Cascade::compare_callees and Cascade::compare_texts in
cascade.rs. See RESEARCH.md, A11 to A13.
Sorting the Report
A fix usually adds code, so by default the pair with the most added instructions comes first. A check before an early exit counts as more added instructions. A call changed to its bounded form comes first of all.
--sort rare gives more weight to an addition that few other functions
share. A fix adds its own code, but another compiler or flag adds the
same code to many functions. This is the inverse document frequency of
information retrieval. On average it does not rank fixes higher than the
default, so it is not the default.
Code: Sort in main.rs. See RESEARCH.md, entry 6.
How the Methods Are Measured
A method stays only when a measurement shows that it helps. The tests compare stripped builds with the symbols of the same builds, which give the right pairs:
- Backports. zlib, Lua, tinyxml2 and smallvec, each built in two ways, with one upstream fix between them.
- Published fixes. The fixes of Real-Life Examples.
- Ubuntu security updates. Each two releases of a C library in a row,
with the
-dbgsympackages as the symbols. - OpenWrt kernels. Kernels of each release, with their symbols.
Each measurement counts the right pairs and the wrong pairs, and the row
of each fix in the report. RESEARCH.md records each result, and also
the methods that did not help.
Comparison With Other Tools
Tests compare hereticus with radiff2 of radare2 6.2.4 and with Ghidra
12.1.4 Version Tracking. All three tools read the same
35 pairs of stripped libraries. Each pair
is one release of zlib, Lua, tinyxml2 or smallvec, before and after one
upstream fix, for ARM, Thumb-2, AArch64 or MIPS. The functions that the fix
edits are known, so each report gets a score.
Warning
These numbers are a narrow test, not a full measure of the three tools. Read How Accurate These Numbers Are before you quote them.
| Tool | Fixes found | Fix on row 1 | Rows | Rows the fix did not touch |
|---|---|---|---|---|
| hereticus | 35 | 20 | 85 | 0 |
| radiff2 | 20 | 9 | 2457 | 2418 |
| Ghidra | 33 | 18 | 759 | 629 |
A tool finds a fix when its report shows every function that the fix
edits. The row of a fix is the last row that a reader must read to see
all of those functions. A fix that edits more than one function cannot be
on row 1: the smallvec fix edits six copies of insert_many, so it takes
six rows.
A row the fix did not touch names no function that the fix edits or
that can change with it. Such rows are noise: most come from code that
moved, and thus has new addresses in it. For example, radiff2 shows the
zlib fix for MIPS, built by GCC at -O2, on
row 33 of
80. hereticus shows it
on row 1, and on no
other row.
How the Tests Score Each Tool
hereticusruns with no options, as a user runs it. Its rows are read as it lists them.- radiff2 runs as
radiff2 -A -C. A row counts as changed when its similarity is below 1, or when radiff2 labels itUNMATCH. - Ghidra runs Auto Version Tracking with the options of its own
AutoVersionTrackingScript. Version Tracking pairs functions, but does not say which pairs changed. Thus a pair counts as changed by the rule ofFindChangedFunctionsScript, which ships with Ghidra: no exact match of the instructions pairs it. - The rows of radiff2 and Ghidra are read the lowest score first. Rows with the same score have no order, so the rows of the fix are read first among them. This rule helps the other tool.
On each build, hereticus must find the fix, show it on the same row as
the other tool or on an earlier row, and show no more rows that the fix did
not touch. If a change makes hereticus worse than the other tool on one
build, the test fails.
Time
just rival-times shows how long each tool takes on each pair. On these
small libraries, hereticus and radiff2 each take less than a second, and
Ghidra takes much longer, as it analyses each program and then tracks the
versions. The test checks only that hereticus takes less time than
Ghidra in all. A time depends on the machine, so this page quotes none.
Functions in a Raw Kernel
Before a tool can compare functions, it must find them. A test counts the
functions that hereticus and Ghidra find in one raw OpenWrt kernel: 22.03.2
for the GL.iNet GL-MT300N v2, MIPS32 little-endian, with no symbols and no
sections. The symbols of its build say where each of its
20988 functions really starts.
| Tool | Starts found | Real | Not real |
|---|---|---|---|
| hereticus | 19741 | 19277 | 464 |
| Ghidra, default | 13801 | 13210 | 591 |
| Ghidra, Aggressive Instruction Finder | 14144 | 13545 | 599 |
Ghidra finds a function when code calls it, or when its first bytes look
like the start of a function. Of the real functions that Ghidra finds,
77% have a direct call. Of the
6103 real functions that only hereticus
finds, 5% have a direct call, and the
address of 76% is in a data word. These
functions are callbacks: system calls, file_operations, net_device_ops,
sysfs handlers. The kernel calls them through tables of pointers. A raw
image does not show where those tables are, and Ghidra does not read data
words as pointers unless it is told to. The Aggressive Instruction Finder
does not follow them either.
This is not a fault in Ghidra. Ghidra is made for a person who reads the result, and code that it reads from data by mistake is hard to remove. So it adds only the functions it is sure of, and a person adds the rest. A script that reads the data words as pointers can close much of the gap. With an ELF file and its symbols, Ghidra finds every function. This test reads one kernel for one architecture, and it counts only where functions start, not how well each tool pairs them.
Functions in Garbled Go
Go malware is obfuscated, mostly with garble, so every Go fixture is
built so too: shfmt for seven architectures. garble strips the
symbols, scrambles the names, and hides the function table of Go behind a
magic number of its own, so Ghidra’s Go analyzer reads nothing and both
tools read the code alone. A test counts the real function starts that each
tool finds, with the hidden table, read with the usual magic put back, as
the answer.
| Build | hereticus finds | hereticus misses | Ghidra finds | Ghidra misses |
|---|---|---|---|---|
| x86-64 | 3624 | 11 | 2429 | 1206 |
| i386 | 3674 | 5 | 2542 | 1137 |
| AArch64 | 3554 | 40 | 2373 | 1221 |
| ARMv7 | 3597 | 75 | 2509 | 1163 |
| ARMv5 | 3632 | 71 | 2523 | 1180 |
| MIPS32 big-endian | 3491 | 88 | 2379 | 1200 |
| MIPS32 little-endian | 3491 | 88 | 1517 | 2062 |
The same test compares shfmt 3.11.0 with 3.12.0, both garbled, as two
releases of one piece of malware. garble names each function anew in
each release, so the pairs come from the code alone, and garble reverse
gives back the names that judge them. The few wrong pairs join twins with
the same code, as unix.mmap and unix.mremap.
Ghidra loads the little-endian MIPS build as 64-bit MIPS32r6, which the ELF
header does not say, and so finds the fewest there. On a build that
garble did not touch, Ghidra 12 reads the function table of Go for x86-64
and AArch64, and finds almost every function there.
How Accurate These Numbers Are
The numbers are correct for these pairs and these settings. They do not show that one tool is better than another in general. Know these limits:
- The pairs are small and few. Each pair is a small library with one small fix. A large binary, or a release with many changes, can give a different result.
- No kernel pair is compared. A raw kernel of a few megabytes takes
radiff2 and Ghidra many minutes for each pair, so the tests do not
compare the reports on the OpenWrt kernels.
tests/truth_kernels.rschecks onlyhereticuson them. The section above compares only where the functions of one kernel start. - The other tools run with their default settings. Other options, or a person who reads the results, can make radiff2 or Ghidra do better. For example, Ghidra can run more correlators, and radiff2 can analyse more deeply.
- The rules that score the other tools are ours. Ghidra does not mark a pair as changed, so the tests use the rule of its own script. radiff2 gives no clear label for a change, so the tests count a row below a similarity of 1 as changed. Other rules give other numbers.
- The scores favour what
hereticusis made for. The tests count the rows that a reader must read to find a fix. Ghidra Version Tracking is made to carry names and comments from one version to the next, not to rank changes, so this score is not its aim. hereticuswas developed with these pairs. Its tests use the same backports, so its rules can fit these pairs better than other binaries.- The libraries are not fully stripped. A shared library keeps the names of the functions it exports, and all three tools can read them. Without names, the results can be different.
- Times depend on the machine. The other tools ran alone, and
hereticusran while other tests ran.
Run the Tests
just rivals # run radiff2 on the pairs
just quick-test # compare with radiff2, in rivals_radare2.rs
just rivals-ghidra # run Ghidra on the pairs
just test # compare with Ghidra too, in truth_ghidra.rs
just rivals-kernel # let Ghidra find the functions of one raw kernel
just rivals-go # let Ghidra find the functions of the garbled Go builds
just rival-times # show how long each tool takes
The Ghidra image is large and a first run takes a long time, so only
just test compares with Ghidra. fixtures/rivals/builds.txt lists the
pairs. The snapshots of the two tests hold the score of each tool on each
pair.
Development
All patches/merge requests are welcome!
just quick-test # about a minute
just test # the same, plus the slow and the `truth_*` tests
just fixtures # rebuild the ARM32, MIPS, Mach-O and garbled Go corpus
just rivals # run radiff2 on the backports, which `quick-test` compares with
just rivals-ghidra # run Ghidra Version Tracking on them, which `test` compares with
just ghidra-extension # build the Ghidra extension and run it in Ghidra, which `test` checks
just bench # time the read and the comparison of the backports
Tests
| Tests | Where | What they check |
|---|---|---|
| Unit and property tests | each crate, beside the code | one function, on regular and on random inputs |
| Fixtures | hereticus/tests/ | small programs of fixtures/src/, built at each level for each architecture |
| Backports | cross_build.rs, backports.rs | real libraries built before and after one upstream fix, against their symbols |
| Rivals | hereticus-cli/tests/rivals_radare2.rs, truth_ghidra.rs | that hereticus does no worse than radiff2 and Ghidra |
| Truth | truth_*.rs | published fixes, OpenWrt kernels and Ubuntu updates, against their symbols |
| Docs | tests/docs.rs, docs_output.rs | each number and output that the docs show |
A snapshot of insta locks each count. When a change moves a count, the
test fails and shows the new count. Accept it with cargo insta review
where the change is better, and update the docs that quote it.
Add a Published Fix
- Add the URLs and the SHA-256 of the two binaries to
fixtures/cve_binaries.py, and runjust truth. - Add a
Fixtohereticus-cli/tests/truth_cves.rs, with the function of the first binary that the fix edits, as the report names it. - Write the report in
real-life-reports/, and a page inbook/src/real-life-examples/that includes it. Tag each number and each output, as Numbers in the Docs says. - Add the page to
book/src/SUMMARY.md, and a row to the table of Real-Life Examples.
Add an Architecture
An architecture needs three parts:
- A decoder and a lifter in
hereticus-il. - A file of rules in
hereticus-recovery: which instruction opens a function, which word is padding, and where the unwind index is. - One file in
hereticus-arch: which register holds the frame, which instruction only keeps it, and which constant is an address.
Then add fixtures for it to fixtures/build.sh, and run the tests.
Benchmarks
hereticus/benches/ reads and compares backports of zlib for x86-64 and of
Lua for ARM32, with symbols, stripped and as raw images. Each bench has two
forms:
criterion_benchmarkmeasures the wall time.gungraun_benchmarkcounts the instructions under Valgrind, and needsgungraun-runnerof the same version asgungrauninCargo.lock.
On each pull request, .github/workflows/benchmark.yml runs both on the base
branch and on the pull request, and writes the change as a comment. Use the
counts of gungraun to make a decision, because the wall time of a CI runner
changes with its load.
Crates
The hereticus crate re-exports the crates below. To use only one part,
depend on that crate. Each crate in the table needs only the crates in its
row.
| Crate | Does | Needs |
|---|---|---|
hereticus-il | Decode machine code, and lift it to an IL | nothing |
hereticus-memory | The address space of a binary | nothing |
hereticus-unpack | Unpack firmware archives | nothing |
hereticus-recovery | Find functions in stripped binaries | il, memory |
hereticus-arch | The rules the matching reads | il, memory, recovery |
hereticus-image | Read ELF, Mach-O and raw images | arch, il, memory, recovery |
hereticus-callees | Find the callee of each call | arch, il, image, memory, recovery |
hereticus-view | Show code as instructions or as IL | arch, callees, il, image, memory, recovery |
hereticus-match | Compare the functions of two binaries | arch, callees, il, image, memory, recovery, view |
Numbers in the Docs
A number that the docs quote from a locked test names its source in a
comment right after it, and tests/docs.rs checks that the two agree:
0.95%<!-- from truth_kernels: all / wrong% --> of the pairs are wrong
64488<!-- from boundaries.rs: exact --> exact
A source without .rs is a snapshot of hereticus/tests/snapshots or of
hereticus-cli/tests/snapshots, and
the key is a row and a column of its tables. A source with .rs is a test,
and the key is a field that the test sets once. When a snapshot changes,
just quick-test fails until the docs quote the new numbers. The entries
of RESEARCH.md record the numbers of their own time, so they have no tag.
An output that the docs show names the arguments that make it, and
hereticus-cli/tests/docs_output.rs runs the tool with them and checks
that the docs show a part of what it prints:
<!-- output: --diff mul_by_constant fixtures/build/O2/basic.o fixtures/build/O2/basic_v2.o -->
...
<!-- end output -->
The Book
Each page of Real-Life Examples includes a file of
real-life-reports/. To change a page, edit its report. To see the book:
mdbook serve book