Incident Report: Venus Protocol vTHE Exchange Rate Manipulation + borrowBehalf Drain

Executive Summary

On BNB Smart Chain, an attacker exploited Venus Protocol’s vTHE (THENA/THE) market by combining three pre-obtained approvals with a classic exchange-rate inflation technique. The attacker held ERC-20 transferFrom allowances for the THE token from six victim addresses and a Comptroller approvedDelegates entry from a seventh address who was an active vTHE depositor. By directly donating 36.1 M THE tokens (bypassing mint) to the vTHE pool, the attacker inflated that pool’s exchange rate by approximately 11×, causing the victim’s vTHE collateral to appear worth far more than it actually was. The attacker then called borrowBehalf three times on behalf of the victim, drawing out ~1.58 M USDC, ~913,858 CAKE, and ~1,972.5 WBNB. All funds went directly to the attacker’s constructor-deployed contract.

Total value extracted (attacker contract’s net receipts):

  • 60,004,468 vUSDC (equivalent to ~1.58 M USDC)
  • 913,858.26 CAKE
  • 1,972.53 WBNB

Incident Details

FieldValue
ChainBNB Smart Chain (BSC, Chain ID 56)
Transaction0x4f477e941c12bbf32a58dc12db7bb0cb4d31d41ff25b2457e6af3c15d7f5663f
Block86,731,941
Attacker EOA0x43c743e316f40d4511762eedf6f6d484f67b2f82
Attacker Contract0x737bc98f1d34e19539c074b8ad1169d5d45da619 (deployed in this tx)
Exploited ProtocolVenus Protocol (Core Lending Pool)
Attack TypeExchange Rate Manipulation + borrowBehalf Drain
Root CausegetCashPrior() reads live ERC-20 balance, enabling donation-based exchange rate inflation

Contracts Involved

LabelAddressRole
vTHE Market0x86e06eafa6a1ea631eab51de500e3d474933739fVenus vToken whose exchange rate is inflated
THE Token0xf4c8e32eadec4bfe97e0f595add0f4450a863a11THENA governance token, underlying of vTHE
Venus Comptroller0xfd36e2c2a6789db23113685031d7f16329158384Controls collateral/borrow checks; holds approvedDelegates
Comptroller Impl (V11+)0xb2243da976f2cbaaa4dd1a76bf7f6efbe22c4cfcPrimary implementation with approvedDelegates storage
ComptrollerLens0x732138e18fa6f8f8e456ad829db429a450a79758getHypotheticalAccountLiquidity computation
VToken Impl0x1be1ce8352328278ac4e0488436c0f1607282550Shared VBep20 implementation; contains borrowBehalf
vUSDC0xeca88125a5adbe82614ffc12d0db554e2e2867c8First borrow target (USDC)
vCAKE (vTokenSuppliedA)0x86ac3974e2bd0d60825230fa6f355ff11409df5cSecond borrow target (CAKE)
vWBNB (vTokenSuppliedB)0x6bca74586218db34cdb402295796b79663d816e9Third borrow target (WBNB)
Victim (borrower)0x1a35bd28efd46cfc46c2136f878777d69ae16231Active vTHE depositor; had previously delegated to attacker

Attack Flow (Step by Step)

Phase 1 — Pre-Attack Approvals (Prior Transactions)

Before this transaction, the attacker obtained two classes of off-chain permissions from victims, likely through phishing or compromised private keys:

  1. ERC-20 allowances on THE token from six large THE holders, authorising the attacker contract to call THE.transferFrom(victim, vTHE_pool, amount).

  2. Comptroller delegate approval from 0x1a35bd28efd46cfc46c2136f878777d69ae16231, the one address who also had vTHE collateral deposited. This victim called Comptroller.updateDelegate(attackerContract, true) in a prior transaction, setting approvedDelegates[0x1a35bd...][0x737bc9...] = true.

Phase 2 — Exchange Rate Inflation (Trace index 1–12)

The attacker contract’s constructor begins by directly transferring THE tokens from six victim wallets into the vTHE market contract without calling vTHE.mint():

