ROOTVIELROOTVIEL
← Knowledge Base

Web3 & smart contracts

Verified by ROOTVIEL13 min

Delegatecall Misuse

delegatecall runs another contract’s code against the caller’s own storage, balance, and identity. If the target address, or the code at it, is attacker-influenced, the attacker’s logic writes the caller’s storage — overwriting owners, pointers, or any slot — and acts as the caller.

Classification

Domain
Web3 & smart contracts
ROOTVIEL support
Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
Evidence scope
Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
Analysis family
Rootchain
CWE
CWE-829
OWASP
SC01

Definition

Delegatecall misuse is the execution of untrusted or attacker-influenced code via delegatecall, which borrows code from a target contract but runs it in the calling contract’s context — the caller’s storage, the caller’s balance, and the caller’s msg.sender/msg.value. Unlike a normal call, nothing is sandboxed: the borrowed code can write any storage slot of the caller and spend its balance. If an attacker controls which address is delegatecalled, or the code living at that address, they control the caller.

The misuse takes two common shapes: delegatecalling an address an attacker can set or influence (direct), and delegatecalling a trusted-looking library or implementation whose storage layout does not match the caller’s, so writes land on the wrong slots (a layout/collision bug that corrupts critical state).

Context

delegatecall is foundational to upgradeable proxies (a proxy delegatecalls its implementation), to diamond/multi-facet patterns, and to library-style code reuse. These are legitimate and widespread; the hazard is in how the target is chosen and how storage layouts are coordinated.

  • ▸Proxy patterns where the implementation address is stored in a slot that could be overwritten, or set without strict access control.
  • ▸Generic "execute" or multicall helpers that delegatecall an address taken from calldata.
  • ▸Libraries delegatecalled with a storage layout that does not match the calling contract, so writes collide with unintended slots (owner, implementation pointer, balances).
  • ▸Uninitialized proxy implementations whose own constructor-equivalent logic can be invoked to seize control (a well-known proxy-initialization hazard).

Solidity & EVM fundamentals

The EVM distinguishes CALL from DELEGATECALL. CALL executes the callee’s code in the callee’s context (its storage, its address as this). DELEGATECALL executes the callee’s code in the caller’s context: storage reads and writes hit the caller’s storage, address(this) is the caller, and msg.sender/msg.value are preserved from the original call. The callee is merely a code source.

Context borrowing
Under delegatecall, SSTORE/SLOAD operate on the CALLER’s storage. The target decides what to write; the caller provides where (its own slots).
Storage layout coupling
Slots are assigned by declaration order (and mappings/dynamic types by hashing). If target and caller disagree on which variable owns slot 0, a write the target thinks updates "x" overwrites whatever the caller keeps in slot 0 — often an owner or implementation pointer.
Identity preservation
Because msg.sender is preserved and address(this) is the caller, borrowed code can pass the caller’s own access-control checks and move the caller’s balance.
selfdestruct / state-destroying ops
Code reached via delegatecall runs as the caller; historically a delegatecalled selfdestruct could destroy the caller. Treat any state-affecting opcode in delegatecalled code as acting on the caller.

The correct mental model: delegatecall is "run this code as me." You must trust that code as much as your own, and you must guarantee its storage layout is compatible with yours. Both conditions are easy to violate.

Root cause

The root cause is executing code you do not fully control in a context where it has full authority over your state. Two independent failures produce it: (1) the delegatecall target is attacker-influenced (set via unprotected setter, read from calldata, or an uninitialized implementation), so the attacker supplies the code; (2) the target is trusted but its storage layout is incompatible, so even honest code writes the caller’s wrong slots.

Either failure breaks the same invariant: the caller’s critical storage (ownership, implementation pointer, accounting) can be written by logic the caller did not author or did not lay out consistently.

How the attack works, step by step

  1. 1The attacker finds a delegatecall whose target they can influence — e.g. a setImplementation with weak access control, an execute(address,bytes) that delegatecalls calldata, or an uninitialized proxy whose implementation can still be set.
  2. 2The attacker points the delegatecall at a contract they wrote whose code, when run in the caller’s context, writes the caller’s critical slots — for instance setting slot 0 (the owner) to the attacker.
  3. 3The caller delegatecalls the malicious target; SSTORE in the borrowed code writes the CALLER’s storage, overwriting owner (or the implementation pointer) with attacker-chosen values.
  4. 4Now holding ownership or control of the implementation pointer, the attacker invokes privileged functions, upgrades the implementation to fully malicious code, or drains the caller’s balance — all as address(this).
  5. 5In the layout-collision variant, no malicious target is needed: a trusted library writing its "variable" overwrites the caller’s owner/pointer because the slots coincide, corrupting the caller through honest code.

Vulnerable example

A synthetic contract exposing a generic delegatecall to a caller-supplied address with weak gating. Running attacker code in this context lets the attacker overwrite owner (slot 0) and seize control.

