On this page
Abstract
Several EVM detectors decided whether a contract was protected by looking at the names of the values in a comparison: does one operand contain nonce, does another contain totalSupply, is this constant spelled 0. That works on source and on decompiled output that has recovered names. It does nothing on the intermediate representation a lifter produces from stripped bytecode, where every operand is a numbered temporary, and it does nothing quietly. Because the predicates were consumed negatively (no guard found, therefore vulnerable), a predicate that could never match reported every protected contract as unprotected, at Critical. We measured the problem across the mainnet corpus, pinned it with a test that produces opposite verdicts for structurally identical code, and replaced each name test with a recogniser for the shape the compiler actually emits. The interesting part is not that names are gone; it is that for each guard, the first structural replacement we reached for would have been worse than the bug it fixed.
The measurement
Start with what the lifter emits. Across the mainnet corpus we keep for regression, every binary-operation operand in the lifted representation is one of a handful of generated shapes: a numbered temporary, a memory slot, a storage slot, a calldata word, or a wrapped-arithmetic helper result. There are 14,886 such operands. The count of them spelled nonce, totalSupply, supply or 0, in any capitalisation, is zero.
That is not surprising once stated. Compilation discards identifiers, and a lifter working from bytecode has nothing to recover them from. Constants are folded into temporaries by the SSA pass, so even 0 does not appear as a literal at a comparison; it appears as a temporary defined earlier by a constant instruction. A predicate written as operand.contains("nonce") is comparing a human word against t42.
The consequence depends on how the predicate is consumed. If a detector says “this looks vulnerable because the operand is named nonce”, an inert predicate means the detector never fires: a false negative, bad but bounded. Every predicate in this family was the other way round. The detector looked for the protective check by name, and reported the vulnerability when it did not find one. Inert meant the check was never found, which meant every contract that implemented the guard correctly was reported as if it had not. Fail-open, at the highest severity the detector could assign.
Pinning it before fixing it
A bug of this shape deserves a test that would have caught it, and writing that test first told us something the fix alone would not have.
We instrumented the array-length guard in the ERC-1155 batch-transfer detector to record every equality comparison it examined across every safeBatchTransferFrom body in the corpus. The suppressing branch had never been reached. Not rarely: never. Then we wrote the test that now stands in the suite. It builds one comparison twice. In the first copy the operands are spelled ids.length and amounts.length; in the second they are _t42 and _t17. Same instruction, same operand positions, same everything the compiler could see. The first copy suppressed the finding. The second fired it at Critical. Identical structure, opposite verdict, decided purely by operand spelling.
That test is deliberately kept alongside the fix rather than replaced by it. The fix makes both copies agree; the test is what makes the disagreement impossible to reintroduce.
What replaced each name test
Every guard in the family needed its own recogniser, and each one had a plausible structural replacement that would have converted the fail-open into a fail-closed. A fail-closed suppression is a silent false negative: the detector stays quiet on a contract that genuinely lacks the guard. In a security tool that is the worse direction, so each replacement had to be argued, not just written.
An array length is a double load
The ERC-1155 guard checks that the ids and amounts arrays passed to a batch transfer have equal length. The name-based version looked for .length in the operand spelling. The first structural idea, “both operands came from calldata”, is wrong: any comparison between two calldata arguments would suppress the finding, including comparisons that have nothing to do with lengths.
The discriminator is how the ABI encodes a dynamic array. The argument slot at offset 4 + 32·i holds not the length but a head pointer, and the length lives at the position that pointer names. Reading an array length therefore takes two calldata loads: one for the head, one at head + 4. A scalar argument takes one. The recogniser chases the operand back through the compiler’s ADD and MUL arithmetic, depth-capped so a pathological chain cannot stall it, and asks whether it bottoms out in a load whose address was itself produced by a load. Two loads is an array length. One load is a scalar. The comparison suppresses only when both sides are the former.
A nonce is a read-modify-write with a constant
The permit-replay detector looks for the nonce increment that makes an EIP-2612 signature single-use. The name-based version searched for nonce in a storage-key operand. The first structural idea, “the storage key traces back to a KECCAK256”, is also wrong, and wrong in the dangerous direction: allowance[owner][spender] is a keccak-derived key too, so a permit that only writes the allowance and never touches a nonce would be suppressed as protected.
What distinguishes a nonce from an allowance is not how its key is derived but what happens to the slot. A nonce is read, compared, and then written back incremented, on the same key, in the same function. The read is the check and the write is the increment. The recogniser looks for exactly that: a storage load and a storage store whose keys are the same value, with an addition between them.
That version shipped and was then tightened within the week, because it was still too loose. Any add or subtract on the slot matched, so balance = balance - amount looked like a nonce update. The modifier has to be a constant. +1 is a counter; - amount is a balance. Adding that condition was the line between the two, and it also exposed a test fixture that had been passing by being unrealistic: it fed the recogniser a raw "0x1" operand string, a shape no lifter emits, which is exactly the class of fixture the original measurement was about.
A zero-value call is not a withdrawal
Two smaller cases round out the set. The ether-withdrawal detector treated any CALL as a value transfer; a call whose value operand is a constant zero is not one, and after constant propagation that is a check on the defining instruction of the operand, not on its spelling. The EXTCODESIZE comparison in the contract-existence check compared against a constant that, post-SSA, arrives as a temporary; resolving the temporary through its constant definition is a two-line change that turned a never-matching predicate into a working one.
The LP-token inflation detector’s first-deposit guard is the one that best shows the pattern. It had looked for supply in an operand name. The guard it wanted is a comparison of a storage read against zero, feeding a branch, in the deposit path. That shape is in the IR regardless of what anyone called the variable.
Keeping the names
None of the name tests were deleted. Each survives as a second arm, OR’d with the structural one, because the same detectors run on decompiled output that has recovered names, and there the spelling is real evidence. What changed is that the name arm is no longer the only arm, so its silence no longer decides the verdict.
Each replacement was checked by falsification rather than by a passing suite. Stub the structural arm to return false, and exactly the new test fails while the negative control passes; stub it to return true, and the negative control fails. That is a cheap discipline and it caught two replacements that were matching for the wrong reason.
What this says about detector design
The general lesson is older than this codebase: a static analyser working from bytecode must key on shapes the compiler is obliged to produce, never on names it was free to discard. What we want to add is the operational half of that.
First, measure the operand population before trusting any predicate on it. The 14,886-operand census took an afternoon and answered the question definitively; arguing from how compilers work would have taken longer and convinced fewer people.
Second, classify each predicate as fail-open or fail-closed before touching it. The same inert predicate is an annoyance in one position and a Critical false positive on every protected contract in the other, and the fix that is right for one is wrong for the other.
Third, distrust the first structural replacement. “Both from calldata”, “keccak-derived key”, “any add on the slot” each felt like the obvious generalisation and each would have traded a loud false positive for a silent false negative. The right recogniser was, in every case, one step more specific than the first idea, and finding that step meant reading what the compiler emits for the guard, not what a programmer writes.