THE.transferFrom(0xf052219f..., vTHE, 13,223,597 THE)  // log 276
THE.transferFrom(0x89e3615f..., vTHE,  9,474,403 THE)  // log 277
THE.transferFrom(0xbb378204..., vTHE,  7,532,701 THE)  // log 278
THE.transferFrom(0x564a073f..., vTHE,  3,915,245 THE)  // log 279
THE.transferFrom(0x1a35bd28..., vTHE,    697,951 THE)  // log 280
THE.transferFrom(0x16f09b91..., vTHE,  1,252,816 THE)  // log 281
Total donated: 36,096,716 THE  (net to vTHE)

The vTHE pool’s getCashPrior() is:

function getCashPrior() internal view override returns (uint) {
    return IERC20(underlying).balanceOf(address(this));
}

Since balanceOf returns the live ERC-20 balance, directly sending 36.1 M THE to the pool immediately increases cash by that amount. The exchange rate formula is:

exchangeRate = (cash + totalBorrows - totalReserves) / totalSupply

Pre-attack (from prestate):

  • totalBorrows ≈ 3,324,339 THE
  • totalSupply of vTHE = S (unknown from available data, but the inflation ratio is calculable)

Adding 36.1 M THE to cash with no change to totalSupply inflates the exchange rate by approximately 36.1M / (original cash + borrows). From the subsequent borrow amounts, the effective collateral value of the victim’s vTHE position increased from ~$193,000 to over $3 M, an approximately 11–15× inflation.

Phase 3 — First borrowBehalf: USDC (Trace index 51–237)

With the exchange rate inflated, the attacker calls:

vUSDC.borrowBehalf(victim=0x1a35bd28..., amount=1,581,454.956... USDC)

Execution path:

  1. VBep20.borrowBehalf checks comptroller.approvedDelegates(victim, msg.sender)true (set in prior tx)
  2. borrowInternal calls comptroller.borrowAllowed(vUSDC, victim, borrowAmount)
  3. borrowAllowed calls getHypotheticalAccountLiquidity(victim, vUSDC, 0, borrowAmount)
  4. ComptrollerLens._calculateAccountPosition enumerates victim’s assets:
    • Finds victim has vTHE supply position
    • Calls vTHE.getAccountSnapshot(victim) → returns victim’s vTHE balance and the now-inflated exchange rate
    • Computes sumCollateral = vTHE_balance * inflated_exchangeRate * THE_oracle_price * collateralFactor
  5. The inflated collateral satisfies the borrow check
  6. 1,581,454.96 USDC is transferred from vUSDC to the attacker contract

Phase 4 — Attacker Supplies USDC, Borrows THE (Trace index 238–407)

The attacker uses the borrowed USDC to create its own supply position in vUSDC, then borrows THE from vTHE against that new position:

USDC.approve(vUSDC, 1,581,454 USDC)
vUSDC.mint(1,581,454 USDC)           → attacker gets 60,004,468 vUSDC
Comptroller.enterMarkets([vUSDC])
vTHE.borrow(4,628,903 THE)           → attacker takes THE from pool
THE.transfer(vTHE, 4,628,903 THE)    → attacker immediately repays THE

This intermediate step accrues interest in the vTHE pool and refreshes accrual state across all markets, which the attacker then explicitly triggers by iterating borrowBalanceCurrent + getUnderlyingPrice across 26+ vToken markets (trace index 408–1250). This state refresh is required to ensure the victim’s borrow capacity is calculated fresh against current prices for the two remaining borrowBehalf calls.

Phase 5 — Second borrowBehalf: CAKE (Trace index 1251–1432)

vCAKE.borrowBehalf(victim=0x1a35bd28..., amount=913,858.26 CAKE)

Same delegate check and liquidity check as Phase 3. The victim’s collateral still appears inflated. 913,858.26 CAKE is sent to the attacker.

Phase 6 — Third borrowBehalf: WBNB (Trace index 1451–1662)

vWBNB.borrowBehalf(victim=0x1a35bd28..., amount=1,972.53 WBNB)

1,972.53 WBNB is sent to the attacker.

