Platform: Code4rena
Start Date: 09/12/2021
Pot Size: $50,000 USDC
Total HM: 19
Participants: 21
Period: 7 days
Judge: 0xean
Total Solo HM: 14
Id: 61
League: ETH
Rank: 3/21
Findings: 4
Award: $6,572.11
🌟 Selected for report: 3
🚀 Solo Findings: 2
leastwood
The AaveYield
contract provides users with the option to choose Aave as their strategy of choice to generate yield. Users will make deposits to this strategy via the SavingsAccount
contract. Upon deposit, shares are minted at a 1:1 exchange rate and the user receives an equivalent amount of aTokens
. As the strategy accrues yield, each user's shares increase in value in proportion to their claim on the underlying pool balance.
However, as shares are minted 1:1, any user who enters the pool after the accrual of yield, will therefore also have claim over the underlying rewards. Hence, malicious users could extract any yield by constantly entering and exiting the pool whenever the strategy is able to claim rewards.
Consider the following example:
AaveYield
contract by depositing 1000 tokens and in turn receives 1000 aTokens
and 1000 shares
at a 1:1 exchange rate. This is outlined in AaveYield._depositERC20
.aTokens
will return 1200 tokens to Alice.shares
as well.shares
and 2200 underlying tokens.As a result, Bob is able to extract yield from the pool without having to deposit their funds. By repeating this exploit, Bob can continue to extract yield generated by the protocol. Hence, this issue severely impacts how rewards are accrued for the protocol.
function getSharesForTokens(uint256 amount, address asset) external view override returns (uint256 shares) { shares = (amount.mul(1e18)).div(getTokensForShares(1e18, asset)); }
function _depositERC20(address asset, uint256 amount) internal returns (address aToken, uint256 sharesReceived) { aToken = liquidityToken(asset); uint256 aTokensBefore = IERC20(aToken).balanceOf(address(this)); address lendingPool = ILendingPoolAddressesProvider(lendingPoolAddressesProvider).getLendingPool(); //approve collateral to vault IERC20(asset).approve(lendingPool, 0); IERC20(asset).approve(lendingPool, amount); //lock collateral in vault AaveLendingPool(lendingPool).deposit(asset, amount, address(this), referralCode); sharesReceived = IERC20(aToken).balanceOf(address(this)).sub(aTokensBefore); }
https://github.com/code-423n4/2021-12-sublime/blob/main/contracts/SavingsAccount/SavingsAccount.sol#L130-L143 https://github.com/code-423n4/2021-12-sublime/blob/main/contracts/yield/AaveYield.sol#L192-L209 https://github.com/code-423n4/2021-12-sublime/blob/main/contracts/yield/AaveYield.sol#L290-L304
Manual code review Discussions with Ritik This issue was submitted late as I was still confirming some details with the sponsor after the contest had closed :/
The Sublime team has been considering implementing a wrapper for aTokens
such that tokens are not minted 1:1 but are instead in line with how the Yearn and Compound strategies operate. This should ensure that users who enter the strategy at a later date, mint an equivalent amount of shares equivalent to the exchange rate of 1:indexed interest.
#0 - ritik99
2021-12-27T09:49:12Z
Duplicate of #137
🌟 Selected for report: leastwood
2816.7538 USDC - $2,816.75
leastwood
If for whatever reason the Chainlink oracle returns a malformed price due to oracle manipulation or a malfunctioned price, the result will be passed onto users, causing unintended consequences as a result.
In the same time it's possible to construct mitigation mechanics for such cases, so user economics be affected by sustainable price movements only. As price outrages provide a substantial attack surface for the project it's worth adding some complexity to the implementation.
https://github.com/code-423n4/2021-12-sublime/blob/main/contracts/PriceOracle.sol#L149-L161
function getLatestPrice(address num, address den) external view override returns (uint256, uint256) { uint256 _price; uint256 _decimals; (_price, _decimals) = getChainlinkLatestPrice(num, den); if (_decimals != 0) { return (_price, _decimals); } (_price, _decimals) = getUniswapLatestPrice(num, den); if (_decimals != 0) { return (_price, _decimals); } revert("PriceOracle::getLatestPrice - Price Feed doesn't exist"); }
The above code outlines how prices are utilised regardless of their actual value (assuming it is always a non-zero value).
Manual code review.
Consider querying both the Chainlink oracle and Uniswap pool for latest prices, ensuring that these two values are within some upper/lower bounds of each other. It may also be useful to track historic values and ensure that there are no sharp changes in price. However, the first option provides a level of simplicity as UniswapV3's TWAP implementation is incredibly resistant to flash loan attacks. Hence, the main issue to address is a malfunctioning Chainlink oracle.
#0 - ritik99
2022-01-08T13:48:39Z
The described suggestion is fairly complex - besides the increase in code complexity, we'd also have to decide the bounds within which the Uniswap and Chainlink oracles should report prices that won't be trivial. We've also noted in the assumptions section of our contest repo that oracles are assumed to be accurate
#1 - 0xean
2022-01-21T01:00:29Z
" We expect these feeds to be fairly reliable." - Based on this quote, I am going to leave this open at the current risk level. These are valid changes that could significantly reduce the risk of the implementation and unintended liquidations.
Fairly reliable != 100% reliable
🌟 Selected for report: leastwood
2816.7538 USDC - $2,816.75
leastwood
The emergencyWithdraw
function is implemented in all yield sources to allow the onlyOwner
role to drain the contract's balance in case of emergency. The contract considers ETH as a zero address asset. However, there is a call made on _asset
which will revert if it is the zero address. As a result, ETH tokens can never be withdrawn from the NoYield
contract in the event of an emergency.
Consider the case where _asset == address(0)
. An external call is made to check the contract's token balance for the target _asset
. However, this call will revert as _asset
is the zero address. As a result, the onlyOwner
role will never be able to withdraw ETH tokens during an emergency.
function emergencyWithdraw(address _asset, address payable _wallet) external onlyOwner returns (uint256 received) { require(_wallet != address(0), 'cant burn'); uint256 amount = IERC20(_asset).balanceOf(address(this)); IERC20(_asset).safeTransfer(_wallet, received); received = amount; }
Affected function as per below: https://github.com/code-423n4/2021-12-sublime/blob/main/contracts/yield/NoYield.sol#L78-L83
Manual code review.
Consider handling the case where _asset
is the zero address, i.e. the asset to be withdrawn under emergency is the ETH token.
#0 - 0xean
2022-01-21T16:48:22Z
Upgrading to Sev 3 in line with #4 / #115 as this results in funds being stuck in the contract.
126.7539 USDC - $126.75
leastwood
PriceOracle.getChainlinkLatestPrice
is missing additional validation to ensure that the round is complete and has returned a valid/expected price. The getChainlinkLatestPrice
function improperly casts an int256 price
to uint256
without first checking the value. As a result, the variable may underflow and return an unexpected result, potentially causing further issues in other areas of the protocol that rely on this function.
https://github.com/code-423n4/2021-12-sublime/blob/main/contracts/PriceOracle.sol#L48-L94
function getChainlinkLatestPrice(address num, address den) public view returns (uint256, uint256) { PriceData memory _feedData1 = chainlinkFeedAddresses[num]; PriceData memory _feedData2 = chainlinkFeedAddresses[den]; if (_feedData1.oracle == address(0) || _feedData2.oracle == address(0)) { return (0, 0); } int256 price1; int256 price2; { uint80 roundID1; uint256 timeStamp1; uint80 answeredInRound1; ( roundID1, price1, , timeStamp1, answeredInRound1 ) = AggregatorV3Interface(_feedData1.oracle).latestRoundData(); if(timeStamp1 == 0 || answeredInRound1 < roundID1) { return (0, 0); } } { uint80 roundID2; uint256 timeStamp2; uint80 answeredInRound2; ( roundID2, price2, , timeStamp2, answeredInRound2 ) = AggregatorV3Interface(_feedData2.oracle).latestRoundData(); if(timeStamp2 == 0 || answeredInRound2 < roundID2) { return (0, 0); } } uint256 price = uint256(price1) .mul(10**_feedData2.decimals) .mul(10**30) .div(uint256(price2)) .div(10**_feedData1.decimals) .mul(10**decimals[den]) .div(10**decimals[num]); return (price, 30); }
Manual code review. Chainlink best practices.
Consider validating the output of latestRoundData
to match the following code snippet:
( uint80 roundID, int256 price, , uint256 updateTime, uint80 answeredInRound ) = AggregatorV3Interface(_feedData1.oracle).latestRoundData(); require( answeredInRound >= roundID, "Sublime::getEtherPrice: Chainlink Price Stale" ); require(price > 0, "Sublime::getEtherPrice: Chainlink Malfunction"); require(updateTime != 0, "Sublime::getEtherPrice: Incomplete round");
#0 - ritik99
2022-01-07T22:38:53Z
This issue seems to consider the possibility that the returned price might be non-positive. Such a scenario would be extremely unlikely, especially for popularly used assets, and even in such cases we expect the function to revert. Additionally, as noted in the contest repo assumptions the oracles are assumed to be reliable.
#1 - 0xean
2022-01-21T16:50:23Z
Downgrading to low risk, but believe the check to be valid to once again ensure that the oracle returns a sane variable.
leastwood
The scripts/deploy/deploy.ts
file outlines the deployment script used by the Sublime team. Some of the contracts deployed utilise the Transparent Upgradeable Proxy standard. This standard involves first deploying an implementation contract and later a proxy contract which uses the implementation contract as its logic. When users make calls to the proxy contract, the proxy contract will delegate call to the underlying implementation contract.
It is important to note that the proxy standard gives special privileges to an admin
account. These privileges include upgrading the proxy or transferring the admin
to a new account. This means, the implementation contract isn't vulnerable to ownership takeover and self destruct attack vectors.
The initialize
function is implemented in several contracts and aims to replace the role of the constructor
when deploying proxy contracts. It is vital that these proxy contracts are deployed and initialized in the same transaction to avoid any malicious frontrunning. However, scripts/deploy/deploy.ts
does not follow this pattern when deploying proxy contracts. As a result, a malicious attacker could monitor the Ethereum blockchain for bytecode that matches any of the affected contracts and frontrun the initialize
transaction to gain ownership of the contract. This can be repeated as a Denial Of Service (DOS) type of attack, effectively preventing Sublime's contract deployment, leading to unrecoverable gas expenses.
The deployment script deploys SavingsAccount
according to the following logic:
export async function createSavingsAccount(proxyAdmin: SignerWithAddress): Promise<SavingsAccount> { const deployHelper: DeployHelper = new DeployHelper(proxyAdmin); const savingsAccountLogic = await deployHelper.core.deploySavingsAccount(); const savingsAccountProxy: SublimeProxy = await deployHelper.helper.deploySublimeProxy(savingsAccountLogic.address, proxyAdmin.address); const savingsAccount: SavingsAccount = await deployHelper.core.getSavingsAccount(savingsAccountProxy.address); return savingsAccount; } export async function initSavingsAccount( savingsAccount: SavingsAccount, admin: SignerWithAddress, strategyRegistry: StrategyRegistry, creditLines: Address ) { await savingsAccount.connect(admin).initialize(admin.address, strategyRegistry.address, creditLines); }
The SavingsAccount
contract grants ownership and configures the contract according to the following code snippet:
function initialize( address _owner, address _strategyRegistry, address _creditLine ) external initializer { __Ownable_init(); super.transferOwnership(_owner); _updateCreditLine(_creditLine); _updateStrategyRegistry(_strategyRegistry); }
The contract is deployed and initialized in two separate transactions, making it prone to frontrunning my malicious actors.
https://github.com/code-423n4/2021-12-sublime/blob/main/scripts/deploy/deploy.ts https://github.com/code-423n4/2021-12-sublime/blob/main/utils/createEnv/savingsAccount.ts
Manual code review
It would be worthwhile to ensure the affected proxy contracts are deployed and initialized in the same transaction, or enforce that only the contract's deployer can call the initialize
function. This could be set in the proxy contracts constructor()
.
#0 - ritik99
2021-12-27T13:32:57Z
Duplicate of #106