All incidents

TomInu Reflection Pair Inflation Flashloan Exploit

Share
Jan 26, 2023 07:10 UTCAttackLoss: 22.14 WETHPending manual check1 exploit txWindow: Atomic
Estimated Impact
22.14 WETH
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Jan 26, 2023 07:10 UTC → Jan 26, 2023 07:10 UTC

Exploit Transactions

TX 1Ethereum
0x6200bf5c43c214caa1177c3676293442059b4f39eb5dbae6cfd4e6ad16305668
Jan 26, 2023 07:10 UTCExplorer

Victim Addresses

0x2d0e64b6bf13660a4c0de42a0b88144a7c10991fEthereum
0xb835752feb00c278484c464b697e03b03c53e11bEthereum

Loss Breakdown

22.14WETH

Similar Incidents

Root Cause Analysis

TomInu Reflection Pair Inflation Flashloan Exploit

1. Incident Overview TL;DR

On Ethereum mainnet block 16489409, transaction 0x6200bf5c43c214caa1177c3676293442059b4f39eb5dbae6cfd4e6ad16305668 turned a reflection-accounting bug in TomInu into a permissionless reserve drain. EOA 0x14d8ada7a0ba91f59dc0cb97c8f44f1d177c2195 called helper contract 0xdb2d869ac23715af204093e933f5eb57f2dc12a9, which borrowed WETH from the Balancer Vault, bought TomInu from the TomInu/WETH Uniswap V2 pair at 0xb835752feb00c278484c464b697e03b03c53e11b, called deliver() twice, used skim() to extract the reflection-created surplus, and then used swap() to pull nearly all WETH from the pair.

The root cause is a deterministic accounting flaw in TomInu. TomInu leaves its Uniswap pair inside the reflection set while exposing public deliver(uint256). Because deliver() reduces _rTotal, any non-excluded holder can lower the global reflection rate; since the pair is not excluded, balanceOf(pair) rises without any real token transfer into the pair. The attacker turned that artificial balance increase into transferable tokens via skim() and then into WETH via swap().

2. Key Background

TomInu is a reflection token. For non-excluded accounts, the public balanceOf() value is not stored directly; it is derived from reflected ownership divided by a global rate. That matters because changing the global rate changes the apparent token balances of every non-excluded holder, including AMM pairs.

The relevant TomInu source collected for 0x2d0e64b6bf13660a4c0de42a0b88144a7c10991f shows the core mechanism:

constructor (address payable teamWalletAddress, address payable marketingWalletAddress) public {
    _rOwned[_msgSender()] = _rTotal;
    IUniswapV2Router02 _uniswapV2Router = IUniswapV2Router02(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
    uniswapV2Pair = IUniswapV2Factory(_uniswapV2Router.factory())
        .createPair(address(this), _uniswapV2Router.WETH());
    uniswapV2Router = _uniswapV2Router;
}

function balanceOf(address account) public view override returns (uint256) {
    if (_isExcluded[account]) return _tOwned[account];
    return tokenFromReflection(_rOwned[account]);
}

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);
    _tFeeTotal = _tFeeTotal.add(tAmount);
}

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    uint256 currentRate = _getRate();
    return rAmount.div(currentRate);
}

