On this page
  1. Abstract
  2. The census
  3. Retirement one: the default handler that “silently succeeds”
  4. Retirement two: two facts that cannot share a function
  5. The one that stayed: precision errors
  6. A related deletion: blanket confidence scaling
  7. What retirement should cost
  8. References

Abstract

A detector is a claim: “when this program has property P, I will say so.” A detector that cannot observe P is not a weak detector, it is a false claim, and it costs more than nothing because users read its silence as evidence. This month we retired two Solana detectors on exactly that basis. Neither was wrong about the vulnerability it described. In both cases the fact the detector needed to see, a constant return code on a dispatch path in one, a royalty read and a cross-program call in the same function in the other, cannot be present in a compiled sBPF program the way the detector assumed. This article records the census that surfaced them, the measurements that closed each case, and a third detector that was on the same list and instead survived, going from 44 findings on the fixture corpus to one hand-verified true positive. We think negative results of this kind deserve to be written up as carefully as new capabilities, because they are what stops a detector count from being a vanity metric.

The census

The Solana detectors work over a lifted representation: a program’s sBPF is decoded, its control flow recovered, and its instructions raised into typed statements (a register assignment, an account-data store, a signer check, a cross-program invocation, and so on). Each detector pattern-matches over those statements.

The question the census asks is simple. For each detector, which statement kinds does its code actually read? And of those, which kinds does the lifter ever emit on real programs? A detector can have no findings on the corpus for two reasons, and they need different responses. If the corpus lacks the shape, that is a fixture gap, fixable by writing the fixture. If the detector reads a statement kind the lifter never produces, no fixture will ever help.

Four detectors read only never-emitted kinds. The list is kept as a test that can only shrink, and the census is careful about the direction of its own inference: a detector reading a never-emitted kind is conclusively inert, but a detector reading an emitted kind is not conclusively live, because one match arm may only ever run on state another arm populates. Inert is a proof; reachable is a lower bound.

The two retirements below came off that list. Both also had detector and remediation pages in the knowledge base, and both pages were removed with the code, on the rule that a page describing a retired detector is a claim the product can no longer back.

Retirement one: the default handler that “silently succeeds”

The first detector looked for instruction dispatchers whose fallback arm returns success rather than an error, so that an unrecognised instruction is accepted instead of rejected. That is a real bug class, and on source it is a one-line check.

On bytecode the detector had two arms. The precise one, which looked for a load at offset zero of the instruction data feeding the dispatch, matched zero sites across the corpus. The broad one matched 1,657 sites, and none of them traced back to instruction data at all. Worse, its notion of “the path errors” treated the program’s exit instruction as an error path. exit is how an sBPF handler returns successfully. The detector had the polarity of its central predicate backwards, and had never been in a position to notice because neither arm had ever produced a grounded finding.

The obvious response is to re-ground it, and the lifter does recover the structure: 179 valid dispatch sites across 165 of the 170 fixture programs, so the first two predicates could be rebuilt on real evidence. The deciding measurement was the third one. Of the 178 exit instructions reachable from a recovered default handler, the number carrying a constant return value is zero. 165 return a value copied dynamically from a register, and 13 have no local definition at all. A detector at this point cannot distinguish return Err(InvalidInstructionData) from Ok(()), because the compiled code does not distinguish them at the exit; the distinction was resolved earlier, through register state the lifter would need cross-block reaching definitions to follow. That is a real capability, and it is not one a detector can assume. The detector was retired.

Retirement two: two facts that cannot share a function

The second detector claimed to find NFT programs that read a royalty field from token metadata and then perform a cross-program invocation that bypasses it. The claim correlates two observations in one function: a read of the metadata account’s royalty offset, and an outgoing cross-program call.

The measurement that closed it is about the compiler, not the detector. Rust programs invoke other Solana programs through a small SDK helper, and the compiler out-of-lines that helper into a single shared wrapper rather than inlining it at each call site. Across all 176 programs in the corpus, every one of the 13 cross-program invocation sites sits inside a wrapper function of exactly 35 basic blocks. Those wrapper functions contain zero account-data reads and zero lamport transfers. The royalty read, wherever it is, is in the caller; the invocation is in the wrapper. The two facts the detector correlates cannot share a function, in any program this compiler produces.

An earlier issue on the same detector had blamed a different cause: that the royalty read was being emitted in a statement form the detector did not match. That turned out to be true and irrelevant. After the form was fixed, the read was recovered, exactly once in the corpus, and the detector still could not fire, because the read was in the wrong function. Grounding the statement form was never the blocker. That reversal is recorded on the issue, because a diagnosis that was confidently wrong is worth more to the next reader than one that was silently corrected.

Whether the detector could be rebuilt as an interprocedural query (read in the caller, invocation in a callee, linked through the call graph) is an open question. It was not a small change to the existing detector, and the existing detector was making a claim it could not meet, so it was retired rather than left running.

The one that stayed: precision errors

A third detector on the census list, covering precision loss in token arithmetic, took the other path, and the contrast is the useful part.

Its statement matching was converted to the register form the lifter actually emits. Run against the corpus, it produced 44 findings across 27 of the 170 programs. Thirty-four of them came from one arm, which reported “missing decimal scaling in a token calculation” for any multiply or divide by a constant between 2 and 1,000 that was not a power of ten. On lifted sBPF, multiplying by 8 and dividing by 4 is how the compiler indexes into structs and arrays. The arm was reporting pointer arithmetic as a token bug.

The conversion was measured and reverted the same day, filed as a blocker, and re-landed once the arm was grounded: the constant has to sit on a value that flows to or from a token-amount field, not on an address computation. The corpus count went from 44 to one, and that one was hand-labelled a true positive. In the mul_div_mod fixture’s entry function, block 18 divides a register by a value and then multiplies the same register by the same value two statements later, a divide-before-multiply that discards precision. Notably, that link is visible only in the register namespace, which is the form the detector had just been converted to read. The name-based fixture form the detector used to be tested against could not have expressed it.

So the census list did not simply mean “retire”. It meant “ground or retire”, and the grounding step is what separates the two outcomes: precision errors had a fact to observe, once the arm stopped confusing struct offsets for token amounts. The other two did not.

While measuring these, a separate piece of the Solana pipeline came under the same lens. A post-processing step applied per-detector confidence multipliers keyed on the detector’s name, seven of them, intended to damp known-noisy detectors. Measured over the corpus, the step ran 4,425 times. Six of the seven multipliers never fired at all. The seventh, for the access-control family, fired 1,647 times, and because the “is this an Anchor program” predicate it depended on is never true on real bytecode, it had reduced to a single blanket discount on one detector family. Six were deleted, the seventh was moved into the eight detectors it affected so each states its own evidence, and one program-scope multiplier was kept because it is measured, not assumed.

What retirement should cost

Three practical rules came out of this, and they apply beyond Solana.

A detector’s registration is a claim, and the claim needs a corpus witness: at least one real program where the detector fires and the finding is correct. A detector without a witness is not necessarily wrong, but it is not yet a detector, and the count of registered detectors should not include it.

When a detector has no witness, distinguish the corpus gap from the impossibility before writing fixtures. The census does that mechanically for the statement-kind case. The exit-code and out-of-lining cases needed a specific measurement each, and each measurement was cheaper than the fixture work it prevented.

Retire loudly. The registry count went from 164 to 162, the knowledge-base pages came down, and the reasons are written up here rather than folded into a changelog line. A user who searched for one of these detectors last month and finds nothing today should be able to learn why in one click, and the answer “it could never have worked on your program” is one they are entitled to.

References