Reproduced Exploit
Meson release to a contract reverts on USDT and leaves 1,000 USDT stuck
Releasing funds to a smart-contract wallet calls IERC20Minimal.increaseAllowance, which ABI-decodes a bool. USDT does not implement that method, so the call reverts with no bool and transferWithBeneficiary never commits. 1,000 USDT stays in the pool and the beneficiary receives 0. The same call on…
Loss
1,000 USDT (1000000000 base units) stays in the pool. The contract beneficiary receives 0 USDT
Chain
Ethereum
Category
dependency
Date
Oct 2022
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, 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.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 18109-usdt-transfers-to-third-party-contracts-will-fail-trailofbit. Standalone Foundry PoC and full write-up: 18109-usdt-transfers-to-third-party-contracts-will-fail-trailofbit_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/dependency/unsafe-external-call · vuln/dos/frozen-funds · vuln/logic/missing-allowance
Reproduction: self-contained synthetic Foundry reduction; see output.txt.
Key info#
| Field | Value |
|---|---|
| Loss | 1,000 USDT (1000000000 base units) stays in the pool. The contract beneficiary receives 0 USDT |
| Vulnerable contract | MesonRelease.releaseToContract (reduction of MesonHelpers._transferToContract) |
| Attacker EOA | 0x1111111111111111111111111111111111111111 |
| Attack contract | Exploit |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2022-10 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | High-level increaseAllowance against a USDT-like token that returns no bool |
TL;DR#
Releasing funds to a smart-contract wallet calls IERC20Minimal.increaseAllowance, which ABI-decodes a bool. USDT does not implement that method, so the call reverts with no bool and transferWithBeneficiary never commits. 1,000 USDT stays in the pool and the beneficiary receives 0. The same call on a token whose increaseAllowance returns true lets the wallet pull 1,000 to the beneficiary.
Background#
Trail of Bits reviewed Meson from 22 August to 9 September 2022 (final report 3 October 2022, Damilola Edwards). To pay a third-party contract, MesonHelpers increases that contract's allowance and then calls transferWithBeneficiary. increaseAllowance is an OpenZeppelin helper, not part of ERC-20. Mainnet USDT does not implement it, and USDT's transfer / approve / transferFrom return no bool. A user who releases a USDT swap to a contract wallet cannot complete the release. The tokens already locked in the pool stay there.
The vulnerable code#
uint256 adjustedAmount = amount;
// @> VULN: increaseAllowance is not ERC-20. USDT does not implement it and returns no bool, so this reverts and the transfer to the contract never commits.
IERC20Minimal(token).increaseAllowance(contractAddr, adjustedAmount);
ITransferWithBeneficiary(contractAddr).transferWithBeneficiary(token, adjustedAmount, beneficiary, 1);
The audited lines are IERC20Minimal(token).increaseAllowance(contractAddr, adjustedAmount) then transferWithBeneficiary (MesonHelpers.sol lines 66–67 in the report). The interface declares returns (bool). USDT has no such function and no fallback, so the selector 0x39509351 reverts and the outer release reverts with it.
Root cause#
The release path assumes every supported token implements OpenZeppelin's increaseAllowance and returns a bool. USDT does neither. The transfer to the contract is reverted, not completed. A later deployment switched this call to approve, which is what the report recommended. This reduction keeps the audited call.
Preconditions#
- The pool already holds the user's 1,000 USDT (6 decimals). That deposit is a separate, successful mint into the pool.
- The recipient is a contract wallet with
transferWithBeneficiary. - The out-token is USDT-like:
transfer,approve, andtransferFromreturn no bool, andincreaseAllowanceis absent. - The control token implements
increaseAllowanceand returns true, and itstransferFromreturns true.
Attack walkthrough#
mintparks 1000000000 USDT in the pool (output.txt:44).balanceOfreads it back (output.txt:50).releaseToContractcallsUsdtLike.increaseAllowance(output.txt:52).- The token reverts: unrecognized selector
0x39509351, no fallback (output.txt:53). The release reverts (output.txt:54). - The pool still holds 1000000000 USDT (output.txt:56). The beneficiary's USDT balance is 0 (output.txt:58). Allowance stays 0 (output.txt:60).
- Control: the same release against
ReturningToken.increaseAllowancereturns true (output.txt:70). - The wallet's
transferFromreturns true and moves 1000000000 to0x2222222222222222222222222222222222222222(output.txt:72, output.txt:77, output.txt:81). The pool's balance of that token is 0 (output.txt:83). - The test logs stuck
1000000000(output.txt:13), USDT paid0(output.txt:15), and control received1000000000(output.txt:19).[PASS](output.txt:6).
Diagrams#
Impact#
The USDT beneficiary receives nothing. 1,000 USDT (1000000000 base units) remains in the pool, so that swap cannot be released to a contract wallet. The same amount of a token that returns true from increaseAllowance is pulled to 0x2222222222222222222222222222222222222222. No attacker profit is required. The harm is the locked payout.
Remediation#
Do not call increaseAllowance. Set the allowance with the ERC-20 approve path, and do it with a low-level call that treats empty returndata as success, the same way Meson already treats transfer. Fetch the current allowance, add the amount, and approve the sum in the same transaction so the update stays atomic.
- IERC20Minimal(token).increaseAllowance(contractAddr, adjustedAmount);
+ uint256 next = IERC20Minimal(token).allowance(address(this), contractAddr) + adjustedAmount;
+ token.call(abi.encodeWithSelector(0x095ea7b3, contractAddr, next));
ITransferWithBeneficiary(contractAddr).transferWithBeneficiary(token, adjustedAmount, beneficiary, data);
A high-level approve that ABI-decodes a bool still reverts on USDT, because USDT approve returns no bool. The low-level call is the part that accepts that shape.
How to reproduce#
cd 18109-usdt-transfers-to-third-party-contracts-will-fail-trailofbit_exp
forge test -vv
Sources#
- AuditVault finding: 18109-usdt-transfers-to-third-party-contracts-will-fail-trailofbit
- Trail of Bits, Meson Protocol Security Assessment, 3 October 2022: https://github.com/trailofbits/publications/blob/master/reviews/MesonProtocol.pdf
MesonFi/meson-contracts-soliditycontracts/utils/MesonHelpers.sol_transferToContract(report figureMesonHelpers.sol:66–67).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 18109-usdt-transfers-to-third-party-contracts-will-fail-trailofbit_exp (evm-hack-registry mirror).
- AuditVault finding: 18109-usdt-transfers-to-third-party-contracts-will-fail-trailofbit.
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.