We do not have a reliable USD price for the recorded assets yet.
0xc10472ac1bf9f2e58ff2c83596b4535334c90814Ethereum0xf41b389e0c1950dc0b16c9498eae77131cc08a56EthereumGondi's PurchaseBundler.executeSell public entrypoint let an unprivileged attacker steal any NFT whose owner had already approved PurchaseBundler as an operator. In seed transaction 0x0089f51edf53299ad357229ec4614efc57b3fcd3f395d088f33ce9a9261d2820, attacker EOA 0x8d171c74c85cd2ec9f38143dd5d8a7c89df47051 used helper contract 0xe95e3cfc4939d6d98dbda31aafe950c3ee84d73c to call executeSell against SuperRare token IDs 30251, 30635, and 30661. The attacker supplied calldata that only looked like a loan repayment payload after the first four bytes; the actual selector executed a harmless view function instead of repayLoan.
Because PurchaseBundler decoded only the payload tail, it accepted the fake repayment data, forwarded the original bytes into MultiSourceLoan.multicall, and then unconditionally executed _givebackNFT, which called safeTransferFrom(collection.ownerOf(tokenId), _msgSender(), tokenId). That path directly pulled approved NFTs from their live owners and handed them to the attacker. Across the two evidenced seed transactions, the trace shows 84 ERC-721 transfer events, which corresponds to 42 stolen NFTs because each successful theft appears as a victim-to-helper transfer followed by a helper-to-attacker transfer.
PurchaseBundler at 0xc10472ac1bf9f2e58ff2c83596b4535334c90814 is a Gondi helper contract intended to coordinate sell flows around Gondi loans. The relevant loan contract is MultiSourceLoan at 0xf41b389e0c1950dc0b16c9498eae77131cc08a56.
The sell flow assumes each executionData[i] item is a loan-repayment call. Instead of validating that assumption, executeSell decodes executionData[i][4:] as LoanRepaymentData and checks only that the embedded collateral address and token ID match the requested NFT. Later, _sell forwards the raw bytes into MultiSourceLoan.multicall, which delegates whatever selector the attacker supplied.
The target NFT collection for the demonstrated path is SuperRare at 0xb932a70a57673d89f4acffbe830e8ed7f75fb9e0. Pre-state checks for block 24618992 show that token 30251 was owned by 0xC53F977136E1AA9031343fad39dCB4C11A1EB3C6, had no single-token approval, and had isApprovedForAll(owner, PurchaseBundler) == true. That approval alone is enough for PurchaseBundler to transfer the NFT if it ever calls safeTransferFrom with the live owner as the from argument.
Evidence from the pre-state artifact:
block=24618992
owner_30251=0xC53F977136E1AA9031343fad39dCB4C11A1EB3C6
getApproved_30251=0x0000000000000000000000000000000000000000
isApprovedForAll_owner_purchasebundler=true
Origin: validator-reviewed pre-state check for the SuperRare seed path.
The root cause is a selector/payload binding failure in PurchaseBundler.executeSell. The contract treats executionData[i][4:] as trustworthy LoanRepaymentData, but it never requires the first four bytes to be the repayLoan selector. That means an attacker can embed syntactically valid collateral metadata behind an unrelated selector and pass the contract's local consistency checks.
The second defect is the release path after _sell returns. PurchaseBundler assumes that a correct loan repayment has already occurred and then calls _givebackNFTOrPunk for each requested NFT. For normal ERC-721s, _givebackNFT simply reads the current owner from the collection and transfers the token to the caller whenever the caller is not already the owner.
MultiSourceLoan itself does exactly what the supplied selector requests because Multicall.multicall delegatecalls each calldata item without additional semantic checks. In the seed exploit, the selector is 0xacb1dfdb, which maps to BaseLoan.getUsedCapacity(address,uint256), a harmless view function that returns a number and does not repay any loan. The exploit therefore needs only three conditions: a publicly callable executeSell, attacker-controlled executionData, and an NFT owner who has already approved PurchaseBundler as operator.
Relevant victim-side code:
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 {
for (uint256 i = 0; i < executionData.length; ++i) {
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();
}
}
_sell(executionData, swapData);
for (uint256 i = 0; i < collections.length; ++i) {
_givebackNFTOrPunk(collections[i], tokenIds[i]);
}
}
function _sell(bytes[] calldata executionData, bytes[] calldata swapData) private {
_saveSwapData(swapData);
_multiSourceLoan.multicall(executionData);
}
function _givebackNFT(ERC721 collection, uint256 tokenId) private {
if (collection.ownerOf(tokenId) != _msgSender()) {
collection.safeTransferFrom(collection.ownerOf(tokenId), _msgSender(), tokenId);
}
}
function multicall(bytes[] calldata data) external payable override returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; ++i) {
(success, results[i]) = address(this).delegatecall(data[i]);
if (!success) revert MulticallFailed(i, results[i]);
}
}
function getUsedCapacity(address _lender, uint256 _offerId) external view returns (uint256) {
return _used[_lender][_offerId];
}
Origin: validator-reviewed source snippets from PurchaseBundler.sol, Multicall.sol, and BaseLoan.sol.
The ACT opportunity exists in Ethereum mainnet pre-state sigma_B immediately before block 24618993. At that point, approved victim NFTs were still owned by third parties, PurchaseBundler was already authorized as an operator for at least some of those owners, and the attacker could submit a public transaction with arbitrary executionData.
The exploit begins when the attacker calls helper contract 0xe95e3cfc4939d6d98dbda31aafe950c3ee84d73c, which in turn calls PurchaseBundler.executeSell. The attacker selects a whitelisted marketplace address, 0x0000000000000068f116a894984e2db1123eb395 (Seaport), to satisfy the superficial marketplace gate. The real payload is a crafted executionData item whose first four bytes are 0xacb1dfdb, the selector for getUsedCapacity(address,uint256), while the remaining bytes ABI-decode into a fake LoanRepaymentData whose collateral fields name the victim NFT.
The critical code-level breakpoint occurs during the local validation loop in executeSell. The contract decodes executionData[i][4:], checks only the decoded collateral address and token ID, and never verifies that the original selector actually represents repayLoan. This breaks the invariant that NFT release should only happen after a real repayment transition for the same collateral.
After those superficial checks pass, _sell forwards the original attacker bytes into MultiSourceLoan.multicall. Multicall.multicall performs delegatecall(data[i]), so the inner selector fully determines what runs. The seed call trace for 0x0089... shows the exact transition:
{"from":"0xc10472ac1bf9f2e58ff2c83596b4535334c90814","to":"0xf41b389e0c1950dc0b16c9498eae77131cc08a56","input":"0xac9650d800000000","type":"CALL"}
{"from":"0xf41b389e0c1950dc0b16c9498eae77131cc08a56","to":"0xf41b389e0c1950dc0b16c9498eae77131cc08a56","input":"0xacb1dfdb00000000","type":"DELEGATECALL","output":"0x0000000000000000000000000000000000000000000000000000000000000000"}
Origin: validator-filtered excerpt from the traced seed exploit transaction 0x0089f51edf53299ad357229ec4614efc57b3fcd3f395d088f33ce9a9261d2820.
Because the delegated call is only getUsedCapacity, no loan is repaid, no borrower signature path runs, and no collateral custody is proven. Nevertheless, control returns successfully to PurchaseBundler, which then enters _givebackNFT. The trace next shows ownerOf(30251) returning the live victim owner and safeTransferFrom moving the token away from that owner:
{"from":"0xc10472ac1bf9f2e58ff2c83596b4535334c90814","to":"0xb932a70a57673d89f4acffbe830e8ed7f75fb9e0","input":"0x6352211e00000000","output":"0x000000000000000000000000c53f977136e1aa9031343fad39dcb4c11a1eb3c6","type":"STATICCALL"}
{"from":"0xc10472ac1bf9f2e58ff2c83596b4535334c90814","to":"0xb932a70a57673d89f4acffbe830e8ed7f75fb9e0","input":"0x42842e0e000000000000000000000000c53f977136e1aa9031343fad39dcb4c11a1eb3c6","type":"CALL"}
The resulting ERC-721 Transfer event in the same trace shows token 30251 moving from 0xc53f... to helper 0xe95e.... Immediately afterward, the helper contract transfers that same token to attacker EOA 0x8d171c74c85cd2ec9f38143dd5d8a7c89df47051. The same two-hop pattern repeats for token IDs 30635 and 30661.
This is why the exploit is ACT. The attacker does not need privileged credentials, a private key compromise, a real borrower signature, or a real repayment. The only attacker-controlled inputs are public calldata and a fresh caller address. Any searcher observing the on-chain state could have submitted the same selector/payload mismatch as long as a victim NFT had approved PurchaseBundler.
The adversary cluster consists of EOA 0x8d171c74c85cd2ec9f38143dd5d8a7c89df47051 and helper contract 0xe95e3cfc4939d6d98dbda31aafe950c3ee84d73c. The collector metadata shows the seed transaction is sent from the EOA to the helper, and the trace shows the helper is the direct caller of PurchaseBundler.executeSell.
The end-to-end flow is:
PurchaseBundler.executeSell with empty payment arrays, a whitelisted marketplace address, the target collection array, the target token ID array, and one crafted executionData item per target.PurchaseBundler decodes the payload tail, accepts the fake collateral metadata, and calls _multiSourceLoan.multicall(executionData).MultiSourceLoan.multicall delegatecalls the harmless getUsedCapacity selector and returns successfully.PurchaseBundler disables the temporary marketplace approval and then runs _givebackNFTOrPunk, which pulls the approved NFT from the live owner into the helper contract.The seed transaction 0x0089... demonstrates the clean single-collection path on SuperRare. The earlier transaction 0x83bac5d4b222b97f9734637c072589da648941b8a884ce1a61324dc0449e6a06 shows the same exploit repeated in a larger batch. The traced batch emits 78 ERC-721 transfer events, while the single-collection seed emits 6. Combined, the two traces contain 84 ERC-721 transfer events, which implies 42 completed thefts because each successful theft is represented by two transfers.
The attacker's balance-diff artifacts also show that the attacker only spent gas in native ETH in the two seed transactions:
{"address":"0x8d171c74c85cd2ec9f38143dd5d8a7c89df47051","delta_wei":"-57403272565848"}
{"address":"0x8d171c74c85cd2ec9f38143dd5d8a7c89df47051","delta_wei":"-349169882014282"}
Origin: validator-reviewed native balance deltas for tx 0x0089... and tx 0x83ba....
The directly evidenced loss is 42 ERC-721 tokens. This number comes from the two seed exploit traces:
0x0089f51edf53299ad357229ec4614efc57b3fcd3f395d088f33ce9a9261d2820 contains 6 ERC-721 transfer events.0x83bac5d4b222b97f9734637c072589da648941b8a884ce1a61324dc0449e6a06 contains 78 ERC-721 transfer events.Each successful theft produces exactly two transfer events in the traces and on explorers: victim owner to attacker helper, then attacker helper to attacker EOA. Dividing 84 by 2 yields 42 stolen NFTs across the two evidenced transactions.
The loss mechanism is direct custody loss, not fungible slippage or fee leakage. Victims lose ownership of NFTs that were not in protocol custody and were never legitimately repaid out of a loan flow. The attacker does not need to supply sale consideration, valid repayment funds, or a borrower signature. The only hard prerequisite is that the victim collection lets PurchaseBundler move the token because of a pre-existing operator approval or equivalent transfer permission.
PurchaseBundler verified source at 0xc10472ac1bf9f2e58ff2c83596b4535334c90814.MultiSourceLoan verified source at 0xf41b389e0c1950dc0b16c9498eae77131cc08a56.Multicall.sol source used by MultiSourceLoan.BaseLoan.getUsedCapacity(address,uint256) source showing selector 0xacb1dfdb.0x0089f51edf53299ad357229ec4614efc57b3fcd3f395d088f33ce9a9261d2820.0x83bac5d4b222b97f9734637c072589da648941b8a884ce1a61324dc0449e6a06.0xb932a70a57673d89f4acffbe830e8ed7f75fb9e0.30251 at block 24618992.