All incidents

OMPxContract bonding-curve loop exploit drains ETH reserves

Share
Aug 06, 2024 09:58 UTCAttackLoss: 4.37 ETHManually checked1 exploit txWindow: Atomic

Root Cause Analysis

OMPxContract bonding-curve loop exploit drains ETH reserves

1. Incident Overview TL;DR

On Ethereum mainnet block 20468780, EOA 0x40d115198d71cab59668b51dd112a07d273d5831 used a freshly deployed helper contract 0xfaddf57d079b01e53d1fe3476cc83e9bcc705854 plus Balancer/WETH liquidity to execute a single-transaction looping strategy against OMPxContract 0x09a80172ed7335660327cd664876b5df6fe06108.
The helper borrowed 100 WETH from the Balancer Vault, unwrapped it to 100 ETH, and repeatedly interacted with OMPxContract in a tight loop that combined large purchase and buyBack calls. Each loop iteration transferred nearly the full 100 ETH back out of OMPxContract while reclaiming the same large OMPxToken amount, producing a small per-iteration ETH shortfall.
Over dozens of iterations in the same transaction, these shortfalls accumulated into a net loss of 4.372869914943298279 ETH from OMPxContract. After repaying Balancer principal and fees and paying gas, the adversary-controlled cluster (EOA + helper) realized a net profit of approximately 4.371401571200395847 ETH.

2. Key Background

OMPxContract is an owner-controlled bonding-curve style contract that mints and burns OMPxToken (0x633b041c41f61d04089880d7b5c7ed0f10ff6f85) against ETH reserves. The core design goal is that the ETH reserves held by OMPxContract track the outstanding OMPxToken supply along a monotonic price curve.

Two public pricing functions underpin this design:

// OMPxContract (0x09a80172...6108), pricing core
function getBuyBackPrice(uint256 buyBackValue) public view returns (uint256 price_) {
    if (address(this).balance == 0) return 0;
    uint256 eth;
    uint256 tokens = token.totalSupply();
    if (buyBackValue > 0) {
        eth = address(this).balance.sub(buyBackValue);
    } else {
        eth = address(this).balance;
    }
    return (eth.sub(feeBalance)).mul(1e18).div(tokens);
}

function getPurchasePrice(uint256 purchaseValue, uint256 amount) public view returns (uint256 price_) {
    uint256 buyerContributionCoefficient = getDiscountByAmount(amount);
    uint256 price = getBuyBackPrice(purchaseValue).mul(buyerContributionCoefficient).div(descPrecision);
    if (price <= 0) { price = 1e11; }
    return price;
}

getBuyBackPrice uses address(this).balance (optionally reduced by buyBackValue) and token.totalSupply() to compute a per-token price in ETH, net of feeBalance. getPurchasePrice reuses this buyback price and applies a discount coefficient based on the amount being purchased.
User-facing flows then combine these prices with token mint/burn and ETH transfers:

// purchase: user sends ETH and receives tokens
function purchase(uint256 tokensToPurchase, uint256 maxPrice) public payable returns (uint256 tokensBought_) {
    uint256 currentPrice = getPurchasePrice(msg.value, tokensToPurchase);
    // ...
    token.safeTransfer(msg.sender, tokensWuiAvailableByCurrentPrice);
    // partial ETH refund if msg.value > totalDealPrice
}

// buyBack: user returns tokens and receives ETH
function buyBack(uint256 tokensToBuyBack, uint256 minPrice) public {
    uint currentPrice = getBuyBackPrice(0);
    uint256 totalPrice = tokensToBuyBack.mul(currentPrice).div(1e18);
    token.safeTransferFrom(msg.sender, this, tokensToBuyBack);
    msg.sender.transfer(totalPrice);
}

For the system to be safe, iterating combinations of purchase and buyBack should preserve an accounting invariant: after any complete cycle, OMPxContract’s ETH reserves (net of feeBalance) must remain at least as large as the ETH value implied by the outstanding OMPxToken supply at the curve price.

Balancer’s Vault (0xba12222222228d8ba445958a75a0704d566bf2c8) and WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2) provide permissionless flash-loan-like liquidity: any contract can temporarily borrow WETH, unwrap it to ETH, use that ETH to interact with other contracts, and rewrap/repay within the same transaction as long as the borrowed amount plus fees are returned. The adversary uses this to source 100 ETH of temporary capital without prior holdings.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability is a loop-unsafe bonding-curve accounting bug in OMPxContract’s pricing logic. Both purchase and buyBack ultimately rely on getBuyBackPrice, which uses the current address(this).balance and token.totalSupply() to derive a price. However:

  • purchase calls getPurchasePrice(msg.value, tokensToPurchase), which in turn calls getBuyBackPrice(purchaseValue) using the incoming ETH amount as buyBackValue. This effectively computes prices using a synthetic ETH reserve address(this).balance - msg.value rather than the post-trade reserve.
  • buyBack calls getBuyBackPrice(0), using the current on-chain balance with no adjustment for pending intra-transaction flows.

