01

THE CASE

One image, one defect, two rules

The firmware is ARDUINO-F103-PWM, a 25,460-byte unit test from the public Fuzzware experiments repository. Its one job is to start a PWM output.

In July the pipeline ran the image in an emulator, recovered a control-flow graph, and trapped every function that was neither observed nor reached. Its static analysis found 396 live blocks out of 2,464. It classed 258 functions as dead and wrote 16,814 bytes of trap instructions over them. It then replayed 3 missions, counted zero trap hits, and recorded the result as safe. It also reported 71.9 percent fewer ROP gadgets.

None of that survived an audit. The log from that run shows all 3 patched executions ending in an invalid instruction at address 0x0, while the originals ran to their budget. The patched firmware crashed in every replay, and the validator, which counted trap hits and nothing else, recorded it as safe. Even a correct validator could not have vouched for main(): the emulator stalls in clock setup and never gets that far.

The 258 functions it called dead include main(), setup(), loop() and analogWrite(). They also include TIM_CCxChannelCmd, the function the upstream project uses to decide whether this unit test passed at all.

FIGURE 1

One image, two rules

All 25,460 bytes of the image, left to right. Top: what the July rule overwrote. Bottom: what the current rule changed.

Bytes overwritten in the withdrawn July image and in the current runThe withdrawn July image differs from the original in 16,395 of 25,460 bytes, spread across the code section and covering main, setup, loop, analogWrite and TIM_CCxChannelCmd. The current run changed zero bytes.mainsetup, loopanalogWriteTIM_CCxChannelCmdJULY, WITHDRAWN: BYTES OVERWRITTEN16,395 bytes differCURRENT RUN: BYTES CHANGED0 bytescodedata
Marked functions
FunctionSizeBytes that differ in the July imageCurrent run
Reset_Handler52 bytes1 of 52untouched
main32 bytes26 of 32untouched
setup20 bytes17 of 20untouched
loop52 bytes40 of 52untouched
analogWrite176 bytes152 of 176untouched
TIM_CCxChannelCmd28 bytes28 of 28untouched
TIM_CCxNChannelCmd28 bytes27 of 28untouched
Drawn from a byte comparison of the original image and the withdrawn July image. The July image is not published. A byte is counted only if it differs, so a trap byte that happens to equal the original is not counted; the July log records 16,814 bytes written.
Same imageJuly pipeline (withdrawn)November gate (0.5.0-pilot)
Live blocks the static analysis found396 of 2,464396 of 2,464
Rule for changing codeTrap what is neither observed nor reachedChange only with a checked exclusion proof, then operator approval
Functions rewritten2580
Bytes written16,814 of 25,4600
main()26 of 32 bytes differuntouched
Upstream pass condition, TIM_CCxChannelCmd28 of 28 bytes differuntouched
ValidationRecorded safe, 0 trap hits, while 3 of 3 patched runs ended in an invalid instructionNothing to validate
Claim made71.9 percent fewer ROP gadgetsNone
StatusWithdrawnNo rewrite; evidence corrected below

The row that matters is the first one. When the current version ran in November it found the same 396 live blocks. The defect that made main() look dead in July was still there. What had changed was the rule. July's rule let missing evidence authorize a change. November's rule does not, so the same defect changed nothing.

A flaw in the evidence did not become a flaw in the firmware.

That defect was found and fixed while this note was being prepared. The corrected numbers are in the evidence section, and the correction itself is recorded near the end.

02

THE GENERAL PROBLEM

Why July's check passed

The July validation passed for the wrong reason, and that failure is not special to this prototype.

Brown and colleagues evaluated ten debloating tools for USENIX Security 2024. The tools produced a passing program on 22 percent of medium and high complexity benchmarks, and only 13 percent of attempts were sound and robust under differential testing. A replay that passes is evidence about the missions replayed. The July validation replayed missions that never left start-up code.

The same inference appears wherever reachability is used to close a finding. The VEX justification vulnerable_code_not_in_execute_path is defined as code that is "not reachable through the execution of the code, including non-anticipated states of the product". A trace cannot support a statement about states nobody anticipated. PRUNER does not produce VEX. The definition is quoted here because it sets the standard of evidence that such a claim implies.

Not observed is a measurement. Unreachable is a proof claim.

03

METHOD

What the review records now

PRUNER takes a raw Cortex-M image, its load address, its entry point and the size of its vector table. Nothing else is required for a review.

