Reproduced Exploit
Coinbase/Base FeeVault — `totalProcessed` inflated without sending funds (unchecked `SafeCall.send`)
1. FeeVault.withdraw() does totalProcessed += value before attempting the outbound transfer. 2. It transfers via SafeCall.send(RECIPIENT, gasleft(), value), which performs a low-level call and returns false on failure instead of reverting — and
Chain
Optimism
Category
dependency
Date
Jul 2023
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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, Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 54655-vault-s-totalprocessed-count-can-be-inaccurately-increased-b. Standalone Foundry PoC and full write-up: 54655-vault-s-totalprocessed-count-can-be-inaccurately-increased-b_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/dependency/unchecked-return-value · vuln/dos/unbounded-loop · vuln/accounting/integrity
Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only
forge-std— no fork, no RPC, noanvil_state. Full trace: output.txt. PoC: test/54655-vault-s-totalprocessed-count-can-be-inaccurately-increased-b_exp.sol.
Key info#
| Impact | HIGH — fee-accounting integrity broken: totalProcessed is inflated by funds that never moved, misleading off-chain reconciliation and double-counting the same ETH on the next successful withdraw |
| Protocol | Coinbase / Base — Optimism FeeVault (BaseFeeVault / SequencerFeeVault / L1FeeVault) |
| Vulnerable code | FeeVault.withdraw() — totalProcessed += value before an unchecked SafeCall.send() |
| Bug class | Unchecked low-level call return value; SafeCall.send returns false on failure instead of reverting |
| Finding | Cantina — Coinbase review, Jul 2023 · #54655 · reporter Zach Obront |
| Report | cantina_coinbase_july2023.pdf |
| Source | AuditVault |
| Status | Audit finding — caught in review (not exploited on-chain). Reproduced here as a standalone local PoC. |
| Compiler | ^0.8.19 (PoC) |
This is an audit finding, not a historical on-chain incident. The PoC copies
SafeCall.send and FeeVault.withdraw verbatim and reproduces the accounting
corruption locally.
TL;DR#
FeeVault.withdraw()doestotalProcessed += valuebefore attempting the outbound transfer.- It transfers via
SafeCall.send(RECIPIENT, gasleft(), value), which performs a low-levelcalland returnsfalseon failure instead of reverting — andwithdraw()ignores the return value. - The real
RECIPIENT(aFeeDisburser) does non-trivial work inreceive(). Becausewithdraw()forwardsgasleft(), a caller who constrains the tx gas budget starves the recipient, whosereceive()runs out of gas and reverts. - The value-bearing call reverts (ETH rolled back into the vault),
SafeCall.sendreturnsfalse, butwithdraw()completes anyway — withtotalProcessedalready inflated. - Result:
totalProcessedclaims the full balance was disbursed while no ETH left the vault and the recipient got nothing — corrupting fee accounting and double-counting the funds on the next successful withdraw.
The vulnerable code#
withdraw() (verbatim from the finding):
uint256 value = address(this).balance;
totalProcessed += value; // @> premature credit, BEFORE the transfer
emit Withdrawal(value, RECIPIENT, msg.sender);
emit Withdrawal(value, RECIPIENT, msg.sender, WITHDRAWAL_NETWORK);
if (WITHDRAWAL_NETWORK == WithdrawalNetwork.L2) {
SafeCall.send(RECIPIENT, gasleft(), value); // @> return value ignored
}
SafeCall.send (verbatim) returns the success flag rather than reverting:
function send(address _target, uint256 _gas, uint256 _value) internal returns (bool) {
bool _success;
assembly { _success := call(_gas, _target, _value, 0, 0, 0, 0) }
return _success; // caller must check this — withdraw() does not
}
Root cause#
Two mistakes compose: (1) totalProcessed is credited before the transfer,
and (2) the transfer's success is never checked (SafeCall.send signals
failure by return value, not revert). Since withdraw() forwards gasleft(), any
caller can pick a gas budget that starves a gas-consuming recipient, making the
transfer fail while withdraw() still succeeds with inflated accounting.
Preconditions#
withdraw()is permissionless (intended).- The
RECIPIENTconsumes non-trivial gas inreceive()(the real Base recipient is aFeeDisburser— modeled here by a gas-heavy recipient). - The caller constrains the transaction gas so the forwarded 63/64 is insufficient.
Attack walkthrough#
From output.txt:
- The FeeVault holds 10 ETH;
totalProcessed == 0, recipient balance== 0. - Any user calls
withdraw{gas: 1_000_000}(). The 63/64 rule forwards ~945k gas to the recipient. totalProcessed += 10 etherexecutes (premature credit).SafeCall.sendcalls the recipient, whosereceive()loop needs billions of gas → out of gas → revert → the value transfer is rolled back andsendreturnsfalse.withdraw()ignores thefalseand completes.- HARM:
totalProcessed == 10 ETH, butvault.balance == 10 ETH(funds stuck) andrecipient.balance == 0. Accounting is corrupted; the 10 ETH will be double-counted on the next successful withdraw. Addingrequire(success)makes the PoC revert — the fix-sensitivity control.
Diagrams#
Remediation#
Check the return value (revert on failure) and/or credit totalProcessed only
after a confirmed transfer:
bool success = SafeCall.send(RECIPIENT, gasleft(), value);
require(success, "FeeVault: ETH transfer failed");
Applying require(success) makes the PoC revert — confirming the fix.
How to reproduce#
cd ~/RustroverProjects/audits/evm-hack-registry/54655-vault-s-totalprocessed-count-can-be-inaccurately-increased-b_exp
forge test --match-test test_totalProcessedInflatedWithoutSendingFunds -vvv
# Fully local — no fork, no RPC, no anvil_state required.
# Expected: [PASS]; totalProcessed == 10 ETH while vault balance == 10 ETH and recipient == 0.
PoC source: test/54655-vault-s-totalprocessed-count-can-be-inaccurately-increased-b_exp.sol
— verbatim SafeCall.send + FeeVault.withdraw, with a gas-heavy recipient that
deterministically OOGs to trigger the swallowed failure.
Note: the reduction forces the send to fail via an always-gas-heavy recipient (rather than the finding's attacker-chosen
gas: 55_000on a normal recipient) — same vulnerable line, same fix. No actual fund loss occurs (funds stay in the vault); the harm is accounting corruption / double-count on the next withdraw, exactly as the finding describes.
Reference: finding #54655 by Zach Obront in the Cantina Coinbase review (Jul 2023) · curated by AuditVault
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 54655-vault-s-totalprocessed-count-can-be-inaccurately-increased-b_exp (evm-hack-registry mirror).
- AuditVault finding: 54655-vault-s-totalprocessed-count-can-be-inaccurately-increased-b.
Alerts & third-party analyses
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.