DEV Community

ohmygod
ohmygod

Posted on

The Gondi Exploit Dissected: How a Broken Authorization Check in an NFT Lending Protocol Let Anyone Walk Away With 78 NFTs

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:

  1. Takes custody of the escrowed NFT from the lending pool
  2. Lists or sells it via an integrated marketplace
  3. Uses the proceeds to call repay() on the loan contract
  4. 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
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Call sellAndRepay() on someone else's loan
  2. Trigger the release of the escrowed NFT
  3. Provide their own marketplaceData to route the NFT wherever they want
  4. 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:

  1. Trivially simple — a single missing require statement
  2. 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
  3. 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 { ... }
Enter fullscreen mode Exit fullscreen mode

2. Refinancing Flows

// Can anyone refinance someone else's loan with worse terms?
function refinance(uint256 loanId, LoanTerms calldata newTerms) external { ... }
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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, "");
    }
}
Enter fullscreen mode Exit fullscreen mode

Lessons for Auditors

  1. 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.

  2. 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.

  3. 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).

  4. 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.sender verification 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)