Reproduced Exploit

n00d (SushiBar fork) Exploit — ERC777 Reentrancy via Stale `totalSushi` Share Inflation

SushiBar is the canonical SushiSwap staking vault (enter/leave) re-deployed for the n00d token. enter() mints staking shares using the formula shares = _amount × totalShares / totalSushi, then calls sushi.transferFrom(msg.sender, address(this), _amount) last (SushiBar.sol:746-756). The vault implic…

Oct 2022EthereumReentrancy13 min read

Loss

20.668 WETH drained from the n00d/WETH Uniswap-V2 pair (~$26K at the Oct-2022 ETH price; SlowMist reported th…

Chain

Ethereum

Category

Reentrancy

Date

Oct 2022

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: 2022-10-N00d_exp in the evm-hack-registry mirror. Upstream DeFiHackLabs PoC: src/test/…/N00d_exp.sol.


Vulnerability classes: vuln/reentrancy/single-function · vuln/arithmetic/precision-loss

Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one is extracted standalone). Full verbose trace: output.txt. Verified vulnerable source: SushiBar.sol and the ERC777 token n00dToken.sol.


Key info#

Loss20.668 WETH drained from the n00d/WETH Uniswap-V2 pair (~$26K at the Oct-2022 ETH price; SlowMist reported the full incident at ~$22.8K)
Vulnerable contractSushiBar (xn00d) — 0x3561081260186E69369E6C32F280836554292E08
Tokenn00dToken (ERC777) — 0x2321537fd8EF4644BacDCEec54E5F35bf44311fA
Victim pooln00d/WETH Uniswap-V2 pair — 0x5476DB8B72337d44A6724277083b1a927c82a389
Attack tx0x8037b3dc0bf9d5d396c10506824096afb8125ea96ada011d35faa89fa3893aea
Chain / fork block / dateEthereum mainnet / 15,826,379 / Oct 25, 2022
Compiler (token)Solidity v0.8.16, optimizer 1 run · (SushiBar) v0.6.12
Bug classRead-only/state reentrancy → share-price inflation via the ERC777 tokensToSend hook firing before balances settle

TL;DR#

SushiBar is the canonical SushiSwap staking vault (enter/leave) re-deployed for the n00d token. enter() mints staking shares using the formula shares = _amount × totalShares / totalSushi, then calls sushi.transferFrom(msg.sender, address(this), _amount) last (SushiBar.sol:746-756). The vault implicitly assumes the transferFrom is atomic and side-effect-free.

