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:

  1. That the caller has an actual active loan for that NFT.
  2. That the caller is the NFT owner or borrower.
  3. That the executionData encodes a valid loan repayment (the attacker passes getUsedCapacity() — a harmless view call — instead of a real repayment selector).

This means any caller can supply an executionData array where:

  • LoanRepaymentData.loan.nftCollateralAddress is set to an NFT collection whose owners previously approved PurchaseBundler as an ERC721 operator.
  • LoanRepaymentData.loan.nftCollateralTokenId is 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:

StepLegitimate BorrowerAttacker
CallerReal borrower with open loanAny EOA with no loan
executionDataEncodes repayLoan(loanId, ...) callEncodes getUsedCapacity(0, 0) (dummy view)
_sellRepays actual loan, releases collateralSucceeds trivially (view call returns 0)
_givebackNFTReturns NFT to borrower (no-op since borrower already owns it)Transfers victim’s NFT to attacker

Attack Execution

High-Level Flow

  1. Attacker (0x8d171c74) deploys exploit contract (0xe95e3cfc) off-chain before the attack.
  2. Attacker identifies 8 victim addresses with NFTs in multiple blue-chip collections who previously approved PurchaseBundler (0xc10472ac) as an ERC721 operator.
  3. Attacker calls 0xe95e3cfc.0x80949a07(bundler, executeSellSelector, ..., collections[], tokenIds[], ...) with a pre-built list of 81 (collection, tokenId, victim) tuples.
  4. The exploit contract loops, calling executeSell on PurchaseBundler once per NFT target.
  5. For each call: PurchaseBundler verifies the whitelisted marketplace, calls setApprovalForAll(marketPlace, true), then calls _sell(executionData, swapData) which performs a dummy multicall on MultiSourceLoan (executing getUsedCapacity, a no-op view function).
  6. After _sell returns, _givebackNFT reads ownerOf(tokenId) (the victim), then calls safeTransferFrom(victim, attackerContract, tokenId) using PurchaseBundler’s operator rights.
  7. The NFT lands in the attacker contract, which immediately forwards it to the attacker EOA via onERC721Received.
  8. 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.json ERC-721 Transfer events):
Victim AddressNFTs Stolen
0xfaa9dccf86f5cd4db872377c18368b5d58bc7b3819
0xd0dbf8221003ce87dd9adba82d259f9ff7b186ff10
0xbd4c7c8a7670a710bed980dae50448e34b310fec5 (Doodles)
0x1024b947717954500fd8411d5a2385077dab03191
0x23602ca06e977c86339ffddad74966e824ab691e1
0xd57a16e69edfa1860ea48625b147957f4f8966461 (BAYC)
0x3a8684ecf13ee46022827fd33e2a77ef40392e151
0xb94bdd969642752ebdee56a5e0b81ac9bbfabd951
  • 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.json records ERC-721 transfers with raw_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 PurchaseBundler itself 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 granted setApprovalForAll to 0xc10472ac remain at risk if the contract is not paused or users revoke approvals.

Evidence

  • Transaction: 0x83bac5d4b222b97f9734637c072589da648941b8a884ce1a61324dc0449e6a06 on Ethereum, block 24618641 (2026-03-09T08:12:11 UTC), status 0x1 (success).
  • Gas used: 9,400,142 gas (confirmed from receipt.json gasUsed: 0x8f6f4e).
  • ApprovalForAll events: Receipt logs show 78 ApprovalForAll events (topic 0x17307eab...) with PurchaseBundler as owner — 39 true (granting marketplace) and 39 false (revoking) — confirming the bundler’s executeSell pattern of set-then-revoke during each iteration.
  • ERC-721 Transfer events: Receipt contains exactly 78 ERC-721 Transfer events (topic 0xddf252ad...): 39 from victim addresses → attacker contract, 39 from attacker contract → attacker EOA. Total confirmed: 39 NFTs stolen.
  • Selector verification:
    • executeSell(...) = 0x7239e3e9 (confirmed via cast sig)
    • setApprovalForAll(address,bool) = 0xa22cb465
    • safeTransferFrom(address,address,uint256) = 0x42842e0e
    • multicall(bytes[]) = 0xac9650d8
    • isWhitelisted(address) = 0x3af32abf (OperatorFilterRegistry’s isWhitelisted)
  • Revert pattern: All 42 reverted executeSell calls target SuperRare (0xb8ea78fc) and revert at RoyaltyRegistry.validateTransfer(0xcaee23ea) (error selector 0xef28f901), 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 _storeMsgSender modifier stores caller() (the attacker contract 0xe95e3cfc) into transient storage slot 0x00 at function entry. _givebackNFT reads _msgSender() which resolves to 0xe95e3cfc. The attacker’s onERC721Received (selector 0x150b7a02) then forwards the NFT to the EOA — confirmed by the 39 Transfer events from 0xe95e3cfc0x8d171c74.