Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

FixBinaryRow of the fixRows in the report
CVE-2026-30874procd, OpenWrt, stripped124
CVE-2026-35385dropbear scp, OpenWrt, stripped119
CVE-2024-23265AppleDiskImages2, iOS kernelcache133
CVE-2021-30883AppleCLCD, iOS kernelcache1333

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:

ToolFixes foundFix on row 1Rows the fix did not touch
hereticus35200
radiff22092418
Ghidra3318629

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 | head is enough, and the agent reads a few rows, not a project.
  • The output is short. --format compact gives one line for each pair, and --format json gives one object for each pair.
  • Each name works as input. The report names a stripped function sub_7630, and --diff sub_7630 shows its diff.
  • The diff reads alike on each architecture. --view il shows a compare, a call or a text in one form, as if (w0 == 0) goto ... and call 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-code lets a CI job or a script stop on a change.

See Use With a Coding Agent.

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 sstrip leaves 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

  1. Installation: get the binary.
  2. Usage: read the report, and the options.
  3. Examples: a raw firmware stub, and the diff of one function.
  4. Real-Life Examples: published fixes, step by step.
  5. 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

ColumnWhat it shows
FUNCTIONThe name of the function. A stripped function is sub_<address>, and a -> b gives its address in each binary.
CHANGEThe kind of change. See the table below.
SCOREHow alike the two functions are, from 0.00 to 1.00. See The Similarity Score.
+/-/~The instructions that the edit added, removed and rewrote.
TOUCHEDThe kind of code that the edit changed. See the list below.
MATCHED BYWhy the two functions are a pair. See Pairing Functions.

The CHANGE column has these words:

WordThe pairShown
changedHas different instructionsYes
reorderedHas the same instructions in a different orderYes
changed unreadDiffers in bytes that the decoder cannot readYes
changed valuesHas the same instructions, with other constants or textsOnly with --show-retouched, or where a text changed
changed dataLooks like data, not code, and its bytes differOnly with --show-data
sameHas no change that a reader would seeOnly 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, as strcpy to strlcpy.
  • 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, for grep, cut and awk.
  • 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

StatusWhen
0Nothing changed, or something changed and --exit-code is not given
1Something changed, and --exit-code is given
2An 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

  • --threshold is the lowest score that still pairs two functions. A lower value pairs more functions, and more of them wrongly.
  • --near-miss is the lowest score at which a gone or new function names its closest candidate.
  • --sort changes the order of the changed functions. added, the default, puts the most added code first, because a fix usually adds a check. rare gives more weight to code that few other functions add. removed and modified sort by the other two counts, and confidence keeps 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 sstrip left 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 -O2 the 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.

  • s changes the order, and S reverses it.
  • t lists only the pairs that touched one kind of code, as call.
  • / filters on the names. The letters must come in order, but need not be next to each other.
  • a shows every function, r the pairs that changed only a constant, and d the 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.
  • v changes between assembly and IL.
  • l changes between two columns and one.
  • n and p go to the next and the previous function.
  • Esc goes back to the list.

All Keys

? shows each key, on each screen.

