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: 45/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#L331 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L725 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L397
Based on sponsors, any user whose prime token is burnt, needs to stake 1000 XVS and wait for 90 days in order to receive prime token again. In certain edge case, user can bypass 90 days lock period and receive prime token immediately by staking 1000 XVS.
VIP (Venus Improvement Proposal) can vote to issue()
and burn()
prime tokens. Specifically focusing on issue()
, VIP may choose to issue prime tokens for various reasons, such as recognizing individuals with exceptional contributions or planning for future promotions, among other purposes.
However, a notable issue emerges when VIP issue()
irrevocable prime tokens to users who have been staking at least 1000 XVS for some duration, let's say 90 days for simplicity's sake. These users, despite meeting the staking criteria, have not yet claim()
their prime tokens. When VIP issue()
irrevocable prime tokens to these same users, the problem lies in the fact that the stakedAt
mapping is not reset to zero.
Here's the relevant code snippet:
if (isIrrevocable) { for (uint256 i = 0; i < users.length; ) { Token storage userToken = tokens[users[i]]; if (userToken.exists && !userToken.isIrrevocable) { _upgrade(users[i]); } else { _mint(true, users[i]); _initializeMarkets(users[i]); } unchecked { i++; } } }
This situation may not pose a problem for users who have not staked before. However, it creates a future concern when VIP decide to burn()
prime tokens issued to users who previously staked. In this case, the stakedAt
mapping is not reset once more in the _burn()
function. Consequently, users can bypass the 90-day lock period in the claim()
function and instantly receive prime tokens by staking 1000 XVS.
function claim() external { if (stakedAt[msg.sender] == 0) revert IneligibleToClaim(); if (block.timestamp - stakedAt[msg.sender] < STAKING_PERIOD) revert WaitMoreTime(); stakedAt[msg.sender] = 0; _mint(false, msg.sender); _initializeMarkets(msg.sender); }
While this issue can be solved by calling xvsUpdated()
after burn()
to reset stakeAt
, user can simply front run burn()
and stake enough XVS to make sure isAccountEligible = true
, so none of the following conditions will trigger.
function xvsUpdated(address user) external { uint256 totalStaked = _xvsBalanceOfUser(user); bool isAccountEligible = isEligible(totalStaked); if (tokens[user].exists && !isAccountEligible) { if (tokens[user].isIrrevocable) { _accrueInterestAndUpdateScore(user); } else { _burn(user); } } else if (!isAccountEligible && !tokens[user].exists && stakedAt[user] > 0) { stakedAt[user] = 0; } else if (stakedAt[user] == 0 && isAccountEligible && !tokens[user].exists) { stakedAt[user] = block.timestamp; } else if (tokens[user].exists && isAccountEligible) { _accrueInterestAndUpdateScore(user); } }
Add following PoC to Prime.ts:
describe("User doesn't have to wait 90 days", () => { let prime: PrimeScenario; let xvsVault: XVSVault; let xvs: XVS; beforeEach(async () => { ({ prime, xvsVault, xvs } = await loadFixture(deployProtocol)); }); it("stakedAt mapping doesn't reset", async () => { const user = user1; //user stakes XVS and starts waiting for 90 days await xvs.connect(user).approve(xvsVault.address, bigNumber18.mul(10000)); await xvsVault.connect(user).deposit(xvs.address, 0, bigNumber18.mul(10000)); //Moves past 90 days //But user does not claim prime token await mine(90 * 24 * 60 * 60); //VIP (Venus Improvement Proposal) votes and issues user irrevocable prime status await prime.issue(true, [user.getAddress()]); //User withdraws XVS and falls below minimum threshold await xvsVault.connect(user).requestWithdrawal(xvs.address, 0, bigNumber18.mul(10000)); //Check user is irrevocable expect((await prime.tokens(user.getAddress())).exists).to.be.equal(true); expect((await prime.tokens(user.getAddress())).isIrrevocable).to.be.equal(true); //VIP votes to burn user since no XVS staked await prime.burn(user.getAddress()); //Check user prime token is burnt expect((await prime.tokens(user.getAddress())).exists).to.be.equal(false); expect((await prime.tokens(user.getAddress())).isIrrevocable).to.be.equal(false); //Check stakedAt mapping is not reset expect(await prime.stakedAt(user.getAddress())).to.be.gt(0); //User restakes XVS above minimum threshold and claims prime token without waiting for 90 days await xvs.connect(user).approve(xvsVault.address, bigNumber18.mul(10000)); await xvsVault.connect(user).deposit(xvs.address, 0, bigNumber18.mul(10000)); await prime.connect(user).claim(); expect((await prime.tokens(user.getAddress())).exists).to.be.equal(true); }); });
Manual Review and Hardhat
function issue(bool isIrrevocable, address[] calldata users) external { _checkAccessAllowed("issue(bool,address[])"); if (isIrrevocable) { for (uint256 i = 0; i < users.length; ) { Token storage userToken = tokens[users[i]]; if (userToken.exists && !userToken.isIrrevocable) { _upgrade(users[i]); } else { _mint(true, users[i]); _initializeMarkets(users[i]); + delete stakedAt[users[i]]; } unchecked { i++; } } } else { for (uint256 i = 0; i < users.length; ) { _mint(false, users[i]); _initializeMarkets(users[i]); delete stakedAt[users[i]]; unchecked { i++; } } } }
Context
#0 - c4-pre-sort
2023-10-06T22:02:20Z
0xRobocop marked the issue as duplicate of #633
#1 - c4-judge
2023-11-01T19:32:31Z
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#L288
Malicious users can back-run addMarket()
and block stuff, leading to unfair reward distribution.
When VIP votes to addMarket()
, scores for all prime users must be updated before distributing rewards. It must NOT be a gradual update. Or the following scenario can happen, leading to unfair reward distribution.
After minting Prime tokens if the Venus community decides to add an existing market to the Prime token program then the score of all users has to be updated to start giving them rewards. The scores cannot be applied gradually in this case as the initial Prime users for the market will get large rewards for some time. So this script will prevent this scenario.
The script will have to call permission-less function updateScores()
multiple times due to out-of-gas issue. Note that, the script will NOT pause the market or Prime. It does not solve the issue mentioned above. Since addMarket()
is invoked by VIP (Venus Improvement Proposal). Users can simply back-run addMarket()
by calling accrueInterestAndUpdateScore()
to "initialized" this specific market for them.
Subsequently, a malicious user could delay/extend this "updateScore" process(triggered in script) by performing block stuffing with dummy transactions. It's important to highlight that, according to data from https://www.cryptoneur.xyz/en/gas-fees-calculator, gas used range from 6 million to 15 million gas for an average block, costs approximately $3.87 to $9.68 on the Binance Smart Chain (BSC). Rewards are distributed in each block. If the financial gains outweigh the associated costs, malicious users may continue to exploit this vulnerability for profit.
Manual Review
Consider pausing the market or key functions that accrue interest in addMarket()
.
Run the script and unpause the market when all scores are updated.
Context
#0 - c4-pre-sort
2023-10-05T21:28:36Z
0xRobocop marked the issue as primary issue
#1 - c4-pre-sort
2023-10-05T21:28:40Z
0xRobocop marked the issue as high quality report
#2 - 0xRobocop
2023-10-05T21:29:06Z
Consider QA.
Highlighting for sponsor review.
#3 - c4-sponsor
2023-10-24T15:59:40Z
chechu marked the issue as disagree with severity
#4 - chechu
2023-10-24T16:00:23Z
Consider QA
In BNB chain there are around 10,512,000 blocks per year. Assuming the average minimum cost per block suggested by the warden ($3.87), it would require around $40M rewards / year to make this attack economically profitable. It seems an unlikely scenario
#5 - c4-sponsor
2023-10-24T18:37:31Z
chechu (sponsor) acknowledged
#6 - c4-judge
2023-10-31T19:11:30Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#7 - fatherGoose1
2023-10-31T19:13:49Z
Agreed that some protections could be in place to mitigate this risk, but it is unlikely that rewards per block would outweigh the cost of performing the block stuffing.
#8 - c4-judge
2023-11-03T02:07:30Z
fatherGoose1 marked the issue as grade-b