Gamma Protocol Lending Exploit — Borrow Discount Abuse

On March 11, 2026, the Gamma Protocol (a Compound-fork lending platform formerly known as Planet Finance) on BNB Chain was exploited for approximately 7,882 USDT via a logic flaw in the publicly-callable updateUserDiscount() function. The attacker leveraged a flash-loaned USDT position to repeatedly call borrow() on the gUSDT market, then updateUserDiscount() to reduce their recorded debt via the PlanetDiscount.changeUserBorrowDiscount() callback, thereby freeing collateral to borrow again. The attacker drained the gUSDT pool across multiple borrow-discount-borrow cycles within a single transaction.

Root Cause

Vulnerable Contract

  • Contract: gUSDT (GErc20 proxy)
  • Address: 0x045e2df638ebec29130dd3be61161cba5f00a9c8
  • Is Proxy: Yes → implementation at 0x2f64203ed5606466783b61e8691784f07c25f0ed
  • Source Type: Verified (Etherscan)

Vulnerable Function

  • Function: updateUserDiscount(address user)
  • Selector: 0xebe8702b
  • File: GErc20.sol, line 151

Vulnerable Code

// GErc20.sol — line 151
function updateUserDiscount(address user) external {          // <-- VULNERABILITY: no access control
    changeUserBorrowDiscountInternal(user);
    changeLastBorrowBalanceAtBorrow(user);
}

// GToken.sol — line 599
function changeUserBorrowDiscountInternal(address borrower) internal {
    accrueInterest();
    // Overwrites core accounting state from external PlanetDiscount contract return values
    (accountBorrows[borrower].principal,                       // <-- VULNERABILITY: borrower principal overwritten
     accountBorrows[borrower].interestIndex,                   // <-- VULNERABILITY: interest index overwritten
     totalBorrows,                                             // <-- VULNERABILITY: global totalBorrows overwritten
     totalReserves                                             // <-- VULNERABILITY: global totalReserves overwritten
    ) = PlanetDiscount(discountLevel).changeUserBorrowDiscount(borrower);
}

Why It’s Vulnerable

Expected behavior: The updateUserDiscount() function should only be callable by authorized parties (admin, Gammatroller, or the borrow/repay flow) and should perform a bounded, auditable adjustment to the borrower’s interest terms — never allowing the borrow principal to decrease below the amount actually owed.

Actual behavior: updateUserDiscount() is a public function with no access control — anyone can call it with any user address at any time. It delegates to changeUserBorrowDiscountInternal(), which calls the external PlanetDiscount contract’s changeUserBorrowDiscount() function and blindly accepts the return values to overwrite:

  1. accountBorrows[borrower].principal — the borrower’s recorded debt
  2. accountBorrows[borrower].interestIndex — the borrower’s interest checkpoint
  3. totalBorrows — the market’s total outstanding borrows
  4. totalReserves — the market’s total reserves

This means the PlanetDiscount contract can reduce a user’s borrow principal. After a borrow() call increases the debt, calling updateUserDiscount() reduces it back, freeing collateral room to borrow again. The attacker repeats this cycle to drain the pool.

Normal flow vs Attack flow:

  • Normal: User deposits collateral → calls borrow(X)borrowAllowed() checks liquidity → user’s debt = X → user cannot borrow more until repaying
  • Attack: User deposits collateral → calls borrow(X) → calls updateUserDiscount(self) → debt reduced from X to X-discount → getAccountLiquidity() shows available collateral → calls borrow(Y) again → repeats until pool is drained

Attack Execution

High-Level Flow

  1. Attacker EOA (0x2eb7c45f) calls attacker contract (0x3f192424)
  2. Attacker contract requests a flash loan of ~100,000 USDT from helper (0x172fcd41)
  3. Inside the flash loan callback, attacker swaps a portion of USDT → WBNB via PancakeSwap Router
  4. Attacker deposits USDT into gUSDT market via mint(), receiving gUSDT tokens as collateral
  5. Attacker enters the gUSDT market via Gammatroller enterMarkets()
  6. Borrow-Discount Loop (repeated ~20+ times):
    • Call updateUserDiscount(self) on gUSDT to reduce recorded borrow principal
    • Check getAccountLiquidity(self) to confirm available collateral room
    • Call borrow(amount) on gUSDT to extract more USDT
  7. After draining maximum USDT, attacker calls repayBorrow() to partially repay
  8. Attacker redeems remaining gUSDT collateral via redeem()
  9. Attacker repays flash loan to helper, keeping the profit (~7,882 USDT)

