Reproduced Exploit

Etherspot — a batch consumes lockedAmount twice

_validateBatchCall calls _validateTokenData for every item. That function accepts an item when the wallet balance covers amount and amount equals lockedAmount, then does claimedAmount += amount. It never checks that claimedAmount is still zero.

Jul 2025•Other•logic•4 min read

Chain

Other

Category

logic

Date

Jul 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: 61395-c-01-consumers-can-double-the-lockedamount-in-the-session-dr. Standalone Foundry PoC and full write-up: 61395-c-01-consumers-can-double-the-lockedamount-in-the-session-dr_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/missing-check · vuln/logic/state-update

Reproduction: a self-contained Foundry PoC that compiles and runs offline with only forge-std — no fork and no RPC. Trace: output.txt. PoC: test/61395-c-01-consumers-can-double-the-lockedamount-in-the-session-dr_exp.sol.


Key info#

ImpactHIGH — the session takes 1,000 extra tokens. The wallet loses 2,000 on a 1,000 lock, and claim pays 0
ProtocolEtherspot CredibleAccountModule
Vulnerable code_validateTokenData adds claimedAmount with no check that it is still zero
Bug classA batch counts the same lock once per item
FindingShieldify — Credible Account Module mitigation review, July 2025 · C-01 · id 61395
ReportEtherspot-CredibleAccountModule-Mitigation-Security-Review.md
SourceAuditVault finding
StatusFixed by requiring claimedAmount == 0 before the increment. Reproduced here as a local synthetic.
Compilersolc 0.8.27, cancun, via IR

TL;DR#

_validateBatchCall calls _validateTokenData for every item. That function accepts an item when the wallet balance covers amount and amount equals lockedAmount, then does claimedAmount += amount. It never checks that claimedAmount is still zero.

A two-item batch, each for the 1,000 lock, against a wallet holding 2,000:

  1. Both validations pass. claimedAmount becomes 2,000.
  2. The wallet sends 1,000 to the invoice and 1,000 extra to the session caller.
  3. isSessionClaimed is false because 1,000 != 2,000. claim returns 0.

The vulnerable code#

Pre-fix check, reduced to one locked token and a batch of two equal amounts:

SOLIDITY
function _validateTokenData(address sessionKey, address wallet, uint256 amount, address token) internal returns (bool) {
    LockedToken[] storage tokens = _locks[sessionKey];
    for (uint256 i; i < tokens.length; ++i) {
        if (tokens[i].token == token) {
            if (_walletTokenBalance(wallet, token) >= amount && amount == tokens[i].lockedAmount) {
                // @> VULN: claimedAmount grows again with no check that this lock was already consumed
                tokens[i].claimedAmount += amount;
                return true;
            }
        }
    }
    return false;
}

The fixed condition also requires tokens[i].claimedAmount == 0. The second batch item then fails, only 1,000 moves, and claim pays the lock.

Root cause#

The balance check runs before any transfer, so every item in the batch still sees the full wallet balance. Equality with lockedAmount is not a one-time gate. Each passing item adds the whole lock to claimedAmount.

Preconditions#

  • The session has one locked token. Here lockedAmount is 1,000.
  • The wallet holds at least twice that, here 2,000, and has approved the module.
  • The batch contains two items that each spend exactly lockedAmount of that token.

Attack walkthrough#

  1. Mint 2,000 to the wallet and approve the module. Enable a session that locks 1,000.
  2. executeBatch validates the amount twice, then pulls 1,000 to the invoice and 1,000 to the caller.
  3. claimedAmount is 2,000. The caller holds the extra 1,000. The wallet balance is 0.
  4. isSessionClaimed returns false. claim returns 0.

Control: the second validation fails because claimedAmount is no longer zero. taken is 1,000, the caller receives nothing, the wallet keeps 1,000, and claim pays 1,000.

Diagrams#

flowchart TD A["Wallet holds 2000 and the session locks 1000"] --> B["Batch validates that 1000 twice"] B --> C["claimedAmount becomes 2000"] C --> D["Wallet sends 1000 to the invoice and 1000 extra to the caller"] D --> E["claimed no longer equals locked"] E --> F["claim pays 0"]

Impact#

The smart wallet loses 1,000 tokens beyond the lock. The session caller keeps that extra. The settlement claim pays 0, so the session cannot be closed as claimed. A longer batch of the same amount repeats the pull once per item, for as long as the wallet balance covers another lockedAmount.

Remediation#

Accept the token only when claimedAmount is still zero, then set it to lockedAmount once. Shieldify's fix adds tokens[i].claimedAmount == 0 to the condition in _validateTokenData.

Sources#

Taxonomy#

  • vuln/logic/missing-check
  • vuln/logic/state-update

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.