Calculated from recorded token losses using historical USD prices at the incident time.
0x045e2df638ebec29130dd3be61161cba5f00a9c8BSC0x9de8ca4129d5dd4f540e64c25ac61e55ea429589BSC0x4c9ed6a7c0ff31bf7302c73a2f76701e56bdf467BSCAt BSC blocks 85866660 through 85868041, the adversary EOA 0x2eb7c45fd97872e7d23d5566e096131f857a94ba deployed helper contract 0x3f192424c3da6fef008df9b38b96c0418f34fdf5, then used it in exploit transactions 0xd530a79a914533feb65a5ee349a58f5df999c208501efd2e5b6ceb2083b9ca8b and 0xe0835bb761805689bf3ee510c5b0950ead03977f3d99ddc1a59c6d23151c0f1f. In each realization, the helper flash-borrowed USDT from Pancake, bought and supplied GAMMA to Planet, entered the gGamma and gUSDT markets, and then repeatedly alternated borrow with the public updateUserDiscount(address) entry point on gUSDT. That public update path immediately reduced the helper's debt and the market's accounting even though no repayment had occurred. The helper then repaid only the discounted debt, redeemed collateral, repaid the flash loan plus fee, and later withdrew the residual USDT profit back to the controlling EOA in transactions 0x2e2fb24ab83a753d9548a1d4d6f4bd960e216fea899b528818dd2a399272f7c4 and 0x144b5bd56617542ce755895616122ed35a3a75b0ca92e76bbe3f30d0c68cd284.
The root cause is a broken borrow-discount accounting path in Planet. PlanetDiscountDelegate::changeUserBorrowDiscount(address) treats currentBorrowBal - lastBorrowAmountDiscountGiven as discountable interest, but immediately after a fresh borrow that delta is mostly new principal, not accrued interest. Because gUSDT exposes updateUserDiscount(address) publicly, an unprivileged borrower can trigger that misaccounting on demand and turn newly borrowed principal into debt cancellation.
Planet extends a Compound-style lending market with a discount subsystem. The discount tier is computed from a user's gGamma-backed position relative to the value of the user's other supplied Planet markets. The relevant logic lives in PlanetDiscountDelegate, while each market stores the current discount contract in discountLevel.
Historical eth_call checks at block 85866691, immediately before the first successful exploit transaction, confirm the exploit prerequisites on gUSDT:
gUSDT.discountLevel() = 0x9De8Ca4129D5dD4F540e64c25aC61E55ea429589
gUSDT.reserveFactorMantissa() = 400000000000000000
gUSDT.getCash() = 45143199567119074747481
PlanetDiscount.isMarketListed(gUSDT) = true
That state matters because the buggy discount formula uses the market reserve factor as part of the debt write-down, and the market must be listed in the discount contract for the path to execute. The exploit also requires a nonzero discount tier, which the attacker obtained permissionlessly by buying GAMMA, minting gGamma, and entering the relevant Planet markets. All liquidity sources and contracts used in the exploit, including Pancake flash liquidity and gUSDT's public update function, were permissionless.
The vulnerability is an accounting bug in Planet's borrow-discount mechanism, exposed through a public market entry point. gUSDT's implementation exposes updateUserDiscount(address) without access control, and that function immediately calls into the Planet discount contract and then refreshes the borrow snapshot. Inside PlanetDiscountDelegate::changeUserBorrowDiscount, the contract computes vars.interest = currentBorrowBal - _dis.lastBorrowAmountDiscountGiven, assumes that amount is discountable interest, multiplies it by the market reserve factor and the borrower's discount tier, and subtracts the result from both borrower debt and market-wide accounting. Immediately after a fresh borrow, however, the difference between the current borrow balance and the old snapshot is dominated by newly borrowed principal, not elapsed interest. The attacker therefore turns each fresh borrow into an immediate principal discount. Because gUSDT then records the discounted balance as the new snapshot, the attacker can repeat the same borrow-and-update cycle several times inside a single flash-loan transaction.
The relevant protocol-side code is straightforward:
function updateUserDiscount(address user) external {
changeUserBorrowDiscountInternal(user);
changeLastBorrowBalanceAtBorrow(user);
}
function changeUserBorrowDiscountInternal(address borrower) internal {
accrueInterest();
(accountBorrows[borrower].principal, accountBorrows[borrower].interestIndex, totalBorrows, totalReserves) =
PlanetDiscount(discountLevel).changeUserBorrowDiscount(borrower);
}
vars.interest = currentBorrowBal - _dis.lastBorrowAmountDiscountGiven;
(uint reserveFactor) = GToken(market).reserveFactorMantissa();
uint valueOfGivenInterestGoToReserves = mul_ScalarTruncate(Exp({mantissa: vars.interest}), reserveFactor);
vars.newDiscount = discount * valueOfGivenInterestGoToReserves / 10000;
vars.accountBorrowsNew = currentBorrowBal - vars.newDiscount;
return (vars.accountBorrowsNew, market.borrowIndex(), marketTotalBorrows - vars.newDiscount, market.totalReserves() - vars.newDiscount);
The violated invariant is: a borrow-discount mechanism may only discount genuine accrued interest already owed by the borrower, and a public state-sync function must never let the caller cancel newly borrowed principal or reduce market reserves without repayment.
The exploit becomes possible as soon as the attacker has a positive discount tier. PlanetDiscountDelegate::returnDiscountPercentage(address) derives that tier from the borrower's gGamma exposure relative to other staked Planet exposure, so the attacker first buys GAMMA, mints gGamma, and enters the gGamma and gUSDT markets. At that point the helper has collateral and a nonzero discount rate.
The first updateUserDiscount(address) call for a borrower mostly initializes the snapshot, because _dis.exist must first become true. After that, each new borrow inflates currentBorrowBal while _dis.lastBorrowAmountDiscountGiven still reflects the previously discounted debt. When the attacker immediately calls updateUserDiscount(address), PlanetDiscountDelegate mislabels the fresh delta as interest:
currentBorrowBal.lastBorrowAmountDiscountGiven.reserveFactorMantissa.newDiscount from borrower principal, totalBorrows, and totalReserves.lastBorrowAmountDiscountGiven.That behavior is visible directly in the first successful trace. The helper borrows from gUSDT, then calls the public update function, and the trace emits a positive BorrowDiscountAccrued in the same exploit transaction:
GErc20Delegator::borrow(57925470255398826817825)
emit Borrow(
borrower: 0x3f192424C3DA6FEf008dF9b38b96C0418F34Fdf5,
borrowAmount: 57925470255398826817825,
accountBorrows: 149567698104618044646737
)
GErc20Delegator::updateUserDiscount(0x3f192424C3DA6FEf008dF9b38b96C0418F34Fdf5)
emit BorrowDiscountAccrued(
market: 0x045e2Df638eBEc29130DD3bE61161cbA5F00a9c8,
borrower: 0x3f192424C3DA6FEf008dF9b38b96C0418F34Fdf5,
discountGiven: 1158509405107976536356,
accountBorrowsNew: 148409188699510068110381
)
There is no external repayment between those two events. The same pattern recurs throughout the transaction, and again in the second successful exploit transaction 0xe0835bb761805689bf3ee510c5b0950ead03977f3d99ddc1a59c6d23151c0f1f, where repeated Borrow and BorrowDiscountAccrued pairs occur after fresh borrows in the same block.
Once the helper has discounted its debt, it repays only the reduced borrow balance, redeems its gUSDT and gGamma collateral, unwinds the GAMMA position back to USDT, and repays the Pancake flash loan plus fee. Because the protocol accepted the discounted debt as fully settled, the helper exits with surplus USDT. The snapshot refresh performed by changeLastBorrowAmountDiscountGiven is what makes the exploit repeatable: after each update, the protocol records the already-discounted borrow as the new baseline, so the attacker can borrow again and discount the next principal increment too.
This is deterministic and permissionless. The decoded selector artifact ties 0xebe8702b to updateUserDiscount(address), the traces show the helper invoking that public function, and the historical on-chain state confirms the market was listed and configured with a nonzero reserve factor. No privileged keys, privileged governance calls, or private orderflow were required.
The adversary lifecycle is fully reconstructible from the EOA and helper txlists.
0xf35ad357f4e9ff9ca0fcdcedf848cfd56b155539 and sent several reverted attempts in blocks 85864846 through 85865900.0x556ba390345c3556ec7c00e24f332bf2281a64b843cb950a834fec85c9e47f21 at block 85866660 deployed helper 0x3f192424c3da6fef008df9b38b96c0418f34fdf5.0xd530a79a914533feb65a5ee349a58f5df999c208501efd2e5b6ceb2083b9ca8b at block 85866692 flash-borrowed 200000000000000000000000 raw USDT from Pancake V3 pool 0x172fcD41E0913e95784454622d1c3724f546f849, bought GAMMA, minted gGamma, repeatedly borrowed gUSDT and called updateUserDiscount(address), repaid the discounted debt, redeemed, unwound, and retained 14850274496864854671596 raw USDT inside the helper.0x2e2fb24ab83a753d9548a1d4d6f4bd960e216fea899b528818dd2a399272f7c4 moved that full helper balance to the controlling EOA.0xe0835bb761805689bf3ee510c5b0950ead03977f3d99ddc1a59c6d23151c0f1f at block 85867874 repeated the same public strategy with a 100000000000000000000000 raw USDT flash loan and retained 7882550120405300904390 raw USDT inside the helper.0x144b5bd56617542ce755895616122ed35a3a75b0ca92e76bbe3f30d0c68cd284 transferred the second helper balance to the same EOA.The decoded traces show no privileged control points. The helper only uses public Pancake liquidity, public Pancake router and pair swaps, the public Planet market entry points, and the helper owner's own withdrawal function. That satisfies the ACT adversary model exactly.
The direct victim was the Planet gUSDT market at 0x045e2df638ebec29130dd3be61161cba5f00a9c8. The balance diffs for the two successful exploit transactions show:
0xd530...: gUSDT lost 14909205528888933411325 raw USDT, the helper gained 14850274496864854671596 raw USDT, and the Pancake flash pool collected a 20000000000000000000 raw USDT fee.0xe083...: gUSDT lost 7918184489045138812347 raw USDT, the helper gained 7882550120405300904390 raw USDT, and the Pancake flash pool collected a 10000000000000000000 raw USDT fee.In aggregate, gUSDT lost 22827390017934072223672 raw USDT, which is 22827.390017934072223672 USDT at 18 decimals. The controlling EOA later withdrew 22732824617270155575986 raw USDT from the helper across the two withdrawal transactions. Native balance deltas show 116177623000000000 wei of BNB gas paid across the successful exploit and withdrawal transactions, which does not change the core ACT finding because the exploit is defined by the protocol accounting break and the resulting USDT extraction from gUSDT.
The validation used the following primary evidence:
0x556ba390345c3556ec7c00e24f332bf2281a64b843cb950a834fec85c9e47f21, 0xd530a79a914533feb65a5ee349a58f5df999c208501efd2e5b6ceb2083b9ca8b, 0x2e2fb24ab83a753d9548a1d4d6f4bd960e216fea899b528818dd2a399272f7c4, 0xe0835bb761805689bf3ee510c5b0950ead03977f3d99ddc1a59c6d23151c0f1f, and 0x144b5bd56617542ce755895616122ed35a3a75b0ca92e76bbe3f30d0c68cd284.0x2eb7c45fd97872e7d23d5566e096131f857a94ba and helper txlist for 0x3f192424c3da6fef008df9b38b96c0418f34fdf5.0x2f64203ed5606466783b61e8691784f07c25f0ed, PlanetDiscountDelegator 0x9de8ca4129d5dd4f540e64c25ac61e55ea429589, and PlanetDiscountDelegate 0x4c9ed6a7c0ff31bf7302c73a2f76701e56bdf467.0xebe8702b to updateUserDiscount(address).Borrow -> updateUserDiscount -> BorrowDiscountAccrued sequence and the resulting USDT loss from gUSDT.