Platform: Code4rena
Start Date: 24/10/2023
Pot Size: $36,500 USDC
Total HM: 4
Participants: 147
Period: 6 days
Judge: 0xDjango
Id: 299
League: ETH
Rank: 48/147
Findings: 2
Award: $123.66
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: josephdara
Also found by: 0xAadi, 0xmystery, 0xpiken, Arz, Beosin, Eeyore, HChang26, J4X, KIntern_NA, Limbooo, RamenPeople, SpicyMeatball, Team_Rocket, Yanchuan, castle_chain, degensec, ge6a, lanrebayode77, mert_eren, sorrynotsorry, tnquanghuy0512
119.1406 USDC - $119.14
A staker who has the FULL_RESTRICTED_STAKER_ROLE
can still unstake their USDe.
The StakedUSDe._withdraw
function has the following check to ensure that users with the FULL_RESTRICTED_STAKER_ROLE
cannot withdraw their staked USDe:
if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); }
This only checks for the caller
and receiver
, and not the _owner
. A blacklisted _owner
can approve an unblacklisted address to get around this check.
The StakedUSDe_beforeTokenTransfer
function also has a similar check:
function _beforeTokenTransfer(address from, address to, uint256) internal virtual override { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) { revert OperationNotAllowed(); } if (hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { revert OperationNotAllowed(); } }
The hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)
condition allows blacklisted owners to burn their stUSDe tokens so that StakedUSDe.redistributeLockedAmount
can function. However, this also has the unintended side effect of allowing blacklisted owners to unstake their USDe tokens.
A staker that has the FULL_RESTRICTED_STAKER_ROLE
can unstake their USDe tokens by doing the following:
ERC20.approve
for a caller
address that isn't blacklisted.caller
calls StakedUSDeV2.cooldownAssets
(or StakedUSDeV2.withdraw
if there is no cooldown).StakedUSDeV2.unstake(caller)
after cooldown.Run these two tests inside StakedUSDeV2.blacklist.t.sol
:
function testBypassBlacklistingWithCooldown() public { _mintApproveDeposit(greg, _amount, false); assertEq(usdeToken.balanceOf(greg), 0); assertEq(usdeToken.balanceOf(alice), 0); assertEq(usdeToken.balanceOf(address(stakedUSDe)), _amount); assertEq(stakedUSDe.balanceOf(greg), _amount); // blacklist greg vm.startPrank(owner); stakedUSDe.grantRole(FULL_RESTRICTED_STAKER_ROLE, greg); vm.stopPrank(); vm.prank(greg); stakedUSDe.approve(alice, _amount); vm.prank(alice); vm.expectRevert(IStakedUSDe.OperationNotAllowed.selector); stakedUSDe.cooldownAssets(_amount, greg); (uint104 cooldownEnd,) = stakedUSDe.cooldowns(greg); vm.warp(cooldownEnd + 1); vm.prank(greg); stakedUSDe.unstake(alice);// from silo to alice assertEq(usdeToken.balanceOf(alice), 0, "Alice should not get any USDe"); } function testBypassBlacklistingWithoutCooldown() public { _mintApproveDeposit(greg, _amount, false); assertEq(usdeToken.balanceOf(greg), 0); assertEq(usdeToken.balanceOf(alice), 0); assertEq(usdeToken.balanceOf(address(stakedUSDe)), _amount); assertEq(stakedUSDe.balanceOf(greg), _amount); vm.startPrank(owner); stakedUSDe.grantRole(FULL_RESTRICTED_STAKER_ROLE, greg); // blacklist greg stakedUSDe.setCooldownDuration(0); // set cooldown to 0 vm.stopPrank(); vm.prank(greg); stakedUSDe.approve(alice, _amount); vm.prank(alice); vm.expectRevert(IStakedUSDe.OperationNotAllowed.selector); stakedUSDe.withdraw(_amount, alice, greg); }
Both tests do not revert as expected.
[FAIL. Reason: Call did not revert as expected] testBypassBlacklistingWithCooldown() (gas: 355374) [FAIL. Reason: Call did not revert as expected] testBypassBlacklistingWithoutCooldown() (gas: 312465)
Manual review, Foundry
Add a check for the _owner
in the StakedUSDe._withdraw
function:
if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver) || hasRole(FULL_RESTRICTED_STAKER_ROLE, _owner)) { revert OperationNotAllowed(); }
The two tests above should pass:
[PASS] testBypassBlacklistingWithCooldown() (gas: 299183) [PASS] testBypassBlacklistingWithoutCooldown() (gas: 237396)
Access Control
#0 - c4-pre-sort
2023-10-31T19:00:32Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T19:00:40Z
raymondfam marked the issue as duplicate of #7
#2 - c4-pre-sort
2023-11-01T19:45:33Z
raymondfam marked the issue as duplicate of #666
#3 - c4-judge
2023-11-13T19:34:55Z
fatherGoose1 marked the issue as satisfactory
🌟 Selected for report: 0xmystery
Also found by: 0x11singh99, 0xAadi, 0xAlix2, 0xG0P1, 0xStalin, 0xWaitress, 0x_Scar, 0xhacksmithh, 0xhunter, 0xpiken, Al-Qa-qa, Arz, Avci, Bauchibred, BeliSesir, Breeje, Bughunter101, DarkTower, Eeyore, Fitro, HChang26, Imlazy0ne, J4X, JCK, Kaysoft, Kral01, Madalad, Mike_Bello90, Noro, PASCAL, PENGUN, Proxy, Rickard, Shubham, SovaSlava, Strausses, Team_Rocket, ThreeSigma, Topmark, Udsen, Walter, Yanchuan, Zach_166, ZanyBonzy, adam-idarrha, adeolu, almurhasan, arjun16, ast3ros, asui, ayden, btk, cartlex_, castle_chain, cccz, chainsnake, codynhat, critical-or-high, cryptonue, csanuragjain, deepkin, degensec, dirk_y, erebus, foxb868, ge6a, hunter_w3b, jasonxiale, kkkmmmsk, lanrebayode77, lsaudit, marchev, matrix_0wl, max10afternoon, nuthan2x, oakcobalt, oxchsyston, pavankv, peanuts, pep7siup, pipidu83, pontifex, ptsanev, qpzm, radev_sw, rokinot, rotcivegaf, rvierdiiev, sorrynotsorry, squeaky_cactus, supersizer0x, tnquanghuy0512, twcctop, twicek, young, zhaojie, ziyou-
4.5226 USDC - $4.52
cooldownDuration
even if cooldownDuration
is set to 0.When a user calls cooldownAssets
or cooldownShares
a cooldown starts and have to wait cooldownDuration
in order to claim his staking amount.
When admin sets the setCooldownDuration
to 0:
Suggested fix: check if cooldownDuration == 0
in `unstake function:
function unstake(address receiver) external { UserCooldown storage userCooldown = cooldowns[msg.sender]; uint256 assets = userCooldown.underlyingAmount; if ((block.timestamp >= userCooldown.cooldownEnd) || (cooldownDuration == 0)){ userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0; silo.withdraw(receiver, assets); } else { revert InvalidCooldown(); } }
SafeERC20 library is imported by USDeSilo
but never used.
Second call to getUnvestedAmount
from transferInRewards
can be removed since will always return 0.
function transferInRewards(uint256 amount) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) { if (getUnvestedAmount() > 0) revert StillVesting(); //@audit if next line is executed getUnvestedAmount will return 0 uint256 newVestingAmount = amount + getUnvestedAmount();
#0 - c4-pre-sort
2023-11-02T01:05:20Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T17:15:15Z
fatherGoose1 marked the issue as grade-b