Reproduced Exploit
DeezNutz (DN404) Exploit — Self-Transfer Balance Inflation in a Reflection-Fork of DN404
DeezNutz is a fork of Vectorized's DN404 (hybrid ERC-20/ERC-721) that bolts a SafeMoon-style reflection accounting layer on top. The reflection layer rewrites the core _transfer so that each account's balance is derived from a reflected-units field rOwned (contracts_DN404Reflect.sol:258-264).
Loss
~$170K — 47.14 WETH of genuine pool liquidity drained
Chain
Ethereum
Category
Arithmetic / Overflow
Date
Feb 2024
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: 2024-02-DeezNutz404_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/DeezNutz404_exp.sol.
Vulnerability classes: vuln/logic/state-update · 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 compile together, so this one is extracted). Full verbose trace: output.txt. Verified vulnerable source: contracts_DN404Reflect.sol, contracts_DeezNutz.sol.
Key info#
| Loss | ~$170K — 47.14 WETH of genuine pool liquidity drained |
| Vulnerable contract | DeezNutz ($DN) — 0xb57E874082417b66877429481473CF9FCd8e0b8a |
| Victim pool | DN/WETH UniswapV2 pair — 0x1fB4904b26DE8C043959201A63b4b23C414251E2 |
| Attacker EOA | 0xd215fFAF0F85FB6f93f11E49Bd6175ad58af0DfD |
| Attacker contract | 0xd129D8C12f0E7Aa51157D9E6cC3f7eCe2dc84ECd |
| Attack tx | 0xbeefd8faba2aa82704afe821fd41b670319203dd9090f7af8affdf6bcfec2d61 |
| Chain / block / date | Ethereum mainnet / fork block 19,277,802 / ~Feb 24, 2024 |
| Funding | 2,000 WETH flash loan from Balancer Vault (0-fee) |
| Compiler | DeezNutz Solidity v0.8.20 (optimizer, 100 runs); pair v0.5.16 |
| Bug class | Self-transfer accounting bug — aliased from/to storage write inflates balance |
TL;DR#
DeezNutz is a fork of Vectorized's DN404 (hybrid ERC-20/ERC-721) that bolts a SafeMoon-style
reflection accounting layer on top. The reflection layer rewrites the core _transfer so that
each account's balance is derived from a reflected-units field rOwned
(contracts_DN404Reflect.sol:258-264).
The rewritten _transfer
(:533-684) caches the sender's and
recipient's rOwned into two separate local copies at the top of the function
(:548-549), mutates them as if they
referred to two distinct accounts, then writes both copies back to storage
(:609-610). When from == to
(a self-transfer), both copies alias the same storage slot. The function subtracts rAmount
into rOwnedFrom and adds rTransferAmount into rOwnedTo, then the toAddressData.rOwned write
clobbers the fromAddressData.rOwned write — so the subtraction is silently discarded and the
account is left with rOwned + rTransferAmount. A self-transfer mints the transferred amount out
of thin air.
The attacker:
- Flash-borrows 2,000 WETH from Balancer (0 fee) and buys 57,541,488 DN from the pool.
- Calls
DeezNutz.transfer(self, fullBalance)5 times. Each self-transfer roughly doubles the reported balance, compounding 57.5M → 344.5M DN (~6×) for free. - Sells the inflated DN back into the same pool, pulling 2,048.14 WETH out (sending a small slice straight to the pair first "to pass the k-value test").
- Repays the 2,000 WETH loan and walks off with 47.14 WETH of the LPs' real liquidity.
Background — what DeezNutz is#
DeezNutz (source) advertises itself as
"a DN404 fork that adds fractionalized yield". Concretely it is DN404 + reflections:
- DN404 base. DN404 is a hybrid token: an ERC-20 balance whose whole-token portion is mirrored as
ERC-721 NFTs. The canonical DN404 tracks balances as a plain
uint96 balancefield. DeezNutz keeps thatbalancefield for NFT mint/burn bookkeeping but makes it derived, not authoritative. - Reflection layer (the fork's addition). Balances are stored as reflected units
rOwned(:117) and converted to token amounts on read by dividing by a global raterTotal / tTotal(tokenFromReflection, :384-393). As "fees" accrue,rTotalshrinks, the rate falls, and every holder'sbalanceOfrises — classic SafeMoon reflections.taxFeewas 0 at the fork block, so no fee actually fired during the attack; the rate still drifts because the math is approximate. - Trading gate.
transfer/transferFromrevert for non-owners until the owner callsenableTrading()(contracts_DeezNutz.sol:109-111, 228-230). At the fork block trading was already live, so this was no obstacle.
The critical design point: DeezNutz replaced DN404's clean single-field balance update with a
two-field reflection update copied from a vanilla ERC-20 reflection token — but DN404's _transfer
must also support from == to self-transfers (the base contract explicitly handles that case at
:624-625 for NFT bookkeeping). The
reflection rewrite did not preserve self-transfer safety.
The vulnerable code#
1. Balances are stored as rOwned and read back via a rate#
// contracts_DN404Reflect.sol
function balanceOf(address owner) public view virtual returns (uint256) {
AddressData storage ownerAddressData = _getDN404Storage().addressData[owner];
if (ownerAddressData.isExcluded) return ownerAddressData.tOwned;
return tokenFromReflection(ownerAddressData.rOwned); // rOwned / rate
}
2. _transfer caches rOwned into two copies, mutates them, and writes BOTH back#
function _transfer(address from, address to, uint256 amount) internal virtual {
...
AddressData storage fromAddressData = _addressData(from);
AddressData storage toAddressData = _addressData(to);
_TransferTemps memory t;
...
t.rOwnedFrom = fromAddressData.rOwned; // L548 — for a self-transfer these two
t.rOwnedTo = toAddressData.rOwned; // L549 — read the SAME slot → equal values
...
unchecked {
(uint256 rAmount, uint256 rTransferAmount, ... ) = _getValues(amount);
...
// Transfer between non-excluded addresses (the path taken here):
else if (!fromAddressData.isExcluded && !toAddressData.isExcluded) {
t.rOwnedFrom = t.rOwnedFrom - rAmount; // L584
t.rOwnedTo = t.rOwnedTo + rTransferAmount; // L585
...
}
...
// Update address data rOwned and tOwned
fromAddressData.rOwned = t.rOwnedFrom; // L609 — written first
toAddressData.rOwned = t.rOwnedTo; // L610 — when from==to, OVERWRITES L609
...
}
emit Transfer(from, to, amount);
}
Source: contracts_DN404Reflect.sol:533-684, key lines 548-549, 583-589, 609-610.
3. The public transfer allows to == msg.sender#
// contracts_DeezNutz.sol
function transfer(address to, uint256 amount) public override returns (bool) {
if (!tradingEnabled) {
require(msg.sender == owner(), "Trading is not enabled");
}
_transfer(msg.sender, to, amount); // no check that to != msg.sender
return true;
}
Source: contracts_DeezNutz.sol:105-114.
Root cause — why it was possible#
A correct reflection-token _transfer updates each account exactly once: rOwned[from] -= rAmount; rOwned[to] += rTransferAmount;. That is safe even when from == to, because the two read-modify-write
operations target the same live storage slot in sequence — the net change is
-rAmount + rTransferAmount, i.e. ≈ 0 (or -rFee when a fee applies).
DeezNutz instead does a read-into-locals / mutate-locals / write-back-locals pattern. For
from == to:
t.rOwnedFromandt.rOwnedToare both seeded with the same startingrOwned = R(lines 548-549).- The non-excluded branch sets
t.rOwnedFrom = R − rAmountandt.rOwnedTo = R + rTransferAmount(lines 584-585). These are independent locals; the second does not see the first's subtraction.- The write-back does
rOwned = t.rOwnedFrom(line 609) and thenrOwned = t.rOwnedTo(line 610). The last write wins, so the final stored value isR + rTransferAmount— the subtraction is lost.
Net effect of one self-transfer: rOwned grows by rTransferAmount = rAmount − rFee. With taxFee = 0,
rTransferAmount = rAmount = amount × rate, so the account's token balance grows by amount — and
because the attacker passes amount = balanceOf(self), each self-transfer roughly doubles the
balance. No tokens are pulled from anywhere; totalSupply ($.totalSupply, the DN404 field) is not
even touched on this path — only rOwned is corrupted, and balanceOf reads from rOwned.
Four facts compose into the exploit:
- Aliased storage writes.
from == tomakes the tworOwnedwrite-backs target one slot, and the read-into-locals pattern means the second clobbers the first instead of accumulating on it. balanceOftrustsrOwned. The corrupted field is exactly the onebalanceOf(and therefore Uniswap, when it computes swap output from token balances) reads.transferpermits self as recipient. There is norequire(to != msg.sender)and no DN404 "same owner, no-op" fast-path in the reflection branch.- An AMM monetizes the fake balance instantly. Uniswap will pay real WETH for the inflated DN, so the attacker converts the minted-from-nothing tokens into the pool's genuine reserves.
The reflection rate (rTotal/tTotal) drifts as the attacker's rOwned balloons toward rTotal,
which is why the per-step growth in the trace is ~1.98× rather than exactly 2× and why one step even
appears to dip — but the direction is unambiguous: balance is created for free until the attacker
chooses to dump it.
Preconditions#
- Trading enabled. At the fork block
tradingEnabledwas already true, so anyone could calltransfer. (Even if it were not, the bug is intrinsic and would trigger the moment trading opened.) - A liquid DN/WETH pool to convert the inflated balance into real assets — present here with ~60.6M DN / ~59.1 WETH at fork.
taxFee == 0(true here) maximizes the inflation per self-transfer (rTransferAmount = rAmount); a non-zero fee would only slow the doubling, not stop it.- Working capital to seed the initial DN position. Supplied by a 2,000 WETH Balancer flash loan at 0 fee (output.txt:30-40); fully repaid intra-transaction, so the attack is effectively capital-free.
Attack walkthrough (with on-chain numbers from the trace)#
The pair's token0 = DN, token1 = WETH, so reserve0 = DN, reserve1 = WETH. All figures are
taken directly from getReserves, Sync, and balanceOf results in
output.txt. Amounts shown in whole tokens (÷1e18).
| # | Step | Attacker DN balance | Pool DN reserve | Pool WETH reserve | Effect |
|---|---|---|---|---|---|
| 0 | Flash-borrow 2,000 WETH from Balancer | 0 | 60,633,752 | 59.09 | Capital acquired (0 fee). |
| 1 | Buy DN — swap 2,000 WETH → DN (:52-86) | 57,541,488 | 1,745,042 | 2,059.09 | Attacker holds 57.5M DN; pool DN down ~97%. |
| 2 | Self-transfer #1 transfer(self, bal) (:92-106) | 113,886,170 | 1,745,042 | 2,059.09 | Balance ≈2×. Pool untouched. |
| 3 | Self-transfer #2 (:109-123) | 225,874,083 | 1,745,042 | 2,059.09 | ≈2× again. |
| 4 | Self-transfer #3 (:126-140) | 449,849,450 | 1,745,042 | 2,059.09 | ≈2× again. |
| 5 | Self-transfer #4 (:143-157) | 173,325,537 | 1,745,042 | 2,059.09 | Rate drift makes this step's reported number dip (rOwned still grew). |
| 6 | Self-transfer #5 (:160-174) | 344,520,657 | 1,745,042 | 2,059.09 | Final inflated balance ≈ 6× the bought amount. |
| 7 | Seed pair — transfer(pair, bal/20) = 17,226,033 DN (:182-196) | 327,508,021 | 18,540,424¹ | 2,059.09 | "to pass the k value test" (PoC L60). |
| 8 | Sell all DN — swap 327,508,021 DN → WETH (:199-233) | 0 | 337,860,744 | 10.95 | Pulls 2,048.14 WETH out of the pool. |
| 9 | Repay 2,001 WETH to Balancer (:237-242) | — | — | — | Loan + buffer repaid. |
| 10 | Profit | — | — | — | 47.14 WETH retained (:255-257). |
¹ Pool DN reserve after the direct transfer(pair, …) donation, read at output.txt:206.
Why the self-transfer "doubles" the balance. With taxFee = 0, one self-transfer leaves
rOwned ← rOwned + rTransferAmount where rTransferAmount = amount × rate and amount = balanceOf(self) = rOwned / rate. Substituting, rOwned_new = rOwned + (rOwned/rate)×rate = 2·rOwned. The reported
balance moves by the same factor, perturbed only by the global rate sliding as rOwned approaches
rTotal (the _getCurrentSupply guard at
:1276 resets the rate when the attacker's
share gets too large, producing the non-monotone step 5).
Why "pass the k value test" (step 7). The PoC donates bal/20 DN straight to the pair before the
final swap (PoC DeezNutz404_exp.sol:60). UniswapV2 enforces
x·y ≥ k only inside swap() using its synced reserves; pre-funding the pair with extra DN raises
the effective input so the router's single swapExactTokensForTokens clears the invariant check while
still draining the WETH side.
Profit accounting (WETH)#
| Direction | Amount |
|---|---|
| Borrowed (Balancer flash loan) | 2,000.00 |
| Spent — buy DN (step 1) | 2,000.00 |
| Received — sell inflated DN (step 8) | 2,048.14 |
| Repaid to Balancer (step 9) | 2,001.00 |
| Net profit | +47.14 |
The 2,000 WETH spent to buy DN is the same 2,000 WETH received back from selling the legitimately
bought portion; the extra 47.14 WETH is pure extraction enabled by the ~287M DN that the
self-transfer bug minted for free. At ~$3,600/ETH (Feb 2024) that is ≈ $170K, matching the
PoC header's Total Lost : ~170K USD$ (test/DeezNutz404_exp.sol:6).
Diagrams#
Sequence of the attack#
Balance inflation vs. pool drain#
The flaw inside _transfer when from == to#
Correct vs. broken self-transfer#
Remediation#
- Update a single live storage field, never two aliased local copies. Mutate
fromAddressData.rOwnedandtoAddressData.rOwneddirectly so that, whenfrom == to, the second update sees the first:This removes the read-into-locals / last-write-wins hazard entirely.fromAddressData.rOwned -= rAmount; toAddressData.rOwned += rTransferAmount; // if from==to, reads the just-decremented value - Add an explicit self-transfer guard / fast-path. At minimum,
if (from == to) { /* no-op or fee-only path */ return; }, orrequire(to != from)if self-transfers are not a product requirement. Canonical DN404 already special-casesfrom == tofor NFT bookkeeping (:624-625); the reflection branch must do the same for the balance update. - Keep one authoritative balance source. Forking DN404 (single
uint96 balance) and then layering a second balance model (rOwned/rate) created two sources of truth that the rewritten_transferfailed to keep consistent. Either keep DN404's balance authoritative and apply reflections as a view transformation, or fully replace it — do not maintain both with hand-written dual writes. - Add an invariant test for conservation. A property test asserting that
sum(balanceOf) == totalSupplyand thatbalanceOf(x)is non-increasing acrosstransfer(x, x, amount)would have caught this before deployment. - Audit forks, don't trust the parent's safety. DN404's safety properties do not survive being re-plumbed through a different accounting model; treat a fork's modified core paths as new code.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile together under a single forge test):
_shared/run_poc.sh 2024-02-DeezNutz404_exp -vvvvv
- RPC: a mainnet archive endpoint is required (fork block 19,277,802).
foundry.tomlis configured with amainnetalias; most pruned public RPCs will fail withheader not found/missing trie node. - Result:
[PASS] testExploit()withafter attack, WETH amount: 47.
Expected tail (see output.txt):
after swap, DeezNutz amount: 57541487
after self transfer, DeezNutz amount: 113886170
after self transfer, DeezNutz amount: 225874083
after self transfer, DeezNutz amount: 449849449
after self transfer, DeezNutz amount: 173325537
after self transfer, DeezNutz amount: 344520656
after swap back, WETH amount: 2048
------------------- flashloan finish ----------------
after attack, WETH amount: 47
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: ImmuneBytes analysis — https://twitter.com/ImmuneBytes/status/1664239580210495489 · SlowMist Hacked — https://hacked.slowmist.io/ (DeezNutz / DN404, ~$170K).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2024-02-DeezNutz404_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
DeezNutz404_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "DeezNutz (DN404) 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.