Phase 7 — Final Balances

The attacker contract ends the constructor holding:

  • 60,004,468 vUSDC (redeemable for ~1.58 M USDC)
  • 913,858.26 CAKE
  • 1,972.53 WBNB
  • 0 THE (the donated 36 M THE remains stranded in the vTHE pool; the borrowed+returned 4.6 M THE nets to zero)

The victim 0x1a35bd28... is left with three open borrow positions totaling the borrowed amounts and no ability to repay at fair value.

Vulnerability Analysis

Root Cause — Live-Balance Exchange Rate in getCashPrior()

The fundamental flaw is in VBep20.getCashPrior():

// VBep20.sol line 245-247
function getCashPrior() internal view override returns (uint) {
    return IERC20(underlying).balanceOf(address(this));
}

This is used in exchangeRateStoredInternal():

// VToken.sol line 1853-1882
function exchangeRateStoredInternal() internal view virtual returns (MathError, uint) {
    uint _totalSupply = totalSupply;
    if (_totalSupply == 0) {
        return (MathError.NO_ERROR, initialExchangeRateMantissa);
    } else {
        // exchangeRate = (totalCash + totalBorrows + flashLoanAmount - totalReserves) / totalSupply
        uint totalCash = _getCashPriorWithFlashLoan();  // = getCashPrior() + flashLoanAmount
        ...
    }
}

Because cash is computed from the live ERC-20 balance rather than an internal accounting variable, any address with a sufficiently large ERC-20 balance and approval can instantly inflate the pool’s exchange rate by sending tokens directly to the pool address (a “donation attack”). The totalSupply of vTHE does not change, so existing holders’ collateral value is proportionally inflated.

Contributing Factor — borrowBehalf with Delegate Approvals

The Venus VBep20 contract supports on-behalf borrowing:

// VBep20.sol line 128-131
function borrowBehalf(address borrower, uint borrowAmount) external returns (uint) {
    require(comptroller.approvedDelegates(borrower, msg.sender), "not an approved delegate");
    return borrowInternal(borrower, payable(msg.sender), borrowAmount);
}

The borrowed funds are sent to msg.sender (the attacker), but debt is recorded against borrower (the victim). The collateral check in borrowAllowed is performed against the victim’s asset portfolio. A delegate with ERC-20 transferFrom access can therefore abuse the victim’s collateral without possessing it.

Why the Exchange Rate Manipulation Matters

The Venus ComptrollerLens _calculateAccountPosition method computes collateral value as:

// ComptrollerLens.sol line 233-260
(oErr, vars.vTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) =
    asset.getAccountSnapshot(account);
...
vars.tokensToDenom = mul_(mul_(vars.weightedFactor, vars.exchangeRate), vars.oraclePrice);
vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom, vars.vTokenBalance, vars.sumCollateral);

The exchangeRateMantissa reflects getCashPrior() at call time. Since the donation increased cash before the borrowBehalf calls, the victim’s same number of vTHE tokens was calculated to represent ~11× more THE, thus passing the borrow safety check for amounts far exceeding the victim’s true collateral.

Impact Assessment

Victim / PoolLoss
0xf052219f...13,223,597.9 THE
0x89e3615f...9,474,403.0 THE
0xbb378204...7,532,701.9 THE
0x564a073f...3,915,245.3 THE
0x1a35bd28...697,951.3 THE
0x16f09b91...1,252,816.7 THE
Venus vUSDC pool~1,581,454.96 USDC (bad debt created against victim)
Venus vCAKE pool~913,858.26 CAKE (bad debt created against victim)
Venus vWBNB pool~1,972.53 WBNB (bad debt created against victim)

The 36.1 M THE donated to the vTHE pool is not recoverable by the original holders; it inflated the exchange rate, enriching all remaining vTHE depositors at the six victims’ expense. The three borrow markets have uncollateralised bad debt unless the victim (now insolvent) can be liquidated at a recovery price.

Exploit Code Sketch

