Reproduced Exploit

SofamonExchange — fillAskOrder lets the filler take more than the order

The maker's ask lists orderAmount = 2 at 1 ETH each. fillAskOrder copies the calldata order.amount straight into the Sudoswap-style swap and sends value: order.price, not order.price order.amount. A filler sets amount to 5, pays 1 ETH, and receives 5 NFTs. The fixed router caps the fill at orderAmo…

Aug 2024•Ethereum•logic•5 min read

Loss

5 NFTs taken from an order of 2, paid with 1 ETH instead of 5 ETH

Chain

Ethereum

Category

logic

Date

Aug 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 41367-h-03-orderamount-not-accounted-when-filling-pashov-audit-gro. Standalone Foundry PoC and full write-up: 41367-h-03-orderamount-not-accounted-when-filling-pashov-audit-gro_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/missing-check · vuln/logic/fee-calculation · vuln/logic/wrong-condition

Key info#

FieldValue
Loss5 NFTs taken from an order of 2, paid with 1 ETH instead of 5 ETH
Vulnerable contractExchange.fillAskOrder
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run() with value 1 ETH
Chain / block / dateEthereum model · block 0 · 2024-08
Compilersolc 0.8.27, cancun, via IR
Bug classFill size is not capped at the listed amount, and the payment is not price * amount

TL;DR#

The maker's ask lists orderAmount = 2 at 1 ETH each. fillAskOrder copies the calldata order.amount straight into the Sudoswap-style swap and sends value: order.price, not order.price * order.amount. A filler sets amount to 5, pays 1 ETH, and receives 5 NFTs. The fixed router caps the fill at orderAmount and requires msg.value == price * fill, so the same request of 5 pays 2 ETH and receives 2 NFTs.

Background#

Pashov Audit Group reviewed Sofamon v2 in August 2024 (Sofamon/sofamon-v2-contracts at 44f64fb0a0920cd11387d18e19e83fe6303dd89d). SofamonExchange wraps Sudoswap pair swaps. fulfillMultipleOrders multiplies order.price * order.amount. fillAskOrder and fillBidOrder do not. The report notes that with the unscaled call, order.amount would have to stay 1. This reduction keeps the quoted call and shows the numeric consequence: the swap input is the calldata amount, the ETH attached is a single price, and the maker's listed size is never applied as a cap.

The vulnerable code#

SOLIDITY
function fillAskOrder(FulfillOrder calldata order) public payable {
    uint256[] memory amounts = new uint256[](1);
    amounts[0] = order.amount;

    // @> VULN: pays order.price once and forwards order.amount with no cap at the listed size
    order.orderPool.swapTokenForSpecificNFTs{ value: order.price }(
        amounts, order.price, payable(msg.sender), false, address(0)
    );

    emit AskFilled(address(order.orderPool), msg.sender, order.nftId, order.amount, order.price);
}

fulfillMultipleOrders in the same review does scale the payment:

SOLIDITY
order.orderPool.swapTokenForSpecificNFTs{ value: order.price * order.amount }(
    amounts, order.price * order.amount, payable(msg.sender), false, address(0)
);

The fix used by the control reads the listed size and charges for it:

DIFF
-    amounts[0] = order.amount;
-    order.orderPool.swapTokenForSpecificNFTs{ value: order.price }(
-        amounts, order.price, payable(msg.sender), false, address(0)
-    );
+    uint256 cap = order.orderPool.orderAmount();
+    uint256 fill = order.amount > cap ? cap : order.amount;
+    require(msg.value == order.price * fill, "payment");
+    amounts[0] = fill;
+    order.orderPool.swapTokenForSpecificNFTs{ value: msg.value }(
+        amounts, msg.value, payable(msg.sender), false, address(0)
+    );

Root cause#

amounts[0] is taken from the filler's calldata, and the ETH value is order.price alone. The pool then transfers amounts[0] NFTs as long as msg.value equals the maxExpected argument, which the router also sets to order.price. The maker's stored orderAmount is not an input to this function, so a calldata amount above that listing is filled in full and underpaid.

Preconditions#

  • The pool holds 10 NFTs and publishes orderAmount = 2 at a unit price of 1 ETH.
  • The filler can call fillAskOrder and attach 1 ETH.
  • The pool trusts the amount array from the router. That is the wrapper's job to get right, which fulfillMultipleOrders does and fillAskOrder does not.

Attack walkthrough#

  1. The exploit reads orderAmount and gets 2.
  2. It builds a FulfillOrder with amount = 5 and price = 1 ETH, and calls fillAskOrder with value 1 ETH.
  3. The router sets amounts[0] = 5 and calls swapTokenForSpecificNFTs with value 1 ETH.
  4. The pool sets take = 5, transfers 5 NFTs to the filler, and stores filled = 5.
  5. The filler's NFT balance is 5. The pool's ETH balance is 1 ETH. 5 is greater than the listed 2.

Diagrams#

flowchart TD A["Maker lists orderAmount of 2 at 1 ETH each"] --> B["Filler calls fillAskOrder with amount 5 and value 1 ETH"] B --> C["Router sets amounts 0 to 5"] C --> D["Swap is funded with order.price only"] D --> E["Pool transfers 5 NFTs and keeps 1 ETH"] E --> F["Filler holds 5, which is more than the order of 2"]

Impact#

The filler receives 5 NFTs and pays 1 ETH. The order offered 2 NFTs, which at the quoted unit price would have cost 2 ETH for a capped fill, or 5 ETH for an honest fill of 5. The pool is short 3 NFTs versus the listing and short 4 ETH versus a full payment for 5. The control path, given the same request of 5, caps the fill at 2, requires 2 ETH, and leaves the other 8 NFTs in the pool.

PoC#

BASH
cd 41367-h-03-orderamount-not-accounted-when-filling-pashov-audit-gro_exp
forge test -vv

test_exploit_fill_exceeds_order_amount logs order_amount: 2, taken: 5, paid_wei: 1000000000000000000. test_control_caps_fill_at_order_amount asserts the filler receives 2, the pool records 2, 8 NFTs remain, and the pool holds 2 ETH.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.