function _getRate() private view returns(uint256) {
    (uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
    return rSupply.div(tSupply);
}

On the AMM side, Uniswap V2 pairs keep internal stored reserves and separately hold live ERC-20 balances. skim(address) transfers any live token balance above stored reserves, and swap() infers token input from the post-transfer live balances relative to stored reserves. That pair behavior is not a bug by itself; the exploit works because TomInu lets public callers distort the pair's live balanceOf() reading.

3. Vulnerability Analysis & Root Cause Summary

This incident is an ATTACK case, not an MEV-only opportunity. The exploited flaw sits in TomInu's reflection design, specifically the combination of a public deliver(uint256) entrypoint and a Uniswap pair that was never excluded from reflections. The violated invariant is simple: a third party must not be able to increase an AMM pair's effective token balance relative to stored reserves unless the pair actually receives tokens. TomInu breaks that invariant because deliver() shrinks _rTotal, which lowers the global reflection rate and increases balanceOf(pair) while the pair's reflected ownership stays constant. Once that artificial surplus appears, standard Uniswap V2 pair functions do the rest: skim() converts the fake surplus into attacker-owned tokens, and a later swap() treats the inflated pair balance as genuine token input and releases WETH. The flash loan only supplied temporary WETH for the first buy; it was not the root cause.

4. Detailed Root Cause Analysis

4.1 Pre-state and exploit conditions

The ACT pre-state was Ethereum mainnet immediately before the seed transaction in block 16489409. The necessary public conditions were:

  • TomInu pair 0xb835752feb00c278484c464b697e03b03c53e11b remained a non-excluded reflection holder.
  • Trading was already enabled, so anyone could buy TomInu through the public Uniswap V2 router.
  • The pair still held meaningful WETH liquidity that could be drained after balance inflation.
  • Balancer flash loans were permissionlessly available.

The report in root_cause.json states these same exploit conditions, and the collected trace confirms they were all true during the seed transaction.

4.2 Code-level breakpoint

TomInu exposes an owner-only excludeAccount(address) function, but the constructor never excludes the newly created pair. The result is that the pair remains governed by reflected accounting:

function excludeAccount(address account) external onlyOwner() {
    require(!_isExcluded[account], "Account is already excluded");
    if(_rOwned[account] > 0) {
        _tOwned[account] = tokenFromReflection(_rOwned[account]);
    }
    _isExcluded[account] = true;
    _excluded.push(account);
}

Because the pair was left out of _excluded, any public caller with TomInu balance could call deliver(), reduce _rTotal, and thereby reduce currentRate. The pair's _rOwned value stayed unchanged, so tokenFromReflection(_rOwned[pair]) jumped upward without a token transfer. That is the exact code-level breakpoint identified by the auditor and independently validated here.

4.3 On-chain execution evidence

The seed trace shows the exploit sequence directly:

0x7a250d...::swapExactTokensForTokensSupportingFeeOnTransferTokens(
  104850000000000000000,
  0,
  [WETH, TomInu],
  0xDb2d869ac23715af204093e933f5EB57F2DC12a9,
  1674717047
)

0xb835752f...::getReserves() -> (316871513264115731249, 126994561461014981232, ...)
0x2d0E64B6...::deliver(1465904852700232013011)
0xb835752f...::skim(0xDb2d869ac23715af204093e933f5EB57F2DC12a9)
0x2d0E64B6...::balanceOf(0xb835752f...) -> 2050642424158542203032
0x2d0E64B6...::transfer(..., 1733770910894426471783)
0x2d0E64B6...::deliver(1733769909104888397946)
0xb835752f...::swap(0, 126990751624171150782, 0xDb2d869ac23715af204093e933f5EB57F2DC12a9, 0x)
0x2d0E64B6...::balanceOf(0xb835752f...) -> 11191855315120216048899805
WETH9::balanceOf(0xb835752f...) -> 3809836843830450

Those values show the invariant failure quantitatively:

  • After the initial buy, stored reserves were 316871513264115731249 TINU and 126994561461014981232 wei WETH.
  • After the first deliver(), TomInu.balanceOf(pair) jumped to 2050642424158542203032 TINU with no real TomInu transfer into the pair.
  • skim() transferred the resulting fake surplus of 1733770910894426471783 TINU out of the pair.
  • After the second deliver(), swap() released 126990751624171150782 wei WETH and left the pair with only 3809836843830450 wei WETH and an impossible 11191855315120216048899805 TINU balance, far above the total supply.

4.4 Why this is ACT

This opportunity was realizable by any unprivileged actor using only public information and public contract entrypoints:

  • BalancerVault.flashLoan(...) is public.
  • UniswapV2Router.swapExactTokensForTokensSupportingFeeOnTransferTokens(...) is public.
  • TomInu.deliver(uint256) is public for any non-excluded holder.
  • UniswapV2Pair.skim(address) and UniswapV2Pair.swap(...) are public.

No private key compromise, privileged role, hidden calldata, or attacker-side proprietary artifact was required. The adversary model therefore fits the ACT definition exactly.

4.5 Safety principles violated

The incident breaks two protocol-design principles that are explicit in the auditor output and supported by the code and trace evidence:

  • Reflection-accounting changes must not alter AMM pair balances unless the pair is deliberately isolated from the reflection set.
  • Externally callable supply-affecting functions must preserve conservation of balances relative to external integrations such as AMMs.

5. Adversary Flow Analysis

  1. EOA 0x14d8ada7a0ba91f59dc0cb97c8f44f1d177c2195 called helper contract 0xdb2d869ac23715af204093e933f5eb57f2dc12a9 in transaction 0x6200bf5c43c214caa1177c3676293442059b4f39eb5dbae6cfd4e6ad16305668.
  2. The helper borrowed WETH from Balancer Vault 0xba12222222228d8ba445958a75a0704d566bf2c8 and swapped 104.85 WETH into TomInu through Uniswap V2 router 0x7a250d5630b4cf539739df2c5dacb4c659f2488d, acquiring 1465904852700232013011 TINU.
  3. The helper called TomInu.deliver(1465904852700232013011). Because the pair was still in the reflection set, the pair's apparent TINU balance rose from the stored reserve of 316871513264115731249 to 2050642424158542203032.
  4. The helper called skim() on the pair and received the artificial surplus: 1733770910894426471783 TINU. It then called deliver() again with the skimmed balance, inflating the pair's apparent TINU balance even further.
  5. The helper called swap() on the pair and received 126990751624171150782 wei WETH, repaid the flash loan, withdrew 22.140751624171150782 WETH to ETH, made a builder-side payment observed in the native balance diff for 0xdafea492d9c6733ae3d56b7ed1adb60692c98bc5, and selfdestructed the remaining ETH back to the sender EOA.

This lifecycle matches the adversary stages recorded in root_cause.json: flash-loan funding and seed buy, reflection inflation and skim, and final reserve drain plus settlement.

6. Impact & Losses

The measurable victim-side loss was 22.140751624171150782 WETH from the TomInu/WETH pair. The pair's actual WETH balance fell from 22.144561461014981232 WETH before the exploit path to 0.003809836843830450 WETH after the exploit transaction, and its TomInu balance ended at 11191855315120216048899805, far above TomInu's total supply of 1733820000000000000000.

The attacker-side outcome is also deterministic:

  • balance_diff.json shows the adversary EOA's native balance increasing by 22103097125490228832 wei net of gas.
  • The incident report in root_cause.json records the net ETH profit as 22.103097125490228832.
  • The explicit builder-facing transfer in the trace is 0.03 ETH, while the balance diff for 0xdafea492d9c6733ae3d56b7ed1adb60692c98bc5 shows a 31280409000000000 wei native increase during the transaction.

The affected public components were TomInu token 0x2d0e64b6bf13660a4c0de42a0b88144a7c10991f and the TomInu/WETH Uniswap V2 pair 0xb835752feb00c278484c464b697e03b03c53e11b.

7. References

  1. Seed exploit transaction: 0x6200bf5c43c214caa1177c3676293442059b4f39eb5dbae6cfd4e6ad16305668 on Ethereum mainnet block 16489409.
  2. Verified TomInu source collected for 0x2d0e64b6bf13660a4c0de42a0b88144a7c10991f, especially the constructor, balanceOf(), deliver(), tokenFromReflection(), _getRate(), and excludeAccount().
  3. Seed execution trace collected for the transaction above, showing the public sequence flashLoan -> router swap -> deliver -> skim -> deliver -> swap -> repay -> withdraw.
  4. Seed balance diff artifact for the same transaction, showing the adversary EOA's net ETH gain and the builder-side native balance increase.
  5. Validator challenge result confirming that the causal chain, exploit classification, and evidence quality pass independent review.