Reproduced Exploit
TomInu (TINU) Exploit — Reflective-Token (RFI) Reflection-Rate Skim against a Uniswap-V2 Pair
TomInu (TomInu.sol:663) is a "reflective" (RFI-style) ERC20: every balance is stored twice — a reflection balance _rOwned and a real balance _tOwned — and balanceOf() derives the human-readable balance from the reflection balance via tokenFromReflection = rOwned / rate, where rate = rSupply / tSupp…
Loss
~22 WETH — attacker's final WETH balance 22134561461014981232 wei (~22.134561 WETH) after repaying a 104.85 W…
Chain
Ethereum
Category
Logic / State
Date
Jan 2023
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2023-01-TINU_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/TINU_exp.sol.
Vulnerability classes: vuln/defi/slippage · vuln/logic/incorrect-state-transition
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: TomInu.sol.
Key info#
| Loss | ~22 WETH — attacker's final WETH balance 22134561461014981232 wei (~22.134561 WETH) after repaying a 104.85 WETH Balancer flash loan (output.txt:182); the PoC's console.log rounds this to 22 (output.txt:183). |
| Vulnerable contract | TomInu (reflective ERC20) — 0x2d0E64B6bF13660a4c0De42a0B88144a7C10991F |
| Victim pool | TINU/WETH Uniswap-V2 pair — 0xb835752Feb00c278484c464b697e03b03C53E11B |
| Attacker EOA | 0x14d8ada7a0ba91f59dc0cb97c8f44f1d177c2195 |
| Attacker contract | 0xdb2d869ac23715af204093e933f5eb57f2dc12a9 |
| Attack tx | 0x6200bf5c43c214caa1177c3676293442059b4f39eb5dbae6cfd4e6ad16305668 |
| Flash source | Balancer Vault 0xBA12222222228d8Ba445958a75a0704d566BF2C8 (zero-fee flash loan, output.txt:29) |
| Chain / block / date | Ethereum mainnet / 16,489,408 / Jan 2023 |
| Compiler | Solidity v0.6.12 (commit.27d51765), optimizer enabled, 10,000 runs (_meta.json) |
| Bug class | Reflective / fee-on-transfer token in a vanilla Uniswap-V2 pair: deliver() deflates the reflection rate so the pair's balanceOf() diverges from its stored reserves, enabling skim() + a direct swap() to drain WETH. |
TL;DR#
TomInu (TomInu.sol:663) is a "reflective" (RFI-style) ERC20: every balance is stored twice — a reflection balance _rOwned and a real balance _tOwned — and balanceOf() derives the human-readable balance from the reflection balance via tokenFromReflection = rOwned / rate, where rate = rSupply / tSupply (TomInu.sol:935-939, :1227-1230).
The public deliver(tAmount) function (TomInu.sol:915-922) is meant to let a holder "donate" tokens to all other holders by burning their own reflection: it does _rOwned[sender] -= rAmount; _rTotal -= rAmount; _tFeeTotal += tAmount. It does not move any _tOwned, does not touch the recipient, and emits no Transfer. The net effect is that _rTotal shrinks while _tTotal is unchanged, so rate rises — and every non-excluded address's reported balanceOf inflates by the same factor, including the Uniswap pair's.
- The attacker flash-borrows 104.85 WETH from Balancer (output.txt:25, fee = 0).
- It swaps 104.85 WETH → TINU, buying 1,465.9 TINU from the pair (output.txt:83). Pair reserves drop to 316.87 TINU / 126.99 WETH (output.txt:75).
- It calls
deliver(1,465.9 TINU)._rTotalfalls;raterises; the pair's reportedbalanceOfjumps from 316.87 to 2,050.64 TINU (output.txt:104) — even though only ~316 TINU actually sit there. - It calls
pair.skim(attacker). The pair sends its excess TINU (the difference between its real balance and its stored reserve) —1,733.77 TINU(output.txt:116) — straight to the attacker. - It calls
deliver(1,733.77 TINU)again. The reflection rate collapses further and the pair's reportedbalanceOfballoons to 11,191,855 TINU (output.txt:150). - With the pair holding a reported 11.19M TINU against only 126.99 WETH, the attacker calls
pair.swap(0, 126.98 WETH, attacker, "")directly (output.txt:159). The pair's invariant check (balance0Adjusted * balance1Adjusted >= k) passes becausebalance0(the inflated TINU reading) is now enormous — it drains essentially all the WETH, leaving the pair with0.01 WETH(output.txt:169). - It repays the 104.85 WETH flash loan (output.txt:175) and keeps 22.134561 WETH of profit (output.txt:182).
The entire move is funded by the flash loan and nets ~22 WETH — the pool's honest WETH liquidity.
Background — what TomInu does#
TomInu is a meme/reflective ERC20 deployed on Ethereum mainnet at 0x2d0E64B6…. Reflective tokens (the "RFI" family) attempt to pay holders a yield by deflating the reflection supply rather than by moving tokens:
- There is a real total
_tTotal = 1,733,820 TINU(9 decimals, TomInu.sol:680, :687) and a reflection total_rTotal = MAX - (MAX % _tTotal)(TomInu.sol:681). - Each holder has a reflection balance
_rOwned. Their readable balance is derived:balanceOf = tokenFromReflection(_rOwned) = _rOwned / rate, whererate = _rTotal / _tTotal(TomInu.sol:864-867). - On every transfer,
_reflectFeeburns a fraction of_rTotal(TomInu.sol:1198-1201) and_taketeamaccrues the team fee toaddress(this)(TomInu.sol:1190-1196). The first makes every remaining holder's slice of_tTotallarger (the "reflection yield"); the second is a plain fee to the contract. deliver(tAmount)(TomInu.sol:915-922) lets a non-excluded holder voluntarily give uptAmountof real tokens to the reflection pool: it debits_rOwned[sender]and shrinks_rTotal, raisingratefor everyone else.
On-chain parameters at fork block 16,489,408 (read from the trace):
| Parameter | Value | Source |
|---|---|---|
| Pair token0 / token1 | TINU / WETH (so reserve0 = TINU, reserve1 = WETH) | derived from swap(amount0Out=TINU,…) (output.txt:60) |
| Initial pair WETH balance | 126,994,561,461,014,981,232 wei ≈ 126.994561 WETH | output.txt:58-59 |
| Initial pair TINU balance | 316,871,513,264,115,731,249 raw (9 dec) after the attacker's buy | output.txt:72 |
_taxFee / _teamFee | 0 / 0 at deploy (no fees set) | TomInu.sol:690-693 |
tradingEnabled | true (attack would otherwise revert at :1028) | — |
| Balancer flash-loan fee | 0 | output.txt:29 |
| Flash-loan principal | 104,850,000,000,000,000,000 wei = 104.85 WETH | output.txt:25 |
The fact that tradingEnabled was already on, the pair already had WETH liquidity, and _taxFee/_teamFee were still 0 (so no fee accounting diluted the reflection trick) is what made this attack cleanly reproducible.
The vulnerable code#
1. balanceOf is derived from the reflection rate, not stored#
function balanceOf(address account) public view override returns (uint256) {
if (_isExcluded[account]) return _tOwned[account];
return tokenFromReflection(_rOwned[account]);
}
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
(TomInu.sol:864-867, :935-939)
Because the pair is not excluded from reflections, the pair's TINU balance — which the Uniswap-V2 pair reads via IERC20.balanceOf(address(this)) inside swap/skim/sync — is computed from _rOwned[pair] / rate. Any change to _rTotal therefore silently rewrites the pair's perceived balance without any Transfer event being emitted to the pair.
2. deliver shrinks _rTotal without moving tokens#
function deliver(uint256 tAmount) public {
address sender = _msgSender();
require(!_isExcluded[sender], "Excluded addresses cannot call this function");
(uint256 rAmount,,,,,) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rTotal = _rTotal.sub(rAmount); // ← reduces the reflection supply
_tFeeTotal = _tFeeTotal.add(tAmount); // ← but _tTotal is UNCHANGED
}
_rTotal shrinks while _tTotal does not, so _getRate = _rTotal / _tTotal (TomInu.sol:1227-1230) rises. For every non-excluded holder whose _rOwned is unchanged, balanceOf = _rOwned / rate therefore increases — including the Uniswap pair's. The pair never receives or sends a token; it just looks richer on the next balanceOf call.
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
3. skim and swap trust this derived balance#
skim (standard Uniswap-V2) sends balance0 - reserve0 (and balance1 - reserve1) to to. swap requires (balance0 * 1000) * (balance1 * 1000) >= reserve0 * reserve1 * 1000^2 after the swap. Both use the token's live balanceOf. With deliver having inflated the pair's TINU balanceOf, skim returns free TINU and the invariant check in swap is trivially satisfied.
Root cause — why it was possible#
A Uniswap-V2 pair assumes its two underlying tokens behave like ordinary ERC20s: a token's balanceOf(pair) only changes when an actual transfer/mint/burn moves tokens into or out of the pair, and the pair is notified via Sync/Swap. Reflective tokens violate that assumption — balanceOf is a derived quantity that depends on global rate state (_rTotal, _tFeeTotal) that any holder can mutate via deliver (or that mutates on every taxed transfer). The pair cannot reconcile those mutations because they produce no Transfer event addressed to the pair and no Sync.
Concretely, the composition is:
deliveris permissionless and reduces_rTotal. Anyone holding TINU can donate their reflection to everyone else. There is no cap and no per-address restriction beyond "not excluded".balanceOf(pair)tracks the rate. Since the pair is not excluded, raising the rate inflates the pair's perceived TINU balance.- Uniswap-V2 reads that inflated balance in
skimand in the swap invariant.skimthen returns tokens that the pair never actually received;swapthen passes thekcheck with a fabricatedbalance0.
The result is a one-way value transfer from the pair's WETH reserve to whoever holds TINU and can call deliver + skim + swap in the right order. The flash loan is incidental — it just supplies the seed WETH to buy the TINU used for the two deliver calls; it is repaid in full, so the attacker's only capital at risk was gas.
Preconditions#
tradingEnabled == true(else_transferreverts at TomInu.sol:1028). True at the fork.- The attacker is not
_isExcludedand not blacklisted (sodeliverand_transferwork). True for any fresh contract. - Sufficient flash-loanable WETH to buy enough TINU to make two
delivercalls move the rate meaningfully. The PoC uses 104.85 WETH (output.txt:25); Balancer supplies it at zero fee. - The pair holds WETH liquidity to drain. At the fork it held 126.994561 WETH (output.txt:58-59).
Attack walkthrough (with on-chain numbers from the trace)#
The pair's token0 = TINU, token1 = WETH. TINU has 9 decimals, so the PoC's console.log(... / 1e18) prints values that are 1000x smaller than the true human amount; the raw-wei figures below are authoritative. All numbers are taken directly from the Transfer / Sync / Swap events and balanceOf static-call returns in output.txt.
| # | Step | Pair TINU balanceOf (raw, 9-dec) | Pair WETH balance (raw, 18-dec) | Pool state note |
|---|---|---|---|---|
| 0 | Balancer flash — receive 104.85 WETH (output.txt:25,30-31) | — | — | Flash principal 104,850,000,000,000,000,000 wei; fee 0 (output.txt:29). |
| 1 | Buy — router.swapExactTokensForTokensSupportingFeeOnTransferTokens(104.85 WETH → TINU); pair's swap(1470.45 TINU out) (output.txt:60) | 316,871,513,264,115,731,249 (~316.87 / 1e18 shown, ≈ 316.87M real TINU) output.txt:72 | 126,994,561,461,014,981232 ≈ 126.994561 WETH (output.txt:74) | Sync(reserve0=316.87e18-ish, reserve1=126.99 WETH) (output.txt:75). Attacker now holds 1,465,904,852,700,232,013,011 raw TINU (output.txt:83). |
| 2 | deliver(1,465.9 TINU) (output.txt:97) — _rTotal falls, rate rises | pair balanceOf jumps to 2,050,642,424,158,542,203,032 raw (output.txt:104) | unchanged | No Transfer to the pair; pair never moved. Reported TINU balance inflated by ~6.5×. Attacker's own TINU → ~0 (6 raw, output.txt:108). |
| 3 | pair.skim(attacker) (output.txt:113) — pair sends the excess balance0 - reserve0 | pair TINU balance falls to 323,338,092,012,961,377,606 raw (output.txt:132) | unchanged | Attacker receives 1,733,770,910,894,426,471,783 raw TINU via the pair's transfer (output.txt:116). WETH skimmed = 0 (none in excess) (output.txt:127). |
| 4 | deliver(1,733.77 TINU) again (output.txt:143) | pair balanceOf balloons to 11,191,855,315,120,216,048,899,805 raw (output.txt:150) | unchanged | Second rate deflation; reported TINU balance now ~11.19M (PoC log) vs ~0.32 actual. |
| 5 | pair.swap(0, 126.98 WETH, attacker, "") (output.txt:159) — direct low-level call | 11,191,855,315,120,216,048,899,805 raw (unchanged; no TINU in/out) (output.txt:167) | drops to 10,000,000,000,000,000 = 0.01 WETH (output.txt:169) | Swap(sender=attacker, amount0In=11.19M-ish TINU, amount1Out=126.98 WETH) (output.txt:171). Invariant passes because balance0 (the inflated reading) is enormous. |
| 6 | Repay flash — 104.85 WETH to Balancer (output.txt:175) | — | — | Repays exactly 104,850,000,000,000,000,000 wei; Balancer fee was 0. |
| — | Final attacker WETH | — | 22,134,561,461,014,981,232 wei = 22.134561 WETH (output.txt:182) | console.log rounds to 22 (output.txt:183). |
Profit / loss accounting (WETH, raw wei)#
| Item | Amount (wei) | ~Human (WETH) |
|---|---|---|
| WETH received from Balancer flash (output.txt:25) | +104,850,000,000,000,000,000 | +104.850000 |
| WETH paid into pair on buy (step 1) | −104,850,000,000,000,000,000 | −104.850000 |
WETH pulled out of pair on swap (step 5) (output.txt:159) | +126,984,561,461,014,981,232 | +126.984561 |
| WETH repaid to Balancer (step 6) (output.txt:175) | −104,850,000,000,000,000,000 | −104.850000 |
| Net WETH left with attacker (output.txt:182) | 22,134,561,461,014,981,232 | +22.134561 |
| Pair WETH before attack (output.txt:58-59) | 126,994,561,461,014,981,232 | 126.994561 |
| Pair WETH after attack (output.txt:169) | 10,000,000,000,000,000 | 0.010000 |
| Pair WETH drained | 126,984,561,461,014,981,232 | 126.984561 |
The attacker's net profit (22.134561 WETH) equals the pair's drained WETH (126.984561) minus the 104.85 WETH of flash principal that was round-tripped through the pool and repaid. The 0.010000 WETH left in the pair is the deliberate WETH.balanceOf(pair) - 0.01 ether sliver the PoC leaves behind so the final swap does not revert on a zero-balance edge (test/TINU_exp.sol:75).
Diagrams#
Sequence of the attack#
Pool state evolution#
The flaw inside deliver / balanceOf#
Why the skim is theft: pair's balanceOf vs stored reserve#
Why each magic number#
104.85 etherflash loan (test/TINU_exp.sol:32, output.txt:25): the seed WETH used to buy the TINU that the twodelivercalls consume. It is sized large enough that the resulting buy (step 1) leaves the pair with a small TINU reserve (~316.87) so that the firstdeliverof the bought ~1465.9 TINU roughly 6.5×-inflates the pair's reported balance — enough excess for a meaningfulskim. Balancer's flash fee is0(output.txt:29), so the round-trip is free.deliver(balanceOf(this))(test/TINU_exp.sol:57, :69): the attacker donates all its TINU twice. Donating everything (rather than a fraction) maximizes the_rTotalreduction per call and therefore the inflation of the pair's reported balance.pair.swap(0, WETH.balanceOf(pair) - 0.01 ether, …)(test/TINU_exp.sol:75, output.txt:159): drains the pair's WETH down to a0.01 WETHsliver (output.txt:169) rather than to zero. Leaving0.01 WETHavoids any edge-case revert on a fully-empty reserve and still lets the inflated-TINU side of the invariant dominate. The drained amount is126,994,561,461,014,981,232 - 10,000,000,000,000,000 = 126,984,561,461,014,981,232wei = 126.984561 WETH (output.txt:159).- Why
delivertwice: the firstdeliver+skimis what turns the rate trick into actual TINU in the attacker's hand; the seconddeliveris what makes the pair's reported balance large enough (~11.19M vs ~0.32 actual) that the finalswap'skcheck is satisfied while pulling out nearly all the WETH.
Remediation#
- Never list a reflective / fee-on-transfer / derived-balance token in a vanilla Uniswap-V2 pair. The pair's
skim,sync, and swap invariant all assumebalanceOf(pair)only changes via real token movement. A token whosebalanceOfis a function of global rate state breaks that contract. Use a dedicated fee-aware wrapper pair, or exclude the pair address from reflections at the token level (_isExcluded[pair] = trueand store the pair's balance in_tOwneddirectly). - Gate
deliver.deliveris a value-transfer primitive that mutates global rate state; leaving it permissionless lets anyone reshape every holder'sbalanceOf. Restrict it to a trusted role, or remove it entirely if the "donate-to-holders" feature is not required. - Make
balanceOfconservative for AMM pairs. If reflection accounting must stay, overridebalanceOffor known AMM pair addresses to return the stored reserve (or the raw_tOwned) rather than the derived reflection amount, so the pair's skim/invariant math cannot be gamed. - Enforce
kon received amounts at the router level for fee-on-transfer tokens (Uniswap-V2 already providesswapExactTokensForTokensSupportingFeeOnTransferTokens, but it does not protect against reflection-rate divergence — the fix must be at the token, not the router). - Re-audit any RFI-clone token before listing. TomInu is a near-verbatim "RFI / SafeMoon-style" template; the
deliver/reflection-rate foot-gun is endemic to that template and has been exploited repeatedly (this incident among them).
How to reproduce#
The PoC runs offline via the shared harness; the fork is served from a local anvil_state.json (the PoC's vm.createSelectFork("http://127.0.0.1:8545", 16_489_408) points at the local anvil, not a public RPC — test/TINU_exp.sol:25).
_shared/run_poc.sh 2023-01-TINU_exp --mt testHack -vvvvv
- EVM:
evm_version = "cancun"(foundry.toml:6); the PoC itself ispragma ^0.8.17(test/TINU_exp.sol:2) while the on-chain TomInu isv0.6.12. - No public RPC is named in
foundry.toml; the test forks the pre-baked local anvil state pinned to block 16,489,408. - The detected test function is
testHack(test/TINU_exp.sol:24).
Expected tail (output.txt:4-19, :192-194):
Ran 1 test for test/TINU_exp.sol:TomInuExploit
[PASS] testHack() (gas: 454500)
Logs:
316 TINU in pair before deliver
1465 TINU in attack contract before deliver
-------------Delivering-------------
2050 TINU in pair after deliver
0 TINU in attack contract after deliver
-------------Skimming---------------
323 TINU in pair after skim
1733 TINU in attack contract after skim
-------------Delivering-------------
11191855 TINU in pair after deliver 2
0 TINU in attack contract after deliver 2
Attacker's profit: 22 WETH
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 896.52ms (896.35ms CPU time)
Ran 1 test suite in 898.08ms (896.52ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
(Note: the console.log figures divide TINU raw-wei by 1e18, but TINU has 9 decimals — so the printed "316"/"2050"/"11191855" are 1000× smaller than the true human TINU counts. The authoritative values are the raw-wei figures cited above from the trace events. The final 22 WETH profit log is exact to within rounding; the precise net is 22.134561 WETH (output.txt:182).)
Reference: libevm analysis — https://twitter.com/libevm/status/1618731761894309889 (TomInu / TINU, Ethereum mainnet, Jan 2023, ~22 ETH).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-01-TINU_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
TINU_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "TomInu (TINU) Exploit".
- 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.