Detecting a compromised solc compiler
Almost every assurance signal in the EVM ecosystem terminates at a single tool. Smart contract security audits reason about source code, and verification services such as Etherscan confirm that published source recompiles to the deployed bytecode. Both steps implicitly trust the Solidity compiler one hundred percent. If solc is honest, the chain of trust holds. If solc is not, every downstream control inherits the compromise.
The Solidity compiler is one of the highest-value targets in Web3. A single malicious build that survives in a release channel, a CI cache, or a pinned dependency can silently inject backdoors into thousands of contracts that each pass source audit and source verification.
The mandate
In this engagement we were asked one question. Is it possible to detect a compromised version of solc, a compiler that quietly injects backdoors into otherwise-correct source, from the deployed artifact alone? The method we built takes three inputs the client already has on hand: the source code, the deployed runtime bytecode, and the project's test suite. From these it produces a clear attestation as to whether the bytecode contains reachable behavior the source never authorized.
The reason this matters is structural. Source verification only proves that the published source recompiles to the bytecode under the stated settings. It says nothing about behavior the source author never wrote. A compromised compiler produces malicious bytecode that recompiles cleanly, passes review, and verifies on-chain, so every control built around source code security is satisfied while the backdoor ships.
Simulating a compromised compiler
We did not have a real backdoored solc to test against, so we built one by hand. Rather than inject arbitrary noise, we modelled the two backdoor classes a rational attacker is most likely to choose. Both maximize payoff while keeping the odds of being caught at the source layer low:
- An added custom selector: a complete, hidden function spliced into the dispatch table that does not correspond to anything in the source or ABI. It costs the attacker almost nothing to emit and is invisible to anyone reading the source or the ABI.
- An added malicious branch inside a legitimate function: an extra condition grafted onto a function that genuinely exists, for instance a second hardcoded address admitted by an owner check. Every intended path behaves exactly as specified, so functional tests and source review pass unchanged.
Our method
The analysis works directly on the deployed runtime bytecode and proceeds in three steps.
- Exhaustive entrycode mapping: We reconstruct the complete externally reachable entry surface from the bytecode itself, not from the ABI's declared selectors. That means the full function-selector dispatch table, the fallback and receive paths, and every selector comparison present in the runtime code. The result is the true set of callable entrypoints.
- Reachable-path exploration from a source of truth: Mapping the entry surface tells us where the contract can be entered. We then need to know which legitimate call paths exist. We take the project's test suite as the source of truth for that intended behavior and explore the paths it drives. The same step can additionally run against real on-chain transaction data if the contract is already deployed.
- Proving the surface is closed, and scoring what remains: Using concolic execution and SMT solving, we prove that no entrypoints exist beyond the ones the legitimate paths account for. Any selector reached by the solver but not present in the source code constitutes a backdoor of the first class and we can exhaustively prove their absence. The second vulnerabilitiy class based on legitimate entrypoints and trapdoors within the internal branches can be discovered by recording the symbolic branch expressions and using an LLM-based legitimacy scoring agent with access to the source code. This way a hardcoded address inside an owner check or other conditions not backed by source code evidence get flagged.
Example 1: an unintended function selector that drains the contract
The declared contract exposed a normal ERC-20-style interface. Entrycode mapping, however, recovered one dispatch edge with no counterpart in the ABI: a comparison against the constant selector 0x6b8ff574. No source function hashed to that selector, and no audit artifact referenced it. The path it guards is short and unmistakable:
// Reconstructed from runtime bytecode. Absent from source and ABI.
if (msg.sig == 0x6b8ff574) {
// unconditional: no caller check, no value check
IERC20(address(this)).transfer(
0x00000000000000000000000000000000DEADBeef, // hardcoded sink
IERC20(address(this)).balanceOf(address(this))
);
return;
}The selector is callable by anyone. Because it never appears in the ABI, source review and Etherscan's source view give no indication it exists, and only an exhaustive map of the dispatch table surfaces it. Concolic execution and SMT solving then proved the entrypoint is reachable and is not accounted for by any path the test suite exercises. This is a formal result, not a hunch: an entrypoint with no source-of-truth origin, no access guard, and a hardcoded recipient. That is a complete unauthorized drain path injected below the source layer.
Example 2: a malicious branch condition inside an authorized function
The second backdoor is subtler because it hides inside a function that genuinely exists. The source declares a privileged withdraw guarded by a standard owner check. Exploring the paths the test suite drives recovered the deployed guard and found it admits a second, constant operand the source never mentions:
// Source as written and audited
require(msg.sender == owner, "not owner");
// Guard actually emitted into the deployed bytecode
require(
msg.sender == owner ||
msg.sender == 0xA11CE00000000000000000000000000000000bad, // hardcoded
"not owner"
);Every legitimate path still works exactly as specified, so functional tests, intended-operation fork tests, and source review all pass. But the authorized function now also authorizes one hardcoded attacker address. Here the entrypoint itself is legitimate, so the symbolic branch expression for the guard was handed to the LLM-based agent, which scored the extra || operand against the source and flagged the embedded constant address as having no source-level justification. This is an access-control bypass grafted onto a real function rather than a new function added.
| Backdoor class | Where it hides | Why source controls miss it | What surfaced it |
|---|---|---|---|
| Unintended selector drain | An extra dispatch edge with no ABI entry | Selector never appears in source, ABI, or Etherscan source view | Entrycode mapping, with concolic execution and SMT solving proving the path is unaccounted for |
| Malicious branch condition | Inside an existing, legitimately authorized function | Intended behavior is unchanged; functional tests and source review pass | LLM-based legitimacy scoring of the symbolic branch expression against the source |
What the engagement demonstrated
Both backdoor classes slip straight past the controls that secure source code, and both are detectable from deployed bytecode alone. The decisive shift is to stop trusting the compiler as an axiom and instead treat the bytecode as the thing under assessment: enumerate every real entrypoint, prove the surface is closed against the paths legitimate use actually drives, and score each remaining guard against what a faithful compilation should have produced. A compromised solc can lie about the source, but it cannot hide the callable paths it actually emitted.
The outcome is a new piece of pre-deployment assurance the client did not have before. Because we drive the analysis from recorded test traces rather than exploring the program's paths blindly, we sidestep the path-explosion problem that makes most symbolic execution too slow to run as a matter of routine. That keeps the cost low enough to run on every compilation, not just once before launch. Pointed at the bytecode and the test suite ahead of each deploy, it gives a defensible, repeatable answer to the one question source audits and verification cannot reach: does the artifact about to go on-chain contain any path the source never authorized?
For teams building on EVM, whose assurance ultimately rests on the compiler, this is the gap worth closing. If you want to know whether your deployed bytecode contains paths your source never authorized, submit a request.