Reproduced Exploit

Omo topOff locks a redeem that names another receiver

A user deposits 1000 OUSD and requests a redeem that pays a different receiver. The registered agent calls topOff. The function loads userToAccount[record.receiver]. That receiver has no Dynamic Account, so the call reverts NO_REGISTERED_ACCOUNT. withdraw returns 0 and the 1000 OUSD stays in the va…

Jan 2025•Ethereum•dos•4 min read

Loss

1000 OUSD stuck in the vault. topOff reverts and withdraw pays 0

Chain

Ethereum

Category

dos

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: 53315-c-04-funds-get-locked-in-the-vault-pashov-audit-group-none-o. Standalone Foundry PoC and full write-up: 53315-c-04-funds-get-locked-in-the-vault-pashov-audit-group-none-o_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/dos/frozen-funds · vuln/logic/missing-check

Key info#

FieldValue
Loss1000 OUSD stuck in the vault. topOff reverts and withdraw pays 0
Vulnerable contractOmoVault.topOff
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2025-01
Compilersolc 0.8.27, cancun, via IR
Bug classAgent authorization is checked on the redeem receiver

TL;DR#

A user deposits 1000 OUSD and requests a redeem that pays a different receiver. The registered agent calls topOff. The function loads userToAccount[record.receiver]. That receiver has no Dynamic Account, so the call reverts NO_REGISTERED_ACCOUNT. withdraw returns 0 and the 1000 OUSD stays in the vault. The same redeem on a vault that loads userToAccount[record.owner] pays the receiver 1000.

Background#

Pashov Audit Group reviewed Omo on 2025-01-25 (C-04, high). After a user requests a redeem, an agent registered on that user's Dynamic Account is supposed to call OmoVault.topOff and finish the payout. The implementation looks the agent up on record.receiver instead of record.owner.

The vulnerable code#

SOLIDITY
Record storage record = pending;
// @> VULN: userToAccount is keyed by record.receiver, who never registered an agent
address userAccount = userToAccount[record.receiver];
require(userAccount != address(0), "NO_REGISTERED_ACCOUNT");

The report's fix changes the key:

DIFF
-        address userAccount = userToAccount[record.receiver];
+        address userAccount = userToAccount[record.owner];
         require(userAccount != address(0), "NO_REGISTERED_ACCOUNT");

Root cause#

The redeem record has two addresses. owner is the user who deposited and who registered the Dynamic Account. receiver is who should be paid, and that address is allowed to be someone else. topOff treats them as the same account. When they differ, the receiver key is empty even though the owner's agent is authorized.

Preconditions#

  • The owner has registered a Dynamic Account whose agents(0) is the caller.
  • The owner has deposited 1000 OUSD and requested a redeem of that 1000 to a different receiver.
  • The receiver has no userToAccount entry.

Attack walkthrough#

  1. 1000 OUSD is minted to the owner and deposited into the vault.
  2. The owner requests a redeem with receiver set to a second account and assets set to 1000.
  3. The agent calls topOff.
  4. userToAccount[record.receiver] is address(0).
  5. The call reverts NO_REGISTERED_ACCOUNT before the transfer.
  6. withdraw returns 0. The receiver's balance is 0. The vault still holds 1000.

Diagrams#

flowchart TD A["Owner deposits 1000 OUSD"] --> B["Owner requests redeem to a different receiver"] B --> C["Agent calls topOff"] C --> D["userToAccount is read at record.receiver"] D --> E["That account is address 0"] E --> F["topOff reverts NO_REGISTERED_ACCOUNT"] F --> G["withdraw pays 0 and 1000 OUSD stays in the vault"]

Impact#

The 1000 OUSD cannot be paid to the receiver the owner named. withdraw returns 0 and the vault balance stays 1000. Keying the same lookup on record.owner lets topOff pass the agent check and transfer the 1000 to the receiver, leaving the vault at 0.

PoC#

BASH
cd 53315-c-04-funds-get-locked-in-the-vault-pashov-audit-group-none-o_exp
forge test -vv

test_exploit_redeem_to_other_receiver_locks_assets asserts topOff reverted, withdraw paid 0, the vault holds 1000, and the receiver holds 0, while the owner is the one with a registered account. test_control_owner_key_pays_the_receiver asserts the owner-keyed vault pays 1000 to the receiver and the vault balance is 0.

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.