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: 2/40
Findings: 4
Award: $1,735.56
π Selected for report: 1
π Solo Findings: 0
768.967 USDC - $768.97
onewayfunction
Anyone can steal XDEFI from the XDEFIDistribution
contract, thereby making the contract insolvent. In the process, they also make the updateDistribution()
function uncallable -- and thus make the value of _pointsPerUnit
unchangeable.
This comes with some risk to the attacker. The size of the risk is directly proportional to the smallest allowable lock duration.
When a user calls the lock()
function, XDEFI tokens are transferred into the XDEFIDistribution
contract, and then _lock()
is called.
The _lock()
function does two important accounting-related things: (a) it records the amount of XDEFI tokens transferred into the contract (by increasing totalDepositedXDEFI
) and (b) it records the user's Position
(which depends on the value of _pointsPerUnit
).
However, before the _lock()
function performs (a) and (b), it first hands over control of the flow of execution to the attacker (caller). It does this during the call to _safeMint()
. In particular, the _safeMint()
function calls _checkOnERC721Received
, which makes an unsafe call to the receiver of the newly minted token. Note that this unsafe external call is not a staticcall
, and so it can mutate state.
This unsafe external call happens after the contract has received the user's XDEFI
tokens, but before any other accounting is done (e.g., before totalDepositedXDEFI
has been updated). So an attacker can set the destination_
address of their call to the lock()
function to be a malicious contract with a malicious onERC721Received
function. The malicious onERC721Received
would call the updateDistribution()
function (which does not have the noReenter
modifier). When this happens, the updateDistribution()
function calls the _updateXDEFIBalance()
function, which will incorrectly add the attacker's XDEFI deposit to the distributableXDEFI
variable.
This results in the _pointsPerUnit
being incorrectly increased, which means anyone who locked
before this attacking tx will be able to withdraw more XDEFI tokens than they should be able to. This also means the contract is insolvent, and the last user(s) trying to unlock
will not be able to get all their XDEFI back. It also means that all future calls to updateDistribution()
will revert because the _updateXDEFIBalance
function will return a negative number, which will cause L147 to revert when trying to cast to a uint256
.
Since the updateDistribution()
function is the only function that can update the _pointsPerUnit
variable, this means the _pointsPerUnit
variable is effectively frozen. (It's possible to unfreeze all this by having an altruist send a bunch of XDEFI directly to the XDEFIDistribution
contract and then calling the updateDistribution
function, but I think that's unlikely to occur, as they get nothing in return for their sacrifice).
Tl;Dr: The attacker first makes one or more valid locks. When their duration has passed, they perform the above "malicious lock" attack, which lets their previous locks be unlocked to withdraw more XDEFI than they deserve. After the "malicious lock's" duration has passed, the attacker can withdraw their remaining lock (which will just result in them withdrawing the same amount of XDEFI that they put in during the "malicious lock").
The risk to the attacker is that after their malicious lock, the contract is insolvent. So if there is a run on the contract, the contract may be drained of all XDEFI before the attacker's "malicious lock" can be unlocked. This risk is proportional to the minimum allowable lock duration.
Instead of using the _safeMint
function in the _lock
and merge
functions, consider using the basic _mint
function, which doesn't make any unsafe external calls.
#0 - deluca-mike
2022-01-09T05:30:10Z
Valid and duplicate of #25
π Selected for report: leastwood
Also found by: MaCree, WatchPug, cmichel, egjlmn1, kenzo, onewayfunction, sirhashalot
140.1442 USDC - $140.14
onewayfunction
More than one historical lock-position may be represented by a single tokenId
, violating the "uniqueness" property claimed by the xdefi-distribution repo's README.md.
The README.md says:
The NFT's score is embedded in the
tokenId
, so the chain enforces it (first/leftmost 128 bits is the score, last/rightmost 128 bits is a sequential identifier, for uniqueness).
The _generateNewTokenId
function use1 the totalSupply() in an attempt to enforce this uniqueness property.
However, during a merge, two or more NFTs are burned, while only one new NFT is minted is minted. So during a merge, the totalSupply()
decreases. This means it is possible for future token to have the same tokenId
as a token that has previously been burned, violating the "uniqueness" property. (This can even be done intentionally by with a prudent choice of amount
and duration
when calling lock()
).
I don't know how important (or not) the uniqueness assumptions are for your off-chain code, so I rated this a "medium". Feel free to adjust the severity however you see fit, of course.
If uniqueness of the tokenId
matters (for example, for off-chain services), consider modifying the _generateNewTokenId
function so that is uses a nonce (say, uint128 totalEverMinted
) instead of the totalSupply(). The nonce can be increased during every mint by hooking into the _beforeTokenTransfer
or _afterTokenTransfer
hooks of the _mint
function.
#0 - deluca-mike
2022-01-09T05:05:43Z
Valid and duplicate of #17
π Selected for report: TomFrenchBlockchain
Also found by: onewayfunction
45.1044 USDC - $45.10
onewayfunction
The amount_ <= MAX_TOTAL_XDEFI_SUPPLY
component of the first require
statement in the _lock
function is unneeded and/or trivially bypassable.
If the totalSupply
of the XDEFI token cannot be greater than MAX_TOTAL_XDEFI_SUPPLY
, then amount_ <= MAX_TOTAL_XDEFI_SUPPLY
can never be false.
If the totalSupply
of the XDEFI token can be greater than MAX_TOTAL_XDEFI_SUPPLY
, then this check can be trivially bypassed by sending multiple lock
transactions, each with amount_ <= MAX_TOTAL_XDEFI_SUPPLY
, but whose total amounts transferred into the contract exceeding the MAX_TOTAL_XDEFI_SUPPLY
.
I'd remove the amount_ <= MAX_TOTAL_XDEFI_SUPPLY
check from the requirement to save some gas.
#0 - deluca-mike
2022-01-05T09:55:30Z
Agreed, We are removing MAX_TOTAL_XDEFI_SUPPLY
because XDFI is a fixed supply token, and there is no way amount_
(which is passed from the external functions that actually performed the transfers) will be anywhere close to 240M (the supply), let alone greater than 240M.
#1 - deluca-mike
2022-01-09T10:34:26Z
Duplicate #3
π Selected for report: onewayfunction
781.3514 USDC - $781.35
onewayfunction
The owner of the XDEFIDistribution
contract can use flash loans to atomically steal XDEFI from the contract without taking on any capital risk.
In my previous submission, "Anyone can steal XDEFI from the XDEFIDistribution
contract and make the contract insolvent", I showed how any user can use the onERC721Received
hook of the _safeMint
function to steal XDEFI tokens from the contract and generally bork the contract's accounting. The attacker in that case took on some risk proportional to the minimum allowable duration
and was limited in the amount they could steal based on their own capital available (how much XDEFI they had to use during the malicious lockup).
However, when a similar attack is performed by the owner
of the XDEFIDistribution
contract, it can be done (1) without the owner taking on any risk at all and (2) the owner can use flashloans to dramatically increase the amount of XDEFI they can steal.
In particular, the owner can perform all of the following in a single transaction (or in a single flashbots bundle):
First, the owner can call the setLockPeriods
function to allow 0
duration locks with a large multiplier
.
Next, they can flash borrow as much XDEFI as possible from DEXs and loaning platforms. Call this amount of XDEFI X
.
Then they do a (normal) 0
duration lock with X/2
XDEFI. This could give them a large proportion of locked XDEFI.
Next, they do the "malicious lock" technique that I previously reported, using the remaining X/2
XDEFI. This means that their first lock with be able to withdraw more than X/2
XDEFI when they unlock.
Then, in the same transaction -- which is possible because they are using a 0
duration lock -- they can unlock both thier first "normal" lock, as well as their "malicious" lock, giving them more than X
XDEFI in total.
They can repay the flash loan, and keep the difference.
Since the never have to hold a lock for any positive duration, and never even have to have any exposure to XDEFI, the attack is risk free for them. And since they can use flash loans, they'll likely have access to dramatically more capital than a non-owner (who can't use flash loans) could.
In addition to the "use _mint()
instead of _safeMint()
" suggestion from the previous submission, I also recommend adding a require(duration > 0, "INVALID_DURATION");
statement just above L82.
Not only will disallowing 0
duration locks prevent most flashloan shenanigans by the owner, it would also help prevent sandwich attacks that steal incoming distributableX DEFI tokens by sandwiching such incoming txs with a lock
and unlock
transaction.
#0 - deluca-mike
2022-01-09T05:40:34Z
Since the underlying issue is already a problem, I wouldn't consider this derivative issue as a separate high-risk issue, because:
setLockedPeriods
. There is no way to prevent the owner from having some control, and if users see odd behaviour in LockPeriodSet
events, they should not lock anymore, and it would tarnish XDEFI's reputation.Even if we disable 0-second duration from being possible, the owner can still quickly create a 1-second lock duration with a very high multiplier, create a large lock position using the large amount of XDEFI from the treasury, and delete that 1-second lock duration.
Instead, for the above reasons, I would say this is a low-risk, and somewhat solvable by, as recommended, by not allowing lock durations of 0 and solving the underlying issue, defined by #25.
#1 - deluca-mike
2022-01-14T04:57:01Z
The release candidate contract fixes the re-entrancy
issue, as detailed by another issue.
Further, 0-duration locks are prevented from being enabled.
#2 - Ivshti
2022-01-16T01:49:29Z
Not only will disallowing 0 duration locks prevent most flashloan shenanigans by the owner, it would also help prevent sandwich attacks that steal incoming distributableX DEFI tokens by sandwiching such incoming txs with a lock and unlock transaction.
This seems to be a good enough argument for disallowing zero duration locks.
Furthermore, combined with the reentrancy fix this does seem resolved.
As for the severity, agree with the sponsor that it should be reduced to low. Worth noting that the recommendation to disallow zero duration locks does resolve other potential issues