*Part 3 of 12 in "Web3 Security 101". Part 1 said a contract's code is fixed at deployment. Many contracts you use every day are the exception, and this part explains how.*
Immutable code has a cost. A bug found next month cannot be fixed. So most large protocols deploy two contracts instead of one: a proxy that users talk to, and an implementation that holds the logic. The proxy's address never changes. The implementation behind it can1.
For a user, this means that "the audited contract at this address" is true only until the next upgrade. For an attacker, the upgrade function is the biggest prize in the system: whoever can call it can replace all the rules.
delegatecall: logic from one place, state in another
A normal call runs the target's code on the target's storage. delegatecall is different: it runs the target's code on the caller's storage2. A minimal proxy is a fallback that forwards every call:
fallback() external payable {
address impl = _implementation(); // read from a fixed slot
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()) }
}
}The result: balances, owner, settings and everything else live in the proxy. The implementation is only a library of instructions. An upgrade is one write: change the address stored in the proxy. The state stays, the instructions change.
Where the proxy keeps the implementation address
If the proxy stored the address in an ordinary variable, it could collide with the implementation's variables. ERC-1967 fixes the slots3:
// implementation address
bytes32 IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
// admin address
bytes32 ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;These are keccak256("eip1967.proxy.implementation") - 1 and keccak256("eip1967.proxy.admin") - 1. Explorers use them to show "Read as proxy". The same standard defines the events:
Upgraded(address indexed implementation)AdminChanged(address previousAdmin, address newAdmin)BeaconUpgraded(address indexed beacon)for the beacon variant, where many proxies share one pointer.
Who may upgrade: two common designs
- Transparent proxy. The proxy holds an admin address (usually a
ProxyAdmincontract) and only the admin can upgrade. This is the "who owns the door" question from Part 2, now with the highest stakes. - UUPS (ERC-1822). The upgrade function lives in the implementation, and the implementation decides who may call it, in a hook such as
_authorizeUpgrade4. Cheaper to run, but a new implementation without that function, or with a wrong check, locks the proxy forever or opens it to anyone.
In both, the answer to "who can change the code" is an address with a key behind it. A single key there means the whole system depends on that key. Safes and timelocks (Part 4) exist for this reason.
Initializers: the constructor you cannot use
A constructor runs once, in the implementation's own storage, so it cannot set up the proxy's state. Upgradeable contracts replace it with an initialize function, guarded so it can run only once per version5:
function initialize(address admin) public initializer { ... }
function initializeV2(uint256 x) public reinitializer(2) { ... }Each run emits Initialized(version). Initializers are where upgrades most often go wrong without any attacker:
- An implementation left uninitialized can be initialized by a stranger, who becomes its owner.
- An upgrade that adds new state must run the initializer that fills it. If one is skipped, the new variable stays at 0.
- A new implementation that reorders or changes the types of variables breaks the storage layout: new code reads old slots and sees garbage.
Our Ronin write-up is the example of the second case. The upgrade jumped straight to version 4 and never ran initializer version 3. The vote threshold was computed from a total that stayed 0, so the threshold became 0 and withdrawals needed no signatures. The Upgraded event was normal. The logs show Initialized(4) and no Initialized(3). About 48 minutes later $12M left.
What the signals look like
An upgrade leaves traces you can watch:
| Signal | Where | What it tells you |
|---|---|---|
Upgraded(implementation) | Proxy logs | The code changed, and to what address |
AdminChanged(...) | Proxy logs | The right to upgrade moved |
Initialized(n) | Proxy logs | An initializer ran, and which version |
The values of key view functions | State | Whether the new code kept the invariants |
Upgraded(implementation)AdminChanged(...)Initialized(n)view functionsThe first three are events. The fourth is the lesson from Ronin: an upgrade announces itself and then needs a check of the result.
How to catch it
What SmartAlert does today6:
- Upgrade and admin change. Add the proxy address, not the implementation. For an ERC-1967 proxy the product offers the
proxy-upgradedandproxy-admin-changedtemplates, both critical, with the new implementation or admin address in the message. Recipe: Alert on proxy upgrade. - Who sent it. An "allowed senders" rule on the
Upgradedevent, with the addresses of your Safe and deployer. It fires when the upgrade comes from anywhere else, including through another contract. This is the rule for "an upgrade nobody on the team expected". - What it changed. Before your next upgrade, create "Contract state" rules on the values the protocol cannot live without: a vote threshold, a fee cap, a signer count,
paused(). The first check after the upgrade shows whether the numbers survived. This is exactly the rule from the Ronin write-up, a call tominimumVoteWeight()every minute with "below 1" as the condition. Contract-state rules are on Pro and above. - Whether it is paused. A
paused()rule tells you if the team's own brake is on.
What we cannot do. SmartAlert does not read the ERC-1967 storage slot directly today, so it cannot compare the live implementation with a code hash or address you trust. It sees the Upgraded event, not the slot. A state rule can only call a function that is in the contract's ABI, so an invariant with no view function cannot be checked. And when the upgrade and the theft happen in one transaction, the alert arrives with the loss: on Ethereum it comes 2.5 to 4 minutes after the block.
Next, Part 4: Multisigs and Safes: owners, threshold, modules, delegatecall.
Sources
- 1.OpenZeppelin, Proxy Upgrade Pattern. ↩
- 2.Solidity documentation, Delegatecall and libraries. ↩
- 3.EIP-1967, Proxy Storage Slots. ↩
- 4.ERC-1822, Universal Upgradeable Proxy Standard (UUPS). ↩
- 5.OpenZeppelin, Writing Upgradeable Contracts. ↩
- 6.SmartAlert, rule templates for ERC-1967 proxies and the "allowed senders" rule; see the Ronin write-up for the full timeline and the on-chain checks. ↩