It binds the run to the SHA-256 of that exact image, collects evidence that code should be kept, and gives every recovered code block one of four verdicts.

EvidenceVerdictWhat happens
Observed, reachable by control flow, or reached symbolicallyREACHABLEKept
No keep evidence and no verified proofUNKNOWNKept, and listed
Verified exclusion proof and no keep evidencePROVED_UNREACHABLEEligible for review, nothing more
Keep evidence and a proof for the same blockCONFLICTKept, and approval is blocked

The whole policy is nine rules:

observed(b)                              -> reachable(b)
cfg_reachable(b)                         -> reachable(b)
symbolic_witness(b)                      -> reachable(b)
candidate(b), verified_proof(b)          -> proved_unreachable(b)
candidate(b), no_verified_proof(b)       -> unknown(b)
candidate(b), analysis_incomplete(b)     -> unknown(b)
reachable(b), proved_unreachable(b)      -> conflict(b)
conflict(b)                              -> unknown(b)
proved_unreachable(b), rewrite_ready(b)  -> rewrite_authorized(b)

Evidence can add a reason to keep code. It cannot remove one. Even a verified proof authorizes nothing by itself: the last rule also needs the block to be structurally safe to rewrite, and applying the change needs a separate operator switch.

No language model runs anywhere in this pipeline. An earlier draft of this note described a model proposing changes for the gate to check. That is a design direction, not something the pilot did.

The output is an evidence bundle: an HTML report, the per-block verdicts, and an attestation that binds every artifact by hash. It contains no firmware bytes. A second reviewer checks it offline with one command:

python pruner.py verify evidence_bundle
verified 7765e6ab1f3dd5382265c93c07c1c4562e19272a83c685e964a8c61c3feb3e91 (ANALYSIS_ONLY_REVIEW; signature=unsigned)

The bundle from this run is unsigned. Signing is supported and was not used here.

04

EVIDENCE

What the evidence was

Every number in this section belongs to one small run, and the budgets of that run belong in the same breath.

The observation was the built-in smoke emulator: a random value on every peripheral read, no interrupts, a cap of 100,000 instructions and 3 seconds. It executed 84 block addresses and stalled in clock setup. A budget 100 times larger executed the same 84, so the limit is the peripheral model and not the budget. No Fuzzware trace was imported. The symbolic step had a 1-second budget and reached 6 blocks.

On that evidence, of 2,469 recovered code blocks, 83 were observed, 1,777 more are reachable by control flow from the vector table, and 609 are unknown.

FIGURE 2

The evidence ledger

2,469 recovered code blocks, by the strongest evidence each one has. Select a tier to read what it means.

Corrected run0.5.1-pilot
As first reportedsuperseded
70
389
2,005
Unknown: 609

No keep evidence and no proof. Retained. The breakdown below says what this code is and why neither channel reached it.

WHAT THE 609 UNKNOWN BLOCKS ARE
311Functions reachable only through a stored address (61 functions)

Their addresses sit in tables or literals, so they are reached through a pointer, if at all. C++ static initializers, serial driver methods, HAL callbacks.

198Fragments inside functions that are otherwise kept (58 functions)

Pieces of a function whose other blocks are kept: switch targets, tails after an indirect call, and data that the recovery decoded as code.

91Functions the function model reaches by direct calls but the block walk does not (6 functions)

The function model reaches these by direct calls from code the block walk did not keep. A disagreement between two analyses that has not yet been reconciled.

8Blocks outside any function

Padding and literal data between functions, decoded as code.

1Functions nothing refers to (1 function)

Referenced by nothing in the image.

View underlying values
MeasureCorrectedFirst reported
Recovered code blocks2,4692,464
Observed8370
Reachable by control flow only1,777389
Kept in total1,860459
Unknown, retained6092,005
Executed blocks labeled unknown037
Proved unreachable00
Authorized for rewrite00
Both rows describe the same firmware and the same 84 executed addresses. The difference is the correction described near the end of this note, not better evidence. Function names in the breakdown come from the image's ELF file.

The unknown set is the part worth reading. It is not dead code. It is a list of what nobody has shown to be reachable or unreachable:

  • 311 blocks in 61 functions that are reachable only through a stored address: C++ static initializers, serial driver methods, HAL callbacks.
  • 198 fragments inside functions that are otherwise kept.
  • 91 blocks in 6 functions that the function model below reaches by direct calls and the block walk does not. The two have not been reconciled.
  • 8 blocks outside any function, and 1 in _fini, which nothing refers to.