KeyIn the listIn the diff
j / kdown / updown / up
PgDn / PgUpa page down / upa page down / up
g / Gfirst / lasttop / bottom
Enteropen the diff
s / Snext order / reverse the order
tnext kind touched
a / r / devery function / retouched pairs / data regions
/filter the names
] / [next / previous change
vassembly or IL
lside by side or one column
n / pnext / previous function
Escback to the list
?this helpthis help
qquit, after a questionquit, 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_80208000changed0.981/1/1-similar
sub_8020a338changed0.940/0/1-similar
sub_80208af8changed0.950/0/1-similar
sub_80208a18changed0.960/0/1-similar
sub_80209154changed0.960/0/11 memsimilar
sub_8020b1f8changed0.980/0/1-similar
sub_80208e08changed unread0.980/0/1-similar
sub_8020a3bcchanged1.000/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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
dispatchchanged0.6221/11/54 branch, 1 call, 6 jump, 6 mem, 1 constsame name
check_headerchanged0.318/1/01 branch, 1 call, 1 memsame name
handlechanged0.828/4/21 branch, 1 call, 1 jump, 1 mem, 1 constsame name
validate_lengthchanged values1.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 rare puts 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. hereticus makes -O1, -O2 and -Os builds alike, but not an -O0 build 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, not arm32. A MIPS image is big-endian or little-endian: mips32-be or mips32.
  • 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

  • --functions lists each function that hereticus finds 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 hereticus finds and Ghidra does not.
  • Hereticus Function Match, a Version Tracking correlator. It proposes each pair of functions that hereticus finds 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

  1. Install hereticus on the PATH. See Installation.

  2. 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>.zip for 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/.

  3. 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 hereticus sees 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 hereticus thinks 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 BY column of the report names each stage.
  • Tag: what changed, as hereticus: same or hereticus: 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 hereticus agree with those of Ghidra when the first section that hereticus reads is the first that Ghidra loads.
  • Only ELF and raw images are tested in Ghidra. A Mach-O file goes to hereticus as 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.

FixBinary, releaseBuildRow of the fixRows
CVE-2019-19945uhttpd, OpenWrt 18.06.5 to 18.06.6AArch6435
CVE-2020-7248libubox, OpenWrt 19.07.0 to 19.07.1AArch6413
CVE-2021-30883AppleCLCD, iOS 15.0.1 to 15.0.2kext1333
CVE-2024-23265AppleDiskImages2, iOS 17.3.1 to 17.4kext133
CVE-2025-14282dropbear, OpenWrt 24.10.4 to 24.10.5AArch64214
CVE-2025-24257IOGPUFamily, iOS 18.3.2 to 18.4kext19403
CVE-2025-62525the ltq-ptm kernel module, OpenWrt 24.10.3 to 24.10.4MIPS32 BE12
CVE-2025-62526ubusd, OpenWrt 24.10.3 to 24.10.4AArch6458
CVE-2026-28825smbfs, macOS 26.3.1 to 26.4kext9462
CVE-2026-30873jsonfilter, OpenWrt 24.10.5 to 24.10.6AArch6411
CVE-2026-30874procd, OpenWrt 24.10.5 to 24.10.6AArch64124
CVE-2026-35385dropbear scp, OpenWrt 24.10.7 to 24.10.8AArch64119
CVE-2026-53921odhcpd, OpenWrt 24.10.7 to 24.10.8AArch64518
CVE-2026-55614uhttpd, OpenWrt 24.10.7 to 24.10.8AArch64513

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 with Transfer-Encoding: chunked and a Content-Length above 2^31 reads as a negative length: client_parse_header stores it, and client_poll_post_data passes it on to ustream_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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_4076f8 -> sub_4076fcchanged0.916/1/01 branch, 1 jumpsimilar
sub_4040fcchanged0.973/2/01 branch, 2 memsimilar
sub_404288 -> sub_404290changed0.993/1/01 branchsimilar
sub_404bfc -> sub_404c08changed0.971/2/02 memsimilar
sub_404ca4 -> sub_404cacchanged0.981/2/02 memsimilar

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 with snprintf and the size of the buffer, in place of sprintf.
  • 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_1184 -> sub_1188changed0.9510/5/32 call, 2 callee, 3 memsimilar
sub_e70changed0.991/0/11 callee, 1 textsimilar
blobmsg_add_json_elementchanged0.8917/3/31 branch, 1 call, 1 callee, 1 jump, 6 mem, 2 const, 1 textsame 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_fffffff009c0b13c -> sub_fffffff009c0b178changed0.7228/16/53 branch, 4 call, 3 jump, 8 memsimilar
sub_fffffff009c0a39cchanged0.4010/14/21 branch, 2 call, 1 jump, 4 memsame callers
sub_fffffff009c0aedc -> sub_fffffff009c0aef8changed0.8914/5/42 branch, 1 call, 1 memsimilar
…

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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_fffffff028a156c8 -> sub_fffffff008a3341cchanged0.974/0/01 branchsimilar
sub_fffffff028a14798 -> sub_fffffff008a324f4changed0.970/2/11 mem, 2 constsimilar
sub_fffffff028a0e780 -> sub_fffffff008a2c4e0changed0.980/0/11 constsimilar
sub_fffffff028a12400 -> sub_fffffff008a30160changed1.000/1/02 const, 5 textsimilar
sub_fffffff028a0ba10 -> sub_fffffff008a29770changed values1.00-8 const, 1 textidentical 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_15fd0 -> sub_1650cchanged0.9574/37/08 branch, 16 call, 2 jump, 22 mem, 1 textsimilar
sub_15048 -> sub_1513cchanged0.2164/2/08 branch, 8 call, 2 jump, 17 mem, 4 textsame place
sub_19acc -> sub_19c14changed0.9142/20/05 branch, 10 call, 2 jump, 16 mem, 1 constsimilar
sub_3c48 -> sub_3d08changed0.9925/18/75 branch, 2 call, 1 jump, 8 mem, 6 const, 1 textsimilar
sub_13f8c -> sub_14078changed0.925/6/23 call, 6 memsimilar
sub_17e94 -> sub_18078changed0.985/6/23 call, 5 memsimilar
sub_18488 -> sub_18674changed0.944/39/44 branch, 10 call, 3 jump, 7 mem, 2 textsimilar
sub_17b48 -> sub_17dc0changed0.812/45/17 branch, 7 call, 2 jump, 10 mem, 3 textsimilar
sub_13e0c -> sub_13f90changed0.580/38/04 branch, 7 call, 1 jump, 8 mem, 2 textsame references
sub_2293c -> sub_22ad4changed0.990/0/2-similar
sub_c328 -> sub_c40cchanged values1.00-1 textidentical shape
sub_1c2a8 -> sub_1c440changed values1.00-1 textidentical shape
  • sub_13070: new
  • sub_130c0: new

417 matched (404 unchanged, 13 changed), 0 gone, 2 new

The texts each function points at name the rows:

RowFunctionWhat changed
sub_15fd0sink of scpCVE-2019-6111: names from the server must match the wildcard
sub_15048send_msg_userauth_successthe fix: svr_switch_user, inlined, drops to the user
sub_19accrecv_msg_kexdh_inita rekey offers only the host key in use
sub_3c48the options of the servernot read further
sub_13f8cthe owner of the ptythe utmp group is raised around it
sub_17b48execchildno longer changes the user
sub_13e0csvr_agentcleanupno longer takes the user’s IDs to remove the agent socket: the server runs as the user by then
sub_13070, sub_130c0svr_raise_gid_utmp, svr_restore_gidnew

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:

BuildRowsRow of the fixScore
armsr/armv8, AArch641420.21
armsr/armv7, A323310.25
malta/be, MIPS32 big endian2220.28
malta/le, MIPS32 little endian1920.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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_fffffff009a95038 -> sub_fffffff009bb22c0changed0.9396/79/1533 branch, 14 call, 17 jump, 27 mem, 34 constsimilar
…
sub_fffffff009a765c0 -> sub_fffffff009b92fa8changed0.7620/6/32 branch, 1 call, 1 jump, 4 mem, 3 const, 2 textsimilar
…

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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_267cchanged0.7069/37/911 branch, 7 call, 3 jump, 34 mem, 1 constsimilar
  • 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_4d8c -> sub_4d0cchanged0.8836/7/49 branch, 6 call, 3 jump, 11 mem, 1 constsimilar
sub_33c4 -> sub_32ecchanged0.8114/9/62 branch, 1 call, 3 jump, 10 memsimilar
sub_4ca0 -> sub_4c04changed0.868/0/01 branch, 1 jump, 4 mem, 1 constsimilar
sub_374c -> sub_3698changed0.967/2/01 branch, 1 jump, 2 constsimilar
sub_4a3c -> sub_49a0changed0.984/5/02 branch, 1 memsimilar
sub_2b58 -> sub_5878changed0.973/1/11 call, 2 mem, 3 constsimilar
sub_3f4c -> sub_3ea8changed0.992/0/01 call, 1 mem, 5 constsimilar
  • 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:

RowFunctionWhat changed
sub_4d8cthe ACL file loaderuser and group by number; the four new if (!o) return;
sub_33c4ubusd_acl_checkcompares the user and the groups by number
sub_4ca0ubusd_acl_alloc_objthe new if (!len) return NULL; after strlen
sub_374c__ubusd_handle_lookupthe new if (!len), which returns UBUS_STATUS_INVALID_ARGUMENT
sub_4a3cubusd_alloc_event_patternthe fix
sub_2b58, sub_3f4cclient set-upthe 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
_smb_iod_threadchanged0.89285/386/15444 branch, 50 call, 24 jump, 151 mem, 41 const, 15 textsame name
…
_smb2_rq_decompress_readchanged0.9781/68/316 branch, 16 call, 2 jump, 35 mem, 31 const, 2 textsame 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_token reads one token of a JSONPath expression into an operation on its stack. match_token copies the text of a string, a label or a regular expression into it with strdup, and jp_alloc_op copies 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_402c48changed0.925/0/01 branch, 1 callsimilar

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

BuildRowsRow of the fix
armsr/armv8, AArch6411
armsr/armv7, A3241
malta/be, MIPS16e big endian11
malta/le, MIPS16e little endian11

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_call copies the environment that a caller of the hotplug object of ubus gives, and must drop PATH. It compared each variable, as PATH=/tmp, to "PATH" with strcmp, so no variable ever matched, and a caller could set the PATH of the scripts it runs. The fix compares the first 5 bytes to "PATH=" with strncmp (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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_7630 -> sub_75c0changed1.001/0/01 callee, 1 textsimilar
sub_f99c -> sub_fb64changed0.8531/57/511 branch, 10 call, 5 jump, 27 mem, 14 constsimilar
sub_8ab4 -> sub_8bb0changed0.8928/8/23 branch, 3 call, 5 mem, 1 textsimilar
sub_ae68 -> sub_afb8changed0.6914/10/63 branch, 1 call, 2 callee, 4 jump, 5 mem, 2 constsimilar
sub_12994 -> sub_12b20changed0.9219/0/12 branch, 2 call, 1 jump, 3 mem, 1 textsimilar
sub_f604 -> sub_f790changed0.9316/0/01 branch, 2 call, 4 mem, 3 const, 1 textsimilar
sub_e920 -> sub_ea94changed0.947/0/02 branch, 2 memsimilar
sub_70b0 -> sub_8220changed0.8610/0/02 call, 1 mem, 2 textsimilar
sub_12e14 -> sub_1300cchanged0.629/4/67 branch, 1 jump, 3 const, 1 textsame references
sub_10938 -> sub_10aacchanged0.949/0/1918 mem, 7 constsimilar
sub_df54 -> sub_e0a8changed0.969/1/810 memsimilar
sub_11edc -> sub_1204cchanged0.997/0/01 call, 1 mem, 1 const, 1 textsimilar
sub_7b08 -> sub_7a9cchanged0.921/2/31 branch, 2 memsimilar
sub_115f0 -> sub_11788changed0.976/16/14 call, 1 callee, 1 mem, 7 const, 2 textsimilar
sub_fef4 -> sub_1005cchanged0.986/3/13 call, 3 jump, 2 mem, 2 constsimilar
sub_73f0 -> sub_73c8changed0.794/21/41 branch, 23 mem, 24 constsimilar
sub_fe74 -> sub_ffd0changed0.893/0/01 call, 2 mem, 1 constsimilar
sub_8044 -> sub_8378changed0.432/86/29 branch, 10 call, 2 jump, 15 mem, 5 textsame references
sub_8d70 -> sub_8edcchanged0.821/0/12 memsimilar
sub_6a94 -> sub_6b14changed0.890/0/17 constsimilar
sub_a34c -> sub_a4bcchanged0.980/8/29 mem, 7 constsimilar
  • sub_6ca8: new
  • sub_806c: new
  • sub_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

BuildRowsRow of the fix
armsr/armv8, AArch64241
armsr/armv7, A32291
malta/be, MIPS32 big endian251
malta/le, MIPS32 little endian251

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: scp kept 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.
  • -r into 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_1650c -> sub_1623cchanged0.69369/64/1463 branch, 40 call, 1 callee, 31 jump, 63 mem, 4 textsimilar
sub_1a5cc -> sub_1a83cchanged0.9475/58/1912 branch, 16 call, 1 callee, 6 jump, 43 mem, 2 constsimilar
sub_3d08 -> sub_3d88changed0.9921/4/42 branch, 2 call, 1 jump, 9 mem, 2 const, 1 textsimilar
sub_105a4 -> sub_10128changed0.996/9/02 mem, 1 constsimilar
sub_13ec8 -> sub_13530changed0.805/1/02 call, 2 memsimilar
sub_12144 -> sub_11b90changed0.944/5/11 call, 4 memsimilar
sub_13dfc -> sub_134a8changed0.463/22/32 branch, 1 call, 2 jump, 11 memsame callers
sub_11bec -> sub_13594changed0.973/4/21 memsimilar
sub_17dc0 -> sub_18028changed0.983/0/01 branch, 1 memsimilar
sub_1d2a8 -> sub_1d83cchanged0.993/3/32 constsimilar
sub_23f3c -> sub_245c8changed1.003/3/12 memsimilar
sub_1cea8 -> sub_1d440changed1.003/4/32 constsimilar
sub_1cbb0 -> sub_1cb48changed0.961/0/0-similar
sub_10c70 -> sub_107e8changed0.971/2/02 memsimilar
sub_22ad4 -> sub_23160changed0.991/1/1-similar
sub_8200 -> sub_82d0changed0.780/0/1-similar
sub_1d8e4 -> sub_1de78changed0.980/0/1-similar
sub_c40c -> sub_c4dcchanged values1.00-1 textidentical shape
  • sub_10bc8: new

419 matched (399 unchanged, 20 changed), 0 gone, 1 new

The texts each function points at name the first rows:

RowFunctionTexts
sub_1650csink of scp"mtime.sec not delimited", and new "filename does not match request"
sub_1a5ccthe authentication of the server"unknown service in auth"
sub_3d08the options of the server"Bad maxauthtries '%s'"
sub_105a4the 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

BuildRowsRow of the fix
armsr/armv8, AArch64191
armsr/armv7, A32211
malta/be, MIPS32 big endian161
malta/le, MIPS32 little endian281

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_ia wrote an address past the end of the reply buffer.
  • CVE-2026-53918: dhcpv6_ia_handle_IAs kept a pointer to a lease it had freed.
  • CVE-2026-53922: dhcpv6_ia_handle_IAs took 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_13498 -> sub_11ff0changed0.6065/129/841 branch, 5 call, 11 jump, 50 mem, 1 constsame references
sub_11e3c -> sub_123a0changed0.9853/46/88 branch, 6 call, 10 jump, 31 mem, 1 const, 14 textsimilar
sub_9d34 -> sub_9dd8changed0.9823/11/94 branch, 18 mem, 1 constsimilar
sub_fadc -> sub_fd30changed0.9321/0/12 branch, 2 call, 1 jump, 4 memsimilar
sub_e12c -> sub_e338changed0.9721/15/119 branch, 1 jump, 10 mem, 1 textsimilar
sub_d70c -> sub_d8ecchanged0.9821/10/42 call, 20 mem, 6 textsimilar
sub_7230 -> sub_72c4changed0.9919/14/92 branch, 2 call, 1 jump, 19 mem, 2 textsimilar
sub_b868 -> sub_bc6cchanged0.8312/4/01 branch, 6 mem, 1 constsimilar
sub_6ee4 -> sub_6f74changed0.992/0/01 branchsimilar
sub_f6a8 -> sub_f8e8changed0.976/0/02 branch, 2 mem, 1 constsimilar
sub_c300 -> sub_c4ccchanged0.975/4/08 mem, 2 constsimilar
sub_f02c -> sub_f25cchanged0.914/0/01 call, 2 memsimilar
sub_c78c -> sub_c95cchanged0.994/1/11 branch, 1 mem, 1 constsimilar
sub_ac2c -> sub_ad00changed0.993/1/01 branch, 2 mem, 1 constsimilar
sub_93fc -> sub_94a0changed values1.00-2 textidentical shape
sub_d358 -> sub_d538changed values1.00-1 textidentical shape
  • sub_be84: new
  • sub_13a18: new

168 matched (152 unchanged, 16 changed), 0 gone, 2 new

The texts each function points at name the rows:

RowFunctionTexts
sub_13498the relay of router advertisements"Forward a RA on %s"
sub_11e3cthe neighbour and route tables"hop_limit", "rfc9096: ..."
sub_9d34handle_client_request"Got a DHCPv6-request on %s"
sub_fadcndp_setup_interface"/proc/sys/net/ipv6/conf/%s/proxy_ndp"
sub_e12cdhcpv6_ia_handle_IAs, with build_ia"clamping requested PD ...", "IA_PD"
sub_d70cdhcpv6_ia_write_statefile"broken\x20", the lease line
sub_7230config_parse_interface"leasetime"
sub_f6a8handle_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

BuildRowsRow of the fix
armsr/armv8, AArch64185
armsr/armv7, A32201
malta/be, MIPS16e big endian211
malta/le, MIPS16e little endian211

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 sstrip leaves 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, as strcmp@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_header compared Transfer-Encoding with case, so Chunked read 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_data kept 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
FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
sub_8c68 -> sub_8da0changed0.7332/0/22 branch, 2 call, 2 jump, 7 mem, 3 textsimilar
sub_5b9c -> sub_5ba0changed0.8124/7/63 branch, 4 call, 1 callee, 14 memsimilar
sub_6c84 -> sub_6cfcchanged0.8519/12/133 branch, 7 jump, 3 memsimilar
sub_8dcc -> sub_8f80changed0.8917/11/67 branch, 3 call, 2 jump, 5 memsimilar
sub_5d78 -> sub_5dbcchanged0.9616/3/32 branch, 1 call, 3 callee, 1 jump, 6 mem, 1 textsimilar
sub_7394 -> sub_7428changed0.976/7/12 branch, 2 jump, 3 memsimilar
sub_9f50 -> sub_a10cchanged0.965/2/11 call, 3 mem, 1 constsimilar
sub_6ea4 -> sub_6f38changed0.960/0/11 branchsimilar
sub_9b08 -> sub_9cd0changed0.932/5/26 memsimilar
sub_3dd0changed1.001/0/0-similar
sub_9c3c -> sub_9df8changed0.970/0/1-similar
sub_9d50 -> sub_9f0cchanged0.980/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:

RowFunctionWhat changed
sub_8c68uh_auth_add, by "$p$"a $p$ user is looked up by the state of the account
sub_5b9cclient_poll_post_data, by the MIPS16e buildCVE-2026-55612, and chunk sizes parsed safely
sub_6c84uh_urldecode, by the MIPS16e buildthe one-byte overflow
sub_8dccuh_auth_check, by "Authorization Required"no stored hash as a password, and a constant-time compare
sub_5d78client_parse_header, by "Transfer-Encoding"CVE-2026-55614, and Content-Length parsed safely
sub_3dd0main, by its option string
sub_8cecnew, by sizelikely 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

BuildRowsRow of the fix
armsr/armv8, AArch64135
armsr/armv7, A32135
malta/be, MIPS16e big endian135
malta/le, MIPS16e little endian145

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. hereticus only reads them.

SHA-256Size
Old03254e6240c35f7d787ca5175ffc36818185e62bdfc4d88d5b342451a747156d179383 bytes
Newf60b29cfb7eab3aeb391f46e94d4d8efadde5498583a2f5c71bd8212d8ae92da283000 bytes
Zip of theZoob50681094c040883f3da8fe697bd3a9e35e62d1672f59d9f14e0db494f9d41c2182620 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:

FUNCTIONCHANGESCORE+/-/~TOUCHEDMATCHED BY
killer_initchanged0.172224/39/52304 branch, 242 call, 77 jump, 438 mem, 62 const, 1 unreadsame name
table_initchanged0.201046/8/27209 call, 1 jump, 337 mem, 49 constsame name
exploit_workerchanged0.19225/4/1111 call, 1 memsame name
attack_method_udpgeneric -> attack_method_ovhchanged0.65129/31/134 branch, 10 call, 40 mem, 7 constsame place
attack_method_udpdns -> attack_method_tcpfragchanged0.62125/119/3821 branch, 15 call, 5 jump, 68 mem, 17 constsame place
  • killer_init, which kills other bots, and table_init, which holds the texts the bot hides, grew most.
  • exploit_worker starts each exploit in turn: about 20 more of them, as exploit_socket_hnap, _tr064, _huawei, _realtek and _gpon8080, each with its *_IPGen that picks the addresses to try.
  • Six attacks, as attack_method_udpgeneric, are gone, and six new ones, as attack_method_ovh, came. The report pairs each gone attack with a new one at the same place, as it pairs a renamed function, such as rand_alpha_str with rand_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_dray and attack_method_tcpack changed.
  • EDIMAX_IPGen and DRAY_IPGen did not change, but each *_IPGen is 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_str with rand_alphastr, a rename.
  • attack_method_udpvse with attack_method_udpgame: the code is the same but for its constants, so the attack is likely the same, renamed.
  • exploit_socket_aruba with exploit_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.

IndicatorWhere
79.124.8[.]24the host of fetch.sh in the exploits of both builds, and of the sora.* builds in one script
78.142.18[.]20a text in both builds
dotheneedfull[.]xyzthe host of the fetch.* builds in the other script
46.239.223[.]80the 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
  1. Unpack each firmware image, with --unpack.
  2. Read each binary, and find its functions.
  3. Normalize each instruction, and lift it to an IL.
  4. Pair the functions of the two binaries.
  5. Compare each pair, and say what kind of change it has.
  6. 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 hereticus thinks 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 memcpy goes to the stub of memcpy, 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:

  1. The dynamic symbols. A shared library keeps them after stripping, because the loader links through them.
  2. The unwind tables. .ARM.exidx and .eh_frame give the start of each function, and .eh_frame gives its end too. They stay after stripping, because the unwinder needs them at run time.
  3. 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.
  4. 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:

KeptRemoved
The operation, as add or loadThe register numbers
The kind of each operandThe branch targets
The kind of control flowThe constants that are addresses
The size of a constant: zero, small, wideThe 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:

StagePairs two functions whereMATCHED BYAlso in
Stubs by importTwo stubs jump to the same importsame import
Identical codeTheir bytes are the sameidentical bytesBinDiff hash matching
Identical shapeTheir normalized bodies are the sameidentical shapeGhidra Exact Function Instructions Match
NamesTheir symbols have the same name, unless --ignore-namessame nameBinDiff name hash matching
Twins in orderEqual groups of twins sit in the same ordertwin, in orderBinDiff address sequence
Stubs in orderThe stubs align as two ordered lists, with the calls of the pairs as votessame callers
Twins by what they reachOne constant, callee or text tells a twin from the othersidentical shapeDiaphora Same constants
SimilarityTheir score is the best, and no rival comes near itsimilarBinDiff prime signature matching
TablesThey have the same slot in two tables of function pointers, as two vtablessame slot of a table
Unique referencesEach is the only function of its binary that holds one text, constant or call to an importsame referencesBinDiff call graph propagation
CallersThe calls of the pairs vote that they are one functionsame callersBinDiff call graph expansion
ContextTheir callers, callees and texts agree, and their code is close enoughsame callers, or similar where a pair by similarity moves
LayoutThey sit between the same two pairs, in the same ordersame placeBinDiff address sequence; synteny in genomics
Moved codeOne 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 callerthe stage that paired the calleeO2NMatcher 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:

KindThe pair
No changeHas the same code, or differs only in what normalizing removes
ReorderedHas the same instructions in a different order
DataLooks like data, not code, and its bytes differ
Changed valuesHas the same instructions with different constants or texts
Unreadable bytesDiffers in bytes that the decoder cannot read
Changed codeHas 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 sprintf to snprintf. 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 -dbgsym packages 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.

ToolFixes foundFix on row 1RowsRows the fix did not touch
hereticus3520850
radiff220924572418
Ghidra3318759629

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

  • hereticus runs 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 it UNMATCH.
  • 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 of FindChangedFunctionsScript, 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.

ToolStarts foundRealNot real
hereticus1974119277464
Ghidra, default1380113210591
Ghidra, Aggressive Instruction Finder1414413545599

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.

Buildhereticus findshereticus missesGhidra findsGhidra misses
x86-6436241124291206
i3863674525421137
AArch6435544023731221
ARMv735977525091163
ARMv536327125231180
MIPS32 big-endian34918823791200
MIPS32 little-endian34918815172062

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.rs checks only hereticus on 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 hereticus is 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.
  • hereticus was 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 hereticus ran 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

TestsWhereWhat they check
Unit and property testseach crate, beside the codeone function, on regular and on random inputs
Fixtureshereticus/tests/small programs of fixtures/src/, built at each level for each architecture
Backportscross_build.rs, backports.rsreal libraries built before and after one upstream fix, against their symbols
Rivalshereticus-cli/tests/rivals_radare2.rs, truth_ghidra.rsthat hereticus does no worse than radiff2 and Ghidra
Truthtruth_*.rspublished fixes, OpenWrt kernels and Ubuntu updates, against their symbols
Docstests/docs.rs, docs_output.rseach 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

  1. Add the URLs and the SHA-256 of the two binaries to fixtures/cve_binaries.py, and run just truth.
  2. Add a Fix to hereticus-cli/tests/truth_cves.rs, with the function of the first binary that the fix edits, as the report names it.
  3. Write the report in real-life-reports/, and a page in book/src/real-life-examples/ that includes it. Tag each number and each output, as Numbers in the Docs says.
  4. 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:

  1. A decoder and a lifter in hereticus-il.
  2. A file of rules in hereticus-recovery: which instruction opens a function, which word is padding, and where the unwind index is.
  3. 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_benchmark measures the wall time.
  • gungraun_benchmark counts the instructions under Valgrind, and needs gungraun-runner of the same version as gungraun in Cargo.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.

CrateDoesNeeds
hereticus-ilDecode machine code, and lift it to an ILnothing
hereticus-memoryThe address space of a binarynothing
hereticus-unpackUnpack firmware archivesnothing
hereticus-recoveryFind functions in stripped binariesil, memory
hereticus-archThe rules the matching readsil, memory, recovery
hereticus-imageRead ELF, Mach-O and raw imagesarch, il, memory, recovery
hereticus-calleesFind the callee of each callarch, il, image, memory, recovery
hereticus-viewShow code as instructions or as ILarch, callees, il, image, memory, recovery
hereticus-matchCompare the functions of two binariesarch, 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