synthetic — do not use; illustrates the flaw
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // UNSAFE: delegatecalls an attacker-influenced address; borrowed code // writes THIS contract's storage (including owner at slot 0). contract VulnerableProxy { address public owner; // slot 0 address public implementation; // slot 1 constructor() { owner = msg.sender; } // Intended as a convenience, but the target is untrusted. function execute(address target, bytes calldata data) external { // No restriction on 'target' and no trust guarantee on its code. (bool ok, ) = target.delegatecall(data); require(ok, "delegatecall failed"); } } // Attacker-supplied code; when delegatecalled by VulnerableProxy it runs // in the proxy's context, so SSTORE hits the PROXY's slot 0. contract Hijack { address public owner; // aligns with proxy slot 0 address public implementation; // slot 1 function pwn() external { owner = msg.sender; // writes the PROXY's owner to the attacker } }
Note

The attacker calls proxy.execute(address(hijack), abi.encodeWithSelector(Hijack.pwn.selector)). Because delegatecall runs Hijack.pwn in the proxy’s context, the proxy’s owner (slot 0) is overwritten. The same corruption can happen accidentally if a trusted library’s layout collides with slots 0/1.

Impact

  • ▸Total loss of control: ownership and/or the implementation pointer can be overwritten, letting the attacker upgrade the contract to arbitrary malicious logic.
  • ▸Fund loss: borrowed code runs as address(this) and can move the caller’s entire balance.
  • ▸Permanence: in some patterns the caller can be bricked or made permanently attacker-controlled; historically delegatecalled selfdestruct could destroy the caller.
  • ▸Silent corruption (layout variant): honest-looking upgrades or library calls can corrupt critical slots without any malicious actor, surfacing as inexplicable state.
Note

Honesty: an attacker-influenced delegatecall target, or a layout mismatch, is a SIGNAL of this class. It is a FINDING only when a controlled run shows the caller’s critical storage actually overwritten (or control actually seized) via the delegatecall.

Detection: static, dynamic, symbolic, fuzzing, formal

Static
Flag delegatecall whose target derives from calldata, an unprotected setter, or any non-constant source; flag proxy implementations that can be set without strict access control or that are uninitialized; compare declared storage layouts of proxy and implementation/library for collisions.
Dynamic
In a controlled environment, point the delegatecall at a crafted contract and observe whether the caller’s owner/implementation/accounting slots change, or whether balance can be moved.
Symbolic
Ask whether a reachable path lets an attacker-chosen target be delegatecalled, and whether any SSTORE in reachable borrowed code can target a critical slot.
Fuzzing / invariant
Assert invariants like "owner never changes except via the authorized path" and "implementation slot only changes via the upgrade guard," and fuzz for a sequence that violates them.
Formal
Prove storage-layout compatibility between proxy and implementation, and prove that no reachable delegatecall target is attacker-controllable. Layout equivalence is well-suited to formal/structural proof.

Statuses stay distinct: a static target-taint or layout-collision match is a heuristic SIGNAL; a fuzzer invariant break is an artifact to replay; a lab run showing owner overwritten is EVIDENCE; a proof of layout compatibility and non-controllable target is the strongest, within its model. A clean run is UNKNOWN.

How autonomous agents investigate it

An autonomous investigator asks two questions: can an attacker influence what gets delegatecalled, and if so (or even for a trusted target), can borrowed code write a critical slot? It treats both as hypotheses to realize concretely.

  1. 1Observation: locate every delegatecall, trace the provenance of its target (constant, storage, setter, calldata), and record the caller’s storage layout and which slots are critical (owner, implementation, accounting).
  2. 2Hypothesis: for each delegatecall, propose "target is attacker-influenceable" and/or "a reachable write lands on a critical slot (layout collision)" as leads.
  3. 3Execution paths: on a disposable local chain or local fork, craft a malicious target (direct variant) or instantiate the real library against the real layout (collision variant) and drive the delegatecall.
  4. 4Fuzzing / invariant checks: assert owner/implementation-stability invariants and fuzz call sequences for a violation; check storage layouts for overlap programmatically.
  5. 5Adversarial review: refute — is the setter actually protected? is the layout actually compatible (e.g. proper storage gaps / EIP-1967 slots)? Refuted leads are recorded as refuted, not reported.
Note

A reachable attacker-controlled target is a hypothesis; a fuzzer invariant break is an artifact to replay; only a replayed run that deterministically shows a critical slot overwritten (or control seized) is a finding. LLM plausibility decides nothing.

How Rootchain analyzes it

Rootchain is evidence-first. Static target-taint or a layout-collision suspicion is a lead. Rootchain promotes it to a Finding only by reproducing the hijack in a controlled environment — a disposable LOCAL blockchain or a LOCAL fork of real on-chain state — delegatecalling a crafted (or the real, layout-mismatched) target and showing the caller’s critical storage overwritten or control seized, confirmed by a deterministic, AI-free verifier. There is no mode to attack a live public network.

Epistemic ladder
Observation → Hypothesis → Experiment → Evidence → Verification → Finding. Evidence is a run where owner/implementation/accounting provably changes via the delegatecall, versus a baseline where it does not.
AI composes, never decides
The model proposes malicious targets, calldata, and sequences; a deterministic verifier reads the critical storage slots before/after and decides whether control was seized, without the model.
Signal vs Finding
A taint or layout match is a Signal with provenance. A Finding requires the reproducible controlled demonstration of actual corruption/seizure.
Partial