// Pseudocode for the attacker's constructor
constructor() {
    // Phase 1: Donate THE directly to vTHE pool (bypassing mint)
    for each (victim_addr, amount) in donation_list:
        THE.transferFrom(victim_addr, address(vTHE), amount);

    // Phase 2: Exploit inflated exchange rate via borrowBehalf
    vUSDC.borrowBehalf(VICTIM, usdc_borrow_amount);  // comptroller.approvedDelegates check passes

    // Phase 3: Supply borrowed USDC to get vUSDC (optional collateral recycling)
    USDC.approve(address(vUSDC), usdc_borrow_amount);
    vUSDC.mint(usdc_borrow_amount);
    comptroller.enterMarkets([address(vUSDC)]);

    // Phase 4: Accrue state across all markets
    vTHE.borrow(the_borrow_amount);
    THE.transfer(address(vTHE), the_borrow_amount);  // repay immediately
    for each market: market.borrowBalanceCurrent(self); oracle.getUnderlyingPrice(market);

    // Phase 5: Additional borrowBehalf calls
    vCAKE.borrowBehalf(VICTIM, cake_borrow_amount);
    vWBNB.borrowBehalf(VICTIM, wbnb_borrow_amount);
}

Key Evidence

  1. Trace index 1–12: Six sequential THE.transferFrom(victim, vTHE, amount) calls with no corresponding vTHE.mint(), depositing 36.1 M THE directly to pool address.

  2. getCashPrior() in VBep20.sol (line 245–247): Returns IERC20(underlying).balanceOf(address(this)) — live ERC-20 balance, not an internal accounting counter.

  3. exchangeRateStoredInternal() in VToken.sol (line 1853–1882): Uses cash = _getCashPriorWithFlashLoan() which ultimately calls getCashPrior().

  4. Trace index 51: vUSDC.borrowBehalf(0x1a35bd28..., 1,581,454.96 USDC) — followed immediately by balanceOf at depth 3 (no revert), confirming approvedDelegates returned true.

  5. approvedDelegates check (trace index 53–54): comptroller.approvedDelegates(0x1a35bd28..., 0x737bc9...) queried as STATICCALL, returned non-zero (not in prestate as zero, and call did not revert).

  6. ComptrollerLens._calculateAccountPosition (line 233–260): Collateral = vTokenBalance * exchangeRate * oraclePrice * collateralFactor — exchange rate sourced from getAccountSnapshot which calls exchangeRateStoredInternal, itself using the donated cash.

  7. Funds flow: Net +36.1 M THE to vTHE pool; attacker contract net receives 60,004,468 vUSDC + 913,858 CAKE + 1,972.5 WBNB.

Recommendations

Immediate Fix — Internal Cash Accounting

Replace getCashPrior() with an internal variable that is only updated via protocol-sanctioned entry points (mint, repayBorrow, liquidateBorrow):

uint256 internal _totalCash;

function doTransferIn(address from, uint256 amount) internal override returns (uint256) {
    uint256 before = IERC20(underlying).balanceOf(address(this));
    IERC20(underlying).safeTransferFrom(from, address(this), amount);
    uint256 actualAmount = IERC20(underlying).balanceOf(address(this)) - before;
    _totalCash += actualAmount;  // update internal counter
    return actualAmount;
}

function getCashPrior() internal view override returns (uint) {
    return _totalCash;  // use internal accounting, not live balance
}

This pattern is used by Compound V3 (comet) and prevents any direct-transfer inflation attack.

Secondary Fix — Rate-Limit Exchange Rate Growth

Implement a maximum exchange rate change per block (e.g., 0.1% per block) that reverts mints or borrows if the current rate deviates from the last stored rate beyond a threshold. This acts as a circuit breaker.

Tertiary Fix — Restrict borrowBehalf Scope

Require that borrowBehalf delegates can only borrow up to a configured fraction of the borrower’s available liquidity per block, limiting the damage window if a delegate approval is compromised.

Process Fix — Revoke Stale Delegate Approvals

Add a UI warning and automatic expiry for Comptroller delegate approvals that have been inactive for more than N days. The off-chain phishing that obtained the delegate approval is the entry point; reducing the attack surface for live approvals limits future exposure.