Precise Fossilized Lobster
Medium
The mismatched price oracles in Leverager.sol allow attackers to overvalue their collateral and borrow more than they should
The Leverager contract’s price calculation mechanism can be misled due to its reliance on both Chainlink feeds and a Uniswap V3-based TWAP with minimal cross-verification. Whenever one token lacks a direct Chainlink price feed, the system uses the TWAP for conversion. Attackers can steadily manipulate the Uniswap TWAP to diverge from the Chainlink price, thereby inflating or deflating the recorded collateral value at the moment a leveraged position is created. This manipulation lets them bypass leverage constraints, creating effectively undercollateralized positions that appear valid under the contract’s checks.
The issue is within openLeveragedPosition
in Leverager.sol
. The function uses two price sources:
- Chainlink (when available).
- The Uniswap V3 TWAP (via
IVault(lp.vault).twapPrice()
), if the token has no Chainlink feed.
Once the position is opened, the collateral’s USD value is locked in using _calculateTokenValues
:
function openLeveragedPosition(LeverageParams calldata lp) external nonReentrant returns (uint256 _id) {
// Simplified excerpt:
uint256 price = IVault(lp.vault).twapPrice();
require(IVault(lp.vault).checkPoolActivity(), "market too volatile");
// 1) Gather user tokens + flashloaned tokens
// 2) Deposit into the Vault
(uint256 shares, uint256 a0, uint256 a1) = IVault(lp.vault).deposit(...);
// 3) Compute collateral in USD
up.initCollateralUsd = _calculateTokenValues(up.token0, up.token1, a0, a1, price);
// 4) Convert that USD figure to 'denomination' tokens
uint256 bPrice = IPriceFeed(pricefeed).getPrice(lp.denomination);
up.initCollateralValue = up.initCollateralUsd * (10 ** ERC20(lp.denomination).decimals()) / bPrice;
// 5) Borrow from LendingPool and perform swaps
ILendingPool(lendingPool).borrow(lp.denomination, lp.maxBorrowAmount);
...
ILendingPool(lendingPool).repay(lp.denomination, denomBalance);
// 6) Validate the final leverage
_checkWithinlimits(up);
require(!isLiquidateable(_id = id++), "position can't be liquidateable upon opening");
...
}
Inside _calculateTokenValues
, a token with no Chainlink feed is converted to USD using the “price” variable, which is the TWAP from IVault(lp.vault).twapPrice()
:
function _calculateTokenValues(
address token0,
address token1,
uint256 amount0,
uint256 amount1,
uint256 price // the TWAP from the pool
) internal view returns (uint256 usdValue) {
uint256 chPrice0;
uint256 chPrice1;
if (IPriceFeed(pricefeed).hasPriceFeed(token0)) {
chPrice0 = IPriceFeed(pricefeed).getPrice(token0);
usdValue += amount0 * chPrice0 / decimals0;
}
if (IPriceFeed(pricefeed).hasPriceFeed(token1)) {
chPrice1 = IPriceFeed(pricefeed).getPrice(token1);
usdValue += amount1 * chPrice1 / decimals1;
}
// If token0 lacks Chainlink feed, we rely on TWAP for partial conversion:
if (chPrice0 == 0) {
usdValue += (amount0 * price / PRECISION) * chPrice1 / decimals1;
} else if (chPrice1 == 0) {
usdValue += amount1 * PRECISION / price * chPrice0 / decimals0;
}
}
- A user calls
openLeveragedPosition()
to create a leveraged position inLeverager.sol
. - The
vaultParams
for the chosen vault are already set toleverageEnabled = true
. - One of the tokens in that vault lacks a direct Chainlink feed, triggering TWAP-based valuation in
_calculateTokenValues()
. checkPoolActivity()
is set with amaxObservationDeviation
that can tolerate gradual price movements.
- The Uniswap V3 pool for the underlying token pair has sufficient liquidity to let an attacker trade incrementally and move the TWAP without triggering abrupt changes.
- The Chainlink feed for the other token remains steady (not manipulated externally).
- The attacker makes many correctly calculated trades in the Uniswap V3 pool to shift the price incrementally, keeping each observation within
maxObservationDeviation
, bypassing checkPoolActivity(). - The attacker calls
openLeveragedPosition()
, triggering the system to fetch the manipulated TWAP price for the token lacking a Chainlink feed. _calculateTokenValues()
sees a harmlessly normal Chainlink price for the other token, but a distorted TWAP for the manipulated token, inflating the total collateral USD value.- The attacker passes the leverage checks, borrowing more than what would otherwise be allowed.
- The attacker finalizes the position, leaving the protocol exposed to bad debt whenever a liquidation attempt is made at the real market price.
The protocol suffers a potential severe loss of capital. Attackers can open undercollateralized positions based on an inflated TWAP price and leave behind bad debt when liquidation occurs or when price reverts.
Bypassing checkPoolActivity() is shown in #4
After bypassing checkPoolActivity(), the attack is straightforward: call openLeveragedPosition() ->
_calculateTokenValues()` inflates the total collateral USD value -> attacker borrows more than allowed.
Compare TWAP-based valuations with Chainlink prices whenever both tokens have reliable feeds. Reject positions if the two price sources differ by more than a configured threshold.