Open code423n4 opened 1 year ago
kirk-baird marked the issue as duplicate of #3
vaporkane marked the issue as disagree with severity
kirk-baird changed the severity to 2 (Med Risk)
kirk-baird marked the issue as not a duplicate
kirk-baird marked the issue as primary issue
kirk-baird marked the issue as selected for report
vaporkane marked the issue as sponsor confirmed
vaporkane marked the issue as sponsor acknowledged
Lines of code
https://github.com/code-423n4/2023-05-xeth/blob/d86fe0a9959c2b43c62716240d981ae95224e49e/src/wxETH.sol#L212
Vulnerability details
Impact
The first staker can inflate the exchange rate by transferring tokens directly to the contract such that subsequent stakers get minted zero wxETH. Their stake can then be unstaked by the first staker, together with their own first stake and inflation investment. Effectively, the first staker can steal the second stake. The attack exploits the rounding error in tokens minted, caused by the inflation. This vulnerability has a more general impact than just described, in that stakes might be partially stolen and that it also affects further stakers down the line, but the below example demonstrates the basic case.
Proof of Concept
Alice is the first staker, so
totalSupply() == 0
. She stakes1
xETH by callingstake(1)
.and gets minted
previewStake(1)
which thus is
1 * BASE_UNIT / exchangeRate()
, whereso this works out to
1 * BASE_UNIT / INITIAL_EXCHANGE_RATE
. This is what Alice is minted and also the newtotalSupply()
.Bob is about to stake
b
xETH (which Alice can frontrun).Before Bob's stake, Alice transfers
a
xETH directly to the wxETH contract. The xETH balance of wxETH is now1 + a + lockedFunds
;1
from Alice's stake,a
from her token transfer and whateverlockedFunds
may have been added.Now Bob stakes his
b
xETH by callingstake(b)
. By reference to the above code, this mints himpreviewStake(b)
, which isn * BASE_UNIT / exchangeRate()
. This timetotalSupply() > 0
soexchangeRate()
this time is(1 + a) * BASE_UNIT / (1 * BASE_UNIT / INITIAL_EXCHANGE_RATE)
, which simplifies to(1 + a) * INITIAL_EXCHANGE_RATE
. So Bob gets mintedb * BASE_UNIT / ((1 + a) * INITIAL_EXCHANGE_RATE)
. This may clearly be< 1
which is therefore rounded down to0
in Solidity.BASE_UNIT
andINITIAL_EXCHANGE_RATE
are both set to1e18
, so Bob is mintedb/(1 + a)
tokens. In that case we simply have that ifa >= b
then Bob is minted0
wxETH.Now note in
stake()
above that whenever a non-zero amount is staked, those funds are transferred to the contract (even if nothing is minted).Bob cannot unstake anything with
0
wxETH, so he has lost hisb
xETH.totalSupply()
is now1
and the xETH balance of wxETH is1 + a + b + lockedFunds
.exchangeRate()
is therefore(1 + a + b) * BASE_UNIT / 1
.Alice owns the
1
wxETH ever minted so if she unstakes itshe gets
previewUnstake(1)
which thus is
1 * (1 + a + b) * BASE_UNIT / BASE_UNIT == (1 + a + b)
.That is, Alice gets both hers and Bob's stakes.
Recommended Mitigation Steps
Do not use
xETH.balanceOf(address(this))
when calculating the funds staked. Account only for funds transferred throughstake()
by keeping an internal accounting of the balance. Consider implementing a sweep function to access any unaccounted funds, or use them as locked funds if free (but unlikely) funds would be accepted as such.Assessed type
ERC4626