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
| Field | Value |
|---|---|
| Chain | BNB Smart Chain (BSC, Chain ID 56) |
| Transaction | 0x4f477e941c12bbf32a58dc12db7bb0cb4d31d41ff25b2457e6af3c15d7f5663f |
| Block | 86,731,941 |
| Attacker EOA | 0x43c743e316f40d4511762eedf6f6d484f67b2f82 |
| Attacker Contract | 0x737bc98f1d34e19539c074b8ad1169d5d45da619 (deployed in this tx) |
| Exploited Protocol | Venus Protocol (Core Lending Pool) |
| Attack Type | Exchange Rate Manipulation + borrowBehalf Drain |
| Root Cause | getCashPrior() reads live ERC-20 balance, enabling donation-based exchange rate inflation |
Contracts Involved
| Label | Address | Role |
|---|---|---|
| vTHE Market | 0x86e06eafa6a1ea631eab51de500e3d474933739f | Venus vToken whose exchange rate is inflated |
| THE Token | 0xf4c8e32eadec4bfe97e0f595add0f4450a863a11 | THENA governance token, underlying of vTHE |
| Venus Comptroller | 0xfd36e2c2a6789db23113685031d7f16329158384 | Controls collateral/borrow checks; holds approvedDelegates |
| Comptroller Impl (V11+) | 0xb2243da976f2cbaaa4dd1a76bf7f6efbe22c4cfc | Primary implementation with approvedDelegates storage |
| ComptrollerLens | 0x732138e18fa6f8f8e456ad829db429a450a79758 | getHypotheticalAccountLiquidity computation |
| VToken Impl | 0x1be1ce8352328278ac4e0488436c0f1607282550 | Shared VBep20 implementation; contains borrowBehalf |
| vUSDC | 0xeca88125a5adbe82614ffc12d0db554e2e2867c8 | First borrow target (USDC) |
| vCAKE (vTokenSuppliedA) | 0x86ac3974e2bd0d60825230fa6f355ff11409df5c | Second borrow target (CAKE) |
| vWBNB (vTokenSuppliedB) | 0x6bca74586218db34cdb402295796b79663d816e9 | Third borrow target (WBNB) |
| Victim (borrower) | 0x1a35bd28efd46cfc46c2136f878777d69ae16231 | Active 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:
ERC-20 allowances on THE token from six large THE holders, authorising the attacker contract to call
THE.transferFrom(victim, vTHE_pool, amount).Comptroller delegate approval from
0x1a35bd28efd46cfc46c2136f878777d69ae16231, the one address who also had vTHE collateral deposited. This victim calledComptroller.updateDelegate(attackerContract, true)in a prior transaction, settingapprovedDelegates[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 THEtotalSupplyof 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:
VBep20.borrowBehalfcheckscomptroller.approvedDelegates(victim, msg.sender)→true(set in prior tx)borrowInternalcallscomptroller.borrowAllowed(vUSDC, victim, borrowAmount)borrowAllowedcallsgetHypotheticalAccountLiquidity(victim, vUSDC, 0, borrowAmount)ComptrollerLens._calculateAccountPositionenumeratesvictim’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
- The inflated collateral satisfies the borrow check
- 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 / Pool | Loss |
|---|---|
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
Trace index 1–12: Six sequential
THE.transferFrom(victim, vTHE, amount)calls with no correspondingvTHE.mint(), depositing 36.1 M THE directly to pool address.getCashPrior()in VBep20.sol (line 245–247): ReturnsIERC20(underlying).balanceOf(address(this))— live ERC-20 balance, not an internal accounting counter.exchangeRateStoredInternal()in VToken.sol (line 1853–1882): Usescash = _getCashPriorWithFlashLoan()which ultimately callsgetCashPrior().Trace index 51:
vUSDC.borrowBehalf(0x1a35bd28..., 1,581,454.96 USDC)— followed immediately bybalanceOfat depth 3 (no revert), confirmingapprovedDelegatesreturnedtrue.approvedDelegatescheck (trace index 53–54):comptroller.approvedDelegates(0x1a35bd28..., 0x737bc9...)queried asSTATICCALL, returned non-zero (not in prestate as zero, and call did not revert).ComptrollerLens._calculateAccountPosition(line 233–260): Collateral =vTokenBalance * exchangeRate * oraclePrice * collateralFactor— exchange rate sourced fromgetAccountSnapshotwhich callsexchangeRateStoredInternal, itself using the donated cash.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.