Detailed Call Trace

CALL EOA(0x2eb7c45f) → AttackerContract(0x3f192424) 0x29b62c2c
  CALL AttackerContract → FlashLoanHelper(0x172fcd41) 0x490e6cbc
    STATICCALL FlashLoanHelper → USDT.balanceOf()
    STATICCALL FlashLoanHelper → WBNB.balanceOf()
    CALL FlashLoanHelper → USDT.transfer() [flash loan USDT to attacker]
    CALL FlashLoanHelper → AttackerContract.0xa1d48336 [callback]
      CALL AttackerContract → USDT.approve(PancakeRouter)
      CALL AttackerContract → PancakeRouter.swapExactTokensForTokens(USDT→WBNB)
        [PancakeSwap pair swap calls]
      
      [START: Borrow-Discount-Borrow Loop at gUSDT(0x045e2df6)]
      
      CALL AttackerContract → gUSDT.updateUserDiscount(self)
        DELEGATECALL gUSDT → GToken_Impl.updateUserDiscount()
          [PlanetDiscount.changeUserBorrowDiscount() — reduces principal]
      STATICCALL AttackerContract → Gammatroller.getAccountLiquidity(self)
        DELEGATECALL Gammatroller → Impl.getAccountLiquidity()
          [iterates all markets, queries getAccountSnapshot for collateral calc]
      STATICCALL AttackerContract → gUSDT.getCash()
      STATICCALL AttackerContract → gUSDT.totalReserves()
      CALL AttackerContract → gUSDT.borrow(amount)
        DELEGATECALL gUSDT → GToken_Impl.borrow()
          CALL Gammatroller.borrowAllowed() [passes — liquidity available due to discount]
          CALL gUSDT → USDT.transfer(attacker, amount)
      
      [REPEAT: updateUserDiscount → getAccountLiquidity → borrow ... ~20+ iterations]
      
      CALL AttackerContract → gUSDT.repayBorrow(partial)
        DELEGATECALL gUSDT → GToken_Impl.repayBorrow()
      CALL AttackerContract → gUSDT.redeem(gTokenBalance)
        DELEGATECALL gUSDT → GToken_Impl.redeem()
      
      [END: Attacker has more USDT than started]
      
      CALL AttackerContract → USDT.transfer(FlashLoanHelper, loan+fee)
    STATICCALL FlashLoanHelper → USDT.balanceOf() [verify repayment]
    STATICCALL FlashLoanHelper → WBNB.balanceOf()

Financial Impact

  • Total loss from gUSDT pool: ~7,918 USDT (net outflow from 0x045e2df6)
  • Attacker profit: ~7,882 USDT (net gain by attacker contract 0x3f192424)
  • Flash loan fee: ~10 USDT (paid to helper at 0x172fcd41)
  • DEX slippage: ~25 USDT (absorbed by PancakeSwap pair 0x16b9a82)
  • Gas cost: negligible relative to profit
  • Who lost: gUSDT depositors (the lending pool’s USDT reserves were drained)
  • Protocol impact: gUSDT pool depleted by ~7,918 USDT, potentially affecting ability for other depositors to withdraw

Evidence

  • Transaction: 0xe0835bb761805689bf3ee510c5b0950ead03977f3d99ddc1a59c6d23151c0f1f
  • Block: 85867874
  • Chain: BNB Chain (Chain ID 56)
  • Attacker EOA: 0x2eb7c45fD97872E7D23D5566E096131f857a94bA
  • Attacker Contract: 0x3f192424c3da6fef008df9b38b96c0418f34fdf5
  • updateUserDiscount is callable by anyone: Verified in GErc20.sol line 151 — no onlyAdmin, require, or modifier restriction
  • changeUserBorrowDiscountInternal overwrites 4 state variables: Verified in GToken.sol line 601
  • Net USDT flow: funds_flow.json confirms attacker contract 0x3f1924 net +7,882 USDT, gUSDT pool 0x045e2d net -7,918 USDT
  • Borrow function calls: 172 borrow() / updateUserDiscount() calls observed in the trace (12,477 total calls in the tx)
  • Receipt status: 0x1 (success)