Reproduced Exploit

Cod3x: _afterTokenTransfer adds shares to an underlying balance and pays Bob 400 for a prior balance of 0

_afterTokenTransfer receives amount in shares from ERC6909. balanceOf and totalSupply return shares times the liquidity index. The transfer branch then does oldFromBalance + amount and oldToBalance - amount and passes those mixed numbers to handleAction. With index 2, Alice's 1000 shares are worth…

Dec 2024•Other•arithmetic•4 min read

Chain

Other

Category

arithmetic

Date

Dec 2024

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: 49180-mix-of-scaled-and-non-scaled-parameters-in-aftertokentransf. Standalone Foundry PoC and full write-up: 49180-mix-of-scaled-and-non-scaled-parameters-in-aftertokentransf_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/arithmetic/decimal-mismatch · vuln/logic/reward-calculation

Key info#

ProtocolCod3x Lend
FindingSpearbit, AuditVault 49180
Vulnerable codeAToken._afterTokenTransfer
HarmA 400-share transfer to an empty recipient reports a prior balance of 400 and mints 400 incentive. The fair prior share balance is 0, so the fair payment is 0. The one-scale control pays 0
Chainlocal synthetic

TL;DR#

_afterTokenTransfer receives amount in shares from ERC6909. balanceOf and totalSupply return shares times the liquidity index. The transfer branch then does oldFromBalance + amount and oldToBalance - amount and passes those mixed numbers to handleAction. With index 2, Alice's 1000 shares are worth 2000. She sends 400 shares to Bob, who had none. Bob's balance after the transfer is 800 underlying. 800 - 400 shares is 400, and the controller mints Bob 400. His real prior share balance is 0. The control hook reads shares for every term, reports 0, and mints Bob nothing.

The vulnerable code#

SOLIDITY
uint256 oldSupply = totalSupply(id); // not scaled
uint256 oldFromBalance = balanceOf(from, id); // not scaled
uint256 oldToBalance = balanceOf(to, id); // not scaled
// @> VULN: amount is scaled shares and oldFromBalance is unscaled underlying
oldFromBalance = oldFromBalance + amount; // mix: (not scaled) + (scaled)
// @> VULN: unscaled recipient balance minus scaled shares
oldToBalance = oldToBalance - amount; // mix: (not scaled) - (scaled)

The fix reads super.totalSupply and super.balanceOf, which are the share balances the ERC6909 hook is denominated in.

Root cause#

The hook runs after the share balances have moved, and it tries to rebuild the pre-transfer balances by adding the amount back to the sender and subtracting it from the recipient. That only works when amount and balanceOf use the same unit. Here amount is shares and balanceOf is shares times the index. The incentives controller is paid in whatever number comes out.

Preconditions#

Index is 2. Alice holds 1000 shares, so balanceOf is 2000. Bob holds 0. The transfer moves 400 shares. The controller mints its reward token one-for-one with the reported prior balance. run() does not warp.

Attack walkthrough#

  1. Seed Alice with 1000 shares.
  2. Transfer 400 shares to Bob. Alice has 600 shares left (1200 underlying). Bob has 400 shares (800 underlying).
  3. oldFromBalance = 1200 + 400 = 1600. Alice's real prior share balance is 1000.
  4. oldToBalance = 800 - 400 = 400. Bob's real prior share balance is 0.
  5. handleAction mints 400 INC to Bob.
  6. The control token does the same transfer using share balances only. Bob's prior balance is 400 - 400 = 0. He is paid nothing more. Alice's prior share balance is 1000.

Diagrams#

flowchart TD A["Alice holds 1000 shares, index is 2"] --> B["Transfer 400 shares to Bob"] B --> C["balanceOf now reports underlying, amount is still shares"] C --> D["VULN: Bob prior balance becomes 800 minus 400 shares"] D --> E["Controller mints Bob 400"] E --> F["Fair prior share balance is 0"] F --> G["Control uses shares for every term and pays Bob 0"]

Impact#

Bob receives 400 INC. He had no shares before the transfer, so the same-scale hook pays 0. Alice is reported at 1600 instead of her 1000 prior shares, so her incentive base is wrong by 600 as well. A later index above 1 makes every transfer, mint, and burn hand the controller a mixed balance. An index below 1 makes oldToBalance - amount underflow and the transfer revert.

How to reproduce#

BASH
cd 49180-mix-of-scaled-and-non-scaled-parameters-in-aftertokentransf_exp
forge test -vv

run() does not warp. Bob is paid 400. The control pays him 0.

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.