Gondi PurchaseBundler NFT Drain via executeSell Access Control Bypass
On 2026-03-09, the PurchaseBundler contract (0xc10472ac) deployed on Ethereum (block 24618641) was exploited through an access control bypass in its executeSell function. The attacker (0x8d171c74) used a purpose-built contract (0xe95e3cfc) to call executeSell 81 times, successfully draining 39 NFTs from 8 victim addresses across 10 NFT collections by exploiting previously-granted operator approvals to the PurchaseBundler. The attacker suffered zero upfront cost — no flash loan was needed — and transferred all 39 NFTs directly to the attacker EOA in a single transaction.
Root Cause
Vulnerable Contract
- Name:
PurchaseBundler - Address:
0xc10472ac1bf9f2e58ff2c83596b4535334c90814 - Proxy: No (direct deployment)
- Source type: Verified (Etherscan)
- File:
src/lib/callbacks/PurchaseBundler.sol
Vulnerable Function
- Function:
executeSell - Signature:
executeSell(address[],uint256[],address[],uint256[],address,bytes[],bytes[]) - Selector:
0x7239e3e9 - File:
src/lib/callbacks/PurchaseBundler.sol(line 269)
Vulnerable Code
// src/lib/callbacks/PurchaseBundler.sol
function executeSell(
address[] calldata currencies,
uint256[] calldata currencyAmounts,
ERC721[] calldata collections,
uint256[] calldata tokenIds,
address marketPlace,
bytes[] calldata executionData,
bytes[] calldata swapData
) external payable nonReentrant _storeMsgSender {
if (executionData.length != collections.length || collections.length != tokenIds.length) {
revert InvalidExecutionData();
}
// Validate that collections/tokenIds match the loan collateral
for (uint256 i = 0; i < executionData.length; ++i) {
if (executionData[i].length <= 4) {
revert InvalidExecutionData();
}
IMultiSourceLoan.LoanRepaymentData memory repaymentData =
abi.decode(executionData[i][4:], (IMultiSourceLoan.LoanRepaymentData));
if (
address(collections[i]) != repaymentData.loan.nftCollateralAddress
|| tokenIds[i] != repaymentData.loan.nftCollateralTokenId
) {
revert InvalidCollateralError();
} // <-- VULNERABILITY: only checks collection/tokenId match
} // never verifies caller owns the NFT or has an open loan
// // for that tokenId; any caller can claim any NFT
if (!_marketplaceContractsAddressManager.isWhitelisted(marketPlace)) {
revert MarketplaceAddressNotWhitelisted();
}
address buyer = _msgSender();
for (uint256 i = 0; i < currencies.length; ++i) { ... }
for (uint256 i = 0; i < collections.length; ++i) {
collections[i].setApprovalForAll(marketPlace, true); // bundler approves marketPlace for its own holdings
}
if (executionData.length > 1) revert InvalidExecutionData();
_sell(executionData, swapData); // <-- VULNERABILITY: attacker crafts executionData encoding
// // getUsedCapacity() (harmless view), not a real repayment
// // _multiSourceLoan.multicall() succeeds with fake data
for (uint256 i = 0; i < currencies.length; ++i) {
_paybackRemaining(currencies[i], _msgSender());
if (currencies[i] != ETH) {
ERC20(currencies[i]).safeApprove(marketPlace, 0);
}
}
for (uint256 i = 0; i < collections.length; ++i) {
collections[i].setApprovalForAll(marketPlace, false);
}
for (uint256 i = 0; i < collections.length; ++i) {
_givebackNFT(collections[i], tokenIds[i]); // <-- VULNERABILITY: transfers NFT from its current owner
} // (the victim) to _msgSender() (the attacker)
}
function _givebackNFT(ERC721 collection, uint256 tokenId) private {
if (collection.ownerOf(tokenId) != _msgSender()) {
collection.safeTransferFrom( // <-- PurchaseBundler acts as ERC721 operator for victims
collection.ownerOf(tokenId), // and uses that power to pull victim's NFT
_msgSender(), // to _msgSender() = the attacker contract
tokenId
);
}
}
// **Note**: The actual `executeSell` implementation calls `_givebackNFTOrPunk(collections[i], tokenIds[i])`
// which dispatches to `_givebackNFT` for standard ERC-721 collections (i.e., all collections involved in this attack).
// The CryptoPunk-handling path in `_givebackNFTOrPunk` was not triggered in this incident.
Why It’s Vulnerable
Expected behavior: executeSell is designed for borrowers who hold an active Gondi loan backed by a specific NFT. The borrower calls executeSell to sell their NFT (which they own via the loan) through a whitelisted marketplace and simultaneously repay the loan. The function should only transfer NFTs that the caller legitimately owns via an active loan.
Actual behavior: executeSell only validates that the collections[i] and tokenIds[i] parameters match the collateral fields inside the ABI-decoded LoanRepaymentData embedded in executionData[i]. It never checks:
- That the caller has an actual active loan for that NFT.
- That the caller is the NFT owner or borrower.
- That the
executionDataencodes a valid loan repayment (the attacker passesgetUsedCapacity()— a harmless view call — instead of a real repayment selector).
This means any caller can supply an executionData array where:
LoanRepaymentData.loan.nftCollateralAddressis set to an NFT collection whose owners previously approvedPurchaseBundleras an ERC721 operator.LoanRepaymentData.loan.nftCollateralTokenIdis set to a specific victim’s tokenId.
The inner _sell() calls _multiSourceLoan.multicall(executionData). Because the attacker’s executionData encodes a call to getUsedCapacity(address,uint256) (0xacb1dfdb) — which returns 0 without any side effects — the multicall succeeds silently. After _sell returns, _givebackNFT uses PurchaseBundler’s pre-existing operator approval (granted by victims for legitimate BNPL/sell operations) to call safeTransferFrom(victim, attacker, tokenId).
The root flaw is a missing authorization check: executeSell should verify that the caller is the actual borrower (repaymentData.loan.borrower == msg.sender) and that a matching active loan exists on-chain. Without this check, it becomes a general-purpose NFT drain tool for any user who has ever approved the bundler.
Normal flow vs Attack flow:
| Step | Legitimate Borrower | Attacker |
|---|---|---|
| Caller | Real borrower with open loan | Any EOA with no loan |
executionData | Encodes repayLoan(loanId, ...) call | Encodes getUsedCapacity(0, 0) (dummy view) |
_sell | Repays actual loan, releases collateral | Succeeds trivially (view call returns 0) |
_givebackNFT | Returns NFT to borrower (no-op since borrower already owns it) | Transfers victim’s NFT to attacker |
Attack Execution
High-Level Flow
- Attacker (
0x8d171c74) deploys exploit contract (0xe95e3cfc) off-chain before the attack. - Attacker identifies 8 victim addresses with NFTs in multiple blue-chip collections who previously approved
PurchaseBundler(0xc10472ac) as an ERC721 operator. - Attacker calls
0xe95e3cfc.0x80949a07(bundler, executeSellSelector, ..., collections[], tokenIds[], ...)with a pre-built list of 81 (collection, tokenId, victim) tuples. - The exploit contract loops, calling
executeSellonPurchaseBundleronce per NFT target. - For each call:
PurchaseBundlerverifies the whitelisted marketplace, callssetApprovalForAll(marketPlace, true), then calls_sell(executionData, swapData)which performs a dummy multicall onMultiSourceLoan(executinggetUsedCapacity, a no-op view function). - After
_sellreturns,_givebackNFTreadsownerOf(tokenId)(the victim), then callssafeTransferFrom(victim, attackerContract, tokenId)usingPurchaseBundler’s operator rights. - The NFT lands in the attacker contract, which immediately forwards it to the attacker EOA via
onERC721Received. - 39 of 81 calls succeed (42 fail due to operator filter checks on collections such as SuperRare that enforce royalty transfer validation via
0x721c002b). The attacker contract also makes 39 additional forwarding calls (safeTransferFrom from attacker contract to attacker EOA), totalling 120 depth-1 calls from the attacker contract.
Detailed Call Trace
The top-level structure repeats 120 times; the first successful iteration (KnownOrigin token #54218) is shown below:
EOA (0x8d171c74) -[CALL]->
AttackerContract (0xe95e3cfc) . 0x80949a07 [unresolved]
|
+-[CALL x120]-> PurchaseBundler (0xc10472ac) . executeSell(0x7239e3e9)
|
+-[STATICCALL]-> OperatorFilterRegistry (0x307521f9) . isWhitelisted(0x3af32abf)
| returns: true (1)
|
+-[CALL]-> KnownOrigin (0xfbeef911) . setApprovalForAll(0xa22cb465, marketPlace, true)
| (PurchaseBundler grants marketPlace approval for its own NFT holdings)
|
+-[CALL]-> MultiSourceLoan (0xf41b389e) . multicall(0xac9650d8)
| +-[DELEGATECALL self]-> getUsedCapacity(0xacb1dfdb) -> returns 0
| (harmless view function; _sell succeeds with no side effects)
|
+-[CALL]-> KnownOrigin (0xfbeef911) . setApprovalForAll(0xa22cb465, marketPlace, false)
| (cleanup — bundler revokes approval it just set)
|
+-[STATICCALL]-> KnownOrigin (0xfbeef911) . ownerOf(0x6352211e, tokenId=54218)
| returns: 0xfaa9dccf... (victim)
|
+-[STATICCALL]-> KnownOrigin (0xfbeef911) . ownerOf(0x6352211e, tokenId=54218)
| returns: 0xfaa9dccf... (victim) [called twice by _givebackNFT]
|
+-[CALL]-> KnownOrigin (0xfbeef911) . safeTransferFrom(0x42842e0e,
| from=0xfaa9dccf..., ← victim
| to=0xe95e3cfc..., ← attacker contract
| tokenId=54218)
| +-[CALL]-> AttackerContract (0xe95e3cfc) . onERC721Received(0x150b7a02)
| (NFT forwarded to attacker EOA 0x8d171c74)
Collections that blocked the transfer (41 reverted calls): SuperRare (0xb8ea78fc) uses RoyaltyRegistry.validateTransfer(0xcaee23ea) which enforces operator filter checks and blocked the PurchaseBundler-as-operator transfer.
Financial Impact
- Total NFTs stolen: 39 NFTs across 10 ERC-721 collections, from 8 victim addresses.
- Victim breakdown (from
receipt.jsonERC-721 Transfer events):
| Victim Address | NFTs Stolen |
|---|---|
0xfaa9dccf86f5cd4db872377c18368b5d58bc7b38 | 19 |
0xd0dbf8221003ce87dd9adba82d259f9ff7b186ff | 10 |
0xbd4c7c8a7670a710bed980dae50448e34b310fec | 5 (Doodles) |
0x1024b947717954500fd8411d5a2385077dab0319 | 1 |
0x23602ca06e977c86339ffddad74966e824ab691e | 1 |
0xd57a16e69edfa1860ea48625b147957f4f896646 | 1 (BAYC) |
0x3a8684ecf13ee46022827fd33e2a77ef40392e15 | 1 |
0xb94bdd969642752ebdee56a5e0b81ac9bbfabd95 | 1 |
- Collections drained: KnownOrigin (
0xfbeef911, 5 NFTs), ArtBlocks (0xa7d8d9ef, 22 NFTs), Doodles (0x8a90cab2, 5 NFTs), BAYC (0xbc4ca0ed, 1 NFT), and 6 other collections (1 NFT each). - USD value: The
funds_flow.jsonrecords ERC-721 transfers withraw_amount: 0(standard for ERC-721 Transfer events emitted as ERC-20-style); actual USD value of stolen NFTs was not computed by the pipeline. Given ArtBlocks, BAYC, and Doodles NFTs are blue-chip assets, the total likely ranges in the hundreds of thousands of USD. - Attacker profit: No flash loan or upfront capital required. The net gain to the attacker is ownership of 39 NFTs at zero cost.
- Protocol solvency: The
PurchaseBundleritself holds no liquidity; the loss is entirely borne by individual NFT holders who had approved it. The Gondi lending protocol’s loan state is unaffected. However, all users who ever grantedsetApprovalForAllto0xc10472acremain at risk if the contract is not paused or users revoke approvals.
Evidence
- Transaction:
0x83bac5d4b222b97f9734637c072589da648941b8a884ce1a61324dc0449e6a06on Ethereum, block 24618641 (2026-03-09T08:12:11 UTC), status0x1(success). - Gas used: 9,400,142 gas (confirmed from receipt.json
gasUsed: 0x8f6f4e). - ApprovalForAll events: Receipt logs show 78
ApprovalForAllevents (topic0x17307eab...) withPurchaseBundleras owner — 39true(granting marketplace) and 39false(revoking) — confirming the bundler’sexecuteSellpattern of set-then-revoke during each iteration. - ERC-721 Transfer events: Receipt contains exactly 78 ERC-721
Transferevents (topic0xddf252ad...): 39 from victim addresses → attacker contract, 39 from attacker contract → attacker EOA. Total confirmed: 39 NFTs stolen. - Selector verification:
executeSell(...)=0x7239e3e9(confirmed viacast sig)setApprovalForAll(address,bool)=0xa22cb465safeTransferFrom(address,address,uint256)=0x42842e0emulticall(bytes[])=0xac9650d8isWhitelisted(address)=0x3af32abf(OperatorFilterRegistry’sisWhitelisted)
- Revert pattern: All 42 reverted executeSell calls target SuperRare (
0xb8ea78fc) and revert atRoyaltyRegistry.validateTransfer(0xcaee23ea)(error selector0xef28f901), proving that collections enforcing royalty operator filters blocked the theft. Only collections without such enforcement were successfully drained. (Trace confirms: 81 total executeSell calls — 39 succeed, 42 revert.) _msgSender()forensics: The_storeMsgSendermodifier storescaller()(the attacker contract0xe95e3cfc) into transient storage slot 0x00 at function entry._givebackNFTreads_msgSender()which resolves to0xe95e3cfc. The attacker’sonERC721Received(selector0x150b7a02) then forwards the NFT to the EOA — confirmed by the 39 Transfer events from0xe95e3cfc→0x8d171c74.