Platform: Code4rena
Start Date: 04/01/2022
Pot Size: $25,000 USDC
Total HM: 3
Participants: 40
Period: 3 days
Judge: Ivo Georgiev
Total Solo HM: 1
Id: 75
League: ETH
Rank: 4/40
Findings: 3
Award: $939.38
π Selected for report: 0
π Solo Findings: 0
768.967 USDC - $768.97
cmichel
The XDEFIDistribution.updateDistribution
function does not have re-entrancy locks and can be called from lock -> _lock -> safeMint
which gives control back to the msg.sender
if it is a contract by performing msg.sender.onERC721Received(...)
.
As the totalDepositedXDEFI
is not yet updated with the locked amount at the time of calling safeMint
, re-entering into updateDistribution
will count the deposited amount as rewards and distribute them to all previous holders by increasing _pointsPerUnit
.
For simplicity, assume there's a zero-second locking duration and all these actions can be done in a single transaction. It also leads to loss of funds in the case where there's no zero-second locking duration.
lock
and has an onERC721Received
receiver function. The _lock
's safeMint
will call this function with NFT_2 and the attacker calls updateDistribution
which distributes the locked amount as a reward to all previous stakers, including themself as part of owning NFT_1.Funds can be stolen from the contract.
_mint
instead of _safeMint
.updateDistribution
and migrate
.#0 - deluca-mike
2022-01-09T06:00:30Z
Valid, and duplicate of #25
π Selected for report: leastwood
Also found by: MaCree, WatchPug, cmichel, egjlmn1, kenzo, onewayfunction, sirhashalot
140.1442 USDC - $140.14
cmichel
The XDEFIDistribution.merge
function burns tokens, which decreases the ERC721Enumerable.totalSupply()
and the _generateNewTokenId
function returns a token ID as the concatenation of the points and totalSupply() + 1:
function _generateNewTokenId(uint256 points_) internal view returns (uint256 tokenId_) { // Points is capped at 128 bits (max supply of XDEFI for 10 years locked), total supply of NFTs is capped at 128 bits. return (points_ << uint256(128)) + uint128(totalSupply() + 1); }
If a user is unlucky it can happen that there is a collision and they cannot merge their tokens as the _safeMint
fails when trying to mint an already existing token ID.
totalSupply() = N
and the second last token that was minted has 300 points. Its token id is: tokenId = (N - 1) << 128 | 300
N - 2
. The mint uses totalSupply() + 1 = N - 2 + 1 = N - 1
. The merged token ID is now also tokenId = (N-1) << 128 | 300
. The merge fails as in ERC721._mint
as this token already existsToken merges can fail
You probably don't want totalSupply()
to ever decrease.
Either don't call _burn
and use a different way of invalidating these tokens.
Or just use a counter for the lower bits of tokenId
instead of totalSupply() + 1
.
#0 - deluca-mike
2022-01-09T05:04:51Z
Valid and duplicate of #17
π Selected for report: leastwood
Also found by: MaCree, WatchPug, cmichel, egjlmn1, kenzo, onewayfunction, sirhashalot
140.1442 USDC - $140.14
cmichel
The XDEFIDistribution._lock_
function mints a new token and the _generateNewTokenId
function returns a token ID as the concatenation of the points and totalSupply() + 1:
function _generateNewTokenId(uint256 points_) internal view returns (uint256 tokenId_) { // Points is capped at 128 bits (max supply of XDEFI for 10 years locked), total supply of NFTs is capped at 128 bits. return (points_ << uint256(128)) + uint128(totalSupply() + 1); }
Upon observing a lock
call, an attacker can frontrun the transaction and forge a lock
themselves that will use the same tokenId as the new call.
Preattack step: Attacker prepares many tokens that can be used to reduce the totalSupply
by burning them in merge
. The attacker can use the shortest lock duration and an amount of 1
. This step can be prepared at any time and it can be assumed that these tokens are already unlocked at the time of attack.
Attack step
totalSupply() = N
.lock
transaction that would use P
points.lock
transaction with their own transaction that does the following.:
lock
to create a new token that also locks P
points (at the lowest duration). The token will get tokenId = (N + 1) << 128 | P
. The new totalSupply() = N + 1
- 2 + 1 = -1
. The new totalSupply() = (N + 1) - 1 = N
.tokenId = (N + 1) << 128 | P
. The transaction fails in _safeMint -> mint
as the token already existsAll locks can be denied which is the main purpose of the contract. An attacker can prevent anyone else from staking tokens and therefore keep all rewards to themselves. While the attacker needs to potentially transfer a large amount of xDEFI to reach the desired points of the victim transaction, if there is a very low lock duration, they can just keep unlocking their tokens again and the capital requirement is low.
Keep the lowest lock duration high (in the magnitude of days) to require a large capital requirement to consistently perform this attack.
Alternatively, just use a tokenId that is collision-resistant, for example, a simple counter instead of using totalSupply()
which can decrease.
#0 - deluca-mike
2022-01-09T05:04:34Z
Valid and duplicate of #17, but not high-risk since no funds lost, and a lock for a very specific amount and duration would be temporarily denied under specific transient conditions.
#1 - Ivshti
2022-01-16T00:42:50Z
agreed with the assessment as a duplicate, and agreed that at worst this is a nasty griefing attack but not high severity
30.2712 USDC - $30.27
cmichel
The XDEFIDistribution.updateDistribution
function needs to be called immediately after sending rewards to the contract.
Otherwise, users can frontrun it and still get a share of these transferred rewards even though they locked after they were sent - contradicting the desired functionality:
However, it is important that new locking of XDEFI results in positions that are only eligible for portions of future rewards, and do not result in the "stealing" of past rewards from existing locked position holders.
EOAs that can't atomically transfer + call updateDistribution
, or smart contracts that don't immediately call it, can be frontrun and users can optimize their reward per lock time.
Add an option for EOAs to atomically send funds and update the _pointsPerUnit
accumulator by adding a function that does both the transfer + updateDistribution
.
#0 - deluca-mike
2022-01-09T06:31:34Z
Technically valid, but this was expected, and we disagree with severity, as explained in the duplicate issue #30.
And specifically with this idea of front-running, that was also perfectly acceptable, and unavoidable. Just like dividends for an equity, if dividends are announced, anyone can jump into the stock before the "snapshot" (ex-dividend date) to claim rewards. Allowing a 0-duration lock (which is not enabled by default) is at XDEFI's discretion. Further, even if we disable 0-duration locks, people can still front-run a distribution. The idea is the XDEFI will enable durations that make sense. Obviously the lower the duration, the easier it is for people to front-run reward distributions without long commitments. This was expected, and frankly, obvious.