SchnoodleV9 reflection allowance bug drains SNOOD/WETH liquidity
Exploit Transactions
0x9a6227ef97d7ce75732645bd604ef128bb5dfbc1bfbe0966ad1cd2870d45a20eVictim Addresses
0xd45740ab9ec920bedbd9bab2e863519e59731941EthereumLoss Breakdown
Similar Incidents
GPv2Settlement allowance leak lets router drain WETH and USDC
37%TecraCoin TcrToken burnFrom Allowance Bug Exploits
36%SBR reserve desynchronization exploit drains WETH from UniswapV2 pair
35%PLNTOKEN transferFrom burn hook drains WETH reserves
35%AnyswapV4Router WETH9 permit misuse drains WETH to ETH
34%WETH Drain via Unprotected 0xfa461e33 Callback on 0x03f9-62c0
33%Root Cause Analysis
SchnoodleV9 reflection allowance bug drains SNOOD/WETH liquidity
1. Incident Overview TL;DR
An unprivileged attacker deployed a small helper contract and, in a single Ethereum mainnet transaction, drained the SNOOD reserve from the SchnoodleV9/WETH Uniswap V2 pool and swapped the remaining SNOOD into approximately 104 WETH for their EOA. SchnoodleV9Base mis-scales allowances in its reflection logic so that _spendAllowance applies _getStandardAmount to an already standard transferFrom amount; under a huge reflect rate this rounds the allowance check down to zero, allowing transferFrom to pull tokens without any prior approval.
2. Key Background
- SchnoodleV9 is a reflection-style ERC20/777 token deployed behind a proxy at 0xd45740ab9ec920bedbd9bab2e863519e59731941 with implementation 0xeac2a259f3ebb8fd1097aeccaa62e73b6e43d5bf.
- The token uses reflected balances in storage and converts between reflected and standard units using _getReflectedAmount and _getStandardAmount based on a global reflect rate.
- A SNOOD/WETH Uniswap V2 pair exists at 0x0f6b0960d2569f505126341085ed7f0342b67dae and holds significant SNOOD liquidity that can be moved via standard ERC20 transfer and transferFrom calls.
- The attacker EOA 0x180ea08644b123d8a3f0eccf2a3b45a582075538 deploys an unverified helper contract 0x273521f6582076a5fc54da9af9bfca5435ffe9ec that orchestrates balanceOf, transferFrom, transfer, and Uniswap swap calls in one transaction.
3. Vulnerability Analysis & Root Cause Summary
SchnoodleV9Base applies its reflection conversion function _getStandardAmount to the transferFrom amount inside _spendAllowance, even though the amount is already in standard units. Because the global reflect rate is extremely large, this conversion returns zero for normal-sized amounts, so the allowance check does not constrain transferFrom and an attacker can pull tokens from arbitrary holders such as the SNOOD/WETH Uniswap pair without any approved allowance. Category: ATTACK Key security principles violated:
- Incorrect unit handling in allowance accounting breaks the standard ERC20 allowance invariant.
- Failure to maintain a consistent mapping between reflected and standard units for both balances and allowances.
- Inadequate testing or auditing of reflection math under extreme reflect-rate conditions, allowing integer underflow to bypass access control on transferFrom.
4. Detailed Root Cause Analysis
Invariant: For any holder H, spender S, and transferFrom(H -> X, amount), the token contract must enforce that allowance(H,S) and amount are expressed in the same unit and that allowance(H,S) >= amount before decreasing the allowance and moving tokens. Breakpoint operation: In SchnoodleV9Base._spendAllowance, the contract calls super._spendAllowance(owner, spender, _getStandardAmount(amount)) on the transferFrom amount that is already in standard units; given the enormous reflect rate, _getStandardAmount(amount) returns 0, so super._spendAllowance observes an amountToSpend of 0 and does not require or consume any positive allowance. SchnoodleV9Base overrides standard ERC20/777 allowance logic to store allowances in reflected units and expose a standard-unit allowance(...) view. _approve uses _getReflectedAmount(value) to write reflected allowances, allowance(...) returns _getStandardAmount(super.allowance(owner, spender)), and _spendAllowance calls super._spendAllowance(owner, spender, _getStandardAmount(amount)). In the seed transaction, the attacker contract calls SchnoodleV9.transferFrom on the SNOOD/WETH Uniswap pair with an amount equal to essentially the full SNOOD balance of the pair. At this point, the reflect rate (totalReflectedSupply / totalSupply) is on the order of 1e47, derived from the ratio between the pair's reflected balance in storage and its balanceOf return value. When _spendAllowance is invoked, it computes _getStandardAmount(amount) where amount is already in standard units; dividing this relatively small amount by the huge reflect rate underflows to 0 in integer arithmetic. As a result, super._spendAllowance is called with an amountToSpend of 0, so it neither checks nor decrements a positive allowance, effectively treating the call as if no allowance were required. This breaks the fundamental allowance invariant and allows transferFrom to move tokens from the Uniswap pair without any prior approve call. Vulnerable components:
- SchnoodleV9Base.sol: functions allowance, _approve, _spendAllowance, _getReflectedAmount, and _getStandardAmount in the implementation at 0xeac2a259f3ebb8fd1097aeccaa62e73b6e43d5bf.
- SchnoodleV9 proxy at 0xd45740ab9ec920bedbd9bab2e863519e59731941 that delegates ERC20/777 calls to SchnoodleV9Base.
- SNOOD/WETH Uniswap V2 pair at 0x0f6b0960d2569f505126341085ed7f0342b67dae, whose SNOOD reserve is exposed to unauthorized transferFrom due to the broken allowance logic. ACT exploit conditions:
- SchnoodleV9 must be deployed with reflection enabled and a very large reflect rate, so that _getStandardAmount(amount) returns 0 for normal-sized transferFrom amounts.
- A victim address (here the SNOOD/WETH Uniswap V2 pair) must hold a significant SNOOD balance that can be targeted by transferFrom without any existing allowance.
- The attacker must be able to deploy a contract or send a transaction that calls SchnoodleV9.transferFrom and then orchestrates subsequent transfers and Uniswap swaps; no special permissions are required.
- The on-chain state must not include any additional checks (e.g., explicit whitelists or pause mechanisms) that would block transferFrom or the Uniswap swap in this configuration.
// SchnoodleV9Base allowance handling (simplified)
function _spendAllowance(address owner, address spender, uint256 amount) internal virtual {
super._spendAllowance(owner, spender, _getStandardAmount(amount));
}
Snippet from verified SchnoodleV9Base implementation showing _spendAllowance applying _getStandardAmount to the transferFrom amount, which under a huge reflect rate rounds to zero and breaks the allowance invariant.
5. Adversary Flow Analysis
The adversary uses a single contract-creation transaction to deploy a helper contract that queries the SNOOD balance of the SNOOD/WETH Uniswap pair, calls transferFrom to pull almost all SNOOD from the pool without any approval, sends most SNOOD back to the pool to trigger SchnoodleV9 fee logic, and then swaps the remaining SNOOD for WETH, realizing a net profit of roughly 104 WETH after gas.
Adversary-related accounts:
- 0x180ea08644b123d8a3f0eccf2a3b45a582075538 (chainid 1): Sender of the seed transaction, direct recipient of the 104.047009087796436864 WETH transfer from the SNOOD/WETH Uniswap pair, and originator of subsequent Tornado Cash deposits in the surrounding blocks.
- 0x273521f6582076a5fc54da9af9bfca5435ffe9ec (chainid 1): Contract created in the seed transaction by the attacker EOA that orchestrates calls to SchnoodleV9, the SNOOD/WETH pair, and WETH9 in the exploit trace.
Victim contracts:
- SchnoodleV9 proxy at 0xd45740ab9ec920bedbd9bab2e863519e59731941 (chainid 1)
- SchnoodleV9 implementation at 0xeac2a259f3ebb8fd1097aeccaa62e73b6e43d5bf (chainid 1)
- SNOOD/WETH Uniswap V2 pair at 0x0f6b0960d2569f505126341085ed7f0342b67dae (chainid 1)
Lifecycle stages:
- Adversary contract deployment and setup: The attacker EOA 0x180e… deploys helper contract 0x2735… and immediately uses it to start interacting with SchnoodleV9 and the SNOOD/WETH Uniswap pair in the same transaction. Evidence: artifacts/root_cause/data_collector/iter_1/tx/1/0x9a62…/receipt.json and trace.callTracer.json show contract creation of 0x2735… and subsequent calls within the same transaction.
- Unauthorized SNOOD withdrawal from Uniswap pair: 0x2735… calls SchnoodleV9.transferFrom on the SNOOD/WETH pair address 0x0f6b09…, pulling essentially the entire SNOOD reserve from the pool without any approve transaction granting allowance. Evidence: trace.callTracer.json for the seed tx shows a STATICCALL to SchnoodleV9.balanceOf(0x0f6b09…) followed by a CALL to transferFrom(0x0f6b09…, 0x2735…, amount) from 0x2735…, with no prior approve call from the pair.
- Fee-triggering transfer and profitable WETH swap: The helper contract sends most of the withdrawn SNOOD back to the Uniswap pair via SchnoodleV9.transfer, triggering fee and redistribution logic that routes large SNOOD amounts to fee recipients, then performs a Uniswap swap that transfers 104.047009087796436864 WETH from the pair to the attacker EOA. Evidence: The seed tx receipt logs show SNOOD Transfer events from 0x0f6b09… to 0x6d257d… and 0x76d805… and a WETH Transfer of 104.047009087796436864 WETH from the pair to 0x180e…; the callTracer trace shows getReserves and swap calls on the pair preceding the WETH transfer.
// Seed transaction trace (callTracer) for 0x9a62...
{"tx": "0x9a6227ef97d7ce75732645bd604ef128bb5dfbc1bfbe0966ad1cd2870d45a20e", "key_calls": ["SchnoodleV9.balanceOf(0x0f6b09...)", "SchnoodleV9.transferFrom(0x0f6b09..., 0x2735..., amount)", "SchnoodleV9.transfer(0x0f6b09..., amount)", "UniswapV2Pair.swap(..., 104.047009087796436864 WETH to 0x180e...)"]}
Seed transaction trace summary based on artifacts/root_cause/data_collector/iter_1/tx/1/0x9a62…/trace.callTracer.json, highlighting the unauthorized transferFrom and WETH swap.
6. Impact & Losses
- Loss: 104.047009087796436864 WETH The attacker removed essentially all SNOOD from the SNOOD/WETH Uniswap V2 pool, routed a portion of it through SchnoodleV9's fee mechanism to designated fee addresses, and received 104.047009087796436864 WETH from the pool in exchange, reducing the pool's WETH reserves and harming liquidity providers; the attacker later sent the proceeds into Tornado Cash.
7. References
- [1] Seed tx receipt: artifacts/root_cause/data_collector/iter_1/tx/1/0x9a62…/receipt.json
- [2] Seed tx callTracer trace: artifacts/root_cause/data_collector/iter_1/tx/1/0x9a62…/trace.callTracer.json
- [3] SchnoodleV9 and SchnoodleV9Base source: artifacts/root_cause/seed/1/0xeac2a259f3ebb8fd1097aeccaa62e73b6e43d5bf/src
- [4] Seed balance_diff.json: artifacts/root_cause/seed/1/0x9a62…/balance_diff.json
- [5] Attacker EOA txlist around incident: artifacts/root_cause/data_collector/iter_1/address/1/0x180ea08644b123d8a3f0eccf2a3b45a582075538/txlist_14980000-14986000.json