Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x3F |
| Mnemonic | EXTCODEHASH |
| Gas | 100 (warm) / 2600 (cold) |
| Stack Input | addr (20-byte address, zero-padded to 32 bytes) |
| Stack Output | keccak256(addr.code) (hash of the code at the target address) |
| Behavior | Returns the keccak256 hash of the bytecode at the given external address. Introduced in EIP-1052 (Constantinople, February 2019) as a gas-efficient alternative to EXTCODECOPY for code verification. Returns 0 for non-existent accounts and for empty accounts (per EIP-161). Returns keccak256("") (0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) for accounts that exist (have a balance or nonce) but contain no code. Subject to EIP-2929 warm/cold access pricing. |
Threat Surface
EXTCODEHASH was designed to answer “what code is at address X?” more efficiently than EXTCODECOPY — instead of copying the full bytecode and hashing it locally, a single opcode returns the keccak256 hash. It is used for contract identity verification, allowlisting/blocklisting, and as a replacement for the deprecated isContract() pattern. Despite being strictly more informative than EXTCODESIZE (it returns a full code fingerprint rather than just a length), EXTCODEHASH inherits nearly all of its predecessor’s unreliability for distinguishing EOAs from contracts and introduces additional subtleties.
The threat surface is dominated by three properties:
-
Distinguishing EOAs from contracts via codehash is unreliable. EXTCODEHASH returns the keccak256 of empty bytes (
keccak256("")) for EOAs that have received ETH (they exist but have no code). It returns0for addresses that have never been touched. During constructor execution, the deploying contract has no runtime bytecode yet, so EXTCODEHASH returnskeccak256("")— the same value as a funded EOA. AfterSELFDESTRUCT(pre-Dencun, or same-tx post-Dencun), the code is cleared and the hash changes. With EIP-7702 (Pectra, 2025), EOAs can carry a 23-byte delegation designator, giving them a non-trivial codehash. The three-way distinction (non-existent →0, exists-no-code →keccak256(""), has-code →keccak256(code)) is frequently misunderstood, and contracts that testcodehash != 0orcodehash != keccak256("")to detect “is this a contract?” are wrong in multiple edge cases. -
Codehash is not an immutable identity. The primary use case for EXTCODEHASH over EXTCODESIZE is code identity — “is the code at this address the expected code?” But metamorphic contracts (CREATE2 + SELFDESTRUCT + redeploy) change the code at a fixed address, and EXTCODEHASH faithfully returns the new hash. Any system that checked the codehash at approval time and trusts the address thereafter is vulnerable: the codehash changes the moment the code does. Post-Dencun (EIP-6780), SELFDESTRUCT no longer clears code except in the same transaction as deployment, significantly limiting this on L1 — but the attack remains viable on L2s and chains that haven’t adopted EIP-6780.
-
Cold access gas is a DoS vector. Like EXTCODESIZE and EXTCODECOPY, EXTCODEHASH costs 2600 gas for cold access versus 100 gas warm. Contracts that call EXTCODEHASH in a loop over user-supplied addresses can be gas-griefed. In relayer/meta-transaction contexts, the attacker chooses the addresses while the relayer pays the gas.
Smart Contract Threats
T1: Unreliable isContract via Codehash (Critical)
EXTCODEHASH is often used as a “better” isContract() replacement, but it fails in multiple contexts:
function isContract(address addr) internal view returns (bool) {
bytes32 hash;
assembly { hash := extcodehash(addr) }
// Returns true if codehash is neither 0 (non-existent) nor keccak256("") (EOA)
return hash != 0 && hash != 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470;
}False negatives (contract exists but check returns false):
- During constructor execution, the contract has no runtime bytecode. EXTCODEHASH returns
keccak256("")— the same as a funded EOA — soisContract()returnsfalse. An attacker calling from a constructor bypasses the check identically to the EXTCODESIZE bypass. - After
SELFDESTRUCTin the same transaction (pre-Dencun or same-tx-as-deployment post-Dencun), code is cleared. EXTCODEHASH returnskeccak256("")or0depending on whether ETH remains. - Precompiled contracts (0x01-0x0a) have no EVM bytecode. EXTCODEHASH returns
keccak256("")(they exist as accounts) but there is no runtime code, so the check may misclassify them.
False positives (not a contract but check returns true):
- EIP-7702 delegated EOAs carry a 23-byte delegation designator. EXTCODEHASH returns the hash of the designator, which is neither
0norkeccak256(""). The check reports the EOA as a “contract.”
Why it matters: This is the same fundamental unreliability that led OpenZeppelin to remove Address.isContract() in v5.0. Switching from EXTCODESIZE to EXTCODEHASH does not fix the problem.
T2: Codehash Changes After SELFDESTRUCT — Stale Trust (Critical)
Systems that record a codehash at approval time and later trust the address based on that recording are vulnerable when the code changes:
- Contract A deploys at address X via CREATE2.
extcodehash(X)= H1. - A governance system or allowlist records
(X, H1)as approved. - Contract A self-destructs (pre-Dencun, or same-tx post-Dencun).
extcodehash(X)→keccak256("")or0. - Contract B deploys to address X via CREATE2 (same deployer, same salt, different init code).
extcodehash(X)= H2 ≠ H1. - If the system checks
extcodehash(X) != 0(contract exists) but does not re-verifyextcodehash(X) == H1, the malicious contract passes.
Post-Dencun nuance: EIP-6780 restricts SELFDESTRUCT to only clear code when called in the same transaction as deployment. This kills the multi-transaction metamorphic pattern on L1, but:
- The same-transaction variant still works (deploy, self-destruct, redeploy in one tx).
- L2s and alt-EVMs that haven’t adopted EIP-6780 remain fully vulnerable.
- Legacy contracts deployed before Dencun with SELFDESTRUCT still have their code, but their behavior may confuse systems that expected destruction.
Why it matters: The Tornado Cash governance attack (May 2023) exploited exactly this pattern — an approved proposal contract was replaced with malicious code at the same address.
T3: Metamorphic Contracts Defeat Codehash-Based Allow/Block Lists (High)
Protocols that use EXTCODEHASH for allowlisting (“only interact with contracts whose codehash matches this whitelist”) or blocklisting (“reject contracts whose codehash matches known malicious code”) face a fundamental limitation:
-
Allowlist bypass: A metamorphic contract presents approved code (hash H1) during the allowlisting phase, then swaps to malicious code (hash H2) before interaction. The allowlist still contains H1, but the contract now runs H2. If the allowlist checks the address rather than re-verifying the hash at interaction time, the bypass succeeds.
-
Blocklist evasion: An attacker deploys malicious code with hash H_bad, gets blocklisted, self-destructs, and redeploys functionally identical malicious code with a trivially different bytecode (e.g., an extra
JUMPDEST). The new hash H_bad’ is not on the blocklist. Blocklisting by codehash is a game of whack-a-mole. -
Proxy contracts: All proxies using the same minimal proxy bytecode (EIP-1167) share the same EXTCODEHASH. Blocklisting that hash blocks every minimal proxy, not just the malicious one. Allowlisting it allows every minimal proxy, including malicious clones.
Why it matters: Codehash-based access control provides a false sense of security. It works only if the code at an address is truly immutable and the hash is verified at every interaction, not just at registration.
T4: Constructor-Time Codehash Bypass (High)
During contract construction, the runtime bytecode has not yet been stored. EXTCODEHASH on a contract mid-construction returns keccak256("") — identical to a funded EOA. This means:
modifier onlyEOA() {
bytes32 hash;
assembly { hash := extcodehash(msg.sender) }
require(
hash == 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470,
"Must be EOA"
);
_;
}An attacker deploys a contract whose constructor calls the protected function. During construction, extcodehash(address(this)) returns keccak256(""), so onlyEOA() passes. This is the same constructor bypass that plagues EXTCODESIZE, and switching to EXTCODEHASH does not mitigate it.
A factory contract can deploy hundreds of attack contracts in a single transaction, each calling the protected function from its constructor.
T5: Cold Access Gas Griefing (High)
EXTCODEHASH costs 2600 gas for cold access (first touch of an address in a transaction) versus 100 gas warm. An attacker who can influence the list of addresses checked via EXTCODEHASH can force expensive cold state reads:
function batchVerify(address[] calldata targets, bytes32 expectedHash) external view {
for (uint256 i = 0; i < targets.length; i++) {
bytes32 hash;
assembly { hash := extcodehash(targets[i]) }
require(hash == expectedHash, "hash mismatch");
}
}With 1,000 unique cold addresses, the EXTCODEHASH calls alone cost 2,600,000 gas. In meta-transaction or relayer contexts, the relayer pays gas while the attacker chooses addresses, creating a direct financial griefing vector.
Historical context: The 2016 Shanghai DoS attacks exploited underpriced state-access opcodes (including EXTCODESIZE at ~20 gas) to force ~50,000 cold state reads per block. EIP-2929 raised cold access to 2600 gas, but loops over cold addresses remain a griefing risk.
Protocol-Level Threats
P1: State Access DoS (Medium)
EXTCODEHASH forces a state trie lookup for the target account’s code hash. Cold access requires disk I/O to load the account from the state trie. While EIP-2929 priced cold access at 2600 gas (reducing the DoS amplification compared to the pre-Berlin flat cost), EXTCODEHASH remains one of the cheaper ways to force state reads against arbitrary addresses. Unlike EXTCODECOPY, which must load the full bytecode, EXTCODEHASH only needs the code hash field from the account object — but the initial trie lookup cost is identical.
P2: EIP-1052 Semantic Complexity (Low)
The three-valued return semantics of EXTCODEHASH create implementation complexity:
0for non-existent accounts (never received ETH, nonce 0, no code)0for empty accounts per EIP-161 (balance 0, nonce 0, no code — treated as non-existent)keccak256("")for accounts that exist but have no code (e.g., EOA with a balance)keccak256(code)for accounts with code
An errata to EIP-1052 (PR #2144) clarified that empty accounts per EIP-161 must return 0, not keccak256(""). All major clients (Geth, Nethermind, Besu, Erigon) implement this correctly, but the subtlety has been a source of confusion in client development and testing.
P3: EIP-7702 Delegation Designator Semantics (Medium)
EIP-7702 (Pectra, 2025) writes a 23-byte delegation designator (0xef0100 || address) to an EOA’s code field. EXTCODEHASH returns the keccak256 of this designator — a hash distinct from both 0 and keccak256(""). This means:
extcodehash(addr) != keccak256("")now evaluatestruefor delegated EOAs- The returned hash does not correspond to the delegated contract’s codehash
- Any pattern using EXTCODEHASH to distinguish EOAs from contracts is further broken
- Allowlists based on codehash will not match unless they include the specific delegation designator hash
P4: Consensus Safety (Low)
EXTCODEHASH is deterministic — it reads the code hash from the account state committed in the state trie. All conformant client implementations agree on the result. No consensus bugs have been attributed to EXTCODEHASH. The EIP-1052 errata regarding empty accounts (PR #2144) was addressed before any divergence reached mainnet.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
| Non-existent address (never touched) | Returns 0 | Indistinguishable from an EIP-161 empty account; contracts testing codehash == 0 can’t tell if the address has never existed or was pruned |
| EOA with balance (no code) | Returns keccak256("") = 0xc5d246... | Same hash as a contract mid-construction or a precompile; isContract checks based on “not keccak256 of empty” will misclassify funded EOAs |
| Precompile (0x01-0x0a) | Returns keccak256("") (account exists, no EVM bytecode) | Precompiles are valid call targets but indistinguishable from funded EOAs via codehash |
| Contract during constructor execution | Returns keccak256("") (runtime code not yet stored) | Root cause of constructor-time bypass; codehash looks identical to an EOA |
Contract after SELFDESTRUCT (same tx, pre-Dencun) | Code remains until end-of-tx; returns keccak256(code) | Within-tx EXTCODEHASH still reflects the original code; changes only at tx boundary |
Contract after SELFDESTRUCT (next tx, pre-Dencun) | Returns 0 if no balance remains, keccak256("") if balance remains | Address looks like an EOA or non-existent; cached trust assumptions broken |
Contract after SELFDESTRUCT (post-Dencun, EIP-6780) | Code NOT cleared unless SELFDESTRUCT runs in same tx as deployment | EXTCODEHASH returns original code hash; metamorphic pattern blocked on L1 except same-tx variant |
| EIP-7702 delegated EOA | Returns keccak256 of the 23-byte delegation designator | EOA has a non-trivial codehash; breaks both directions of isContract() |
| Proxy contract (EIP-1167 minimal proxy) | All clones share the same codehash | Cannot distinguish malicious clones from legitimate ones; blocklisting blocks all, allowlisting allows all |
| Pre-funded CREATE2 address (no code yet) | Returns keccak256("") (account exists due to balance) | Looks like an EOA; a contract will eventually deploy here, but codehash doesn’t indicate that |
| Cold vs. warm access | 2600 gas vs. 100 gas (EIP-2929) | Gas cost varies 26x based on prior access; critical for gas estimation in loops |
Real-World Exploits
Exploit 1: Tornado Cash Governance Takeover — Metamorphic Codehash Swap ($2.17M, May 2023)
Root cause: Governance system verified a proposal contract at approval time but did not re-verify the codehash at execution time. The proposal was replaced with malicious code at the same address via CREATE2 + SELFDESTRUCT + redeploy.
Details: The attacker submitted a governance proposal to Tornado Cash that appeared identical to a previously approved, legitimate proposal. The proposal contract was deployed via CREATE2 at a deterministic address. Voters reviewed the code, verified it matched the benign proposal, and approved it.
After the vote passed, the attacker triggered a hidden SELFDESTRUCT in the proposal contract, clearing its code. The attacker then redeployed malicious code to the same address via CREATE2 (the init code produced different runtime bytecode, but the CREATE2 address derivation used the same deployer and salt, yielding the same address). EXTCODEHASH at the proposal address now returned a completely different hash — but the governance system only checked the address, not the codehash, at execution time.
The malicious code granted the attacker 1.2 million fake TORN governance votes (far exceeding ~700,000 legitimate votes). With full governance control, the attacker drained 483,000 TORN tokens ($2.17M) from governance vaults.
EXTCODEHASH’s role: Had the governance system re-verified extcodehash(proposal) == approvedHash in the execute() function, the attack would have been detected — the hash changed when the code was replaced. The failure was not in EXTCODEHASH itself but in the failure to use it at the critical moment. This exploit is the canonical example of why codehash must be verified at interaction time, not just at registration time.
Impact: $2.17M stolen. Full governance takeover of one of Ethereum’s highest-profile protocols.
References:
- Composable Security: Understanding the Tornado Cash Governance Attack
- pcaversaccio: Tornado Cash Exploit PoC
- Halborn: Tornado Cash Hack Explained
Exploit 2: Fomo3D Airdrop Drain — Codehash/Codesize Bypass via Constructor (July 2018)
Root cause: isHuman() modifier using code-based checks (EXTCODESIZE, equivalent pattern with EXTCODEHASH) bypassed by calling from a contract constructor.
Details: Fomo3D used an isHuman() modifier to restrict airdrop participation to EOAs. The check relied on the fact that contracts have code — but during constructor execution, no runtime code exists. EXTCODESIZE returns 0 and EXTCODEHASH returns keccak256("") — both indistinguishable from a funded EOA.
Attackers deployed contracts whose constructors called Fomo3D’s airdrop function. The isHuman() guard passed because the attack contract had no code yet. The attack was amplified by pre-computing which deployer nonce would produce an address that won the pseudo-random airdrop selection (Fomo3D’s “randomness” was derived from on-chain data including the sender address).
EXTCODEHASH’s role: Although Fomo3D used EXTCODESIZE, the codehash-based variant is equally vulnerable. extcodehash(addr) == keccak256("") during construction, which is the same value returned for a funded EOA. Switching from EXTCODESIZE to EXTCODEHASH does not fix this class of attack.
Impact: Multiple attackers drained significant ETH from the airdrop pot over several weeks. The exploit was automated and repeatable.
References:
Exploit 3: OpenZeppelin Address.isContract() Removal — Ecosystem Pattern Death (October 2023)
Root cause: Systemic ecosystem reliance on code-based account type detection (EXTCODESIZE and EXTCODEHASH variants) despite fundamental unreliability.
Details: OpenZeppelin’s Address.isContract() was the most widely deployed contract detection utility in the Solidity ecosystem. Early versions used extcodesize(account) > 0. After EIP-1052, some implementations switched to or supplemented with extcodehash(account) checks (Issue #135 in the OpenZeppelin upgradeable contracts repo proposed exactly this). Neither approach solved the core problem:
- Returns “not a contract” during constructor execution (bypass via constructor calls)
- Returns “not a contract” for CREATE2 pre-computed addresses (no code yet)
- Returns “not a contract” for destroyed contracts (SELFDESTRUCT)
- Returns “not a contract” for precompiles (no EVM bytecode)
- Returns “is a contract” for EIP-7702 delegated EOAs (have delegation designator)
- The
keccak256("")return for funded EOAs matches the return for mid-construction contracts
OpenZeppelin filed Issue #3417 and removed isContract() entirely in PR #3945, shipped with Contracts v5.0. The removal forced hundreds of downstream projects to refactor their contract detection logic. The decision acknowledged that neither EXTCODESIZE nor EXTCODEHASH can reliably distinguish EOAs from contracts.
EXTCODEHASH’s role: EXTCODEHASH was proposed as a fix for EXTCODESIZE’s limitations (Issue #135), but analysis showed it suffers from the same fundamental ambiguities. The three-valued return semantics add complexity without resolving the core unreliability.
References:
- OpenZeppelin PR #3945: Remove Address.isContract
- OpenZeppelin Issue #135: Use extcodehash instead of extcodesize
- Solidity Issue #14794: Add nuances of .codehash usage to documentation
Exploit 4: Optimism Bridge onlyEOA Bypass — Code-Based Guard Failure (2023)
Root cause: The Optimism Base bridge used OpenZeppelin’s Address.isContract() in an onlyEOA modifier to prevent contract callers. The check could be bypassed from a constructor, risking permanent loss of bridged funds.
Details: The Optimism bridge restricted certain operations to EOAs via require(!Address.isContract(msg.sender)). A Code4rena audit (2023-05-base, Issue #69) identified that this guard could be bypassed by calling from a contract constructor, where both EXTCODESIZE and EXTCODEHASH indicate “no contract.” If a user bridged funds through a contract constructor, the bridge would accept the transaction but potentially send funds to an address that couldn’t retrieve them on the destination chain.
EXTCODEHASH’s role: The vulnerability applies equally to EXTCODEHASH-based checks. During constructor execution, extcodehash(msg.sender) returns keccak256(""), passing any “is this an EOA?” guard.
Impact: Classified as medium severity in the Code4rena audit. No confirmed exploitation on mainnet, but the finding contributed to the ecosystem-wide acknowledgment that code-based EOA detection is broken.
References:
Attack Scenarios
Scenario A: Constructor-Based Codehash Bypass (Airdrop Drain)
contract VulnerableAirdrop {
mapping(address => bool) public claimed;
bytes32 constant EOA_HASH = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470;
function claim() external {
bytes32 hash;
assembly { hash := extcodehash(caller()) }
require(hash == EOA_HASH, "Only EOAs");
require(!claimed[msg.sender], "Already claimed");
claimed[msg.sender] = true;
_mint(msg.sender, AIRDROP_AMOUNT);
}
}
// During construction, extcodehash(address(this)) == keccak256("") == EOA_HASH
contract AirdropSniper {
constructor(address airdrop, address attacker) {
VulnerableAirdrop(airdrop).claim();
IERC20(airdrop).transfer(attacker, IERC20(airdrop).balanceOf(address(this)));
}
}
contract SniperFactory {
function snipe(address airdrop, uint256 count) external {
for (uint256 i = 0; i < count; i++) {
new AirdropSniper(airdrop, msg.sender);
}
}
}Scenario B: Governance Proposal Codehash Swap (Metamorphic)
contract GovernanceVault {
mapping(uint256 => address) public proposals;
mapping(uint256 => bool) public approved;
function propose(address proposal) external returns (uint256 id) {
id = nextId++;
proposals[id] = proposal;
}
function execute(uint256 id) external {
require(approved[id], "Not approved");
address proposal = proposals[id];
bytes32 hash;
assembly { hash := extcodehash(proposal) }
// VULNERABLE: only checks that code exists, not that it matches
require(hash != 0 && hash != 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470, "No code");
(bool ok,) = proposal.delegatecall(abi.encodeWithSignature("execute()"));
require(ok);
}
}
// Fix: store and re-verify the exact codehash
contract SecureGovernanceVault {
struct Proposal {
address target;
bytes32 codehash;
}
mapping(uint256 => Proposal) public proposals;
mapping(uint256 => bool) public approved;
function propose(address proposal) external returns (uint256 id) {
bytes32 hash;
assembly { hash := extcodehash(proposal) }
require(hash != 0, "No code at proposal");
id = nextId++;
proposals[id] = Proposal(proposal, hash);
}
function execute(uint256 id) external {
require(approved[id], "Not approved");
Proposal memory p = proposals[id];
bytes32 currentHash;
assembly { currentHash := extcodehash(p.target) }
require(currentHash == p.codehash, "Code changed since approval");
(bool ok,) = p.target.delegatecall(abi.encodeWithSignature("execute()"));
require(ok);
}
}Scenario C: Codehash-Based Allowlist Bypass via Proxy Clones
contract TokenGate {
mapping(bytes32 => bool) public allowedCodehashes;
function addAllowedCode(address template) external onlyOwner {
bytes32 hash;
assembly { hash := extcodehash(template) }
allowedCodehashes[hash] = true;
}
function gatedAction(address caller) external {
bytes32 hash;
assembly { hash := extcodehash(caller) }
require(allowedCodehashes[hash], "Code not allowed");
// ... perform privileged action
}
}
// All EIP-1167 minimal proxies share the same codehash.
// If the allowlist approves ONE minimal proxy, ALL minimal proxies pass.
// Attacker deploys a minimal proxy pointing to a malicious implementation
// but with the same proxy bytecode -> same codehash -> allowed.Scenario D: Cold Access Gas Griefing via Relayer
contract RelayedCodeVerifier {
function verifyTargets(address[] calldata targets, bytes32 expectedHash) external view {
for (uint256 i = 0; i < targets.length; i++) {
bytes32 hash;
assembly { hash := extcodehash(targets[i]) }
if (hash != expectedHash) revert("mismatch");
}
}
}
// Attacker submits a meta-transaction with 1,000 unique cold addresses.
// EXTCODEHASH cold access = 2,600 gas each -> 2,600,000 gas consumed.
// The relayer pays for the gas. The attacker pays nothing.Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Unreliable isContract via codehash | Do not use EXTCODEHASH to distinguish EOAs from contracts | Remove isContract() checks entirely; OpenZeppelin removed theirs in v5.0 |
| T1: If EOA-only is truly required | Use msg.sender == tx.origin (with caveats) | Blocks contract callers including constructors, but also blocks smart wallets and ERC-4337 accounts; not future-proof |
| T2: Stale codehash trust | Re-verify codehash at interaction time, not just at registration | require(extcodehash(target) == recordedHash) in execute(), not just in propose() |
| T2: Governance proposal integrity | Store and re-check codehash at execution | Record (address, codehash) pair at proposal; reject execution if hash changed |
| T3: Allowlist bypass via metamorphic contracts | Combine address + codehash verification at every interaction | Check extcodehash(addr) == expectedHash on every call, not just during allowlisting |
| T3: Blocklist evasion via redeployment | Use behavioral detection, not codehash matching | On-chain circuit breakers, off-chain monitoring, and incident response rather than bytecode blocklists |
| T4: Constructor-time codehash bypass | Do not use code-based checks for access control | Use reentrancy guards, per-address rate limits, and Merkle-based allowlists instead of contract detection |
| T5: Cold access gas griefing | Bound loops over user-supplied address lists | Cap array length (require(targets.length <= MAX_BATCH)); pre-warm addresses via access lists (EIP-2930) |
| T5: Relayer gas griefing | Cap gas for relayed meta-transactions | Set explicit gas limits on relayed calls; require gas deposits from meta-transaction signers |
| General | Use off-chain allowlists for access control | Merkle tree of approved addresses verified via MerkleProof.verify() instead of on-chain code detection |
Compiler/EIP-Based Protections
- EIP-1052 (Constantinople, 2019): Introduced EXTCODEHASH at 400 gas (later repriced). The three-valued return semantics (
0/keccak256("")/keccak256(code)) were intentionally designed to distinguish non-existent accounts from existing ones. - EIP-2929 (Berlin, 2021): Introduced warm/cold access pricing. EXTCODEHASH cold access costs 2600 gas (up from 400), reducing DoS amplification.
- EIP-2930 (Berlin, 2021): Access lists allow transactions to pre-declare addresses they will touch, converting cold accesses to warm at a fixed per-address cost. Mitigates gas estimation issues in loops.
- EIP-6780 (Dencun, 2024): SELFDESTRUCT no longer clears code except in the same transaction as deployment. Effectively kills the multi-transaction metamorphic contract pattern on L1, making EXTCODEHASH results more stable across transactions.
- EIP-7702 (Pectra, 2025): EOAs can delegate to contract code. EXTCODEHASH returns the hash of the 23-byte delegation designator, not the delegated code’s hash. Permanently breaks code-based EOA/contract distinction.
- EIP-7637 (Proposed): Proposes standardizing EXTCODEHASH to return
0for all non-contract addresses (including EOAs with balance), simplifying the three-valued return to a binary. Would reduce confusion but has not been adopted. - OpenZeppelin v5.0 (October 2023): Removed
Address.isContract(). Ecosystem-wide signal that code-based contract detection is deprecated regardless of whether EXTCODESIZE or EXTCODEHASH is used.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | High | Fomo3D airdrop drain (2018), countless isContract() bypasses, OpenZeppelin removal |
| T2 | Smart Contract | Critical | Medium (Low post-Dencun on L1) | Tornado Cash governance takeover ($2.17M, 2023) |
| T3 | Smart Contract | High | Medium | Codehash-based allowlist bypasses in audit findings; EIP-1167 clone confusion |
| T4 | Smart Contract | High | High | Constructor bypass of code-based guards (Fomo3D, Optimism bridge finding) |
| T5 | Smart Contract | High | Medium | 2016 Shanghai DoS attacks (EXTCODESIZE, same gas model); relayer gas griefing |
| P1 | Protocol | Medium | Low | 2016 Shanghai DoS (mitigated by EIP-2929) |
| P2 | Protocol | Low | Low | EIP-1052 errata (PR #2144); no mainnet divergence |
| P3 | Protocol | Medium | Medium | EIP-7702 breaking code-based EOA/contract distinction |
| P4 | Protocol | Low | Low | — |
Related Opcodes
| Opcode | Relationship |
|---|---|
| EXTCODESIZE (0x3B) | Returns code length at an external address. EXTCODEHASH is strictly more informative — it returns a full code fingerprint instead of just the length. Both share the same cold/warm gas pricing and the same constructor-returns-false edge case. EXTCODESIZE cannot detect metamorphic code swaps; EXTCODEHASH can (if checked at interaction time). |
| EXTCODECOPY (0x3C) | Copies code bytes from an external address into memory. EXTCODEHASH was introduced (EIP-1052) specifically to avoid the gas cost of EXTCODECOPY when only a code fingerprint is needed. EXTCODECOPY + keccak256 is functionally equivalent but costs more gas (3 + 3×ceil(len/32) + memory expansion vs. flat 100/2600). |
| CODESIZE (0x38) | Returns code size of the currently executing contract (self-referential). EXTCODEHASH is the external, hash-based variant. During constructor execution, CODESIZE returns the init code length while EXTCODEHASH on address(this) returns keccak256(""). |
| BALANCE (0x31) | Returns ETH balance at an address. Same EIP-2929 cold/warm pricing model. BALANCE can distinguish a non-existent address (balance 0) from a funded EOA (balance > 0), but EXTCODEHASH’s 0 vs. keccak256("") distinction provides the same signal. Combined, BALANCE > 0 && EXTCODEHASH == keccak256("") reliably identifies funded accounts without code. |