Platform: Code4rena
Start Date: 28/09/2023
Pot Size: $36,500 USDC
Total HM: 5
Participants: 115
Period: 6 days
Judge: 0xDjango
Total Solo HM: 1
Id: 290
League: ETH
Rank: 42/115
Findings: 2
Award: $129.33
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: deth
Also found by: 0xDetermination, 0xpiken, 3agle, Brenzee, Flora, HChang26, KrisApostolov, Satyam_Sharma, Testerbot, aycozynfada, berlin-101, gkrastenov, mahdirostami, merlin, rokinot, rvierdiiev, said, santipu_, sl1, tapir, twicek
124.9633 USDC - $124.96
https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L339-L342 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L365 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L398-L399
Users who have been granted an irrevocable token have the ability to mint a revocable token and accrue interest with it even if they do not possess a staked XVS balance of at least 1000 in the XVS Vault.
The protocol distinguishes between two types of tokens: Revocable and Irrevocable. Users can mint Revocable tokens themselves, and these tokens can be revoked if a user's staked XVS balance falls below 1000. In contrast, Irrevocable tokens are only minted through protocol timelock proposals and are unaffected by a user's current staking balance.
Irrevocable tokens are minted to notable users based on community voting, typically in recognition of their substantial XVS staking over an extended period.
The issue arises from how the mechanism for granting Revocable tokens to users functions. It checks the amount of staked XVS only in the xvsUpdated()
function and verifies the stakedAt
timestamp during token minting.
In the xvsUpdated()
function:
function xvsUpdated(address user) external { // @audit This is the only XVS check for the user's staked XVS amount: uint256 totalStaked = _xvsBalanceOfUser(user); bool isAccountEligible = isEligible(totalStaked); // ... }
In the claim()
function:
function claim() external { // @audit Only checks the stakedAt timestamp when minting/claiming the token: if (stakedAt[msg.sender] == 0) revert IneligibleToClaim(); if (block.timestamp - stakedAt[msg.sender] < STAKING_PERIOD) revert WaitMoreTime(); // ... // mint logic ... }
With this context, let's explore the vulnerability scenario:
The assumption is that a user holding an Irrevocable token could lose it if they withdraw all their tokens or remain inactive for a specific duration.
In this scenario, a distinguished user who receives an Irrevocable token through the issue()
function can exploit the situation. They can front-run the mint transaction by calling xvsUpdated()
on themselves, setting their stakedAt
to the current timestamp. After 90 days, they can withdraw most of their staked XVS from the vault without being revoked because of their Irrevocable token status. After some time, a timelock proposal is approved, and the community burns the user's Irrevocable token. Now, since stakedAt[user] + 90 days < block.timestamp
, the user can call claim()
and mint themselves a Revocable token without meeting the minimum staked XVS requirement.
This malicious user can exploit this process to continue accruing interest as if they still held an Irrevocable token.
Consider the following hardhat PoC which clearly demonstrates the vector at hand: https://gist.github.com/CrisCodesCrap/c9b509565d427095d919d2d14ec5d2be
Manual Review, Hardhat
One potential mitigation strategy involves resetting the stakedAt
parameter for users when minting irrevocable tokens.
delete stakedAt[users[i]]; _mint(true, users[i]); _initializeMarkets(users[i]);
Other
#0 - c4-pre-sort
2023-10-04T21:13:21Z
0xRobocop marked the issue as duplicate of #633
#1 - c4-judge
2023-11-01T19:53:51Z
fatherGoose1 marked the issue as satisfactory
#2 - c4-judge
2023-11-05T00:50:32Z
fatherGoose1 changed the severity to 3 (High Risk)
🌟 Selected for report: Bauchibred
Also found by: 0x3b, 0xDetermination, 0xMosh, 0xScourgedev, 0xTheC0der, 0xTiwa, 0xWaitress, 0xdice91, 0xfusion, 0xpiken, 0xprinc, 0xweb3boy, ArmedGoose, Aymen0909, Breeje, Brenzee, Daniel526, DavidGiladi, DeFiHackLabs, Flora, Fulum, HChang26, Hama, IceBear, J4X, Krace, KrisApostolov, Maroutis, Mirror, MohammedRizwan, Norah, PwnStars, SPYBOY, TangYuanShen, Testerbot, ThreeSigma, Tricko, al88nsk, alexweb3, ast3ros, berlin-101, bin2chen, blutorque, btk, d3e4, deth, e0d1n, ether_sky, ge6a, gkrastenov, glcanvas, hals, imare, inzinko, jkoppel, jnforja, joaovwfreire, josephdara, kutugu, lotux, lsaudit, mahdirostami, merlin, n1punp, nadin, neumo, nisedo, nobody2018, oakcobalt, orion, peanuts, pep7siup, pina, ptsanev, rokinot, rvierdiiev, said, santipu_, sashik_eth, seerether, squeaky_cactus, terrancrypt, tonisives, twicek, vagrant, xAriextz, y4y
4.3669 USDC - $4.37
https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L349-L357 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L397-L405
Users can delay Prime token minting to earn extra interest and a larger portion of rewards for themselves.
The protocol features an issue()
function designed for minting both revocable and irrevocable tokens to users via timelock proposals.
However, a potential issue arises due to a check within the _mint()
function:
if (tokens[user].exists) revert IneligibleToClaim();
This check causes the entire transaction to fail if any user already possesses a token. This creates an opportunity for a user who meets the criteria of stakedAt[user] + 90 days < block.timestamp
and is on the list of intended recipients for token minting to front-run the transaction and force it to revert by claiming their token.
This scenario benefits such a user because of the way the reward system operates. Reward distribution per user is determined by the sumOfMembersScore
specific to the token. When more users are added, their scores contribute to the sumOfMembersScore
, resulting in a smaller share of rewards per block for each user.
Consider a situation with an ongoing reward distribution cycle. In such a case, a user can simply follow the steps described above to claim additional interest from the contract before other users are included, maximising their gains.
The central assumption here is that issue()
is executed via a governance proposal, preventing immediate re-execution in the same block due to the required mutation of proposal calldata.
Consider the following PoC demonstrating the issue at hand: https://gist.github.com/CrisCodesCrap/426f1e1f5b70b370c09ee9cceb8d5e01
Manual Review, Hardhat
You could either adopt a pull-over-push approach or implement a check to determine if each user is eligible to call claim()
independently before processing them.
for (uint256 i = 0; i < users.length; ) { if (stakedAt[user] + 90 days < block.timestamp) continue; _mint(false, users[i]); _initializeMarkets(users[i]); delete stakedAt[users[i]]; unchecked { i++; } }
DoS
#0 - 0xRobocop
2023-10-04T21:09:57Z
Consider QA
#1 - c4-pre-sort
2023-10-04T21:10:01Z
0xRobocop marked the issue as low quality report
#2 - c4-judge
2023-11-01T20:05:48Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#3 - fatherGoose1
2023-11-01T20:06:30Z
This report has validity regarding its DOS impact, but governance would simply issue a critical proposal which would remediate the situation quickly.
#4 - c4-judge
2023-11-03T02:42:53Z
fatherGoose1 marked the issue as grade-b