That list is what a reviewer gets. The function names come from the ELF file, which this public target ships; a raw image without one gets the same list by address. An engineer who knows the product can walk it in a review. Is the UART ever initialized in this product? If it is not, those functions are the conversation to have with the supplier.

A check against known-live code

The upstream ground truth for this unit test says a passing run must execute one of two functions, TIM_CCxNChannelCmd or TIM_CCxChannelCmd. The smoke run observed neither. Control-flow evidence keeps both. July's rule overwrote both. One mission's trace missed the code the firmware exists to run.

05

THE REWRITE GATE

Why the gate stayed shut, and what opened it

PRUNER can also rewrite. It replaces a function with trap instructions if a separately checked proof excludes that function and an operator then approves the exact bytes. On this image the gate stayed shut during the review, and under the rule it used it was always going to.

The proof model is a graph over the 273 functions named in the symbol table of the image's ELF file. Its roots are the vector handlers, the entry point, and every function whose address appears anywhere in the image. Direct calls and branches add edges. A control transfer the model cannot bound gets an edge to every function.

That last rule decides the result. The model lists 46 unbounded sites, and one reachable site is enough to make all 273 functions reachable. A root holds one, so the closure is complete at the first step. The checker's answer, 273 of 273 reachable, says nothing about which functions are dead.

What the 46 unbounded sites areCount
Calls through a register29
Branches through a register2
Table branches, which are switch statements6
Runs of bytes the decoder could not read, which are data inside functions8
A direct call whose decoded target is outside the image1

The more useful question is how far the closure reaches without that rule.

RuleReachableOutside
Vector handlers and entry, direct calls and branches only19875
Plus every function whose address appears anywhere in the image2721
Plus all-function successors for each unbounded site (the checked model)2730

Even with every indirect call resolved perfectly, the function graph could exclude one function: _fini, 12 bytes. That is what you would expect from a build that already discards unreferenced functions. What is left is reachable in the graph whether or not this product ever uses it. Removing any of it would need an argument about how the device is configured, not about the call graph.

What opened it

After the review, that rule was replaced with facts checked against the bytes. Each function is decoded from its entry, so data inside it is never read as code. A switch table is bounded only when its guard is found in the two instructions before it. Every byte of a function has to be accounted for, and a function with anything left over keeps the old rule.

On this image that took 6 checked table bounds. All 273 functions were then explained, and the closure excluded exactly the function the ladder predicts: _fini. The unchanged gate planned 12 bytes, a policy registered beforehand applied them to a copy, and replay matched on 3 seeds. It is 12 bytes that nothing calls, which is all a call-graph argument can remove from a build that already discards unused functions.

An image that still carries such functions is different. On NUTTX-F103-GPIO, from the same public set, the same procedure excluded 31 functions and rewrote 2,562 of 46,857 bytes, with no conflict at the gate and no trap in replay. Of the 11 samples in that set that load, 10 were rewritten and none failed replay.

The claims in these runs came from a small pattern matcher. The same harness can take them from a language model, which is never trusted: it can only name a structure for the checker to verify, or raise an objection that stops an automatic rewrite. No model run is recorded yet. The method, its assumptions and its limits are written up separately in the repository.

Three limits on the gate

It needs the ELF. Function boundaries come from the symbol table. A raw image without one gets the evidence bundle and no rewrite path.

The checker is separate, not independent. It is a second program that never imports the first, and it rebuilds the model from the firmware before comparing. But the same person wrote both, against the same rules, with the same decoder libraries. It catches a bug in the producer or a tampered artifact. It cannot catch an error in the rules they share, and it has had no outside review. The structure checker behind the newer runs has a single implementation.

The gate has opened only for code nothing refers to. On a 248-byte image built for the purpose, with 13 functions, the checker excluded 5. PRUNED Security signed a certificate for one of them, the dry run planned exactly 4 bytes, the operator switch applied them, and replay matched on 3 seeds. A trace that reports that function as executed turns the result into BLOCKED_CONFLICT. This shows that the gate can open and that it opens for exactly the certified bytes. On the public images it opened under four recorded assumptions, the heaviest of which is that an indirect call only ever reaches a function whose address is stored somewhere in the image. Nothing checks that assumption yet, and the replay behind those runs is the same shallow emulation described above.

06

CLAIM BOUNDARY

What this does not show