When an attacker-controlled contract can rapidly alternate purchase and buyBack in a single transaction with a fixed, very large token amount, these price functions are repeatedly evaluated on stale or partially updated ETH balances. The resulting small per-iteration mismatch between the ETH paid in and ETH paid out accumulates, allowing the attacker to extract value from reserves without increasing net token supply.

The concrete invariant is:

Let ETH_reserve be OMPxContract’s ETH balance net of feeBalance, and P(totalSupply) be the bonding-curve price per token. After any complete purchase/buyBack lifecycle,
ETH_reserve >= P(totalSupply) * totalSupply / 1e18
must hold.

In tx 0xd927...de9b6, the helper contract repeatedly calls OMPxContract with selectors:

  • 0x70876c98 (purchase(uint256,uint256)) with 100 ETH value.
  • 0x47d8167d (buyBack(uint256,uint256)).

As evidenced by the trace, this loop causes OMPxContract’s ETH_reserve to fall below the value implied by the outstanding token supply, violating the invariant and leaking 4.372869914943298279 ETH from the contract into the adversary’s control.

4. Detailed Root Cause Analysis

4.1 Code-level mechanism

The core accounting mechanism is:

  • Prices depend on address(this).balance and token.totalSupply().
  • getBuyBackPrice(buyBackValue) subtracts buyBackValue from the current ETH balance (if non-zero) before computing price.
  • getPurchasePrice(purchaseValue, amount) calls getBuyBackPrice(purchaseValue) using the user’s ETH contribution as buyBackValue, then applies a discount.

This induces an asymmetric treatment of ETH flows when purchase and buyBack are interleaved in a single transaction. In particular:

  1. Before any exploit loop, OMPxContract holds some ETH reserve R0 and totalSupply S.
  2. In a purchase call, the contract evaluates prices using eth = R0 - 100 ETH (conceptually) via getBuyBackPrice(msg.value), while the actual post-trade ETH balance will be closer to R0 + 100 ETH - fee - refund. This mismatch allows purchase of a very large token amount at a price computed from a lower effective reserve.
  3. In a subsequent buyBack with the same large token amount, getBuyBackPrice(0) uses the higher on-chain ETH balance but the same supply S, leading to an over-generous buyback payout relative to the earlier purchase cost.
  4. Repeating this purchase/buyBack pair with carefully chosen parameters produces a small ETH surplus per iteration, which can be compounded by looping while the contract’s state remains within acceptable bounds for require checks.

4.2 Evidence from OMPxContract source

The relevant binding between ETH reserves, token supply, and prices is visible in the verified source:

// OMPxContract: buyback pricing and ETH payout
function buyBack(uint256 tokensToBuyBack, uint256 minPrice) public {
    uint currentPrice = getBuyBackPrice(0);
    require(currentPrice >= minPrice);
    uint256 totalPrice = tokensToBuyBack.mul(currentPrice).div(1e18);
    // ...
    token.safeTransferFrom(msg.sender, this, tokensToBuyBack);
    // send out eth
    msg.sender.transfer(totalPrice);
}

The contract does not implement any reentrancy guard (nonReentrant) or per-transaction caps on token amounts or ETH flows for purchase or buyBack. As long as each individual call satisfies the require checks, an external helper contract is free to sequence them arbitrarily within one transaction.

4.3 Evidence from on-chain trace

The QuickNode callTracer trace for tx 0xd927843e30c6b2bf43103d83bca6abead648eac3cad0d05b1b0eb84cd87de9b6 shows the precise looping pattern. A representative excerpt:

