Unprotected initialize() behind a delegatecall proxy allows anyone to seize or overwrite owner in proxy storage.
| Severity | High |
| Difficulty | Low |
| Type | Access Control / Initialization |
| Target | src/upgradeable-proxy/ImplementationV1.sol (via SimpleProxy) |
| Finding ID | SCSL-PROXY-01 |
Rated High because any external account can become owner of the proxy's storage and invoke owner-only functions such as setValue. In a production vault this maps to full protocol takeover (fund movement, parameter changes, upgrades) without exploiting unrelated bugs.
Difficulty is rated Low: the pattern is well documented in upgradeable-contract guidance, public incidents involve unprotected initializers, and the PoC is a single unprivileged call through the proxy address.
Severity tiers used in this lab (aligned with common audit firm conventions):
| Severity | Description |
|---|---|
| Critical | Catastrophic financial loss or complete protocol takeover. |
| High | Significant financial loss or core functionality break. |
| Medium | Real impact but requires specific conditions. |
| Low | Limited impact, hard to exploit, or only edge cases. |
| Informational | Best-practice / code quality issues. |
ImplementationV1 is designed to run behind SimpleProxy using delegatecall. Users interact with the proxy address, but state (owner, value) is stored in the proxy's storage slots.
The vulnerable initialize(address owner_) function is external, has no access control, and can be called more than once. An attacker can either front-run the legitimate deployer and call initialize(attacker) first, or wait until after the admin initializes and call initialize(attacker) again to overwrite owner.
Once owner points to the attacker, setValue and any other owner-gated logic on the proxy address execute under attacker control.
Upgradeable systems split code (implementation contract) from state (proxy contract):
User → SimpleProxy.fallback → delegatecall → ImplementationV1 logic
State (owner, value) lives in proxy storage, not implementation storage
Constructors run only when the implementation contract is deployed. They do not execute when users call the proxy. Therefore privileged setup must happen in a separate initialize function called on the proxy address after deployment.
If initialize is public and not one-time, it becomes an unguarded write to the most sensitive storage slots in the system.
SimpleProxy stores its implementation pointer in the EIP-1967 slot so it does not collide with owner at slot 0 in the logic contract layout.
src/upgradeable-proxy/ImplementationV1.sol
function initialize(address owner_) external {
owner = owner_;
emit Initialized(owner_);
}There is no:
initializer/ one-time guard,- check that
msg.senderis allowed to perform setup, _disableInitializers()on the implementation to block direct initialization of the logic contract address.
src/upgradeable-proxy/SimpleProxy.sol forwards arbitrary calls:
fallback() external payable {
_delegate(_getImplementation());
}So any caller can reach initialize through the proxy.
| Attack | Result |
|---|---|
| Front-run initialization | Attacker becomes owner before the admin; admin's later initialize either overwrites or fails depending on ordering — with ImplementationV1, either party can win the race because there is no one-time guard. |
| Re-initialization | Admin initializes correctly; attacker later calls initialize(attacker) and overwrites owner. Admin loses setValue and any other owner powers. |
In the PoC, the attacker sets value to 1337 after seizing ownership. In production, the same bug on a treasury, vault, or governance module equals full administrative takeover.
Missing initialization hardening in an upgradeable logic contract.
- No one-time initializer.
initializecan run arbitrarily many times, soownercan be replaced at any point. - No authorized initializer. Any
msg.sendermay callinitialize; there is no deployer-only or factory-only gate. - Implementation left initializable. Without
_disableInitializers()in the logic contract constructor, the implementation address itself can also be initialized, confusing monitoring and tooling (fixed inFixedImplementationV1).
This is the same class of bug as unprotected init functions in proxy-based systems and parallels Access Control findings where privileged state can be written by arbitrary callers.
Reproduce locally:
forge test --match-test testExploit_UnprotectedInitializeLetsAttackerTakeOwnership -vvvv
forge test --match-test testExploit_AttackerCanReinitializeAndOverwriteOwner -vvvvTest file: test/upgradeable-proxy/ProxyPoC.t.sol
- Deploy
ImplementationV1andSimpleProxy. - Cast the proxy address to
ImplementationV1. - Attacker calls
vault.initialize(attacker)through the proxy. - Attacker calls
setValue(1337).
Verifying assertions:
vault.owner() == attacker
vault.value() == 1337
- Admin calls
initialize(admin)andsetValue(100). - Attacker calls
initialize(attacker). ownerbecomes attacker; admin'ssetValue(200)reverts withnot owner.
- Protect
initializewith OpenZeppelinInitializableand theinitializermodifier so it succeeds only once per proxy instance. - Call
_disableInitializers()in the implementation constructor so the bare logic contract cannot be initialized directly. - Reject zero admin —
require(owner_ != address(0))to avoid bricking ownership. - Initialize atomically in deployment scripts — deploy proxy, then call
initializein the same transaction (or via a factory) to reduce front-running windows.
Example fix (this lab's FixedImplementationV1):
constructor() {
_disableInitializers();
}
function initialize(address owner_) external initializer {
require(owner_ != address(0), "zero owner");
owner = owner_;
emit Initialized(owner_);
}- Use audited proxy patterns — UUPS, Transparent Proxy, or OpenZeppelin Upgrades plugins — instead of a hand-rolled proxy for production.
- Restrict
upgradeTowithonlyOwner/ timelock (this lab'sSimpleProxy.upgradeTois intentionally unguarded for teaching). - Add storage gaps and documented layout for
V2upgrades; validate withforge inspect/ upgrade safety checks to avoid storage collisions. - Add regression tests that assert replayed
initializereverts. SeetestFix_BlocksReinitialize. - Consider an initialization registry or factory that is the sole address allowed to call
initializeif deployer EOA front-running is a concern.
| File | Role |
|---|---|
src/upgradeable-proxy/FixedImplementationV1.sol |
Same interface as ImplementationV1, but uses Initializable, initializer, and _disableInitializers(). |
Verification tests in test/upgradeable-proxy/ProxyPoC.t.sol:
| Test | Asserts |
|---|---|
testExploit_UnprotectedInitializeLetsAttackerTakeOwnership |
Vulnerable version: attacker seizes owner via proxy. |
testExploit_AttackerCanReinitializeAndOverwriteOwner |
Vulnerable version: attacker overwrites admin's ownership. |
testFix_BlocksReinitialize |
Fixed version: replayed initialize reverts; admin keeps control. |
testFix_AllowsLegitimateInit |
Fixed version: honest admin init and setValue still work. |
testFix_BlocksDirectInitializeOnImplementation |
Fixed version: logic contract address cannot be initialized directly. |
Run the upgradeable proxy suite:
forge test --match-path test/upgradeable-proxy/ProxyPoC.t.sol -vvExpected output:
[PASS] testExploit_AttackerCanReinitializeAndOverwriteOwner() (gas: 378920)
[PASS] testExploit_UnprotectedInitializeLetsAttackerTakeOwnership() (gas: 366879)
[PASS] testFix_AllowsLegitimateInit() (gas: 478135)
[PASS] testFix_BlocksDirectInitializeOnImplementation() (gas: 232070)
[PASS] testFix_BlocksReinitialize() (gas: 489740)
Run all lab tests:
forge testExpected result:
20 tests passed, 0 failed
delegatecallreuses proxy storage. Always ask: "Who can write the first value into privileged slots?"- Constructors ≠ proxy setup. Code in a constructor never runs at the proxy address; use
initializewith strict guards. - One-time initialization is mandatory for
owner, oracle, pauser, and other admin roles in upgradeable contracts. _disableInitializers()on the implementation prevents a duplicate "initialized" logic contract from confusing integrators and tools.- Related real-world pattern. SukukFi H-01 (unauthorized
withdrawon ERC-4626-style vaults) is a different surface — missingmsg.senderauthorization — but the lesson is the same: privileged operations must validate the caller. Initialization is the setup-time variant of that mistake. - Optional follow-up in this lab. Storage layout collisions when upgrading from
V1toV2, and unprotectedupgradeTo, are separate teaching topics not covered by this report.