Reproduced Exploit

Hanji LOB: getTraderBalance is stale during the withdraw callback

withdraw sends the tokens and only then decreases balances[trader]. The token notifies the recipient in between. getTraderBalance during that callback still returns 1000. A lender that requires getTraderBalance >= amount transfers another 1000. The trader keeps the honest 1000 withdraw and the atta…

Jan 2025•Other•reentrancy•2 min read

Chain

Other

Category

reentrancy

Date

Jan 2025

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: 56842-potential-read-only-reentrancy-mixbytes-none-xpress-markdown. Standalone Foundry PoC and full write-up: 56842-potential-read-only-reentrancy-mixbytes-none-xpress-markdown_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/reentrancy/read-only · vuln/logic/state-update

Reproduction: cheatcode-free local synthetic. The balance write is after the token transfer. Full trace: output.txt. PoC: test/56842-potential-read-only-reentrancy-mixbytes-none-xpress-markdown_exp.sol.

AuditVault taxonomy: lang/solidity · sector/dex · severity/high · MixBytes XPress / Hanji OnchainCLOB

Key info#

ProtocolXPress / Hanji OnchainCLOB
FindingMixBytes, AuditVault 56842, high
Vulnerable codeLOB.withdraw / getTraderBalance
HarmLender pays 1000 BOOK against a view that still shows 1000 after the collateral has already moved
Chainlocal synthetic

TL;DR#

withdraw sends the tokens and only then decreases balances[trader]. The token notifies the recipient in between. getTraderBalance during that callback still returns 1000. A lender that requires getTraderBalance >= amount transfers another 1000. The trader keeps the honest 1000 withdraw and the attacker EOA receives the extra 1000. Updating the balance before transfer makes the same callback see 0, so the lender pays nothing and keeps its 1000.

The vulnerable code#

SOLIDITY
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "bal");
    token.transfer(msg.sender, amount);
    // @> VULN: storage balance is updated after the token transfer notifies the recipient
    balances[msg.sender] -= amount;
}

Diagrams#

flowchart TD A["Trader book balance is 1000"] --> B["withdraw transfers 1000 BOOK"] B --> C["Callback reads getTraderBalance"] C --> D["View still returns 1000"] D --> E["Lender sends another 1000"] E --> F["Book then subtracts the 1000"] F --> G["Attacker keeps the extra 1000"]

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.