// Seed tx call trace excerpt (tx 0xd927...de9b6)
{
  "from": "0xba12222222228d8ba445958a75a0704d566bf2c8",
  "to": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
  "type": "CALL",
  "input": "0xa9059cbb...faddf57d079b01e53d1fe3476cc83e9bcc705854...",
  "value": "0x0"
},
{
  "from": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
  "to": "0xfaddf57d079b01e53d1fe3476cc83e9bcc705854",
  "type": "CALL",
  "value": "0x56bc75e2d63100000"   // 100 WETH -> helper as ETH
},
{
  "from": "0xfaddf57d079b01e53d1fe3476cc83e9bcc705854",
  "to": "0x09a80172ed7335660327cd664876b5df6fe06108",
  "type": "CALL",
  "input": "0x70876c98...017d15453a5c62f040c5b4...0000000000000000000009184e72a000",
  "value": "0x56bc75e2d63100000"   // purchase with 100 ETH
},
{
  "from": "0x09a80172ed7335660327cd664876b5df6fe06108",
  "to": "0xfaddf57d079b01e53d1fe3476cc83e9bcc705854",
  "type": "CALL",
  "value": "0x50dc43a752c1b36f4"   // ETH back to helper
}

Later in the same transaction, the helper invokes buyBack repeatedly:

// Looping buyBack calls with same token amount
{
  "from": "0xfaddf57d079b01e53d1fe3476cc83e9bcc705854",
  "to": "0x09a80172ed7335660327cd664876b5df6fe06108",
  "type": "CALL",
  "input": "0x47d8167d...017d15453a5c62f040c5b4...00000000000000000000000000000001",
  "value": "0x0"
},
{
  "from": "0x09a80172ed7335660327cd664876b5df6fe06108",
  "to": "0xfaddf57d079b01e53d1fe3476cc83e9bcc705854",
  "type": "CALL",
  "value": "0x80f1cddb401fa696"   // ETH payout for buyBack
}

Etherscan’s internal txlist for OMPxContract over block 20468780 compresses this into multiple internal transfers where:

  • Helper sends exactly 100 ETH to OMPxContract multiple times.
  • OMPxContract sends slightly less than 100 ETH back each time, in varying amounts.

The prestate balance diff confirms the net effect:

// prestateTracer native balance deltas for tx 0xd927...de9b6
{
  "address": "0x09a80172ed7335660327cd664876b5df6fe06108",
  "delta_wei": "-4372869914943298279"
},
{
  "address": "0x40d115198d71cab59668b51dd112a07d273d5831",
  "delta_wei": "4371401571200395847"
}

OMPxContract loses exactly 4.372869914943298279 ETH, and the EOA gains 4.371401571200395847 ETH over this single transaction (the small difference is gas/miner-related). No other contract shows a correspondingly large negative delta, confirming that the loss is borne by OMPxContract reserves and crystallized as profit for the adversary.

4.4 Why this is an ACT opportunity

All components of the exploit are permissionless:

  • Balancer Vault flash-loan and WETH unwrap/rewrap flows are callable by any contract.
  • OMPxContract’s purchase and buyBack functions are public and non-owner-gated.
  • The helper contract is a standard adversary-deployed orchestrator without privileged roles.

Any unprivileged actor with access to the same on-chain contracts and sufficient technical understanding could reproduce the exploit by deploying an equivalent helper and sending the same type of transaction.

5. Adversary Flow Analysis

5.1 Adversary-related cluster

  • 0x40d115198d71cab59668b51dd112a07d273d5831 (EOA):

    • Originator of the exploit tx 0xd927843e30c6b2bf43103d83bca6abead648eac3cad0d05b1b0eb84cd87de9b6.
    • Deployed helper contract in the immediately preceding tx 0xfd1b40b640d0bb798499387a15a6adc1c97bed918edcdf7a58e38068aac14af2 at block 20468778.
    • Receives the helper’s final SELFDESTRUCT payout.
  • 0xfaddf57d079b01e53d1fe3476cc83e9bcc705854 (helper/orchestrator contract):

    • Created by the EOA one block earlier.
    • Implements Balancer/WETH interaction and the looping strategy against OMPxContract.
    • Self-destructs to the EOA at the end of the exploit.

