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: 6/36
Findings: 3
Award: $8,036.28
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: NentoR
5431.9558 USDC - $5,431.96
https://github.com/pendle-finance/pendle-core-v2-public/blob/main/contracts/router/ActionAddRemoveLiqV3.sol#L166-L172 https://github.com/pendle-finance/pendle-core-v2-public/blob/main/contracts/router/ActionAddRemoveLiqV3.sol#L444-L449 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarm/PendlePowerFarmLeverageLogic.sol#L467-L482 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarm/PendlePowerFarmLeverageLogic.sol#L165-L171
PendlePowerManager
is incompatible with the latest deployment of PendleRouter
and will cause reverts when attempting to open a farm position.
The latest deployment of the Pendle router, PendleRouterV3
, is incompatible with the PendlePowerManager
contract. The sponsor is expecting full compatibility with both but this is not the case here. The problem stems from the calls made to PENDLE_ROUTER.removeLiquiditySingleSy()
and PENDLE_ROUTER.addLiquiditySingleSy()
:
function _logicOpenPosition( bool _isAave, uint256 _nftId, uint256 _depositAmount, uint256 _totalDebtBalancer, uint256 _allowedSpread ) internal { // ... ( uint256 netLpOut , ) = PENDLE_ROUTER.addLiquiditySingleSy( { _receiver: address(this), _market: address(PENDLE_MARKET), _netSyIn: syReceived, _minLpOut: 0, _guessPtReceivedFromSy: ApproxParams( { guessMin: netPtFromSwap - 100, guessMax: netPtFromSwap + 100, guessOffchain: 0, maxIteration: 50, eps: 1e15 } ) } ); // ... } function _logicClosePosition( uint256 _nftId, uint256 _borrowShares, uint256 _lendingShares, uint256 _totalDebtBalancer, uint256 _allowedSpread, address _caller, bool _ethBack, bool _isAave ) private { // ... ( uint256 netSyOut , ) = PENDLE_ROUTER.removeLiquiditySingleSy( { _receiver: address(this), _market: address(PENDLE_MARKET), _netLpToRemove: withdrawnLpsAmount, _minSyOut: 0 } ); // ... }
The issue is that the signatures of those have changed in V3:
function addLiquiditySingleSy( address receiver, address market, uint256 netSyIn, uint256 minLpOut, ApproxParams calldata guessPtReceivedFromSy, LimitOrderData calldata limit ) external returns (uint256 netLpOut, uint256 netSyFee); function removeLiquiditySingleSy( address receiver, address market, uint256 netLpToRemove, uint256 minSyOut, LimitOrderData calldata limit ) external returns (uint256 netSyOut, uint256 netSyFee);
There's a new parameter called limit
that's not accounted for in the calls in the PendlePowerFarmLeverageLogic
helper contract. This will lead to calls always reverting with RouterInvalidAction
due to Pendle's proxy not being able to locate the selector used.
Coded POC (PendlePowerFarmControllerBase.t.sol
):
function _setUpCustom(address _pendleRouter) private { _setProperties(); pendleLockInstance = IPendleLock(AddressesMap[chainId].pendleLock); wethInstance = IWETH(AddressesMap[chainId].weth); wiseOracleHubInstance = WiseOracleHub(AddressesMap[chainId].oracleHub); aaveHubInstance = IAaveHub(AddressesMap[chainId].aaveHub); vm.startPrank(wiseLendingInstance.master()); controllerTester = new PendleControllerTester( AddressesMap[chainId].vePendle, AddressesMap[chainId].pendleTokenAddress, AddressesMap[chainId].voterContract, AddressesMap[chainId].voterRewardsClaimer, AddressesMap[chainId].oracleHub ); pendlePowerFarmTokenFactory = controllerTester.PENDLE_POWER_FARM_TOKEN_FACTORY(); PoolManager.CreatePool memory params = PoolManager.CreatePool({ allowBorrow: true, poolToken: AddressesMap[chainId].aweth, poolMulFactor: 17500000000000000, poolCollFactor: 805000000000000000, maxDepositAmount: 1800000000000000000000000 }); wiseLendingInstance.createPool(params); IAaveHub(AddressesMap[chainId].aaveHub).setAaveTokenAddress( AddressesMap[chainId].weth, AddressesMap[chainId].aweth ); if (block.chainid == ETH_CHAIN_ID) { wiseOracleHubInstance.addOracle( AddressesMap[chainId].aweth, wiseOracleHubInstance.priceFeed(AddressesMap[chainId].weth), new address[](0) ); wiseOracleHubInstance.recalibrate(AddressesMap[chainId].aweth); } _addPendleTokenOracle(); _addPendleMarketOracle( AddressesMap[chainId].PendleMarketStEth, address(wiseOracleHubInstance.priceFeed(AddressesMap[chainId].weth)), AddressesMap[chainId].weth, 2 ether, 3 ether ); IPendlePowerFarmToken derivativeToken = _addPendleMarket(AddressesMap[chainId].PendleMarketStEth, "name", "symbol", MAX_CARDINALITY); pendleChildLpOracleInstance = new PendleChildLpOracle(address(pendleLpOracleInstance), address(derivativeToken)); address[] memory underlyingTokens = new address[](1); underlyingTokens[0] = AddressesMap[chainId].weth; wiseOracleHubInstance.addOracle( address(derivativeToken), IPriceFeed(address(pendleChildLpOracleInstance)), underlyingTokens ); address[] memory underlyingTokensCurrent = new address[](0); wiseOracleHubInstance.addOracle(CRV_TOKEN_ADDRESS, IPriceFeed(CRV_ETH_FEED), underlyingTokensCurrent); wiseOracleHubInstance.recalibrate(CRV_TOKEN_ADDRESS); curveUsdEthOracleInstance = new CurveUsdEthOracle(IPriceFeed(ETH_USD_FEED), IPriceFeed(CRVUSD_USD_FEED)); wiseOracleHubInstance.addOracle( CRVUSD_TOKEN_ADDRESS, IPriceFeed(address(curveUsdEthOracleInstance)), new address[](0) ); wiseOracleHubInstance.recalibrate(CRVUSD_TOKEN_ADDRESS); wiseOracleHubInstance.addTwapOracle( CRV_TOKEN_ADDRESS, CRV_UNI_POOL_ADDRESS, CRV_UNI_POOL_TOKEN0_ADDRESS, CRV_UNI_POOL_TOKEN1_ADDRESS, UNI_V3_FEE_CRV_UNI_POOL ); wiseOracleHubInstance.addTwapOracleDerivative( CRVUSD_TOKEN_ADDRESS, CRVUSD_UNI_POOL_TOKEN0_ADDRESS, [ETH_USDC_UNI_POOL_ADDRESS, CRVUSD_UNI_POOL_ADDRESS], [ETH_USDC_UNI_POOL_TOKEN0_ADDRESS, CRVUSD_UNI_POOL_TOKEN0_ADDRESS], [ETH_USDC_UNI_POOL_TOKEN1_ADDRESS, CRVUSD_UNI_POOL_TOKEN1_ADDRESS], [UNI_V3_FEE_ETH_USDC_UNI_POOL, UNI_V3_FEE_CRVUSD_UNI_POOL] ); address underlyingFeed = CRVUSD_TOKEN_ADDRESS; _addPendleMarketOracle( CRVUSD_PENDLE_28MAR_2024, address(curveUsdEthOracleInstance), underlyingFeed, 0.0008 ether, 0.0016 ether ); derivativeToken = _addPendleMarket(CRVUSD_PENDLE_28MAR_2024, "name", "symbol", MAX_CARDINALITY); pendleChildLpOracleInstance = new PendleChildLpOracle(address(pendleLpOracleInstance), address(derivativeToken)); underlyingTokens = new address[](1); underlyingTokens[0] = underlyingFeed; wiseOracleHubInstance.addOracle( address(derivativeToken), IPriceFeed(address(pendleChildLpOracleInstance)), underlyingTokens ); PoolManager.CreatePool[] memory createPoolArray = new PoolManager.CreatePool[](3); createPoolArray[0] = PoolManager.CreatePool({ allowBorrow: true, poolToken: CRVUSD_TOKEN_ADDRESS, poolMulFactor: 17500000000000000, poolCollFactor: 740000000000000000, maxDepositAmount: 2000000000000000000000000 }); createPoolArray[1] = PoolManager.CreatePool({ allowBorrow: false, poolToken: controllerTester.pendleChildAddress(AddressesMap[chainId].PendleMarketStEth), poolMulFactor: 17500000000000000, poolCollFactor: 740000000000000000, maxDepositAmount: 2000000000000000000000000 }); createPoolArray[2] = PoolManager.CreatePool({ allowBorrow: false, poolToken: controllerTester.pendleChildAddress(CRVUSD_PENDLE_28MAR_2024), poolMulFactor: 17500000000000000, poolCollFactor: 740000000000000000, maxDepositAmount: 2000000000000000000000000 }); for (uint256 i = 0; i < createPoolArray.length; i++) { wiseLendingInstance.createPool(createPoolArray[i]); } powerFarmNftsInstance = new PowerFarmNFTs("", ""); powerFarmManagerInstance = new PendlePowerManager( address(wiseLendingInstance), controllerTester.pendleChildAddress(AddressesMap[chainId].PendleMarketStEth), _pendleRouter, AddressesMap[chainId].entryAssetPendleMarketStEth, AddressesMap[chainId].PendleMarketStEthSy, AddressesMap[chainId].PendleMarketStEth, AddressesMap[chainId].pendleRouterStatic, AddressesMap[chainId].dex, 950000000000000000, address(powerFarmNftsInstance) ); wiseLendingInstance.setVerifiedIsolationPool(address(powerFarmManagerInstance), true); vm.stopPrank(); if (block.chainid == ETH_CHAIN_ID) { address wethWhaleEthMain = 0x8EB8a3b98659Cce290402893d0123abb75E3ab28; vm.startPrank(wethWhaleEthMain); wethInstance.transfer(wiseLendingInstance.master(), 1000 ether); vm.stopPrank(); } vm.startPrank(wiseLendingInstance.master()); IERC20(AddressesMap[chainId].weth).approve(address(powerFarmManagerInstance), 1000000 ether); wiseSecurityInstance = IWiseSecurity(wiseLendingInstance.WISE_SECURITY()); positionNftsInstance = IPositionNFTs(wiseLendingInstance.POSITION_NFT()); } function testCompatibleWithRouter() public { address pendleRouter = 0x0000000001E4ef00d069e71d6bA041b0A16F7eA0; _decideChain(true); _setUpCustom(pendleRouter); _testFarmShouldEnterAndExitIntoToken(); } function testFail_IncompatibleWithRouterV3() public { address pendleRouterV3 = 0x00000000005BBB0EF59571E58418F9a4357b68A0; _decideChain(true); _setUpCustom(pendleRouterV3); // Reverts with "RouterInvalidAction" _testFarmShouldEnterAndExitIntoToken(); }
Manual Review
Support only the latest router (V3) or add conditional checks to use the respective selector for each router version.
Other
#0 - c4-pre-sort
2024-03-17T14:41:41Z
GalloDaSballo marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-03-17T14:42:22Z
GalloDaSballo marked the issue as remove high or low quality report
#2 - c4-pre-sort
2024-03-17T15:33:45Z
GalloDaSballo marked the issue as sufficient quality report
#3 - GalloDaSballo
2024-03-17T15:34:18Z
Worth flagging as it seems valid but may not be necessary if the sponsor is opting into using the previous deployment (which may not be deprecated)
#4 - c4-pre-sort
2024-03-17T15:34:25Z
GalloDaSballo marked the issue as primary issue
#5 - trust1995
2024-03-26T17:51:39Z
By definition a codebase can never be guaranteed to be compatible with the latest version. Requesting warden to provide evidence lack of integration with v3 achieves M+ severity.
#6 - c4-judge
2024-03-26T18:35:58Z
trust1995 marked the issue as unsatisfactory: Out of scope
#7 - 0xNentoR
2024-03-27T12:19:12Z
@trust1995 The reason why I reported this was because the sponsor told me they expect compatibility with both. The problem is that an older version of pendle router is used here and the calls for addLiquiditySingleSy()
and removeLiquiditySingleSy()
are basically incompatible with the new one and will revert because of a missing parameter. To understand the rationale behind this submission, let's examine the deployed contracts:
Current router version (taken from test files): https://arbiscan.io/address/0x0000000001E4ef00d069e71d6bA041b0A16F7eA0 PendleRouterV3 (latest): https://arbiscan.io/address/0x00000000005BBB0EF59571E58418F9a4357b68A0
Here's the code for both on Deth.net: Old router: https://arbiscan.deth.net/address/0xFc0617465474a6b1CA0E37ec4E67B3EEFf93bc63 New one: https://arbiscan.deth.net/address/0x00000000005BBB0EF59571E58418F9a4357b68A0
The functions can be found inActionAddRemoveLiq
and ActionAddRemoveLiqV3
Old one:
/// @dev swaps SY to PT, then adds liquidity function _addLiquiditySingleSy( address receiver, address market, IPYieldToken YT, uint256 netSyIn, uint256 minLpOut, ApproxParams calldata guessPtReceivedFromSy ) internal returns (uint256 netLpOut, uint256 netSyFee) { MarketState memory state = IPMarket(market).readState(address(this)); // ... }
New one:
function addLiquiditySingleToken( address receiver, address market, uint256 minLpOut, ApproxParams calldata guessPtReceivedFromSy, TokenInput calldata input, LimitOrderData calldata limit ) external payable returns (uint256 netLpOut, uint256 netSyFee, uint256 netSyInterm) { (IStandardizedYield SY, , IPYieldToken YT) = IPMarket(market).readTokens(); netSyInterm = _mintSyFromToken(_entry_addLiquiditySingleSy(market, limit), address(SY), 1, input); (netLpOut, netSyFee) = _addLiquiditySingleSy( receiver, market, SY, YT, netSyInterm, minLpOut, guessPtReceivedFromSy, limit ); // ... } function _addLiquiditySingleSy( address receiver, address market, IStandardizedYield SY, IPYieldToken YT, uint256 netSyIn, uint256 minLpOut, ApproxParams calldata guessPtReceivedFromSy, LimitOrderData calldata limit ) internal returns (uint256 netLpOut, uint256 netSyFee) { uint256 netSyLeft = netSyIn; uint256 netPtReceived; if (!_isEmptyLimit(limit)) { (netSyLeft, netPtReceived, netSyFee, ) = _fillLimit(market, SY, netSyLeft, limit); _transferOut(address(SY), market, netSyLeft); } (uint256 netPtOutMarket, , ) = _readMarket(market).approxSwapSyToAddLiquidity( YT.newIndex(), netSyLeft, netPtReceived, block.timestamp, guessPtReceivedFromSy ); // ... }
_readMarket()
comes from ActionBase
, here's it's definition:
function _readMarket(address market) internal view returns (MarketState memory) { return IPMarket(market).readState(address(this)); }
You can see that both call readState()
from the underlying Pendle market. Here you can find all market deployments: https://docs.pendle.finance/Developers/Deployments/Arbitrum#markets
I'll use the first one for the example. Arbiscan: https://arbiscan.io/address/0x7D49E5Adc0EAAD9C027857767638613253eF125f Deth.net: https://arbiscan.deth.net/address/0x7D49E5Adc0EAAD9C027857767638613253eF125f
If you look at the definion of readState()
, you'll see the following:
function readState(address router) public view returns (MarketState memory market) { market.totalPt = _storage.totalPt; market.totalSy = _storage.totalSy; market.totalLp = totalSupply().Int(); (market.treasury, market.lnFeeRateRoot, market.reserveFeePercent) = IPMarketFactory( factory ).getMarketConfig(router); market.scalarRoot = scalarRoot; market.expiry = expiry; market.lastLnImpliedRate = _storage.lastLnImpliedRate; }
readState()
on all markets reaches out to the factory contract it was deployed with to grab the market config. And there are two versions: MarketFactory
and MarketFactoryV3
. Again, both can be found in the docs but here are the links:
MarketFactory: https://arbiscan.io/address/0xf5a7De2D276dbda3EEf1b62A9E718EFf4d29dDC8 MarketFactory V3: https://arbiscan.io/address/0x2FCb47B58350cD377f94d3821e7373Df60bD9Ced
You can take a market from the docs and query Market::isValidMarket()
. Using the first one, the old factory will return true whereas the new one, false.
So basically what this all means is that the protocol won't be able to use new markets. Pendle can start phasing out the old ones and migrating them over. The fix is rather easy on the protocol's end, they just need to account for the newly added parameter and can support both, if they wish, using a conditional check.
#8 - trust1995
2024-03-27T13:18:03Z
@vonMangoldt can you confirm the warden's claims around your intentions?
#9 - 0xNentoR
2024-03-29T05:38:48Z
@trust1995 Still no response from the sponsor here
#10 - trust1995
2024-03-29T07:55:22Z
Without sponsor's take the warden's claim that V3 should be compatible with the design is accepted.
#11 - c4-judge
2024-03-29T07:55:26Z
trust1995 marked the issue as satisfactory
#12 - c4-judge
2024-03-29T08:12:09Z
trust1995 marked the issue as selected for report
#13 - thebrittfactor
2024-04-29T14:35:32Z
For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.
2444.3801 USDC - $2,444.38
https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L98-L130 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L502-L524 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L89-L95
Random actors, malicious or not, can DOS the Pendle power farm vault by sending rewards to it through PendlePowerFarmToken::addCompoundRewards()
or PendlePowerFarmController::exchangeRewardsForCompoundingWithIncentive()
. This results in all users being unable to access their positions or open new ones for a set amount of time imposed by PendlePowerFarmToken::_validateSharePriceGrowth()
.
The PendlePowerFarmToken
has a mechanism in place that protects the vault from people looping and increasing the share price:
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(); } }
This is a private function invoked from the syncSupply()
modifier, which is used for the following functions:
manualSync()
addCompoundRewards()
depositExactAmount()
withdrawExactShares()
withdrawExactAmount()
The entry point of this exploit is addCompoundRewards()
:
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 ); }
We can see that it allows users to donate their tokens to the vault to increase the totalLpAssetsToDistribute
. This is then distributed among holders with subsequent calls to the aforementioned functions.
This is the code for the syncSupply()
modifier:
modifier syncSupply() { _triggerIndexUpdate(); _overWriteCheck(); _syncSupply(); _updateRewards(); _setLastInteraction(); _increaseCardinalityNext(); uint256 sharePriceBefore = _getSharePrice(); _; _validateSharePriceGrowth( _validateSharePrice( sharePriceBefore ) ); }
The problem here is that even though addCompoundRewards()
calls it, the rewards added do not affect the share price immediately. It's still the previously deposited amount. So, the condition _sharePriceNow > maximum
holds true at the time someone calls addCompoundRewards()
but causes a revert with subsequent calls to functions dependent on the modifier until enough time passes for the condition to hold true again.
Coded POC (PendlePowerFarmControllerBase.t.sol
):
function testDOSVault() public normalSetup(true) { (IERC20 tokenReceived, uint256 balanceReceived) = _getTokensToPlayWith(CRVUSD_PENDLE_28MAR_2024, crvUsdMar2024LP_WHALE); (uint256 depositAmount, IPendlePowerFarmToken derivativeToken) = _prepareDeposit(CRVUSD_PENDLE_28MAR_2024, tokenReceived, balanceReceived); address alice = makeAddr("alice"); address bob = makeAddr("bob"); address charlie = makeAddr("charlie"); IERC20 pendleLpToken = IERC20(CRVUSD_PENDLE_28MAR_2024); pendleLpToken.transfer(alice, 1.3e18); pendleLpToken.transfer(bob, 2e18); pendleLpToken.transfer(charlie, 1e18); vm.startPrank(alice); pendleLpToken.approve(address(derivativeToken), 1.3e18); derivativeToken.depositExactAmount(1.3e18); vm.stopPrank(); vm.startPrank(bob); pendleLpToken.approve(address(derivativeToken), 2e18); derivativeToken.addCompoundRewards(2e18); vm.stopPrank(); // DOS happens once timestamp changes vm.warp(block.timestamp + 1 seconds); // Charlie cannot deposit vm.startPrank(charlie); pendleLpToken.approve(address(derivativeToken), 1e18); vm.expectRevert(); // InvalidSharePriceGrowth derivativeToken.depositExactAmount(1 ether); vm.stopPrank(); // Alice cannot withdraw vm.startPrank(alice); vm.expectRevert(); // InvalidSharePriceGrowth derivativeToken.withdrawExactShares(1e18); vm.stopPrank(); // After 8 weeks, transactions still fail since _sharePriceNow is still greater than maximum (look at PendlePowerFarmToken::__validateSharePriceGrowth()) vm.warp(block.timestamp + 8 weeks); // Alice still cannot withdraw vm.startPrank(alice); vm.expectRevert(); // InvalidSharePriceGrowth derivativeToken.withdrawExactShares(1e18); vm.stopPrank(); // From this point onwards, maximum > _sharePriceNow vm.warp(block.timestamp + 9 weeks); // Charlie can now deposit vm.startPrank(charlie); pendleLpToken.approve(address(derivativeToken), 1e18); derivativeToken.depositExactAmount(1 ether); vm.stopPrank(); // Alice can now deposit vm.startPrank(alice); derivativeToken.withdrawExactShares(1e18); vm.stopPrank(); }
Manual Review
I recommend turning the deposited rewards into shares at the time of calling addCompoundRewards()
, so we can get _validateSharePriceGrowth()
to validate it immediately and revert on the spot if needed. This will prevent the DOS and share price growth manipulation.
DoS
#0 - c4-pre-sort
2024-03-17T15:39:05Z
GalloDaSballo marked the issue as duplicate of #88
#1 - c4-pre-sort
2024-03-17T15:39:14Z
GalloDaSballo marked the issue as high quality report
#2 - c4-judge
2024-03-26T17:02:48Z
trust1995 marked the issue as selected for report
#3 - c4-judge
2024-03-26T17:03:17Z
trust1995 changed the severity to 3 (High Risk)
#4 - trust1995
2024-03-26T17:03:41Z
High is reasonable for temporary freeze of funds.
#5 - vm06007
2024-03-27T14:55:06Z
High is reasonable for temporary freeze of funds.
That is literally definition of a medium - temporary freeze of the funds. High is permanent freeze of funds. So dismiss the high.
#6 - vm06007
2024-03-27T14:56:47Z
https://docs.code4rena.com/awarding/judging-criteria/severity-categorization
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
3 — High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals
#7 - trust1995
2024-03-27T16:23:25Z
This is on the upper end of temporary FoF attacks, it also lasts for an extended duration. I would rule High on much weaker versions of this finding, not even close to downgrade-worthy.
#8 - 0xCiphky
2024-03-27T20:33:47Z
Hey @trust1995 This issue and #125 are the same too.
#9 - GalloDaSballo
2024-04-15T13:00:36Z
Due to a security check on LP value growth, a donation can cause the temporary inability to withdraw from a Farm Contract
Alex: Facts: The Dos has a cost, that grows with supply The Dos can be performed permissionlessly, at any time
I'm unclear as to whether this is tied to liquidation risk, which should raise the severity
Hickuphh3: from what i see, it shouldn't affect liquidations or protocol health, only causing forced hodl with guaranteed returns. hence leaning towards M more than H
Alex: Facts: Information contained in the report mentions exclusively an inability to withdraw (funds stuck) Other facts above still apply
I agree that Medium Severity seems the most appropriate because: More supply = higher cost Dos is temporary Unclear / Missing usage to DOS a key feature
LSDan: I'm also of the opinion that medium is more appropriate here.
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements. Assets are not at direct risk. The minute the DOS stops the assets are once again available Further the cost to DOS the platform is not trivial. As you both pointed out, the more assets being frozen, the higher the cost. and the attacker is donating to the users they are DOSing, leaving the user inconvenienced, but richer at the attacker's expense.
The severity is downgraded to Medium unanimously
Had the Report shown a way to weaponize this to halt liquidations, we would have had a easier time pushing for a higher severity.
However, given the Report only limiting itself to a temporary inability to withdraw, with no loss of principal, the decision was straightforward.
#10 - trust1995
2024-04-15T13:43:40Z
Judge's response
#11 - thebrittfactor
2024-04-29T14:36:06Z
For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.
2444.3801 USDC - $2,444.38
https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L502-L524 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmController.sol#L224-L229 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmTokenFactory.sol#L35-L109 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L81-L130
A malicious actor can DOS a Pendle farm vault immediately after it has been deployed by depositing a small amount of wei through PendlePowerFarmToken::addCompoundRewards()
. Once performed, the governance won't be able to re-deploy at a different address due to a different issue where the salt used for create2
depends on the underlying Pendle market's address and would result in the same affected address being generated.
The governance (master) holds the authority to deploy power farms for particular Pendle markets whenever necessary. This is made possible through the PendlePowerFarmController::addPendleMarket()
function.
Here's the part of it we're interested in:
function addPendleMarket( address _pendleMarket, string memory _tokenName, string memory _symbolName, uint16 _maxCardinality ) external onlyMaster { if (pendleChildAddress[_pendleMarket] > ZERO_ADDRESS) { revert AlreadySet(); } address pendleChild = PENDLE_POWER_FARM_TOKEN_FACTORY.deploy( _pendleMarket, _tokenName, _symbolName, _maxCardinality ); pendleChildAddress[_pendleMarket] = pendleChild; // ... }
This will call into PendlePowerFarmTokenFactory
and deploy a new instance of PendlePowerFarmToken
using the minimal proxy pattern.
function deploy( address _underlyingPendleMarket, string memory _tokenName, string memory _symbolName, uint16 _maxCardinality ) external returns (address) { if (msg.sender != PENDLE_POWER_FARM_CONTROLLER) { revert DeployForbidden(); } return _clone( _underlyingPendleMarket, _tokenName, _symbolName, _maxCardinality ); } function _clone( address _underlyingPendleMarket, string memory _tokenName, string memory _symbolName, uint16 _maxCardinality ) private returns (address pendlePowerFarmTokenAddress) { bytes32 salt = keccak256( abi.encodePacked( _underlyingPendleMarket ) ); // (deployment code) ... PendlePowerFarmToken(pendlePowerFarmTokenAddress).initialize( _underlyingPendleMarket, PENDLE_POWER_FARM_CONTROLLER, _tokenName, _symbolName, _maxCardinality ); }
And here's the last piece of the puzzle before the farm is ready, PendlePowerFarmToken::initialize()
:
function initialize( address _underlyingPendleMarket, address _pendleController, string memory _tokenName, string memory _symbolName, uint16 _maxCardinality ) external { if (address(PENDLE_MARKET) != address(0)) { revert AlreadyInitialized(); } PENDLE_MARKET = IPendleMarket( _underlyingPendleMarket ); if (PENDLE_MARKET.isExpired() == true) { revert MarketExpired(); } PENDLE_CONTROLLER = IPendleController( _pendleController ); MAX_CARDINALITY = _maxCardinality; _name = _tokenName; _symbol = _symbolName; PENDLE_POWER_FARM_CONTROLLER = _pendleController; UNDERLYING_PENDLE_MARKET = _underlyingPendleMarket; ( address pendleSyAddress, , ) = PENDLE_MARKET.readTokens(); PENDLE_SY = IPendleSy( pendleSyAddress ); _decimals = PENDLE_SY.decimals(); lastInteraction = block.timestamp; _totalSupply = 1; underlyingLpAssetsCurrent = 1; mintFee = 3000; INITIAL_TIME_STAMP = block.timestamp; }
As we can see, nowhere in the process do we provide an initial asset supply for the newly created power farm.
The power farm uses the following algorithm for calculating the share price (from PendlePowerFarmToken
):
function previewDistribution() public view returns (uint256) { if (totalLpAssetsToDistribute == 0) { return 0; } if (block.timestamp == lastInteraction) { return 0; } if (totalLpAssetsToDistribute < ONE_WEEK) { return totalLpAssetsToDistribute; } uint256 currentRate = totalLpAssetsToDistribute / ONE_WEEK; uint256 additonalAssets = currentRate * (block.timestamp - lastInteraction); if (additonalAssets > totalLpAssetsToDistribute) { return totalLpAssetsToDistribute; } return additonalAssets; } function previewUnderlyingLpAssets() public view returns (uint256) { return previewDistribution() + underlyingLpAssetsCurrent; } function _getSharePrice() private view returns (uint256) { return previewUnderlyingLpAssets() * PRECISION_FACTOR_E18 / totalSupply(); }
An attacker could call PendlePowerFarmToken::addCompoundRewards()
and deflate the share price. This would cause a DOS because of the following code:
modifier syncSupply() { // ... _; _validateSharePriceGrowth( _validateSharePrice( sharePriceBefore ) ); } function _validateSharePrice( uint256 _sharePriceBefore ) private view returns (uint256) { uint256 sharePricenNow = _getSharePrice(); if (sharePricenNow < _sharePriceBefore) { revert InvalidSharePrice(); } return sharePricenNow; } 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(); } }
Bumping _sharePriceNow
through the donation without any underlying assets will cause a long-term DOS on the vault. Afterward, the governance won't be able to re-deploy a power farm for the same Pendle market because the salt is hard-coded inside PendlePowerFarmTokenFactory::_clone()
.
Here's what the exploit path would look like:
PendlePowerFarmToken::addCompoundRewards()
syncSupply
become DOSed until maximum
is again greater than _sharePriceNow
.create2
would yield the same address. So, this Pendle market is essentially rendered unusable in the context of this protocol.Coded POC (PendlePowerFarmControllerBase.t.sol
):
function testFail_DOSAfterMarketDeployment() public normalSetup(true) { (IERC20 tokenReceived, uint256 balanceReceived) = _getTokensToPlayWith(CRVUSD_PENDLE_28MAR_2024, crvUsdMar2024LP_WHALE); (uint256 depositAmount, IPendlePowerFarmToken derivativeToken) = _prepareDeposit(CRVUSD_PENDLE_28MAR_2024, tokenReceived, balanceReceived); address alice = makeAddr("alice"); tokenReceived.transfer(alice, 10e18); // Malicious actor deposits a reward right after deployment of market // (bigger compound reward => longer DOS) tokenReceived.approve(address(derivativeToken), 100_000 wei); derivativeToken.addCompoundRewards(100_000 wei); // Let some time pass vm.warp(block.timestamp + 40 weeks); // After 40 weeks, Vault is still under DOS and Alice cannot deposit vm.startPrank(alice); tokenReceived.approve(address(derivativeToken), tokenReceived.balanceOf(alice)); // reverts with InvalidSharePriceGrowth derivativeToken.depositExactAmount(tokenReceived.balanceOf(alice)); vm.stopPrank(); }
Logs from the depositExactAmount()
call:
_sharePriceNow: 100001000000000900019000 timeDifference: 24192000 maximum: 8671232876696704000
Manual Review
Make the salt user supplied in addPendleMarket()
and add a mechanism to remove the affected market from PendlePowerFarmController::activePendleMarkets[]
.
Add an initial supply of the underlying asset with each deployment. This will make the attack harder to pull off. The risk will still be there but potential denial of services through it will be much shorter in duration and unprofitable for the attacker. You can also refer to the following article to get a better idea of the vault inflation attack and case studies on how it's tackled in other protocols: https://blog.openzeppelin.com/a-novel-defense-against-erc4626-inflation-attacks
DoS
#0 - c4-pre-sort
2024-03-16T15:57:38Z
GalloDaSballo marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-17T15:39:08Z
GalloDaSballo marked the issue as duplicate of #88
#2 - c4-pre-sort
2024-03-17T15:39:16Z
GalloDaSballo marked the issue as high quality report
#3 - c4-judge
2024-03-26T18:59:15Z
trust1995 marked the issue as satisfactory
#4 - c4-judge
2024-03-27T14:32:47Z
trust1995 marked the issue as not a duplicate
#5 - c4-judge
2024-03-27T14:32:58Z
trust1995 marked the issue as duplicate of #271
#6 - c4-judge
2024-03-27T14:34:53Z
trust1995 marked the issue as selected for report
#7 - c4-judge
2024-03-27T20:49:34Z
trust1995 changed the severity to 2 (Med Risk)
#8 - c4-judge
2024-03-27T20:50:17Z
trust1995 marked the issue as duplicate of #123
#9 - c4-judge
2024-03-27T20:51:45Z
trust1995 changed the severity to 3 (High Risk)
#10 - c4-judge
2024-03-27T20:59:13Z
trust1995 marked the issue as not selected for report
#11 - 0xNentoR
2024-03-28T04:51:27Z
@trust1995 The primary and this one are both written by me. I'd like to ask for this one to get grouped under #271, as it was initially.
#12 - trust1995
2024-03-28T16:45:59Z
@trust1995 The primary and this one are both written by me. I'd like to ask for this one to get grouped under #271, as it was initially.
The groupings are in line with the root cause of each group and will remain as such.
🌟 Selected for report: Dup1337
Also found by: 0x11singh99, NentoR, cheatc0d3, nonseodion, serial-coder, shealtielanz
159.9366 USDC - $159.94
PendlePowerManager::receive()
doesn't forward ETH as specified in natspec/** * @dev Standard receive functions forwarding * directly send ETH to the master address. */ receive() external payable { emit ETHReceived( msg.value, msg.sender ); }
This leads to funds sent to this contract becoming inaccessible.
PendlePowerFarmTokenFactory::_clone()
.In PendlePowerFarmTokenFactory::_clone()
, there's a small risk that the create2
might create an address that already exists. Since the salt is not sender controlled, there'll be no way to deploy the particular Pendle market. Make the salt controllable by the deployer (in PendlePowerFarmController::addPendleMarket()
).
bytes32 salt = keccak256( abi.encodePacked( _underlyingPendleMarket ) );
PendleLpOracle
, PtOracleDerivative
, and PtOraclePure
Chainlink feeds in PendleLpOracle
, PtOracleDerivative
, and PtOraclePure
are immutable and cannot be changed beyond deployment. There is slight risk to it because Chainlink could decide to deprecate a particular feed and deploy a new one at a different address. The contracts have no way to change to that feed unless they all get re-deployed. It's recommended to add a way for the master address to change the feeds.
https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/DerivativeOracles/PendleLpOracle.sol#L63 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/DerivativeOracles/PtOracleDerivative.sol#L49-L53 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/DerivativeOracles/PtOraclePure.sol#L47
The codebase has typos and non-existent words in various places that should be fixed:
<details> <summary>Click here to expand the list</summary> <p>./contracts/MainHelper.sol:12:38 - Unknown word (devison)</p> <p>./contracts/MainHelper.sol:50:37 - Unknown word (devison)</p> <p>./contracts/MainHelper.sol:74:37 - Unknown word (devison)</p> <p>./contracts/MainHelper.sol:79:14 - Unknown word (cashout)</p> <p>./contracts/MainHelper.sol:87:17 - Unknown word (cashout)</p> <p>./contracts/MainHelper.sol:93:15 - Unknown word (cashout)</p> <p>./contracts/MainHelper.sol:109:36 - Unknown word (devison)</p> <p>./contracts/MainHelper.sol:348:36 - Unknown word (reasoanbly)</p> <p>./contracts/MainHelper.sol:369:25 - Unknown word (entrancy)</p> <p>./contracts/MainHelper.sol:580:22 - Unknown word (increas)</p> <p>./contracts/MainHelper.sol:581:28 - Unknown word (postion) fix: (position)</p> <p>./contracts/MainHelper.sol:596:28 - Unknown word (postion) fix: (position)</p> <p>./contracts/MainHelper.sol:645:14 - Unknown word (keccak)</p> <p>./contracts/MainHelper.sol:655:16 - Unknown word (keccak)</p> <p>./contracts/MainHelper.sol:731:11 - Unknown word (postion) fix: (position)</p> <p>./contracts/MainHelper.sol:792:11 - Unknown word (postion) fix: (position)</p> <p>./contracts/MainHelper.sol:812:11 - Unknown word (uncollateralized)</p> <p>./contracts/MainHelper.sol:814:16 - Unknown word (Uncollateralized)</p> <p>./contracts/MainHelper.sol:822:54 - Unknown word (Collateralized)</p> <p>./contracts/MainHelper.sol:874:17 - Unknown word (intervall)</p> <p>./contracts/MainHelper.sol:907:36 - Unknown word (maximise)</p> <p>./contracts/MainHelper.sol:1004:8 - Unknown word (unorganic)</p> <p>./contracts/MainHelper.sol:1076:27 - Unknown word (decresing)</p> <p>./contracts/MainHelper.sol:1120:31 - Unknown word (decresing)</p> <p>./contracts/PoolManager.sol:301:40 - Unknown word (NORMALISATION)</p> <p>./contracts/PositionNFTs.sol:390:22 - Unknown word (bstr)</p> <p>./contracts/PositionNFTs.sol:398:13 - Unknown word (bstr)</p> <p>./contracts/PositionNFTs.sol:407:13 - Unknown word (bstr)</p> <p>./contracts/WiseCore.sol:187:25 - Unknown word (postion) fix: (position)</p> <p>./contracts/WiseCore.sol:425:8 - Unknown word (caluclating)</p> <p>./contracts/WiseCore.sol:472:17 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:474:35 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:476:13 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:487:17 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:501:35 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:539:46 - Unknown word (functionallity)</p> <p>./contracts/WiseCore.sol:560:35 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:565:31 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:565:69 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:572:31 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:572:64 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:592:55 - Unknown word (Collateralized)</p> <p>./contracts/WiseCore.sol:637:26 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseCore.sol:661:26 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseCore.sol:682:26 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLending.sol:32:26 - Unknown word (nside)</p> <p>./contracts/WiseLending.sol:342:47 - Unknown word (Collateralized)</p> <p>./contracts/WiseLending.sol:369:47 - Unknown word (Collateralized)</p> <p>./contracts/WiseLending.sol:371:28 - Unknown word (Uncollateralized)</p> <p>./contracts/WiseLending.sol:439:43 - Unknown word (collateralized)</p> <p>./contracts/WiseLending.sol:458:43 - Unknown word (collateralized)</p> <p>./contracts/WiseLending.sol:1243:37 - Unknown word (postion) fix: (position)</p> <p>./contracts/WiseLending.sol:1270:21 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLending.sol:1312:34 - Unknown word (liqudaiton)</p> <p>./contracts/WiseLending.sol:1337:21 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLending.sol:1354:25 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLending.sol:1410:13 - Unknown word (Mananger)</p> <p>./contracts/WiseLending.sol:1534:35 - Unknown word (cashout)</p> <p>./contracts/WiseLending.sol:1585:42 - Unknown word (reservating)</p> <p>./contracts/WiseLendingDeclaration.sol:182:8 - Unknown word (Orace)</p> <p>./contracts/WiseLendingDeclaration.sol:193:16 - Unknown word (Collateralized)</p> <p>./contracts/WiseLendingDeclaration.sol:243:24 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLendingDeclaration.sol:305:8 - Unknown word (Norming)</p> <p>./contracts/WiseLendingDeclaration.sol:307:31 - Unknown word (NORMALISATION)</p> <p>./contracts/WiseLendingDeclaration.sol:317:13 - Unknown word (THRESHHOLD) fix: (THRESHOLD)</p> </details>onlyMaster
modifier to PendlePowerFarmController::claimVoteRewards()
and PendlePowerFarmController::claimArb()
PendlePowerFarmController::claimVoteRewards()
and PendlePowerFarmController::claimArb()
should apply the onlyMaster
modifier since they deal with sending funds to that address.
https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmController.sol#L581-L598 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmController.sol#L431-L449
#0 - c4-pre-sort
2024-03-17T10:46:02Z
GalloDaSballo marked the issue as insufficient quality report
#1 - trust1995
2024-03-26T10:51:26Z
Without promoted L-1, a little under the bar for B rating.
#2 - c4-judge
2024-03-26T10:51:29Z
trust1995 marked the issue as grade-c
#3 - 0xNentoR
2024-03-27T12:19:25Z
@trust1995 Just wanted to ask whether the grading remains the same after #164 and #129 got downgraded?
#4 - trust1995
2024-03-27T13:36:52Z
Fair enough, with those two it achieves grade-B again.
#5 - c4-judge
2024-03-27T13:36:56Z
trust1995 marked the issue as grade-b