Summary
The withdraw
function in the contract lacks a pause check, allowing users to withdraw staked tokens even when the contract is in an emergency paused state. This undermines the purpose of the emergency pause mechanism, which is designed to halt core functionality during emergencies or vulnerabilities.
and this apply to other Core function that is supposed to be paused when the contract admin calls the setEmergencyPaused()
function for an emergency.
Vulnerability Details
Location
Description
The withdraw
function does not include a mechanism to check whether the contract is paused. The contract implements an emergency pause feature via the setEmergencyPaused
function, which can pause or unpause the contract. However, the withdraw
function does not respect this pause state, allowing users to withdraw funds even when the contract is paused.
function setEmergencyPaused(bool paused) external {
if (!hasRole(EMERGENCY_ADMIN, msg.sender)) revert UnauthorizedCaller();
if (paused) {
_pause();
} else {
_unpause();
}
}
function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) whenNotPaused {
if (amount == 0) revert InvalidAmount();
if (_balances[msg.sender] < amount) revert InsufficientBalance();
_totalSupply -= amount;
_balances[msg.sender] -= amount;
stakingToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
Root Cause
The absence of a whenNotPaused
modifier or an explicit check for the pause state in the withdraw
function allows it to execute even when the contract is paused.
Impact
Financial Impact
Security Impact
Reputation Impact
Tools Used
Manual Code Review: The issue was identified through a thorough manual review of the contract code.
Foundry: Unit tests was written to verify the behavior of the withdraw
function when the contract is paused.
Foundry POC:
pragma solidity ^0.8.20;
import "forge-std/src/Test.sol";
import "../../contracts/core/governance/gauges/GaugeController.sol";
import "../../contracts/core/governance/gauges/RWAGauge.sol";
import "../../contracts/mocks/core/tokens/ERC20Mock.sol";
import "../../contracts/core/tokens/veRAACToken.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract RWAGaugeTest is Test {
GaugeController Controller;
RWAGauge rWAGauge;
ERC20Mock token;
ERC20Mock veraactoken;
address admin;
address user;
address pool;
address public TokenAddress;
uint256 STARTING_BALANCE = 100 ether;
uint256 amount = 10 ether;
function setUp() public {
admin = vm.addr(1);
user = vm.addr(2);
pool = vm.addr(3);
vm.startPrank(admin);
token = new ERC20Mock("ERC20Mock", "ERC");
TokenAddress = address(token);
veraactoken = new ERC20Mock("veraactoken", "VRT");
Controller = new GaugeController(address(veraactoken));
rWAGauge = new RWAGauge(TokenAddress, address(veraactoken), address(Controller));
token.mint(TokenAddress, STARTING_BALANCE);
veraactoken.mint(address(veraactoken), STARTING_BALANCE);
IERC20(TokenAddress).balanceOf(admin);
IERC20(TokenAddress).balanceOf(TokenAddress);
IERC20(TokenAddress).balanceOf(address(this));
vm.stopPrank();
}
function testRWAGauge() public {
token.mint(address(this), amount);
IERC20(address(token)).transfer(address(this), amount);
IERC20(address(token)).approve(address(rWAGauge), amount);
IERC20(address(token)).approve(address(Controller), amount);
IERC20(address(token)).approve(address(veraactoken), amount);
IERC20(address(token)).approve(TokenAddress, amount);
IERC20(TokenAddress).balanceOf(address(this));
veraactoken.mint(address(this), amount);
IERC20(address(veraactoken)).transfer(address(this), amount);
IERC20(address(veraactoken)).approve(address(rWAGauge), amount);
IERC20(address(veraactoken)).approve(address(Controller), amount);
IERC20(address(veraactoken)).approve(address(veraactoken), amount);
IERC20(address(veraactoken)).approve(TokenAddress, amount);
IERC20(TokenAddress).balanceOf(address(this));
uint256 _amount = 1 ether;
rWAGauge.stake(_amount);
IERC20(address(rWAGauge)).balanceOf(address(this));
vm.startPrank(admin);
rWAGauge.setEmergencyPaused(true);
vm.stopPrank();
rWAGauge.withdraw(_amount);
}
}
Foundry Test Response :
├─ [25895] RWAGauge::setEmergencyPaused(true)
│ ├─ emit Paused(account: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf)
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [18333] RWAGauge::withdraw(1000000000000000000 [1e18])
│ ├─ [569] GaugeController::getGaugeWeight(RWAGauge: [0x51a240271AB8AB9f9a21C82d9a85396b704E164d]) [staticcall]
│ │ └─ ← [Return] 0
│ ├─ emit RewardUpdated(user: RWAGaugeTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], reward: 0)
│ ├─ [3281] ERC20Mock::transfer(RWAGaugeTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1000000000000000000 [1e18])
│ │ ├─ emit Transfer(from: RWAGauge: [0x51a240271AB8AB9f9a21C82d9a85396b704E164d], to: RWAGaugeTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], value: 1000000000000000000 [1e18])
│ │ └─ ← [Return] true
│ ├─ emit Withdrawn(user: RWAGaugeTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], amount: 1000000000000000000 [1e18])
│ └─ ← [Stop]
└─ ← [Stop]
Recommendations
Add a pause check to the withdraw
function using one of the following methods:
Option 1: Use a whenNotPaused
Modifier
If the contract inherits from OpenZeppelin's Pausable
or a similar contract:
function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) whenNotPaused {
if (amount == 0) revert InvalidAmount();
if (_balances[msg.sender] < amount) revert InsufficientBalance();
_totalSupply -= amount;
_balances[msg.sender] -= amount;
stakingToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
Option 2: Add an Explicit Check
If the contract does not use a modifier, add an explicit check:
function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
require(!paused, "Contract is paused");
if (amount == 0) revert InvalidAmount();
if (_balances[msg.sender] < amount) revert InsufficientBalance();
_totalSupply -= amount;
_balances[msg.sender] -= amount;
stakingToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}