OrderBook

First Flight #43
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Bypass of Core Token Protection via setAllowedSellToken and emergencyWithdrawERC20

Root cause

The emergencyWithdrawERC20 function only hardcodes protection for a few tokens (i.e., iWETH, iWBTC, iWSOL, and iUSDC). However, it does not check if a token is allowed for active sell orders, which are tracked via the allowedSellToken mapping.

Description

The emergencyWithdrawERC20 function is designed to prevent withdrawal of core assets (iWETH, iWBTC, iWSOL, and iUSDC) to safeguard order book liquidity. However, it fails to account for other tokens that may be dynamically marked as allowed for trading using setAllowedSellToken. As a result, the owner can add new tradeable tokens and later withdraw them via the emergency function, bypassing core asset protection.

Affected Code

setAllowedSellToken

function setAllowedSellToken(address _token, bool _isAllowed) external onlyOwner {
if (_token == address(0) || _token == address(iUSDC)) revert InvalidToken();
allowedSellToken[_token] = _isAllowed;
emit TokenAllowed(_token, _isAllowed);
}

emergencyWithdrawERC20

function emergencyWithdrawERC20(address _tokenAddress, uint256 _amount, address _to) external onlyOwner {
if (
@> _tokenAddress == address(iWETH) || _tokenAddress == address(iWBTC) || _tokenAddress == address(iWSOL)
@> || _tokenAddress == address(iUSDC)
) {
revert("Cannot withdraw core order book tokens via emergency function");
}
if (_to == address(0)) {
revert InvalidAddress();
}
IERC20 token = IERC20(_tokenAddress);
token.safeTransfer(_to, _amount);
emit EmergencyWithdrawal(_tokenAddress, _amount, _to);
}

Risk

Likelihood: High — easy to exploit if malicious owner

Severity: High — leads to unauthorized withdrawal

Impact

  • Critical protocol assets, once marked allowed via setAllowedSellToken, can be silently drained by the owner.

Proof of Concept


function test_attack() public{
address ayo;
ayo = makeAddr("will_sell_dai_order");
MockDAI dai;
dai = new MockDAI(18);
dai.mint(ayo, 30);
assert(dai.balanceOf(ayo) == 30e18);
//first, the owner adds a new token "dai"
vm.prank(owner);
book.setAllowedSellToken(address(dai), true);
vm.stopPrank();
//ayo creates a sell order of dai token
vm.startPrank(ayo);
dai.approve(address(book), 20e18);
uint256 ayoId = book.createSellOrder(address(dai), 20e18, 180_000e6, 2 days);
vm.stopPrank();
assert(ayoId == 1);
assert(dai.balanceOf(address(book))== 20e18);
//owner secretly withdraw all dai tokens before they are bought
vm.startPrank(owner);
book.emergencyWithdrawERC20(address(dai), 20e18, owner);
vm.stopPrank();
assert(dai.balanceOf(owner) == 20e18);
assert(dai.balanceOf(address(book)) == 0);
}

Test Setup:

  • A mock address ayo is created to simulate a user.

  • A new MockDAI token is deployed.

  • ayo is minted 30 DAI tokens.

  • Token Approval for Trading:

    • The contract owner calls setAllowedSellToken(dai, true), allowing DAI to be used in sell orders.

  • Creating a Legitimate Sell Order:

    • ayo approves the contract to spend 20 DAI.

    • ayo creates a sell order for 20 DAI at a price of 180,000 USDC.

    • The order is successfully stored, and 20 DAI are held by the contract (book).

  • The Exploit:

    • The owner calls emergencyWithdrawERC20() to steal the 20 DAI in the contract before the order is bought.

    • Since DAI is not among the hardcoded "core" tokens (iWETH, iWBTC, iWSOL, iUSDC), and there’s no check for allowedSellToken, the withdrawal succeeds.

  • Post-Conditions Verified:

    • Owner now holds the 20 DAI.

    • The contract holds zero DAI, even though an active sell order exists.

Recommended Mitigation

Modify emergencyWithdrawERC20 to also check allowedSellToken[_tokenAddress]:

if (allowedSellToken[_tokenAddress]) {
revert("Cannot withdraw allowed trading token");
}

Or combine the check:

if (
_tokenAddress == address(iWETH) ||
_tokenAddress == address(iWBTC) ||
_tokenAddress == address(iWSOL) ||
_tokenAddress == address(iUSDC) ||
+ allowedSellToken[_tokenAddress]
) {
revert("Cannot withdraw core or allowed tokens via emergency function");
//...rest of code
}

Updates

Lead Judging Commences

yeahchibyke Lead Judge
about 1 month ago
yeahchibyke Lead Judge about 1 month ago
Submission Judgement Published
Invalidated
Reason: Too generic

Appeal created

oluwaseyisekoni Submitter
about 1 month ago
yeahchibyke Lead Judge
about 1 month ago
yeahchibyke Lead Judge 30 days ago
Submission Judgement Published
Invalidated
Reason: Too generic

Support

FAQs

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