On March 8, 2026, an attacker quietly drained 78 NFTs — worth roughly $230,000 — from Gondi, an NFT-backed lending protocol on Ethereum. No flash loans. No oracle manipulation. No sophisticated MEV. Just a single missing authorization check in a contract deployed barely two weeks earlier.
This article breaks down exactly how the exploit worked, why the vulnerability pattern is dangerously common in NFT-fi protocols, and what every developer building composable NFT infrastructure should take away from it.
The Setup: What Gondi Does (And Why It's Complex)
Gondi is an NFT lending platform that lets users borrow against their NFTs as collateral. Think of it as a pawn shop on Ethereum: you lock your Pudgy Penguin in a smart contract, get a loan, and either repay to unlock it or default (and the lender keeps the NFT).
The critical feature here is Sell & Repay — a convenience function that lets borrowers sell their escrowed NFT on the open market and use the sale proceeds to automatically repay the outstanding loan, pocketing any difference.
Under the hood, this involves a Purchase Bundler contract that:
- Takes custody of the escrowed NFT from the lending pool
- Lists or sells it via an integrated marketplace
- Uses the proceeds to call
repay()on the loan contract - Sends any surplus back to the borrower
This is a composability pattern we see constantly in DeFi: one contract orchestrating actions across multiple other contracts on behalf of a user. And it's exactly this orchestration layer where authorization bugs hide.
The Vulnerability: "Who's Asking?" — Nobody Checked
The updated Sell & Repay contract was deployed on February 20, 2026. The previous version presumably worked correctly. So what changed?
Based on the post-incident reports and on-chain analysis, the vulnerability was in the Purchase Bundler function's caller verification. The contract failed to properly validate whether the msg.sender was actually the legitimate borrower (or an authorized delegate) before executing the NFT release flow.
Here's the simplified vulnerable pattern:
// VULNERABLE PATTERN — DO NOT USE
function sellAndRepay(
uint256 loanId,
uint256 minProceeds,
bytes calldata marketplaceData
) external {
Loan memory loan = loanRegistry.getLoan(loanId);
// ❌ MISSING: require(msg.sender == loan.borrower, "not borrower");
// Release NFT from escrow
escrow.release(loan.collateralToken, loan.collateralId, address(this));
// Execute marketplace sale
uint256 proceeds = _executeSale(
loan.collateralToken,
loan.collateralId,
marketplaceData
);
require(proceeds >= minProceeds, "slippage");
// Repay loan
loanRegistry.repay(loanId, proceeds);
// Send surplus to... the caller? The borrower?
uint256 surplus = proceeds - loan.outstandingDebt;
if (surplus > 0) {
payable(msg.sender).transfer(surplus); // ❌ Goes to attacker
}
}
The critical line is what's not there: a check that msg.sender == loan.borrower.
Without that check, anyone who knows a valid loanId can:
- Call
sellAndRepay()on someone else's loan - Trigger the release of the escrowed NFT
- Provide their own
marketplaceDatato route the NFT wherever they want - Pocket any surplus after loan repayment
Loan IDs aren't secret — they're on-chain. The attacker simply enumerated active loans, identified high-value NFTs in escrow, and called sellAndRepay() with marketplace parameters that routed the NFTs to their own wallet (or to a marketplace listing they controlled).
Why This Bug Survived Deployment
This is the kind of bug that makes auditors lose sleep, because it's simultaneously:
-
Trivially simple — a single missing
requirestatement -
Invisible in unit tests — if your test suite only calls
sellAndRepay()from the borrower's address (which it naturally would), you'll never catch this - A regression — the previous version likely had the check; the rewrite lost it
The Rewrite Problem
The contract was redeployed on February 20. Rewrites are one of the most dangerous moments in a contract's lifecycle because:
- Developers focus on new functionality, not re-verifying old assumptions
- Access control checks feel like boilerplate — easy to forget when restructuring
- If the function signature stays the same but the internal logic changes, existing tests may pass while new code paths remain untested
This is exactly what happened. The core lending contracts were fine. The vulnerability was in the periphery — a "convenience" contract that most auditors might spend less time on because it doesn't hold funds long-term.
The Attack Timeline
| Time | Event |
|---|---|
| Feb 20 | Updated Sell & Repay contract deployed |
| Mar 8, ~14:00 UTC | Attacker begins exploit, calling sellAndRepay() on active loans |
| Mar 8, ~14:30 UTC | 78 NFTs extracted, including pieces from Gazers, Doodles, and Lil Pudgy collections |
| Mar 8, ~15:00 UTC | Attacker begins selling stolen NFTs on secondary markets |
| Mar 8, ~16:00 UTC | Gondi team detects exploit, disables Sell & Repay contract |
| Mar 8, ~18:00 UTC | First public disclosure via @gondixyz on X |
| Mar 9-12 | Community helps recover 4 NFTs; Gondi contacts innocent buyers |
The 16-day window between deployment and exploit is notable. The attacker may have discovered the vulnerability immediately but waited — or it may have been found later through routine on-chain monitoring.
The Deeper Pattern: Authorization in Composable NFT Systems
Gondi's bug isn't unique. It's an instance of a pattern I call "delegated action authorization bypass" — where a contract performs actions on behalf of User A, but doesn't verify that the caller is User A.
This pattern appears constantly in NFT-fi:
1. Liquidation Functions
// Who can trigger liquidation? Anyone? Only the lender?
// What happens to the proceeds?
function liquidate(uint256 loanId) external { ... }
2. Refinancing Flows
// Can anyone refinance someone else's loan with worse terms?
function refinance(uint256 loanId, LoanTerms calldata newTerms) external { ... }
3. Bundle Operations
// If I can batch operations on multiple loans,
// do I need to be the borrower for ALL of them?
function batchRepay(uint256[] calldata loanIds) external { ... }
4. Callback-Based Flows
// After an NFT sale callback, who receives the confirmation?
// Is it the original owner or the last caller?
function onERC721Received(...) external returns (bytes4) { ... }
Each of these is a potential authorization bypass if the developer assumes "only the borrower would call this" instead of enforcing it.
Defense Patterns
1. Explicit Caller Verification (The Obvious Fix)
function sellAndRepay(uint256 loanId, ...) external {
Loan memory loan = loanRegistry.getLoan(loanId);
require(
msg.sender == loan.borrower ||
isApprovedDelegate(loan.borrower, msg.sender),
"unauthorized"
);
// ... rest of logic
}
2. The "Sender Is Beneficiary" Pattern
When a function sends value (ETH, tokens, or NFTs) after execution, ensure the beneficiary is always the legitimate owner, never msg.sender:
// ✅ Always send surplus to the borrower, not the caller
if (surplus > 0) {
payable(loan.borrower).transfer(surplus);
}
3. Two-Step Operations for High-Value Actions
For operations involving irreversible asset transfers, consider a commit-reveal or approval pattern:
// Step 1: Borrower initiates (sets intent)
function initiateSellAndRepay(uint256 loanId, ...) external {
require(msg.sender == loan.borrower);
pendingSales[loanId] = SaleIntent({...});
}
// Step 2: Anyone can execute (but parameters are locked)
function executeSellAndRepay(uint256 loanId) external {
SaleIntent memory intent = pendingSales[loanId];
require(intent.deadline > block.timestamp);
// Execute with pre-committed parameters
}
4. Invariant Testing for Authorization
The most effective defense is fuzz testing with authorization invariants:
// Foundry invariant test
function invariant_onlyBorrowerCanSellAndRepay() public {
for (uint256 i = 0; i < activeLoanCount; i++) {
Loan memory loan = loanRegistry.getLoan(activeLoanIds[i]);
// Try calling from a random address that isn't the borrower
vm.prank(address(0xdead));
vm.expectRevert("unauthorized");
purchaseBundler.sellAndRepay(activeLoanIds[i], 0, "");
}
}
Lessons for Auditors
Periphery contracts need the same scrutiny as core contracts. The Purchase Bundler wasn't the lending pool — it was a "convenience wrapper." But it had the authority to release escrowed NFTs. Authority is authority.
Redeployments require full re-audit. "We only changed X" is the most dangerous sentence in smart contract development. Every redeployment is a new contract with a new attack surface.
Test with adversarial callers, not just happy paths. If your test suite only calls functions from expected addresses, you're testing the spec, not the security. Every external function should be tested with calls from
address(0xdead).Map the authorization graph. For every function that moves assets, draw a line from "who can call this" to "where do the assets go." If those lines don't match, you have a bug.
The Broader NFT-Fi Risk
Gondi's exploit was relatively small — $230K, quickly contained, users compensated. But the pattern it exposes is endemic to the NFT-fi sector.
As protocols build increasingly complex composable systems — lending, derivatives, fractionalization, rental, and bundling — the number of authorization boundaries multiplies. Each boundary is a potential exploit point.
The DeFi security community has spent years hardening ERC-20 token flows. NFT-fi protocols are walking the same road, making the same mistakes, but with assets that are non-fungible and therefore harder to recover.
Gondi's response was commendable — fast disclosure, direct user compensation, community-assisted recovery. But the next protocol hit by this pattern might not be as fortunate. When authorization checks fail on a protocol holding Bored Apes instead of Lil Pudgys, the dollar figures jump by orders of magnitude.
Key Takeaways
-
The vulnerability: Missing
msg.senderverification in a Purchase Bundler contract allowed anyone to trigger NFT release and sale from active loans - Root cause: Authorization check lost during a contract rewrite/redeployment on Feb 20
- Impact: 78 NFTs stolen (~$230K), partially recovered through community effort
- The pattern: "Delegated action authorization bypass" — common wherever contracts perform actions on behalf of users
- Defense: Explicit caller checks, sender-is-beneficiary patterns, two-step operations, and invariant-based fuzz testing targeting authorization boundaries
The Gondi team's transparency and rapid response set a good example for incident handling. But the best incident response is the one that never needs to happen. Audit your periphery contracts. Test with adversarial callers. And never assume that "only the borrower would call this."
Top comments (0)