5.2 End-to-end sequence

  1. Helper deployment (block 20468778, tx 0xfd1b40b6...4af2)
    EOA 0x40d1...5831 deploys helper contract 0xfadd...5854. The decompiled bytecode shows functions:

    // Helper contract (0xfadd...5854), Heimdall decompile excerpt
    function kill(address arg0) public { /* onlyOwner selfdestruct */ }
    function Unresolved_70bfc736(address arg0) public pure { /* argument check */ }
    function Unresolved_a04a0908(address arg0, uint256 arg1, uint256 arg2) public payable { /* transfer logic */ }
    

    Ownership and the presence of a kill(address) onlyOwner function confirm that the EOA controls the helper.

  2. Flash-loan priming (block 20468780, tx 0xd927...de9b6, outer call)
    The exploit tx is sent from the EOA to the helper with calldata selector 0x70bfc736 and arguments pointing to OMPxContract and OMPxToken. Inside the helper:

    • Balancer Vault transfers 100 WETH to the helper.
    • The helper calls WETH’s withdraw(100 ETH) and receives 100 ETH.
    • The helper sends 100 ETH to OMPxContract and begins the looping sequence.
  3. Exploit execution loop against OMPxContract (same tx)
    Within the same transaction, the helper executes multiple iterations of:

    • purchase(uint256,uint256) via selector 0x70876c98 with value 100 ETH and a very large token amount parameter.
    • buyBack(uint256,uint256) via selector 0x47d8167d with the same large token amount.

    Internal traces show that each iteration:

    • Transfers 100 ETH from helper to OMPxContract.
    • Causes OMPxContract to transfer a slightly smaller amount of ETH back to the helper.
    • Moves OMPxToken between OMPxContract and the helper without materially changing totalSupply.

    Over dozens of iterations, the net effect is an ETH shortfall of 4.372869914943298279 from OMPxContract, consistent with the prestate balance diff.

  4. Flash-loan repayment and profit realization (same tx)
    After completing the loop:

    • The helper wraps enough ETH back into WETH via WETH.deposit.
    • It repays the Balancer Vault 100 WETH plus protocol fees using standard ERC-20 transfer calls.
    • The helper then executes SELFDESTRUCT, sending its remaining ETH balance 0x3caf8e04e9301ae7 wei (~4.3714 ETH) to the EOA.
  5. Post-exploit consolidation
    Subsequent transactions from the EOA (outside the seed tx) redistribute the profit, but no further interaction with OMPxContract is required to realize the gain. All exploit value transfer is completed within the single transaction 0xd927...de9b6.

6. Impact & Losses

  • Victim contract: OMPxContract 0x09a80172ed7335660327cd664876b5df6fe06108 on Ethereum mainnet.
  • Primary loss:
    • Native ETH loss from OMPxContract: 4.372869914943298279 ETH (delta_wei -4372869914943298279).
  • Adversary profit:
    • EOA 0x40d1...5831 net gain: 4.371401571200395847 ETH (delta_wei +4371401571200395847).
    • Remaining ~0.0888 ETH is paid out to 0xdf99a0839818b3f120ebac9b73f82b617dc6a555 as fees/miner payout.

From a protocol perspective, the entire 4.372869914943298279 ETH loss is borne by OMPxContract’s reserves, directly reducing the ETH collateral backing OMPxToken and harming all token holders. The bonding-curve invariant that reserves should track outstanding supply at the advertised curve price is broken by the adversary’s loop.

7. References

  • Seed transaction and traces

    • Ethereum mainnet tx 0xd927843e30c6b2bf43103d83bca6abead648eac3cad0d05b1b0eb84cd87de9b6: raw tx, receipt, and QuickNode callTracer trace under
      artifacts/root_cause/data_collector/iter_2/tx/1/0xd927...de9b6/{tx.json,receipt.json,trace.call_tracer.json}.
    • Prestate native balance diff for the same tx under
      artifacts/root_cause/data_collector/iter_2/tx/1/0xd927...de9b6/balance_diff.prestate_tracer.json.
  • Contracts

    • OMPxContract verified source (bonding-curve + pricing logic) at
      artifacts/root_cause/data_collector/iter_1/contract/1/0x09a80172ed7335660327cd664876b5df6fe06108/source/etherscan_getsourcecode.json.
    • OMPxToken verified source at
      artifacts/root_cause/data_collector/iter_1/contract/1/0x633b041c41f61d04089880d7b5c7ed0f10ff6f85/source/etherscan_getsourcecode.json.
    • Helper contract 0xfaddf57d079b01e53d1fe3476cc83e9bcc705854 Heimdall decompiled code at
      artifacts/root_cause/data_collector/iter_2/contract/1/0xfaddf57d079b01e53d1fe3476cc83e9bcc705854/decompile/0xfaddf57d079b01e53d1fe3476cc83e9bcc705854-decompiled.sol.
  • Account histories

    • EOA 0x40d115198d71cab59668b51dd112a07d273d5831 normal txlist (showing helper deployment and exploit tx) at
      artifacts/root_cause/data_collector/iter_2/address/1/0x40d115198d71cab59668b51dd112a07d273d5831/txlist.normal.etherscan_v2.json.
    • OMPxContract internal txlist around the incident block window at
      artifacts/root_cause/data_collector/iter_2/address/1/0x09a80172ed7335660327cd664876b5df6fe06108/txlist.internal.etherscan_v2.json.