OrderBook

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

Unrestricted emergency withdraw

Root + Impact

Description

  • The emergencyWithdrawERC20() function inside OrderBook.sol allows the contract owner to withdraw any non-core token without checking whether theses tokens are from any order

  • Once any non-core token (e.g. AAVE) is allowed to sell and its order is created, the owner can simply call emergencyWithdrawERC20() and withdraw all tokens

// Root cause in the codebase with @> marks to highlight the relevant section
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();
}
@> // NO VALIDATION - No checks on if part of `_amount` tokens come from any order
IERC20 token = IERC20(_tokenAddress);
token.safeTransfer(_to, _amount);
emit EmergencyWithdrawal(_tokenAddress, _amount, _to);
}

Risk

Likelihood: High — The contract owner is directly authorized to perform the exploit, and no time-delay or governance control limits this behavior.

Impact: High — Arbitrary withdrawal of sellable assets will break trust and can lead to rug-pull behavior or theft of user-deposited tokens.

Proof of Concept

  1. Add the followingMockAAVE.sol to the test/mocks directory

    // SPDX-License-Identifier: SEE LICENSE IN LICENSE
    pragma solidity 0.8.26;
    import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    contract MockAAVE is ERC20 {
    uint8 private tokenDecimals;
    constructor(uint8 _tokenDecimals) ERC20("MockAAVE", "mAAVE") {
    tokenDecimals = _tokenDecimals;
    }
    function decimals() public view override returns (uint8) {
    return tokenDecimals;
    }
    function mint(address to, uint256 value) public {
    uint256 adjustedValue = value * 10 ** uint256(tokenDecimals);
    _mint(to, adjustedValue);
    }
    }
  2. Import MockAAVEinto TestOrderBook.t.sol

    import {MockAAVE} from "./mocks/MockAAVE.sol";
  3. Declare aaveand initialize it inside setUp()

    contract TestOrderBook is Test {
    OrderBook book;
    MockUSDC usdc;
    MockWBTC wbtc;
    MockWETH weth;
    MockWSOL wsol;
    + MockAAVE aave;
    address owner;
    address alice;
    address bob;
    address clara;
    address dan;
    uint256 mdd;
    function setUp() public {
    owner = makeAddr("protocol_owner");
    alice = makeAddr("will_sell_wbtc_order");
    bob = makeAddr("will_sell_weth_order");
    clara = makeAddr("will_sell_wsol_order");
    dan = makeAddr("will_buy_orders");
    usdc = new MockUSDC(6);
    wbtc = new MockWBTC(8);
    weth = new MockWETH(18);
    wsol = new MockWSOL(18);
    + aave = new MockAAVE(18);
    vm.prank(owner);
    book = new OrderBook(address(weth), address(wbtc), address(wsol), address(usdc), owner);
    usdc.mint(dan, 200_000);
    wbtc.mint(alice, 2);
    weth.mint(bob, 2);
    wsol.mint(clara, 2);
    mdd = book.MAX_DEADLINE_DURATION();
    }
    ...
    }
  4. Append the following test, then run forge test -vv --match-test test_exploitEmergencyWithdrawERC20

    function test_exploitEmergencyWithdrawERC20() public {
    // owner allows AAVE token to be sold
    vm.prank(owner);
    book.setAllowedSellToken(address(aave), true);
    console2.log("Book's AAVE balance: %s\n", aave.balanceOf(address(book)));
    // alice creates sell order for AAVE
    aave.mint(alice, 2);
    vm.startPrank(alice);
    aave.approve(address(book), 2e18);
    uint256 aliceId = book.createSellOrder(address(aave), 2e18,
    1_000e6, 2 days);
    vm.stopPrank();
    assert(aliceId == 1);
    assert(aave.balanceOf(alice) == 0);
    assert(aave.balanceOf(address(book)) == 2e18);
    string memory aliceOrderDetails = book.getOrderDetailsString(aliceId);
    console2.log("Alice creates an order:%s\n", aliceOrderDetails);
    console2.log("Book's AAVE balance: %s", aave.balanceOf(address(book)));
    // owner exploits emergency withdraw
    vm.prank(owner);
    book.emergencyWithdrawERC20(address(aave), 2e18, owner);
    console2.log("Owner calls emergencyWithdrawERC20() to withdraw %s AAVE tokens from the Alice's order", aave.balanceOf(owner));
    console2.log("Book's AAVE balance: %s", aave.balanceOf(address(book)));
    assert(aave.balanceOf(owner) == 2e18);
    }

PoC Results:

forge test -vv --match-test test_exploitEmergencyWithdrawERC20
[⠊] Compiling...
[⠑] Compiling 1 files with Solc 0.8.26
[⠘] Solc 0.8.26 finished in 679.16ms
Compiler run successful!
Ran 1 test for test/TestOrderBook.t.sol:TestOrderBook
[PASS] test_exploitEmergencyWithdrawERC20() (gas: 338299)
Logs:
Book's AAVE balance: 0
Alice creates an order:Order ID: 1
Seller: 0xaf6db259343d020e372f4ab69cad536aaf79d0ac
Selling: 2000000000000000000
Asking Price: 1000000000 USDC
Deadline Timestamp: 172801
Status: Active
Book's AAVE balance: 2000000000000000000
Owner calls emergencyWithdrawERC20() to withdraw 2000000000000000000 AAVE tokens from the Alice's order
Book's AAVE balance: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.79ms (1.33ms CPU time)
Ran 1 test suite in 230.67ms (7.79ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Inside OrderBook.sol:

  1. Add a mapping of locked token balance:

    mapping(address => uint256) public lockedTokenBalance;
  2. Update createSellOrder(), amendSellOrder(), cancelSellOrder() and buyOrder() to track lock changes

    // inside createSellOrder()
    + lockedTokenBalance[_tokenToSell] += _amountToSell;
    IERC20(_tokenToSell).safeTransferFrom(msg.sender, address(this), _amountToSell);
    // inside amendSellOrder()
    // ...
    if (_newAmountToSell > order.amountToSell) {
    uint256 diff = _newAmountToSell - order.amountToSell;
    + lockedTokenBalance[address(token)] += diff;
    token.safeTransferFrom(msg.sender, address(this), diff);
    } else if (_newAmountToSell < order.amountToSell) {
    uint256 diff = order.amountToSell - _newAmountToSell;
    token.safeTransfer(order.seller, diff);
    + lockedTokenBalance[address(token)] -= diff;
    }
    // inside both cancelSellOrder() & buyOrder()
    + lockedTokenBalance[order.tokenToSell] -= order.amountToSell;
  3. Modify emergencyWithdrawERC20() to subtract locked balance from actual balance

    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();
    }
    + uint256 availableBalance = IERC20(_tokenAddress).balanceOf(address(this)) - lockedTokenBalance[_tokenAddress];
    + require(_amount <= availableBalance, "Amount exceeds unlocked balance");
    IERC20 token = IERC20(_tokenAddress);
    token.safeTransfer(_to, _amount);
    emit EmergencyWithdrawal(_tokenAddress, _amount, _to);
    }
Updates

Lead Judging Commences

yeahchibyke Lead Judge about 1 month ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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