Reproduced Exploit

Meson credits a codeless transferFrom and then pays out 1000 real USDC

_unsafeDepositToken calls transferFrom and accepts the call when success is true and the returndata is empty. An address with no code, the state of a token after selfdestruct, returns exactly that. postSwap therefore adds 1000 USDC of credit while the pool's USDC balance stays 1000. withdraw then s…

Oct 2022•Ethereum•input-validation•4 min read

Loss

1000 USDC (1000000000 base units) withdrawn from the pool. The deposit that created the credit moved 0 USDC

Chain

Ethereum

Category

input-validation

Date

Oct 2022

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: 18108-lack-of-contract-existence-check-on-transfer-transferfrom-ca. Standalone Foundry PoC and full write-up: 18108-lack-of-contract-existence-check-on-transfer-transferfrom-ca_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/input-validation/missing · vuln/dependency/unchecked-return-value · vuln/logic/missing-check

Reproduction: self-contained synthetic Foundry reduction; see output.txt.

Key info#

FieldValue
Loss1000 USDC (1000000000 base units) withdrawn from the pool. The deposit that created the credit moved 0 USDC
Vulnerable contractMesonPool._unsafeDepositToken (reduction of MesonHelpers._unsafeDepositToken)
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2022-10
Compilersolc 0.8.27, cancun, via IR
Bug classLow-level call success on an account with no code

TL;DR#

_unsafeDepositToken calls transferFrom and accepts the call when success is true and the returndata is empty. An address with no code, the state of a token after selfdestruct, returns exactly that. postSwap therefore adds 1000 USDC of credit while the pool's USDC balance stays 1000. withdraw then sends that real balance to the caller. Requiring token.code.length > 0 makes the same post revert and leaves the 1000 USDC in the pool.

Background#

Trail of Bits reviewed Meson from 22 August to 9 September 2022 (final report 3 October 2022, Damilola Edwards, TOB-MES-10). MesonHelpers._unsafeDepositToken uses token.call with selector 0x23b872dd (transferFrom) and does not check that token has code. The EVM defines a call to an empty account as successful. The require also treats empty returndata as success, which is how tokens that do not return a bool are supported, and how a missing contract slips through. The pool in this reduction already holds LP USDC. The posted "token" is address(0xdead), which has no code. No selfdestruct is required to reach the state the report describes.

The vulnerable code#

SOLIDITY
(bool success, bytes memory data) = token.call(
    abi.encodeWithSelector(bytes4(0x23b872dd), sender, address(this), amount)
);
require(success && (data.length == 0 || abi.decode(data, (bool))), "transferFrom failed");

postSwap then does credit[msg.sender] += amount. withdraw spends that credit with a real usdc.transfer.

Root cause#

success from call is not evidence that a contract ran. Combined with "empty returndata means success", a codeless address is indistinguishable from a token whose transferFrom returns nothing. The credit is increased off that boolean alone.

Preconditions#

  • The pool holds 1000 USDC of real liquidity. It is minted to the pool before run.
  • The posted token is not address(0) (that case is already rejected) but has codesize 0.
  • The attacker later calls withdraw for the credited amount.

Attack walkthrough#

  1. CODELESS.code.length is 0 (output.txt).
  2. postSwap(CODELESS, 1000e6) performs the low-level call. success is true and data.length is 0, so the require passes.
  3. credit becomes 1000000000. usdc.balanceOf(pool) is still 1000000000. The log line "pool USDC while that credit was outstanding" is 1000000000.
  4. withdraw(1000e6) clears the credit and transfers 1000 USDC to the caller. The pool balance is 0. The caller balance is 1000000000.
  5. Control postSwapChecked requires token.code.length > 0. The call reverts, posted is false, credit stays 0, and the pool still holds 1000000000.

Diagrams#

flowchart TD Hold["Pool holds 1000 real USDC"] --> Call["postSwap calls transferFrom on a codeless address"] Call --> Ok["The call returns success and empty data"] Ok --> Credit["Credit increases by 1000 USDC"] Credit --> Still["The USDC balance does not move"] Still --> Pay["withdraw sends the real 1000 USDC"] Pay --> Gate["A codesize check reverts the post and keeps the USDC"]

Impact#

The attacker receives 1000 USDC that no deposit transferred in. The phantom credit and the pool balance were both 1000 USDC at the same time, so the credit was not backed by a transfer. The checked path pays 0.

Remediation#

Require token.code.length > 0 before the low-level call, or compare the token balance before and after and require it increased by amount.

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.