Root + Impact
Description
The `emergencyWithdrawERC20` function allows the contract owner to arbitrarily withdraw any ERC20 token from the contract as long as it's not one of the core tokens `(iWETH, iWBTC, iWSOL, iUSDC)`.
While the function is gated with `onlyOwner`, there are no additional constraints verifying that the token being withdrawn wasn’t deposited by a user through mistake, abuse, or even through interaction with a previously supported token that was later de-listed via `setAllowedSellToken`.
Since the contract does not prevent users from sending arbitrary tokens to its address (there’s no fallback to block native ETH either), and there’s no mechanism for users to reclaim such tokens, this function effectively gives the owner unilateral control over any tokens that land in the contract and aren’t hardcoded as “core.”
@> 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:
-
This is medium issue because if users follow the right procedures they wont accidentally deposit tokens directly to the contract address
-
But once any user sends funds directly via contract address instead
createSellOrder, non-core ERC20 tokens, its only the owner that can withdraw it.
Impact:
- The contract owner can withdraw any non-core ERC20 tokens accidentally or intentionally sent to the contract.
- Users have no way to recover mistakenly sent or disabled token funds.
- Creates a severe trust assumption around the contract owner.
- No distinction between actual emergencies and routine operations
Proof of Concept
Run `forge test --mt test_adminEmergencyWithdrawToDrainTokensMistakenIntoContract -vvvvv` with the below code in `TestOrderBook.t.so`
function test_adminEmergencyWithdrawToDrainTokensMistakenIntoContract()
public
{
MockUSDC NonCoreERC20Token = new MockUSDC(18);
address ox_fff = makeAddr("naive");
NonCoreERC20Token.mint(ox_fff, 1_000e18);
vm.prank(ox_fff);
NonCoreERC20Token.transfer(address(book), 1_000e18);
assertEq(
NonCoreERC20Token.balanceOf(address(book)),
1_000e18,
"Contract should now hold the user's token"
);
address adminPersonalWallet = makeAddr("admin_wallet");
vm.prank(owner);
book.emergencyWithdrawERC20(
address(NonCoreERC20Token),
1_000e18,
adminPersonalWallet
);
assertEq(
NonCoreERC20Token.balanceOf(address(book)),
0,
"Contract balance should be drained"
);
assertEq(
NonCoreERC20Token.balanceOf(adminPersonalWallet),
1_000e18,
"Admin wallet should now hold the drained tokens"
);
}
Recommended Mitigation
Add proper emergency conditions verification
Implement a waiting period for non-emergency token recoveries
Add community governance for valuable token recoveries (above a certain threshold)
Create transparent criteria for what constitutes an emergency
Consider implementing a community treasury for recovered tokens