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:
accountBorrows[borrower].principal— the borrower’s recorded debtaccountBorrows[borrower].interestIndex— the borrower’s interest checkpointtotalBorrows— the market’s total outstanding borrowstotalReserves— 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)→ callsupdateUserDiscount(self)→ debt reduced from X to X-discount →getAccountLiquidity()shows available collateral → callsborrow(Y)again → repeats until pool is drained
Attack Execution
High-Level Flow
- Attacker EOA (
0x2eb7c45f) calls attacker contract (0x3f192424) - Attacker contract requests a flash loan of ~100,000 USDT from helper (
0x172fcd41) - Inside the flash loan callback, attacker swaps a portion of USDT → WBNB via PancakeSwap Router
- Attacker deposits USDT into gUSDT market via
mint(), receiving gUSDT tokens as collateral - Attacker enters the gUSDT market via Gammatroller
enterMarkets() - 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
- Call
- After draining maximum USDT, attacker calls
repayBorrow()to partially repay - Attacker redeems remaining gUSDT collateral via
redeem() - 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 updateUserDiscountis callable by anyone: Verified inGErc20.solline 151 — noonlyAdmin,require, or modifier restrictionchangeUserBorrowDiscountInternaloverwrites 4 state variables: Verified inGToken.solline 601- Net USDT flow:
funds_flow.jsonconfirms attacker contract0x3f1924net +7,882 USDT, gUSDT pool0x045e2dnet -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)