Reproduced Exploit

ELFi: a -110 margin update leaves WETH at 0 instead of 30

When the cross-margin change is negative and larger than a position's from-balance, the code sets initialMarginInUsdFromBalance to 0 and then computes the consumed token amount from that storage. The amount is 0. SOL's 50 is not subtracted from the -110, so WETH receives the whole -110 and its 90 i…

May 2024•Other•logic•3 min read

Chain

Other

Category

logic

Date

May 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: 34767-h-5-updatepositionfrombalancemargin-function-returns-0-if-am. Standalone Foundry PoC and full write-up: 34767-h-5-updatepositionfrombalancemargin-function-returns-0-if-am_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/incorrect-order-of-operations · vuln/logic/liquidation-logic

Key info#

ProtocolELFi
FindingSherlock 2024-05-elfi-protocol H-5, AuditVault 34767
Vulnerable codePositionMarginProcess.updatePositionFromBalanceMargin
HarmWETH from-balance ends at 0 instead of 30. The account borrows that 30. The control leaves 30 and borrows 0
Chainlocal synthetic

TL;DR#

When the cross-margin change is negative and larger than a position's from-balance, the code sets initialMarginInUsdFromBalance to 0 and then computes the consumed token amount from that storage. The amount is 0. SOL's 50 is not subtracted from the -110, so WETH receives the whole -110 and its 90 is wiped. Fair ordering consumes 50 from SOL first, leaves -60, and WETH keeps 30. The missing 30 is borrowed.

The vulnerable code#

SOLIDITY
if (position.initialMarginInUsdFromBalance <= addBorrowMarginInUsd) {
    position.initialMarginInUsdFromBalance = 0;
    // @> VULN: change amount is read after the storage was set to 0
    lastChange = position.initialMarginInUsdFromBalance * position.initialMargin / position.initialMarginInUsd;
}

SOL: margin 100, from-balance USD 50. The update is -110. addBorrow is 110. 50 <= 110, so from-balance becomes 0 and lastChange is 0. Remaining stays -110.

WETH: margin 100, from-balance USD 90. addBorrow is still 110. 90 <= 110, so WETH is also set to 0. It should have been decreased by 60 and left at 30.

The control computes consumed before the store:

SOLIDITY
uint256 consumed = position.initialMarginInUsdFromBalance * position.initialMargin / position.initialMarginInUsd;
position.initialMarginInUsdFromBalance = 0;

SOL consumes 50. Remaining is -60. WETH's from-balance becomes 30. borrowShortfall transfers nothing.

Root cause#

The amount consumed from this position is derived from the same storage slot the previous line just cleared.

Preconditions#

Two cross positions. SOL from-balance USD is 50. WETH from-balance USD is 90. Both notionals are 100. The settled change is -110. No price update and no time change. This is not the updateAllPositionFromBalanceMargin bug.

Attack walkthrough#

  1. The book is funded with 30, the amount fair accounting would retain. See output.txt.
  2. update(-110) runs the buggy branch on SOL. lastChange is 0.
  3. WETH is wiped. wethLeft is 0.
  4. borrowShortfall sends 30 to the account.
  5. The control's updateFair(-110) leaves WETH at 30 and borrows 0.

Diagrams#

flowchart TD A["Cross update is -110"] --> B["SOL from-balance 50 is set to 0"] B --> C["Consumed amount is read as 0"] C --> D["Remaining stays -110"] D --> E["WETH from-balance 90 is wiped to 0"] E --> F["Fair result would be 30"] F --> G["Account borrows the missing 30"]

Impact#

WETH's from-balance is 0 rather than 30. The account's margin is 30 too low, and it borrows that 30. The control keeps the 30 inside the book.

Remediation#

Read initialMarginInUsdFromBalance * initialMargin / initialMarginInUsd into the consumed amount before assigning 0.

How to reproduce#

BASH
cd 34767-h-5-updatepositionfrombalancemargin-function-returns-0-if-am_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.