SEKAI CTF 2026 — Blockchain Writeups
18 min read

Here are my writeups for every blockchain challenge from SEKAI CTF 2026. For each one I walk through how I analysed the challenge, the vulnerable code, the exploit, the root cause, and the fix. Use the index in the sidebar to jump straight to the challenge you're after.
01 / SEKAI CTF 2026 Blockchain
PP Farming
Reentrancy in the withdrawal path: the ATM pays out before it writes down that you've been paid.
Category: Blockchain
Chains: Ethereum (EVM)
What you're given
The challenge is a small Foundry project plus an instancer:
Spin up an instance and it hands you what you need to talk to a private chain:
Winning is server-side. The instancer calls isSolved() on the ATM, and if it comes back true you get the flag. So the whole thing collapses to one goal: make isSolved() return true over the RPC.
How I read it
I don't start from the top of the file, I start from the win condition:
And the ATM is deployed holding 10 ETH:
So the objective is exact: drain the contract to 0 wei. Now I only care about the functions that move money:
donatePP(address) — credits scores[_to] by msg.value. Money in.
withdrawPP() — pays out scores[msg.sender]. Money out.
The pay-out path is always the first place I look, and this one breaks the most basic rule in the book — Checks-Effects-Interactions.
The bug
It sends the ETH before it zeroes the balance, and msg.sender.call hands execution to whatever contract is calling. There's no reentrancy guard. So if my receive() calls withdrawPP() again, it re-enters while my score is still on the books and pays me a second time out of the same pot. Repeat until the ATM is empty.
Tracing the re-entry
I deposit chunk = 10 ETH to myself first, so the ATM holds 10 + 10 = 20 ETH and scores[attacker] = 10:
Two sends of 10 ETH, 20 → 10 → 0. The ATM is empty, isSolved() flips to true, and I keep the 10 ETH it was seeded with.
Sizing the deposit
Let chunk be what I credit myself:
ATM balance after the deposit: 10 ether + chunk
Each re-entrant withdrawPP() sends exactly chunk
I keep re-entering while atm.balance >= chunk
Any chunk that divides 10 ether lands the balance on 0 exactly; a smaller chunk just means deeper recursion. chunk = 10 ether is the clean choice — two sends and done.
The exploit contract
Playing it out
I dropped the instance details into a few env vars:
First, a look at where we're starting from — 10 ETH in the ATM, isSolved() still false:
Ship the attacker and pull its address straight out of the JSON:
One call does the whole thing — it deposits 10 ETH to itself, calls withdrawPP(), and the re-entry from receive() empties the pot:
The second that landed, the ATM was dry and the check went green:
Flag comes straight from the instancer's Get flag.
Root cause & fix
Textbook Checks-Effects-Interactions violation — state is written after the external call. Zero the balance before paying out, and add a reentrancy guard for good measure:
Flag
02 / SEKAI CTF 2026 Blockchain
PP Farming 2
They patched the reentrancy. They didn’t patch the proxy underneath it.
Category: Blockchain
Chains: Ethereum (EVM)
What you’re given
Same challenge family as before, same shape of drop:
Deploy.s.sol deploys a PerformancePointHelper first, then the ATM, wired to that helper and holding 10 ETH:
Same instancer pattern as challenge 1 — an RPC endpoint, a funded player key, and the ATM’s address. Same win condition, too:
First impression
Same name, same shape — PerformancePointATM, deployed with 10 ETH, same win condition:
Same money-in / money-out pair too:
My first instinct after solving challenge 1 was to check for the exact same bug — and it’s gone. withdrawPP() is wrapped in noReentrancy, and the payout is now routed through a delegatecall into a separate performancePointHelper contract instead of a raw .call. Whoever wrote this patch clearly read the first writeup.
That’s exactly the moment to slow down instead of moving on. A challenge author who deliberately closes the obvious door is telling you the real one is somewhere else in the same building. So instead of asking “is this still reentrant,” I asked a different question: what else changed to make this delegatecall pattern possible, and what does that pattern expose?
Reading the delegatecall
withdrawPP() only delegatecalls one function on the helper, and it’s gated by noReentrancy. But a contract that delegatecalls anything usually has a reason to expose more than one entry point into that helper — otherwise why delegatecall at all instead of a normal external call? So I went looking for how else performancePointHelper gets reached, and found the fallback:
This is a minimal proxy. Any function selector that isn’t handled elsewhere in the ATM gets forwarded, via delegatecall, straight into the helper. There’s exactly one selector on a blocklist — processWithdrawal(address,uint256) — presumably so nobody can call it directly and drain arbitrary accounts by hand.
But a blocklist on one function tells you nothing about what else the helper exposes. So I went and read the helper contract in full, not just the function withdrawPP() calls.
What the helper actually exposes
Harmless-looking on its own — it’s a setter on the helper’s own atm variable. Except it isn’t blocked by the fallback’s selector check, and it isn’t guarded, and — this is the part that matters — it’s never called directly on the helper. It’s only ever reached by sending setATM(address) calldata to the ATM, which the fallback then delegatecalls into the helper’s code.
That’s the whole bug in one sentence: delegatecall executes the helper’s code, but in the ATM’s storage. setATM doesn’t write to the helper’s atm slot — it writes to whatever sits at that same slot number in the caller, which is the ATM.
So the question became: what does the ATM keep at that slot?
Lining up the storage layouts
I wrote out both contracts’ declared storage side by side, slot by slot:
Slot 1 on the helper is atm. Slot 1 on the ATM is performancePointHelper. Delegatecall doesn’t know or care about variable names — it just writes to slot 1 of whoever called it. So setATM(evil), executed by the ATM through the fallback, overwrites performancePointHelper with whatever address I pass in.
Once I control performancePointHelper, I control every future delegatecall withdrawPP() makes — including the one processWithdrawal call it’s not supposed to let me forge.
Building the fake helper
The replacement only needs to satisfy one interface: the selector withdrawPP() calls.
The trick that makes this work is the same one that caused the bug: under delegatecall, address(this) inside processWithdrawal isn’t the helper — it’s still the ATM. So address(this).balance is the ATM’s entire balance, and this function hands all of it to whoever I pass as recipient.
Playing it out
Instance values for this run:
First, the fake helper goes up at $EVIL:
withdrawPP() requires score > 0, so I need a nonzero balance on the books before I touch anything else — 1 wei is plenty:
Now the actual attack — a call that, on the surface, looks like it just points the ATM at a new address:
That single transaction walks through the fallback, delegatecalls the real helper’s setATM, and overwrites performancePointHelper in the ATM’s own storage — slot 1 now holds $EVIL instead of the legitimate helper. From here, withdrawPP() is mine:
withdrawPP() delegatecalls into ExploitHelper.processWithdrawal, which reads address(this).balance — the ATM’s full balance — and forwards it to PLAYER in the same transaction:
Root cause
noReentrancy closed the door from challenge 1, but the fallback still uses delegatecall as an implicit proxy, and it only blocks one selector by name instead of restricting which functions on the helper are reachable at all. Because the helper’s storage layout wasn’t deliberately kept disjoint from the ATM’s — atm sitting at the same slot as performancePointHelper — a completely unrelated setter became a way to overwrite the proxy’s own implementation pointer. Once that pointer is attacker-controlled, every guarantee withdrawPP() relies on is gone.
Fix
Don’t let a fallback forward arbitrary selectors to a delegatecall target. Allowlist the specific functions that are meant to be reachable, rather than blocklisting the one you’re worried about.
If a delegatecall pattern is required, follow the same rule proxies use in production (EIP-1967 style): keep the implementation pointer in a storage slot that’s guaranteed not to collide with anything the implementation contract itself declares — a hashed, out-of-the-way slot, not slot 0/1/2.
Never let any function reachable through delegatecall write to a slot that also holds security-critical state in the caller. If setATM has to exist on the helper for legitimate reasons, it should live at a slot the ATM has explicitly reserved and never touches for anything else.
Flag
03 / SEKAI CTF 2026 Blockchain
Open World
The contract’s economics are airtight. The instancer around it isn’t.
Category: Blockchain
Chains: TON
What you’re given
This one’s a TON challenge, which already meant slowing down — different execution model, different gotchas than the two EVM challenges before it. The project looks like:
And the instancer hands you:
a fresh challenge contract address
a player wallet seed and wallet id
an API v2 endpoint for the local TON chain
The entire game lives in one contract. Storage is small enough to read at a glance:
And the getter the instancer polls to decide whether to hand out the flag:
isSolved only ever flips inside one handler, so that’s where the win condition actually lives — I’ll get to it in a second.
How I read it
Challenge.tolk dispatches on message type, and three of those cases are things a player can trigger directly. First, PlayerBonus — free jettons, no payment required:
FLAG_PRICE / 2 is 50 jettons per successful call. Then Buy, which mints jettons for a price:
And the constants that pin down the economy:
And finally Solve, reached as a forward payload on a jetton transfer to the challenge’s own wallet:
So the whole game: PlayerBonus gives 50 jettons for free, Buy sells jettons at 2 TON each, Solve needs 100 jettons transferred by the player wallet. I did the arithmetic before touching any transactions, because TON challenges tend to be economics puzzles as much as code puzzles:
Player starts with 1 TON. That’s it.
PlayerBonus gives 50 jettons for free.
Solve needs 100.
The missing 50 cost 50 × 2 = 100 TON to buy.
The player has roughly 1/100th of that.
Selling the free 50 back nets ~100 TON, but then you’re at 0 jettons — buying 50 with that TON just returns you to where PlayerBonus already put you. Every path that stays inside one instance is a closed loop.
One detail looked like a possible way out: storage declares remainingPlayerBonus as a counter, and sandbox/deploy-challenge.ts initializes it to 2n, not 1n:
Reading the handler, hasBonus is checked before the decrement, so on paper, calling PlayerBonus twice looks like it should mint 50 + 50 = 100 jettons from a single instance, no cross-session trick required. I tried exactly that against a live instance. It didn’t produce a second batch of jettons — whatever’s gating it (contract balance, message ordering, something in the sandbox harness) held up in practice even though the storage counter alone doesn’t explain why. I also tried a few other angles before giving up on “the contract itself has a bug”:
fake bounced messages sent to the Jetton wallet, hoping for a bounce-accounting double-credit
action-phase failures around RAWRESERVE that might leave a mint half-applied
oversized forward payloads to see if the wallet mis-parses jetton amounts
None of it produced extra jettons — the contract logic held up under all of it. At that point I went back to the challenge’s own name: Open World. That’s not a flavor detail, it’s the hint. It’s telling you the world isn’t contained to your instance.
The vulnerability
The bug isn’t in any .tolk file — it’s environmental. Every time you request a new instance, the instancer gives you a fresh player wallet and a fresh challenge contract, but every instance runs on the same shared local TON chain. Jettons are scoped per-challenge, so a jetton balance from one session is worthless against another session’s challenge. But TON — the chain’s native currency — isn’t scoped to anything. It’s just a balance on a wallet, and wallets can send it to any address on that chain, including a wallet that belongs to a completely different session.
That one fact breaks the isolation the puzzle depends on. Spin up a second, throwaway instance purely as a funding source:
Create the target session (the one you actually need to solve).
Create a sponsor session (disposable).
In the sponsor session, claim the 50 free jettons via PlayerBonus.
Sell those 50 sponsor jettons to the sponsor challenge — that’s ~100 TON back.
Transfer that TON from the sponsor player wallet to the target player wallet.
In the target session, claim the 50 free target jettons.
Buy the missing 50 target jettons using the TON that just arrived from the sponsor.
Transfer all 100 target jettons to the target challenge’s Jetton wallet with a Solve forward payload.
The flag check only asks “did the target player transfer 100 jettons with Solve” — it has no way to know, or care, whether the TON that paid for those jettons originated inside the target session or was wired in from somewhere else.
The Sell handler is what makes a sponsor session useful as a funding source in the first place:
Any session can turn its own free jettons into real TON through this path. That TON is chain-native, so it can be routed to any wallet on the same chain — including the target’s.
Exploit flow
Open two sessions against the instancer:
Run that twice — once for the target, once for the sponsor — and record from each response:
uuid
challenge contract address
api v2 endpoint
wallet id
seed
I wrote solve.ts to drive both sessions through the TON SDK — claim bonuses, sell, transfer, buy, solve, in that order — using the message opcodes straight from messages.tolk:
The target instance’s API endpoint is what the script talks to, since that endpoint proxies to the same shared chain and can see both contracts:
What the script actually does, step by step:
(99.9 rather than a clean 100 to leave headroom for gas on the target side.)
Once isSolved flips, grab the flag through the same instancer connection:
Choose the flag option and enter the target session’s UUID — not the sponsor’s, since the sponsor was only ever scaffolding.
Why this works
The contract goes out of its way to box the player in with fixed, correctly-enforced constants — 2 TON a jetton, 100 jettons to win. What it can’t enforce is where the TON funding a purchase came from, because TON itself isn’t a challenge-scoped resource. Nothing about the target challenge’s on-chain state looks wrong when the player buys the missing 50 jettons — it just sees a wallet with enough TON, same as if the player had earned it any other way inside their own session.
Root cause & fix
This isn’t a logic bug in the Tolk contracts — every constraint they enforce holds. The failure is in the instancer’s infrastructure: it treats each session as economically isolated when it’s actually running all sessions on one shared chain. Fixes belong at the instancer level, not the contract level:
Give each instance its own chain, or at minimum its own account namespace that can’t send value to another instance’s wallets.
If a shared chain is unavoidable for resource reasons, have the challenge track which session funded a purchase, not just the resulting balance — e.g. tag TON transfers with a session identifier and reject cross-session inflows at the Buy handler.
Treat “the flag condition is satisfied” and “the player earned it through the intended path” as two different things to verify, especially in any challenge that models an economy.
Flag
04 / SEKAI CTF 2026 Blockchain
Outer Stellar
The bridge accounting is solid. The instancer that stands it up isn’t.
Category: Blockchain
Chains: Stellar / Soroban + Sui Move
What you’re given
This challenge spans two chains at once — a Soroban contract on Stellar and a Move package on Sui, wired together by a bridge and a relayer that watches both sides. The layout:
The instancer boots a fresh pair of chains per instance and hands you a small HTTP API to drive it: /new to stand up an instance, /info to inspect it, /stop to tear it down, /flag to check whether you’ve won.
How I read it
The /flag route is short enough to read in full before touching anything else:
Everything hinges on has_solved, so that’s next:
It reads a bridge dict out of the instance’s own stored deploy info, and calls stellar_sekai_balance(stellar, bridge, player_pubkey). Nothing here checks that bridge still points at the contract the instancer actually deployed — whatever stellar_contract_id sits in that dict is what gets queried, unconditionally.
Before chasing that thread, I spent time where these challenges usually hide their bugs first: the bridge accounting itself. Two chains, a relayer moving value between them, signed attestations — that’s a lot of surface area, and it’s the kind of thing CTF authors build multi-day rabbit holes into. I did find something real there (more on it later), but it’s slow and unreliable for reaching the full 250 target in bounded time. So the real question became: can I control what has_solved reads as bridge, and does anything re-derive it from something I don’t control?
The vulnerability
/new is the actual route, and it passes the request body straight through:
Which eventually reaches launch_integrated_instance:
bridge = body.get(“bridge”) — read directly off unauthenticated JSON I control. If it’s a dict, it skips the entire deploy_bridge_system path (the code that would actually build and deploy a real bridge) and goes straight to registering whatever I sent:
A presence check, not a validity check — it never confirms stellar_contract_id is a real bridge, that the Sui and Stellar sides are related, or that the code at that ID implements bridge logic at all. And since has_solved() reads this exact same write_deploy_info output back out, whatever I put in stellar_contract_id at /new time is what /flag will query at check time.
The balance query itself just shells out to the Stellar CLI against whatever ID it’s given:
And that’s the exact interface the real bridge exposes too — small enough that forging it is trivial:
So the entire win condition reduces to: make some contract, at an ID I choose, respond to balance(owner) with a number ≥ 250. Nothing about the real bridge, the real Sui side, or any actual token accounting needs to exist.
Building the fake contract
A minimal Soroban contract implementing exactly that one function, ignoring the caller entirely:
1000 is comfortably past the 250 threshold, and the function signature matches the real bridge’s balance(owner: Address) -> i128 exactly, so the checker’s CLI invocation can’t tell the difference.
Output lands at target/wasm32v1-none/release/fake_balance.wasm.
The one piece that has to happen in the right order
/new writes the bridge config before my fake contract exists — I need to hand it a stellar_contract_id for a contract I haven’t deployed yet. Soroban contract IDs are deterministic, derived from the deploying account and a salt, not from the wasm itself, so I can compute the ID first and deploy into it second.
stellar.py hardcodes the standalone network’s root key, since this is a throwaway local chain, not a real one:
Pick a fixed salt and compute the future ID before anything is deployed:
The RPC doesn’t even need to be reachable for this — the ID falls out of the deployer key and salt alone, so I can compute it before an instance exists to deploy against.
Playing it out
Clear out any stale instance first:
Start a fresh one, handing /new the precomputed ID as the “official” bridge before anything real is deployed there:
Pull the instance’s Stellar RPC endpoint out of /info:
which gave me:
Now deploy the fake contract straight into the ID I already told the instancer to trust:
Same ID as predicted — confirming the fake contract is now live exactly where the instancer already believes the real bridge to be.
No Sui transaction, no real bridge interaction, no waiting on relayer timing — the entire multi-chain challenge collapses to one HTTP call and one fake contract deploy.
Why this works
The instancer’s design assumes a clean separation: it deploys the real bridge, and the checker later verifies against that deployment. What actually happens is the public API lets you skip straight to defining “the deployment” yourself. Once /flag trusts the same unauthenticated metadata /new accepted, verifying anything about the actual challenge state becomes optional — it’s just asking a contract you control whether you’ve won, and you have.
False lead: the fee_recipient gap
Before I found the instancer bug, I’d already found a real bug in the bridge itself, and it’s worth showing because it’s a legitimate signature-binding flaw, just not fast enough for a 250-balance target.
The relayer signs attestations for cross-chain completions, but look at exactly what gets signed:
recipient, amount, message_id — no fee_recipient anywhere in the signed payload. The on-chain verifier checks the exact same three fields:
fee_recipient is a function argument, not part of the signed message, and bridge_fee skims amount / 8 off the top and sends it wherever fee_recipient points. Any valid (recipient, amount, message_id, attestation) tuple can be resubmitted with a fee_recipient of my choosing, and the contract will happily pay the fee out to me instead of the relayer.
The instancer also exposes exactly the tuples I’d need, before they’re even confirmed on-chain:
backed by:
So a pending completion — recipient, amount, message ID, and a valid signature — sits readable through /stellar/<uuid>/pending_transactions before it’s submitted. Grab one, resubmit complete_from_sui yourself with fee_recipient pointed at your own address, and you’ve redirected amount / 8 in bridge fees. It’s a real, exploitable bug. It just moves the player’s balance up by single-digit amounts per attestation, which makes it far too slow to reach 250 on its own — the instancer trust bug above gets there in one HTTP call.
Root cause & fix
Two separate bugs, two separate fixes.
The instancer trust bug (the actual fast solve):
Remove the custom bridge path from the public /new endpoint entirely. There’s no legitimate reason for a player to hand the server its own bridge coordinates.
Only ever write bridge metadata that came out of the server’s own deploy_bridge_system flow, never from request bodies.
In /flag, cross-check the bridge contract ID against the one the instancer itself deployed for that instance — a stored, server-generated value, not a replayed client-supplied one.
The fee_recipient signature gap:
Include fee_recipient in the signed attestation payload, so a resubmission with a different fee recipient invalidates the signature.
Don’t expose pending, attested-but-unconfirmed transactions over an unauthenticated HTTP route — anything with a valid signature attached is a bearer instrument until it’s bound to more than three fields.