But sushi here is n00d, an ERC777 token. ERC777 transferFrom invokes a tokensToSend hook on the sender before any balance is moved (OpenZeppelin's _send calls _callTokensToSend then _move, n00dToken.sol:1108-1126). The attacker registers itself as its own ERC777 sender-hook implementer and, from inside the hook, re-enters enter() (N00d_exp.sol:71-83).

Because the outer enter's transferFrom hasn't moved any n00d into the vault yet, every nested enter reads the same stale, low totalSushi while totalShares has already been inflated by the previous mint. Each nested call therefore mints shares against an ever-cheaper share price. After three nested enters the attacker holds 18,872.65 xn00d shares for what should have been a single 4,029.26 n00d deposit, then leave()s to redeem 14,177.02 n00d — a +10,147.76 n00d profit in one flash-loan iteration. Repeated five times inside a Uniswap flash swap, then sold back to the pool, this nets 20.668 WETH — exactly the pool's entire WETH reserve minus the unused portion.


Background — what SushiBar / xn00d does#

SushiBar (SushiBar.sol:737-765) is a verbatim copy of SushiSwap's xSUSHI staking bar. Users enter by depositing the underlying token (sushi, here n00d) and receive a proportional share token (Xn00d). The exchange rate is purely balance-driven:

  • enter(_amount) — mints _amount × totalShares / totalSushi shares to the caller, where totalSushi = sushi.balanceOf(address(this)) and totalShares = totalSupply(). The deposit is pulled after the mint via sushi.transferFrom(...).
  • leave(_share) — burns _share shares and returns _share × totalSushi / totalShares underlying tokens.

In a normal ERC20 world this is safe: transferFrom is the very last action and cannot hand control back to the caller. The share formula stays consistent because, by the time anyone can call enter again, totalSushi and totalShares have both already moved.

The fatal mismatch is the token. n00dToken is an ERC777 (note the tokensToSend / tokensReceived hooks and the ERC1820 registry wiring at n00dToken.sol:786-787). ERC777 was explicitly designed to give the sender a callback, and that callback runs before balances change.

On-chain facts at the fork block (read from the trace):

ParameterValueTrace source
Pair n00d reserve (reserve0)20,147.30 n00dgetReserves output.txt:148
Pair WETH reserve (reserve1)75.40 WETH ← the prizesame
SushiBar n00d balance (totalSushi) at start8,058.77 n00dbalanceOf output.txt:166
token0 / token1n00d / WETHSwap events

The vulnerable code#

1. enter() mints first, pulls tokens last#

SOLIDITY
// SushiBar.sol:746-756
function enter(uint256 _amount) public {
    uint256 totalSushi  = sushi.balanceOf(address(this));   // ← read BEFORE deposit lands
    uint256 totalShares = totalSupply();                    // ← already grown by prior mint
    if (totalShares == 0 || totalSushi == 0) {
        _mint(msg.sender, _amount);
    } else {
        uint256 what = _amount.mul(totalShares).div(totalSushi); // ← stale denominator
        _mint(msg.sender, what);                            // ← shares minted NOW
    }
    sushi.transferFrom(msg.sender, address(this), _amount); // ← ERC777: re-enters HERE
}

The transferFrom is the only thing that increases totalSushi, and it is the last statement. Any reentrancy that happens inside transferFrom sees the pre-deposit totalSushi but the post-mint totalShares.

2. ERC777 hands control to the sender before moving balances#

SOLIDITY
// n00dToken.sol:1108-1126  (OpenZeppelin ERC777._send, reached from transferFrom)
function _send(address from, address to, uint256 amount, ...) internal virtual {
    ...
    address operator = _msgSender();
    _callTokensToSend(operator, from, to, amount, userData, operatorData); // ⚠️ HOOK FIRST
    _move(operator, from, to, amount, userData, operatorData);             //    balances after
    _callTokensReceived(operator, from, to, amount, userData, operatorData, requireReceptionAck);
}

transferFrom_send (n00dToken.sol:1020-1029) → _callTokensToSend (:1208-1219) calls the attacker's registered tokensToSend implementer. Balances only update in _move (:1161-1180), which runs after the hook returns. So inside the hook, SushiBar's n00d balance is unchanged.

3. The attacker's reentrant hook#

SOLIDITY
// N00d_exp.sol:71-83
function tokensToSend(address operator, address from, address to,
                      uint256 amount, bytes calldata, bytes calldata) external {
    if (to == address(Bar) && i < 2) {   // re-enter twice per enter()
        i++;
        Bar.enter(enterAmount);           // ← same enterAmount, stale totalSushi
    }
}

Each time enter() is about to pull tokens into the Bar, the hook fires and calls enter() again (up to twice), producing a 3-deep nest of enter calls that all read the same stale totalSushi.


Root cause — why it was possible#

The bug is a checks-effects-interactions violation that becomes exploitable only because the underlying token is ERC777:

  1. enter() updates totalShares (via _mint) before it updates totalSushi (via transferFrom). The share-price denominator (totalSushi) and numerator (totalShares) are therefore momentarily inconsistent during the call.
  2. ERC777 turns transferFrom into an external call to attacker code, executed before balances move. A plain ERC20 deposit cannot re-enter; an ERC777 deposit hands control to the sender at exactly the wrong moment.
  3. The share formula has no reentrancy guard and no snapshot of the "true" sushi balance. Each nested enter recomputes what = _amount × totalShares / totalSushi against the inflated totalShares and the stale totalSushi, minting more shares per unit deposited each level deeper.

Concretely, in iteration 1 the three nested enter(4029.26) calls all see totalSushi = 8058.77 but a totalShares that grows 7,946.73 → 11,919.97 → 17,879.77:

Nested entertotalShares seentotalSushi seenshares minted (_amount×shares/sushi)
#1 (deepest, mints first)7,946.738,058.773,973.24
#2 (middle)11,919.978,058.775,959.80
#3 (outer)17,879.778,058.778,939.61
Total shares to attacker18,872.65

The attacker deposited a net 4,029.26 n00d (the three transferFroms settle the same single debit once the recursion unwinds — see the duplicated Sent/Transfer emits at output.txt:188-226) but minted 18,872.65 shares. leave(18,872.65) then returns 18872.65 × totalSushi / totalShares = 14,177.02 n00d — a +10,147.76 n00d gain per iteration.


Preconditions#

  • The vault must accept an ERC777 underlying token. SushiBar was written for ERC20 SUSHI; pairing it with ERC777 n00d is what introduces the reentrancy surface.
  • The attacker registers an ERC777 sender hook for its own address via the canonical ERC1820 registry (N00d_exp.sol:40-42). This is permissionless.
  • Working capital in n00d to call enter. In the live attack this was bootstrapped with a Uniswap flash swap (Pair.swap(reserve0-1e18, 0, this, data) with non-empty datauniswapV2Call), so no upfront capital was needed; the loan is repaid in the same transaction (N00d_exp.sol:46-69).

Attack walkthrough (with on-chain numbers from the trace)#

The pair's token0 = n00d, token1 = WETH, so reserve0 = n00d, reserve1 = WETH. The PoC runs the flash-swap-and-reenter loop 5 times (for j = 1; j < 5 does 4, plus one more at N00d_exp.sol:51-52), each time flash-borrowing almost the entire n00d reserve, reentering enter/leave to mint excess n00d, and repaying the loan with the freshly materialised tokens. After 5 rounds the accumulated surplus n00d is sold back to the pool for WETH.

#Stepn00d reserve (post-Sync)WETH reserveEffect
0Initial20,147.3075.40Honest pool. totalSushi (Bar) = 8,058.77.
1Flash-swap 20,146.30 n00d out → uniswapV2Call reenters enter×3 + leave, repays 20,207.92 n00d20,208.9275.40Round 1; surplus n00d retained. Sync output.txt:272
2Flash-swap round 2; repay 20,269.73 n00d20,270.7375.40Sync output.txt:405
3Flash-swap round 3; repay 20,331.73 n00d20,332.7375.40Sync output.txt:536
4Flash-swap round 4; repay 20,393.91 n00d20,394.9175.40Sync output.txt:667
5Flash-swap round 5; repay 20,456.28 n00d20,457.2875.40Sync output.txt:798. WETH reserve still untouched — each round only grows the pool's n00d.
6Sell accumulated 7,748.44 n00d → 20.668 WETH28,205.7154.73Final cash-out output.txt:831-832. Attacker pulls the WETH.

Inside each flash-swap (uniswapV2Call, N00d_exp.sol:62-69):

  1. enterAmount = n00d.balanceOf(this) / 5.
  2. Bar.enter(enterAmount) → its transferFrom triggers tokensToSend → reenter enter twice more. Three mints against the stale totalSushi (see the inflation table above).
  3. Bar.leave(Xn00d.balanceOf(this)) redeems all inflated shares for n00d (output.txt:233-251 shows iteration-1 leave returning 14,177.02 n00d).
  4. n00d.transfer(Pair, n00dReserve*1000/997 + 1000) repays the flash swap with fee (N00d_exp.sol:68). The surplus n00d (loan profit) stays with the attacker.

Iteration-1 share-inflation ground truth#

QuantityValueTrace source
enterAmount (= flash-borrowed n00d / 5)4,029.26 n00doutput.txt:164
Stale totalSushi seen by all 3 nested enters8,058.77 n00doutput.txt:166,174,182
Shares minted (3 nested enters)3,973.24 + 5,959.80 + 8,939.61 = 18,872.65output.txt:167,175,183
Attacker xn00d balance after enters18,872.65output.txt:232
n00d returned by leave(18,872.65)14,177.02output.txt:237
Net n00d gain (iter 1)14,177.02 − 4,029.26 = +10,147.76derived

Profit / loss accounting#

The pool's WETH reserve is the only real value siphoned. n00d is freely minted by the reentrancy and ultimately dumped into the pool; the attacker walks away with WETH.

ItemAmount
WETH in pool before75.40 WETH
WETH in pool after final sell54.73 WETH
WETH extracted by attacker20.668 WETH
Attacker WETH balance after exploit (PoC log)20.668267027908465821 WETH

PoC assertion / log (from output.txt:128):

CODE
Attacker WETH profit after exploit: 20.668267027908465821

No upfront capital was required: every n00d used was either flash-borrowed (and repaid) or freshly minted by the reentrancy. The 20.668 WETH is pure profit, drawn from honest LPs' liquidity.


Diagrams#

Sequence of one flash-swap round (the reentrancy)#

sequenceDiagram autonumber actor A as "Attacker contract" participant P as "n00d/WETH Pair" participant N as "n00dToken (ERC777)" participant B as "SushiBar (xn00d)" A->>P: "swap(reserve0-1e18, 0, A, data)" P->>A: "uniswapV2Call(...) — flash loan of ~20,146 n00d" rect rgb(255,243,224) Note over A,B: "enter() #outer — totalSushi stale = 8,058.77" A->>B: "enter(4,029.26)" B->>B: "mint shares = amt x totalShares / 8,058.77" B->>N: "transferFrom(A -> Bar, 4,029.26)" N->>A: "tokensToSend hook (balances NOT yet moved)" end rect rgb(232,245,233) Note over A,B: "reentry #1 — totalSushi STILL 8,058.77, totalShares grew" A->>B: "enter(4,029.26)" B->>B: "mint MORE shares (cheaper price)" B->>N: "transferFrom -> tokensToSend hook again" end rect rgb(227,242,253) Note over A,B: "reentry #2 (deepest) — mints first on unwind" A->>B: "enter(4,029.26)" B->>B: "mint shares = amt x 7,946.73 / 8,058.77 = 3,973.24" B->>N: "transferFrom completes (i==2, no more reentry)" end Note over B: "Total minted = 18,872.65 shares for one 4,029.26 deposit" A->>B: "leave(18,872.65 shares)" B->>A: "redeem 14,177.02 n00d (+10,147.76 surplus)" A->>P: "transfer repay = reserve x 1000/997 + 1000 n00d" Note over A: "Flash loan repaid; surplus n00d kept"

Pool / vault state evolution#

flowchart TD S0["Stage 0 - Initial<br/>Pool: 20,147 n00d / 75.40 WETH<br/>Bar totalSushi 8,058.77"] S1["Stage 1-5 - 5 flash-swap rounds<br/>Each: mint excess n00d via reentrancy,<br/>repay loan, keep surplus n00d<br/>Pool n00d grows 20,208 -> 20,457<br/>WETH reserve UNCHANGED at 75.40"] S6["Stage 6 - Cash out<br/>Sell 7,748 surplus n00d into pool<br/>Pool: 28,205 n00d / 54.73 WETH"] Win(["Attacker holds 20.668 WETH<br/>= honest LP liquidity"]) S0 -->|"flash-swap + ERC777 reentry x5"| S1 S1 -->|"dump minted n00d for WETH"| S6 S6 --> Win style S6 fill:#ffcdd2,stroke:#c62828,stroke-width:2px style Win fill:#c8e6c9,stroke:#2e7d32

The flaw inside enter() + ERC777 _send#

flowchart TD Start(["enter(_amount) called"]) --> Read["totalSushi = sushi.balanceOf(this)<br/>(deposit NOT yet received)"] Read --> Mint["_mint(shares = _amount x totalShares / totalSushi)<br/>totalShares grows NOW"] Mint --> TF["sushi.transferFrom(caller -> this, _amount)"] TF --> Send{"n00d is ERC777?"} Send -- "no (plain ERC20)" --> Safe["balances move atomically<br/>no callback - SAFE"] Send -- "yes" --> Hook["_callTokensToSend -> attacker.tokensToSend()<br/>BEFORE _move updates balances"] Hook --> Re{"reentry budget i < 2?"} Re -- "yes" --> Loop["call enter(_amount) again<br/>totalSushi STILL stale, totalShares already inflated"] Loop --> Read Re -- "no" --> Move["_move: balances finally settle"] Move --> Broken(["Attacker minted 18,872.65 shares<br/>for one 4,029.26 deposit"]) style Hook fill:#ffcdd2,stroke:#c62828,stroke-width:2px style Broken fill:#ffcdd2,stroke:#c62828,stroke-width:2px style Send fill:#fff3e0,stroke:#ef6c00

Remediation#

  1. Add a reentrancy guard. A nonReentrant modifier on enter/leave makes the nested enter calls revert, eliminating the bug regardless of token type. This is the minimal, decisive fix.
  2. Follow checks-effects-interactions. Pull the deposit (transferFrom) before computing the share price and minting. With the tokens already in the vault, totalSushi reflects reality at mint time, so even a reentrant enter is priced correctly. (Note: with ERC777, the receive hook on the vault could still re-enter, so this should be combined with a guard.)
  3. Do not pair ERC777 tokens with share-vault logic that assumes atomic transfers. ERC777's tokensToSend/tokensReceived hooks are general reentrancy vectors. Vault designers must treat any transfer/transferFrom of an ERC777 token as an external call that can re-enter.
  4. Snapshot the underlying balance defensively. Track the deposited amount as balanceAfter - balanceBefore and use an internally-accounted totalSushi rather than the live balanceOf, so transient hook-time balance reads cannot be exploited.
  5. Cap or sanity-check the mint. Reject an enter whose minted shares imply a per-share redemption value above the pre-call rate by more than a trivial epsilon.

How to reproduce#

BASH
_shared/run_poc.sh 2022-10-N00d_exp -vvvvv
  • RPC: an Ethereum archive endpoint is required for fork block 15,826,379 (Oct 2022). foundry.toml points mainnet at an Infura key; any archive RPC serving that historical block works.
  • Result: [PASS] testExploit() with Attacker WETH profit after exploit: 20.668267027908465821.

Expected tail:

CODE
Ran 1 test for test/N00d_exp.sol:ContractTest
[PASS] testExploit() (gas: 859396)
Logs:
  Attacker WETH profit after exploit: 20.668267027908465821

Suite result: ok. 1 passed; 0 failed; 0 skipped

References: BlockSec — https://twitter.com/BlockSecTeam/status/1584959295829180416 · Ancilia — https://twitter.com/AnciliaInc/status/1584955717877784576 · SlowMist Hacked — https://hacked.slowmist.io/ (n00d, Ethereum). The root cause is the same ERC777 "tokensToSend before balance update" reentrancy that hit the original imBTC/Uniswap and Lendf.me incidents — here applied to a SushiBar share-price formula.


Sources & further analysis#

Reproductions & code

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.