Uninitialised proxy implementation
Upgradeable contracts cannot use constructors, because a constructor runs in the implementation's context, not the proxy's. Instead they use an initialize() function. If that function is left callable on the implementation contract itself, anyone can call it and become its owner.
Vulnerable code
contract VaultV1 is Initializable, OwnableUpgradeable {
function initialize() public initializer {
__Ownable_init(); // sets msg.sender as owner
}
function upgradeTo(address impl) external onlyOwner { /* ... */ }
function kill() external onlyOwner {
selfdestruct(payable(msg.sender));
}
}
Why it breaks
The proxy is initialised at deployment, so the proxy is safe. But the implementation contract sits on-chain with its own untouched storage, and its initializer has never run. An attacker calls initialize() directly on the implementation address, becomes its owner, then calls kill(). The implementation self-destructs. Every proxy delegating to it now points at empty code, and all funds behind those proxies are stranded.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
contract VaultV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // the fix — one line
}
function initialize(address owner_) public initializer {
__Ownable_init(owner_);
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
The implementation contract has a non-zero owner from the block it was deployed.
invariant implementation.owner() != address(0) from deployment block onward watch Initialized event on the implementation
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
function testImplementationCannotBeInitialised() public {
VaultV1 impl = new VaultV1();
vm.prank(attacker);
vm.expectRevert(); // InvalidInitialization
impl.initialize(attacker);
}
function testProxyInitialisedOnce() public {
vm.expectRevert();
vault.initialize(attacker); // already initialised
}
Where it has caused real losses
An uninitialised implementation behind a widely-used bridge was reported through a bug bounty in 2022; the finder was paid $10M, then the largest bounty ever awarded. Had it been found by an attacker instead, the loss would have been substantially larger. The fix was a single line in the constructor.
What we check during review
The full checklist we run on any upgradeable deployment: is _disableInitializers() called in the implementation's constructor? Is initialize() guarded by initializer, and are re-initialisers guarded by reinitializer(n)? Is _authorizeUpgrade actually restricted? Does the new implementation's storage layout append rather than reorder? And is there a storage gap (uint256[50] private __gap) in each inherited contract so future versions can add variables safely?
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Related vulnerabilities
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission