Skip to main content
Sigvex

Red Team / Blue Team EVM · Part 4: Owning the Contract: Access Control and Upgrade Abuse

How broken authorization and unsafe upgradeability let an attacker take the whole contract, not just drain a single function — the endgame of the red-team half.

Red Team / Blue Team EVM · Part 4: Owning the Contract: Access Control and Upgrade Abuse

EVM Red Team / Blue Team — a six-part path from reading a stranger’s deployed bytecode to shipping a contract that survives contact with an attacker.

  1. Read a Deployed Contract Like an Attacker
  2. Mapping the Attack Surface
  3. The Bugs That Move Money
  4. Owning the Contract: Access Control and Upgrades
  5. Blue Team: From Bytecode to Triaged Findings
  6. Blue Team: Hardening and Holding the Line

Part 3 was about bugs that leak money out of a function. This one is about the bug class that skips the leak entirely: instead of tricking a withdrawal, the attacker becomes the person allowed to make it. Reentrancy nets whatever is in one path. Owning the contract nets everything, plus the ability to come back tomorrow. That is why access-control and upgrade flaws sit at the top of the leverage curve — and why they are the last stop in the attacker’s mental model before we switch sides.

The privileged function with no guard

Start with the simplest version. A function changes state that should only be changed by an admin, and nothing stops anyone from calling it:

// No modifier. Anyone can call this.
function setOwner(address newOwner) external {
    owner = newOwner;
}

That is not a subtle bug, but it ships more often than you would think — usually because the guard was on a different function and someone copied the wrong template, or the modifier was removed during a refactor and never put back. The attacker doesn’t need a clever payload here. They read the ABI, see a state-changing function with no caller check, and call it.

The near misses are more interesting than the obvious one. A few patterns that all read as “authorized” to a skimming reviewer but aren’t:

  • tx.origin for auth. require(tx.origin == owner) passes whenever the owner is somewhere in the call chain — so a malicious contract the owner calls can turn around and hit your function with the owner’s origin still attached. Use msg.sender. The SWC registry catalogs this as SWC-115.
  • The wrong modifier. onlyMinter where you meant onlyOwner, or a role check against a role anyone can grant themselves.
  • public where you meant internal. An internal helper that mutates critical state, accidentally left externally callable.
  • An initializer left open. More on this in a second — it is the most expensive version of “the guard isn’t there.”

The access control vulnerabilities post goes deeper on each of these. The mental shortcut for the attacker: every function that writes privileged state is a question — who is allowed to reach this write? If the answer is “anyone,” you are done.

Uninitialized proxies and the initializer race

Upgradeable contracts don’t use constructors for setup. A constructor runs once, at deployment, and writes to the deploying contract’s storage. But in a proxy pattern the logic contract’s code executes in the proxy’s storage via delegatecall — so a constructor on the logic contract writes to the wrong storage and the proxy never gets initialized by it. The replacement is an initialize() function the deployer is supposed to call right after deployment.

“Supposed to” is the whole vulnerability. If initialization and deployment aren’t atomic, there’s a window where the contract exists but has no owner set. Whoever calls initialize() first wins:

function initialize(address _owner) external {
    require(owner == address(0), "already initialized");
    owner = _owner;
}

If that require is missing, initialize is just an unprotected setOwner with a friendlier name — callable again after setup. If the require is present but the deployer’s init transaction is separate from deployment, an attacker watching the mempool can front-run it and pass their own address. Either way they own the proxy. See proxy upgrade vulnerabilities for the full pattern set.

delegatecall, storage collisions, and the Parity freeze

delegatecall is the primitive that makes proxies work and also the one that makes them dangerous. It runs another contract’s code against your storage. Two things go wrong with it.

First, the target. If the address that gets delegatecalled is attacker-influenced, or points at logic the attacker can later replace, the attacker’s code runs with your storage and your balance. That is arbitrary code execution against your contract’s state.

Second, storage collisions. The proxy and the implementation share one storage layout by slot number, not by variable name. If the proxy keeps its admin address in slot 0 and the implementation puts an ordinary uint in slot 0, a write to that uint overwrites the admin. Getting the layouts to line up is entirely on the developer, and a mismatch is invisible in the source of either contract read alone.

The canonical disaster here is the second Parity multisig incident in November 2017. The wallets delegated their logic to a single shared library contract. That library was itself left uninitialized, so an account was able to call its initialization function directly, become the library’s owner, and then invoke a function that triggered the library’s selfdestruct. When the library’s code vanished, every wallet that delegated to it was bricked — roughly 500,000 ETH frozen permanently, worth around $150 million at November 2017 prices and over $280 million at later valuations (see the Parity wallet hacks write-up, and Wikipedia for the broader account). The mechanism is worth restating precisely because it is so cheap to reproduce: an uninitialized contract, a callable initializer, an owner-gated selfdestruct. No exotic tooling — just a guard that was never there.

The upgrade endgame

Once an attacker owns an upgradeable contract, the interesting functions are the ones that change what the contract is:

  • selfdestruct reachable behind a broken guard — brick the contract, freeze everything that routes through it.
  • An unprotected upgradeTo / upgradeToAndCall — point the proxy at logic the attacker wrote. Now the contract does whatever they want, retroactively, over the same funds.

upgradeTo in the wrong hands is the most complete win in this whole series. It doesn’t drain one path; it replaces every path.

The signals you can see without source

Everything above leaves a shape in the bytecode. You don’t get variable names, but you can see the structure — this is the reading skill from Part 1 applied to privilege. Three things worth tracing:

  • Caller checks before privileged writes. Is there a comparison against msg.sender (or, worse, the ORIGIN opcode) guarding a SSTORE to a slot that looks like an owner or role? Or does the write sit in a path anyone can reach?
  • delegatecall targets. Where does the destination address come from — a fixed slot, or something the caller can steer?
  • Upgrade and destruct entry points. Is there a DELEGATECALL-driven upgrade path or a reachable SELFDESTRUCT, and what gates it?

Answering those by hand across a real contract is tedious, which is exactly the kind of tracing an analyzer like Sigvex automates. But the questions are the same whether a person or a tool asks them, and framing them is conceptual work — no finding is real until the guarded path is actually traced.

The one this doesn’t solve

Be honest about the ceiling. A perfectly guarded contract with one owner key is still one compromised key away from total loss. That is a governance and operations problem — key management, multisig thresholds, timelocks, the human process around an upgrade — and no amount of bytecode analysis fully closes it. A tool can tell you the upgradeTo function is correctly gated to a single address. It cannot tell you that address is a hot wallet on a laptop, or that the team intends to rug. Centralization is itself an attack surface; treat “one key controls everything” as a finding, not a footnote.


You’ve now seen the four families the attacker reaches for, from reading bytecode cold to taking the whole contract. That is the offensive mental model complete. Part 5 turns it around: the same questions that make a good exploit make a good detector, and we start building the blue-team pipeline that finds these at scale — from raw bytecode to triaged findings.