Refusing a claim is part of the method, so these are the claims this note does not make.

  • 01

    One image. One public unit test of about 25 KB and one fixture. No customer firmware and no second architecture.

  • 02

    Small budgets. A smoke emulator that stalls in clock setup, a one-second symbolic budget, and no imported traces.

  • 03

    Not whole-machine semantics. The function graph does not model peripherals, DMA, timing, concurrency, a debugger, a bootloader, vector relocation, or self-modifying code.

  • 04

    Not equivalence. Replay is regression evidence for the missions replayed. It cannot establish equivalence over every device state and input.

  • 05

    Not attack surface reduction. The review rewrote nothing. The later experiment removed only code that nothing refers to, which a linker removes at build time. Neither is a measured reduction in risk.

  • 06

    Not signature as truth. A signature says who produced the evidence and that it is intact. It says nothing about whether the model is right.

  • 07

    Not review time saved. Nobody has measured it. Measuring it is what a pilot is for.

Ready for a first design partner. The method has run end to end on public firmware. The next run belongs to a team with firmware of its own.

07

CORRECTIONS

What the first draft got wrong

The first draft of this note reported 459 blocks kept and 2,005 unknown. Those numbers were wrong, and checking them is what showed it.

  • The static walk stopped at every call. The library call used to list what follows a block leaves out the code after a call, and this kind of image has no return edges. So nothing after a call was ever reached, main() included.
  • Executed addresses were credited to the wrong block. As a result, 37 blocks that the emulator had executed were labeled unknown. None are now.
  • The vector table was declared too long. The extra words were the first function in the image, which was read as vectors and dropped from the analysis.
  • Ordinary returns were listed as unbounded control transfers. There were 20. The recognizer matched one spelling of the return instruction and missed two others, in the producer and in the checker alike. The model listed 66 sites and now lists 46.
  • A proof that contradicted observed execution did not raise a conflict. The code was kept either way, but it was reported as merely reachable and the warning was lost. It now blocks approval.
  • The evidence bundle recorded a local file path. It now records the file name.

None of these could have authorized a change, because authorization never depended on keep-side evidence. All of them made the evidence wrong, and evidence is the product.

Same firmware, same traceFirst reportedCorrected
Recovered code blocks2,4642,469
Observed7083
Kept4591,860
Unknown2,005609
Functions with no keep evidence19268
Unbounded sites in the function model6646
Bytes changed00

PRUNER's own comparison of the two evidence bundles shows the effect on identical firmware: 1,422 blocks moved from unknown to reachable, 21 moved the other way, and 5 are new to the analysis. That comparison is one of the things a pilot delivers.

08

NEXT

What comes next

The next steps are to import real Fuzzware traces for this target and measure how much known-live code they recover, run the other samples in the upstream set, have the checker reviewed by someone outside PRUNED Security, and replay on hardware. Rewriting has crossed the proof gate on public test images, for code nothing refers to. Before it means anything for a product it needs hardware replay and a customer image, and any reduction should be reported separately from any security claim.

DESIGN-PARTNER PILOT

Reviewing supplier Cortex-M firmware? PRUNED Security is looking for a first design partner: one product family, one to three raw images, analyzed offline in your environment. You get an evidence bundle and a review session, not a patched binary. No customer has run this yet.

ARTIFACTS AND REFERENCES

Artifacts and references

  1. Pinned ARDUINO-F103-PWM target, Fuzzware experiments open
  2. Fuzzware experiments license, Apache-2.0 open
  3. Findings snapshot for this note, JSON download
  4. Brown et al., A Broad Comparative Evaluation of Software Debloating Tools, USENIX Security 2024 open
  5. Brown and Pande, Is Less Really More?, USENIX CSET 2019 open
  6. OASIS Common Security Advisory Framework 2.0, flag labels open
  7. Scharnowski et al., Fuzzware, USENIX Security 2022 open
  8. Feng, Mera and Lu, P2IM, USENIX Security 2020 open

Every figure on this page is generated from recorded artifacts: run_summary.json, gate_results.json, inventory.json, finite_model.json, closure_proof.json, checker_report.json, and the comparison of the first and corrected bundles. The snapshot lists their hashes. The artifacts are not public yet; the bundle is available on request. Firmware bytes are in none of them. 192 automated tests pass on version 0.6.0-pilot.

How this note was produced: a language model was used for editorial drafting and for reviewing the code and the recorded evidence. Technical claims were checked against the artifacts and the automated tests. PRUNED Security is responsible for the content.