Delegatecall hijack is a mature execute-and-verify target, but maturity is never total — some proxy/diamond configurations are hard to instantiate faithfully. When Rootchain cannot realize the path in the controlled environment, it reports UNKNOWN, never "secure".

Mitigation

Treat delegatecall as "run as me" and remove both failure modes with deterministic controls.

  • ▸Never delegatecall an attacker-influenced address: restrict targets to trusted, immutable or governance-controlled implementations; do not delegatecall addresses taken from calldata.
  • ▸Protect the implementation pointer and ownership with strict access control and an explicit upgrade path; initialize proxies safely (prevent re-initialization and uninitialized-implementation seizure).
  • ▸Guarantee storage-layout compatibility: use established upgradeable patterns with reserved storage gaps, unstructured storage (EIP-1967) for proxy slots, or diamond storage; keep implementation layouts append-only.
  • ▸Prefer audited proxy frameworks over hand-rolled delegatecall; minimize the surface and consider avoiding generic delegatecall helpers entirely.

Corrected example

The corrected proxy confines delegatecall to a single, access-controlled implementation pointer stored at a dedicated EIP-1967 slot (away from slot 0), and only the owner can upgrade it. Why it works: the attacker can no longer choose the target, and the implementation pointer lives at a hashed slot that honest implementation layouts will not collide with. Limits follow.

synthetic — illustrates the fix and its limits
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SaferProxy { // EIP-1967 implementation slot: keccak256("eip1967.proxy.implementation") - 1 bytes32 private constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; // Dedicated admin slot, likewise away from low slots. bytes32 private constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; constructor(address admin_) { _setAddr(ADMIN_SLOT, admin_); } modifier onlyAdmin() { require(msg.sender == _getAddr(ADMIN_SLOT), "not admin"); _; } // Only the admin can change the implementation; target is NOT caller-chosen. function upgradeTo(address newImpl) external onlyAdmin { require(newImpl.code.length > 0, "not a contract"); _setAddr(IMPL_SLOT, newImpl); } // Forwards to the single trusted implementation only. fallback() external payable { address impl = _getAddr(IMPL_SLOT); require(impl != address(0), "no impl"); assembly { calldatacopy(0, 0, calldatasize()) let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } function _getAddr(bytes32 slot) private view returns (address a) { assembly { a := sload(slot) } } function _setAddr(bytes32 slot, address a) private { assembly { sstore(slot, a) } } }
Note

Limits of the fix: this removes attacker-chosen targets and collision with low slots, but it does NOT make the implementation code itself trustworthy — a malicious or buggy implementation reached via upgradeTo still runs as the proxy. Admin-key compromise reintroduces full control. Layout discipline must be maintained across every future implementation.

Verifying the fix

Verify by attempting hijack and collision in a controlled environment and confirming failure, with reproducible tests (conceptually Foundry and Echidna).

  1. 1Reproduction test (must now fail): as a non-admin, attempt to redirect delegatecall to a malicious target or call upgradeTo; assert it reverts and the implementation slot is unchanged.
  2. 2Layout test: deploy successive implementations and assert their declared layouts are append-only and do not read/write the EIP-1967 admin/impl slots; a storage-layout diff tool can make this reproducible.
  3. 3Invariant property (Echidna-style): assert the admin and implementation slots change only via authorized upgradeTo across fuzzed sequences; the property should hold within the budget.
  4. 4Baseline check: confirm legitimate upgrades by the admin work and forwarded calls behave correctly.
Note

Report honestly: "no unauthorized target redirect or slot collision reproduced within the tested budget" is a reproduced-defense result, not immunity — it says nothing about the trustworthiness of a future implementation. Only a proof of layout compatibility and non-controllable target, within its model, approaches a guarantee.

Limitations

  • ▸The fix constrains the target and slots, not the trustworthiness of the implementation code reached by an authorized upgrade.
  • ▸Admin/governance key compromise reintroduces full control; key management is out of scope of the storage-level fix.
  • ▸Complex diamond/multi-facet layouts are hard to model faithfully; a controlled reproduction may be infeasible, yielding UNKNOWN.
  • ▸Static layout-collision detection depends on accurate layout metadata; inline assembly and unusual patterns can defeat it, requiring experimental confirmation.

References

  1. [1]SWC-112 — Delegatecall to Untrusted Callee (SWC Registry)
  2. [2]Solidity docs — Security Considerations (delegatecall, libraries) and Layout of State Variables in Storage
  3. [3]OWASP Smart Contract Top 10 — SC01 Access Control Vulnerabilities
  4. [4]MITRE CWE-829 — Inclusion of Functionality from Untrusted Control Sphere
  5. [5]EIP-1967 — Standard Proxy Storage Slots
  6. [6]OpenZeppelin — Proxies & Upgradeable Contracts (storage gaps, initializers)

References point to public, well-known technical sources for further study.

Related articles