Remediating Uninitialized UUPS Proxy
How to protect UUPS proxy implementations from front-running attacks by disabling initializers on the implementation contract.
Remediating Uninitialized UUPS Proxy
Overview
Related Detector: Uninitialized UUPS Proxy
UUPS proxy implementations require an initialize() function instead of a constructor because initialization must run in the proxy’s storage context. If initialize() can be called by anyone on the bare implementation contract, an attacker can front-run deployment and gain ownership. The recommended fix is to call _disableInitializers() in the implementation constructor, preventing any initialization of the raw implementation.
Recommended Fix
Before (Vulnerable)
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
// VULNERABLE: no constructor — implementation can be initialized by anyone
contract VulnerableProtocol is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public protocolFee;
function initialize(address owner, uint256 fee) public initializer {
__Ownable_init(owner);
__UUPSUpgradeable_init();
protocolFee = fee;
// Attacker calls this first with their address, gains ownership
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
After (Fixed)
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract SecureProtocol is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public protocolFee;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
// Sets the initialized version to type(uint8).max
// All future calls to initialize() or reinitialize() on the implementation will revert
}
function initialize(address owner, uint256 fee) public initializer {
__Ownable_init(owner);
__UUPSUpgradeable_init();
protocolFee = fee;
}
function _authorizeUpgrade(address newImpl) internal override onlyOwner {
// Consider adding a timelock here for additional security
}
}
The /// @custom:oz-upgrades-unsafe-allow constructor annotation tells OpenZeppelin’s upgrades plugin that this constructor is intentionally present and safe. Without it, the plugin would reject the contract as having a constructor (which is normally unsafe for upgradeable contracts).
Alternative Mitigations
Two-Step Ownership Transfer After Deployment
If you cannot use _disableInitializers() (e.g., older OZ versions), deploy the implementation and call initialize() in the same transaction using a deployment script, removing the front-running window:
// Hardhat/Ethers deployment script — atomic deploy + initialize
const Implementation = await ethers.getContractFactory("VulnerableProtocol");
const proxy = await upgrades.deployProxy(Implementation, [ownerAddress, feeBps], {
kind: "uups",
// deployProxy calls initialize() in the same transaction as proxy deployment
initializer: "initialize",
});
await proxy.waitForDeployment();
Using OpenZeppelin’s deployProxy helper ensures initialization happens atomically with deployment, eliminating the front-run window even without _disableInitializers().
Add a Deployment Guard
As a belt-and-suspenders measure, add a deployed-by check to initialize():
contract GuardedProtocol is Initializable, OwnableUpgradeable, UUPSUpgradeable {
address private immutable _deployer;
constructor() {
_deployer = msg.sender;
_disableInitializers(); // Primary fix
}
function initialize(address owner) public initializer {
// Secondary check — even if _disableInitializers somehow fails
require(msg.sender == _deployer || msg.sender == address(this), "Unauthorized initializer");
__Ownable_init(owner);
__UUPSUpgradeable_init();
}
}
Common Mistakes
Mistake: Using initialized Boolean Instead of OZ’s Initializer
// WRONG: custom initialization guard is insufficient
contract MisguidedProtocol {
bool private initialized;
function initialize(address owner) external {
require(!initialized, "Already initialized");
initialized = true;
// Still vulnerable to front-running before initialized is set
// Also doesn't protect the implementation contract itself
_owner = owner;
}
}
Use OpenZeppelin’s initializer modifier which implements a version counter pattern with proper reentrancy protection.
Mistake: Calling _disableInitializers() After Logic
// WRONG: _disableInitializers() must be called in the constructor,
// not at the end of initialize()
function initialize(address owner) public initializer {
_owner = owner;
_disableInitializers(); // Too late — called during initialization, not in constructor
}
_disableInitializers() belongs in the constructor, not in initialize().
Mistake: Missing the OZ Unsafe Allow Annotation
// This will be REJECTED by OpenZeppelin upgrades plugin without the annotation
constructor() {
_disableInitializers();
}
// CORRECT:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
Without the annotation, hardhat-upgrades and foundry-upgrades will report an error when deploying.