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.
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.
Marked functions
| Function | Size | Bytes that differ in the July image | Current run |
|---|---|---|---|
Reset_Handler | 52 bytes | 1 of 52 | untouched |
main | 32 bytes | 26 of 32 | untouched |
setup | 20 bytes | 17 of 20 | untouched |
loop | 52 bytes | 40 of 52 | untouched |
analogWrite | 176 bytes | 152 of 176 | untouched |
TIM_CCxChannelCmd | 28 bytes | 28 of 28 | untouched |
TIM_CCxNChannelCmd | 28 bytes | 27 of 28 | untouched |
| Same image | July pipeline (withdrawn) | November gate (0.5.0-pilot) |
|---|---|---|
| Live blocks the static analysis found | 396 of 2,464 | 396 of 2,464 |
| Rule for changing code | Trap what is neither observed nor reached | Change only with a checked exclusion proof, then operator approval |
| Functions rewritten | 258 | 0 |
| Bytes written | 16,814 of 25,460 | 0 |
main() | 26 of 32 bytes differ | untouched |
Upstream pass condition, TIM_CCxChannelCmd | 28 of 28 bytes differ | untouched |
| Validation | Recorded safe, 0 trap hits, while 3 of 3 patched runs ended in an invalid instruction | Nothing to validate |
| Claim made | 71.9 percent fewer ROP gadgets | None |
| Status | Withdrawn | No 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.
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.
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.
| Evidence | Verdict | What happens |
|---|---|---|
| Observed, reachable by control flow, or reached symbolically | REACHABLE | Kept |
| No keep evidence and no verified proof | UNKNOWN | Kept, and listed |
| Verified exclusion proof and no keep evidence | PROVED_UNREACHABLE | Eligible for review, nothing more |
| Keep evidence and a proof for the same block | CONFLICT | Kept, 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.
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.
The evidence ledger
2,469 recovered code blocks, by the strongest evidence each one has. Select a tier to read what it means.
No keep evidence and no proof. Retained. The breakdown below says what this code is and why neither channel reached it.
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.
Pieces of a function whose other blocks are kept: switch targets, tails after an indirect call, and data that the recovery decoded as code.
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.
Padding and literal data between functions, decoded as code.
Referenced by nothing in the image.
View underlying values
| Measure | Corrected | First reported |
|---|---|---|
| Recovered code blocks | 2,469 | 2,464 |
| Observed | 83 | 70 |
| Reachable by control flow only | 1,777 | 389 |
| Kept in total | 1,860 | 459 |
| Unknown, retained | 609 | 2,005 |
| Executed blocks labeled unknown | 0 | 37 |
| Proved unreachable | 0 | 0 |
| Authorized for rewrite | 0 | 0 |
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.
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 are | Count |
|---|---|
| Calls through a register | 29 |
| Branches through a register | 2 |
| Table branches, which are switch statements | 6 |
| Runs of bytes the decoder could not read, which are data inside functions | 8 |
| A direct call whose decoded target is outside the image | 1 |
The more useful question is how far the closure reaches without that rule.
| Rule | Reachable | Outside |
|---|---|---|
| Vector handlers and entry, direct calls and branches only | 198 | 75 |
| Plus every function whose address appears anywhere in the image | 272 | 1 |
| Plus all-function successors for each unbounded site (the checked model) | 273 | 0 |
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.
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.
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 trace | First reported | Corrected |
|---|---|---|
| Recovered code blocks | 2,464 | 2,469 |
| Observed | 70 | 83 |
| Kept | 459 | 1,860 |
| Unknown | 2,005 | 609 |
| Functions with no keep evidence | 192 | 68 |
| Unbounded sites in the function model | 66 | 46 |
| Bytes changed | 0 | 0 |
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.
ARTIFACTS AND REFERENCES
Artifacts and references
- Pinned ARDUINO-F103-PWM target, Fuzzware experiments open
- Fuzzware experiments license, Apache-2.0 open
- Findings snapshot for this note, JSON download
- Brown et al., A Broad Comparative Evaluation of Software Debloating Tools, USENIX Security 2024 open
- Brown and Pande, Is Less Really More?, USENIX CSET 2019 open
- OASIS Common Security Advisory Framework 2.0, flag labels open
- Scharnowski et al., Fuzzware, USENIX Security 2022 open
- 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.