Reproduced Exploit

Vertex — ammEquilibrium divides the quote by 1e18

ammEquilibrium returns (base.fromInt(), quote.toInt()). fromInt multiplies by 1e18. toInt divides by 1e18. The quote leg should use fromInt. With base and quote reserves of 1e36 and price 1e18, the integer quote is 1e18, toInt returns 1, and fromInt would return 1e36. The pool transfers 1 and keeps…

Jan 2023•Other•arithmetic•3 min read

Chain

Other

Category

arithmetic

Date

Jan 2023

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: 48376-improper-unit-conversion-in-ammequilibrium-ottersec-none-ver. Standalone Foundry PoC and full write-up: 48376-improper-unit-conversion-in-ammequilibrium-ottersec-none-ver_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/arithmetic/decimal-mismatch · vuln/logic/price-calculation

Key info#

ProtocolVertex
FindingOtterSec OS-VTX-ADV-00, AuditVault 48376
Vulnerable codeMathHelper.ammEquilibrium
HarmThe swap pays 1 quote unit instead of 1e36. The pool keeps 1e36 - 1. The factor is 1e36.
Chainlocal synthetic

TL;DR#

ammEquilibrium returns (base.fromInt(), quote.toInt()). fromInt multiplies by 1e18. toInt divides by 1e18. The quote leg should use fromInt. With base and quote reserves of 1e36 and price 1e18, the integer quote is 1e18, toInt returns 1, and fromInt would return 1e36. The pool transfers 1 and keeps 1e36 - 1. The control returns quote.fromInt() and pays 1e36.

The vulnerable code#

SOLIDITY
int256 k = baseX18.toInt() * quoteX18.toInt();
int256 base = (MathHelper.sqrt(k) * 1e9) / MathHelper.sqrt(priceX18);
int256 quote = k / base;
// @> VULN: quote.toInt divides by 1e18; the quote should be quote.fromInt
return (base.fromInt(), quote.toInt());

The fix is return (base.fromInt(), quote.fromInt()).

Root cause#

PRBMath toInt strips 18 decimals and fromInt adds them. The base leg is scaled up and the quote leg is scaled down, so the quote is smaller than the fair value by 1e36. These reserves are what the perp engine uses for LP and position value.

Preconditions#

baseX18 and quoteX18 are 1e36. priceX18 is 1e18. Both square roots are exact, so the result is not a rounding artifact. The pool is funded with 1e36 quote units before the swap. No timestamp warp.

Attack walkthrough#

  1. The fair quote, 1e18 * 1e18, is 1e36. That amount is minted onto the pool.
  2. swap calls ammEquilibrium. k is 1e36. base before scaling is 1e18. quote before scaling is 1e18.
  3. base.fromInt() returns 1e36. quote.toInt() returns 1.
  4. The pool transfers 1 quote unit to the trader.
  5. 1e36 / 1 is 1e36. The pool balance is 1e36 - 1.

The control uses quote.fromInt(), transfers 1e36, and leaves the pool at 0.

Diagrams#

flowchart TD A["Reserves are 1e36 base and 1e36 quote at price 1e18"] --> B["k is 1e36 and the integer quote is 1e18"] B --> C["VULN: return quote.toInt instead of quote.fromInt"] C --> D["Paid quote is 1 and the fair quote is 1e36"] D --> E["Factor is 1e36"] E --> F["Pool keeps 1e36 minus 1"]

Impact#

The trader receives 1 quote unit instead of 1e36. The pool retains 999999999999999999999999999999999999 units, which is 1e36 - 1. The equilibrium quote is off by a factor of 1e36. The fixed conversion pays 1e36 and empties the pool.

Remediation#

Change quote.toInt() to quote.fromInt() in the return of ammEquilibrium. That is the patch in Vertex pull request 116.

How to reproduce#

BASH
cd 48376-improper-unit-conversion-in-ammequilibrium-ottersec-none-ver_exp
forge test -vv

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.