Reproduced Exploit
SinstakeNetworkZombie dividend-donation flash-drain — flash-borrowed ZOMBIE donates dividends that the same-tx buyer/seller immediately withdraws, netting excess ZOMBIE convertible to WBNB
SinstakeNetworkZombie is a "dividend yield" contract: users deposit ZOMBIE (the project ERC-20), receive internal dividend-bearing shares (tokenBalanceLedger_), and earn a slice of a dividendBalance_ pool that grows from a 10% entry fee, a 10% exit fee, and a permissionless donatePool() function. D…
Loss
~705.13 USD (~1.1016 WBNB realized as profit) [output.txt:1564,1565,1784]
Chain
BNB Chain
Category
Oracle Manipulation
Date
Jun 2025
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: 2025-06-SinstakeZombie_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/SinstakeZombie_exp.sol.
Vulnerability classes: vuln/logic/incorrect-order-of-operations · vuln/defi/fee-manipulation · vuln/oracle/spot-price
Reproduction: the PoC compiles and runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Vulnerable contract source is verified on BscScan and was fetched into sources/SinstakeNetworkZombie_731472/SinstakeNetworkZombie.sol.
Key info#
| Loss | ~705.13 USD (~1.1016 WBNB realized as profit) [output.txt:1564,1565,1784] |
| Vulnerable contract | SinstakeNetworkZombie — 0x7314729D691fD074DBbA03ca3c6eF3BE61b31D34 |
| Attacker EOA | 0xc49f2938327aa2cdc3f2f89ed17b54b3671f05de |
| Attack contract | 0xd599588b08eb167ee455f4bdac46fe162e7a6515 |
| Attack tx | 0x8b8e655c0ab0cd400e23e6d6a935aa23226a8a060bb37f40663f2d81ee63b94f |
| Chain / block / date | BNB Chain (BSC) / fork block 51,737,496 / 2025-06-19 |
| Compiler | Solidity ^0.6.12 (verified on BscScan) |
| Bug class | A donation that funds the dividend pool is recognized inside the same transaction, so a temporary flash-borrowed balance mints pool shares, immediately realizes the freshly donated dividend, withdraws it, and still has surplus ZOMBIE to dump against the AMM pair — a classic dividend-yield-fund-manipulation / profit-distribution-ordering flaw. |
TL;DR#
SinstakeNetworkZombie is a "dividend yield" contract: users deposit ZOMBIE (the project ERC-20), receive internal dividend-bearing shares (tokenBalanceLedger_), and earn a slice of a dividendBalance_ pool that grows from a 10% entry fee, a 10% exit fee, and a permissionless donatePool() function. Dividends are credited to holders via a profitPerShare_ accumulator that is re-leveled inside buy(), sell(), withdraw() and donatePool().
The fatal design choice is that donatePool() raises dividendBalance_ and raises profitPerShare_ (via the distribute() call that follows every state-changing action) before any time-lock or per-account lock separates a donor from a buyer. An attacker therefore:
- flash-borrows a large ZOMBIE principal from the ZOMBIE/WBNB BSCswap pair (no upfront capital),
- donates a slice of it to the dividend pool,
- buys shares with another slice (becoming the sole or dominant holder),
sell()s the freshly minted shares back, harvesting the entry-feeallocateFees()instant payout,withdraw()s the donated dividend that now sits individendBalance_/profitPerShare_,- ends the flash-swap callback by returning the borrowed ZOMBIE while keeping the donated + dividend excess, then dumps that excess ZOMBIE into the same pair to extract WBNB.
On-chain numbers from the local fork trace: the attacker entered with 0.0000092 WBNB and exited with 1.1016723 WBNB — net profit 1.101663174078554953 WBNB (WBNB_OUT = 0x0f49e4d1e914e349), exactly matching the assertEq(attackerProfit, WBNB_OUT) checkpoint in the PoC [output.txt:1564,1565,1784]. The pair's WBNB reserve dropped by the same amount, confirming the gain was drained from AMM liquidity.
The economic reason this works is that the contract treats any incoming token transfer (donation) as if it were organic yield that should be shared pro-rata over all existing holders, with no concept of "the donor themselves must not immediately claim the donation." Because a single atomic transaction can be donor + sole-holder + claimer, the attacker captures essentially the entire donation plus the entry-fee instant payout, while the AMM pair — the only other place the price is set — is manipulated within the same block.
Background — what SinstakeNetworkZombie does#
SinstakeNetworkZombie is a Ownable/Pausable "dividend distribution" pool for the ZOMBIE ERC-20 (0xe2a6428fD332287b0470965e16350d3CC1736e3e). It is not an ERC-4626 vault and not an AMM; it is a closed-loop accounting system that mints its own internal share ledger:
tokenBalanceLedger_[addr]— how many "shares" an address holds (not a transferable ERC-20; thetransfer()function exists but is just an internal ledger move).tokenSupply_— total shares minted.dividendBalance_— pool of ZOMBIE earmarked to be dripped out as dividends.profitPerShare_— per-share dividend accumulator (scaled bymagnitude = 2**64).payoutsTo_[addr]— signed per-account watermark so each holder only claims dividends accrued after they bought.
The intended lifecycle:
- Buy (
buy()→buyFor()→purchaseTokens()): pullsbuy_amountZOMBIE from the caller viatransferFrom, takes a 10%entryFee_, credits the callerbuy_amount * 0.9shares, and routes the fee intodividendBalance_(4/5 to drip, 1/5 instant payout viaallocateFees()).distribute()then advancesprofitPerShare_if enough time has passed (distributionInterval = 2 seconds). - Sell (
sell()): takes a 10%exitFee_, burns the shares, routes the fee todividendBalance_, and — critically — pays the seller the remaining 90% of the share value as currently-claimable dividend through thepayoutsTo_watermark mechanism (the seller'spayoutsTo_is decremented byprofitPerShare_ * tokens + taxedeth * magnitude, which frees up exactly_taxedethof dividend to be withdrawn). - Withdraw (
withdraw()): sendsmyDividends()worth of ZOMBIE to the caller and bumpspayoutsTo_so the same dividend can't be claimed twice. - Donate (
donatePool()): anyone can push ZOMBIE intodividendBalance_with no shares minted; this is meant to be the "pump dividends" faucet.
The dividend drip itself (distribute()) is time-gated at 2 seconds, so within a single block distribute() fires once and advances profitPerShare_ by the full accrued share.
The vulnerable code#
All snippets are from the verified source sources/SinstakeNetworkZombie_731472/SinstakeNetworkZombie.sol.
donatePool() — donation instantly inflates the claimable pool#
function donatePool(uint amount) public returns (uint256) {
require(token.transferFrom(msg.sender, address(this), amount));
dividendBalance_ += amount; // (1) pool grows immediately
emit onDonation(msg.sender, amount, now);
}
[source lines 326-332]
There is no lock, no vesting, no check that the donor is not also about to claim. The donated ZOMBIE simply sits in dividendBalance_ and is picked up by the very next distribute() call — which happens at the end of the attacker's subsequent buy().
distribute() — pushes dividendBalance_ into profitPerShare_ every 2 seconds#
function distribute() private {
...
if (SafeMath.safeSub(now, lastPayout) > distributionInterval && tokenSupply_ > 0) {
uint256 share = dividendBalance_.mul(payoutRate_).div(100).div(24 hours);
uint256 profit = share * now.safeSub(lastPayout);
dividendBalance_ = dividendBalance_.safeSub(profit);
profitPerShare_ = SafeMath.add(profitPerShare_, (profit * magnitude) / tokenSupply_);
lastPayout = now;
}
}
[source lines 641-664]
payoutRate_ = 2, distributionInterval = 2 seconds. Because the PoC rolls the block forward (vm.warp(1_750_350_413)), the elapsed-time term now - lastPayout is enormous, so distribute() empties essentially the entire dividendBalance_ into profitPerShare_ in one shot. Once profitPerShare_ has risen, any current holder can call withdraw() and pull out a slice proportional to their share of tokenSupply_.
purchaseTokens() / allocateFees() — the buyer also triggers an instant payout#
function purchaseTokens(address _customerAddress, uint256 _incomingeth) internal returns (uint256) {
...
uint256 _undividedDividends = SafeMath.mul(_incomingeth, entryFee_) / 100; // 10%
uint256 _amountOfTokens = SafeMath.sub(_incomingeth, _undividedDividends);
...
allocateFees(_undividedDividends); // (A) fee -> dividend pool
tokenBalanceLedger_[_customerAddress] = SafeMath.add(tokenBalanceLedger_[_customerAddress], _amountOfTokens);
int256 _updatedPayouts = (int256) (profitPerShare_ * _amountOfTokens); // (B) buyer's dividend baseline set HERE
payoutsTo_[_customerAddress] += _updatedPayouts;
...
}
[source lines 680-727]
function allocateFees(uint fee) private {
uint256 instant = fee.div(5); // 1/5 of the 10% fee = instant payout
if (tokenSupply_ > 0) {
profitPerShare_ = SafeMath.add(profitPerShare_, (instant * magnitude) / tokenSupply_);
}
dividendBalance_ += fee.safeSub(instant); // 4/5 to drip pool
}
[source lines 626-639]
The order matters: allocateFees() (step A) bumps profitPerShare_ before the buyer's payoutsTo_ watermark (step B) is set. Because the watermark uses the post-bump profitPerShare_, the buyer correctly does not steal the instant payout from existing holders — but the instant payout came from the buyer's own fee, so the buyer is effectively being charged a fee that everyone shares, while the buyer is the only holder (see walkthrough). Combined with the donation, the dominant effect is the donated dividendBalance_ that distribute() flushes in immediately afterward.
withdraw() — sends ZOMBIE straight out of the contract#
function withdraw() onlyStronghands public {
address _customerAddress = msg.sender;
uint256 _dividends = myDividends();
payoutsTo_[_customerAddress] += (int256) (_dividends * magnitude);
token.transfer(_customerAddress, _dividends); // pays out of the contract's ZOMBIE balance
...
distribute();
}
[source lines 399-429]
withdraw() pays dividends in ZOMBIE pulled from the contract's own token balance. Because the attacker's donated ZOMBIE is physically inside the contract (it was transferFrom'd in donatePool()), withdraw() happily hands it back — the attacker recovers the donation plus the entry-fee-funded instant payout.
Root cause — why it was possible#
-
Dividend-claim eligibility has no temporal separation from donation/purchase.
donatePool(),buy(),sell(), andwithdraw()all run in a single transaction, and each one ends with adistribute()that can pushdividendBalance_intoprofitPerShare_. The same externally owned account (or its attack contract) can be donor, sole holder, and claimant atomically. There is no lock-up, no epoch boundary, and no "you must have held shares before the donation" check. -
donatePool()is unguarded and permissionless. Anyone can funddividendBalance_, so an attacker with a flash loan can manufacture a dividend pool out of borrowed capital and then immediately consume it. -
distribute()time-vesting is trivially defeatable in one tx. WithpayoutRate_ = 2% per day but the elapsed-time termnow - lastPayoutevaluated againstlastPayoutfrom the previous block, a single block whose timestamp is far fromlastPayoutflushes the entiredividendBalance_intoprofitPerShare_at once. The intended "slow drip over 24h" collapses into an instantaneous payout whenever a transaction lands after a gap. -
The contract's own token balance is the dividend source.
withdraw()callstoken.transfer(msg.sender, _dividends)directly from the contract's ZOMBIE holdings. Since the donated ZOMBIE is sitting in that exact balance, the attacker's withdrawal is physically funded by the donation they just made — there is no separate "yield reserve" that was meant to be drained only slowly. -
No reentrancy or flash-loan protection. The contract neither records entry balances for the transaction nor defers payout settlement. Combined with a BSCswap flash-swap callback (
BSCswapCall) that lets the attacker hold the borrowed ZOMBIE across the entire donate→buy→sell→withdraw sequence before returning it, the whole loop is risk-free for the attacker.
Preconditions#
- Permissionless. No privileged role is needed.
donatePool(),buy(),sell(),withdraw()are allpublic. - Requires a flash loan / flash swap of ZOMBIE. The attacker used the ZOMBIE/WBNB BSCswap pair's
BSCswapCallcallback to borrow 27,300 ZOMBIE (27.3e12 units) with zero upfront capital. tokenSupply_can be driven near zero before the attack so that the attacker becomes the dominant (or only) holder and captures essentially all of the donated dividend. This is the case at fork block 51,737,496.- A timestamp gap larger than
distributionInterval(2s) sodistribute()flushes the fulldividendBalance_— trivially satisfied between blocks.
Attack walkthrough (with on-chain numbers from the trace)#
All amounts are in raw token units (ZOMBIE and WBNB both 18 decimals). Numbers cited as [output.txt:NNNN].
| # | Step | Amount (ZOMBIE / WBNB) | Source |
|---|---|---|---|
| 0 | Attacker WBNB balance before | 0.000009211251839167 WBNB | [output.txt:1564] |
| 1 | Flash-borrow ZOMBIE from BSCswap pair via swap(0, 27,300,000,000,000, attackContract, data) | +27,300 ZOMBIE borrowed | [output.txt:1616] |
| 2 | donatePool(10,000,000,000,000) — donation into dividend pool | −10,000 ZOMBIE; dividendBalance_ += 10,000 | [output.txt:1634] |
| 3 | buy(15,300,000,000,000) — purchase shares; 10% fee (1,530) routed via allocateFees, 13,770 shares minted | −15,300 ZOMBIE; +13,770 shares | [output.txt:1652,1664] |
| 4 | distribute() fires inside buy(), flushing dividendBalance_ into profitPerShare_ | profitPerShare_ jumps; onBalance emitted at 72,628,559,142,666 | [output.txt:1668] |
| 5 | sell(13,770,000,000,000) — burn all shares; exit fee 10% → 12,393 ZOMBIE credited as claimable dividend | shares → 0; payoutsTo_ freed | [output.txt:1686] |
| 6 | withdraw() — contract transfers 68,798,636,722,097 ZOMBIE (~68,798) to the attack contract | +68,798 ZOMBIE out of the contract | [output.txt:1696,1706] |
| 7 | Inside BSCswapCall, return 27,387,360,000,000 ZOMBIE to the pair to satisfy the flash-swap invariant (27,300 principal + 87.36 fee) | −27,387 ZOMBIE returned | [output.txt:1716,1732] |
| 8 | Attack contract still holds ~39,423,446,103,659 ZOMBIE of surplus (68,798 − 27,387 − 0 remaining) | surplus ≈ 39,423 ZOMBIE | [output.txt:1741] |
| 9 | Push surplus ZOMBIE into the pair, then swap(1,101,663,174,078,554,953, 0, attacker, 0x) for WBNB | pair pays attacker 1.101663174078554953 WBNB | [output.txt:1755,1765] |
| 10 | Attacker WBNB balance after | 1.101672385330394120 WBNB | [output.txt:1784] |
Profit / loss accounting
- Attacker WBNB:
1.101672385330394120 − 0.000009211251839167 = 1.101663174078554953WBNB ≈ +1.1016 WBNB (~705 USD at the time). - Pair WBNB reserve: dropped by exactly
1.101663174078554953WBNB (asserted byassertEq(pairWbnbBefore - ..., WBNB_OUT)) — the gain is paid by AMM liquidity. - Attacker's gross ZOMBIE inflow from the contract (
68,798) minus the ZOMBIE returned to the pair for the flash (27,387) leaves~39,423ZOMBIE of surplus, which is exactly the amount converted into the WBNB profit in step 9. The contract's own ZOMBIE balance fell by the donated10,000plus the entry-fee-sourced instant payout — i.e. the attacker extracted more ZOMBIE than they ever deposited, because the dividend mechanism printed claimable dividends out of the donation they themselves had just made.
Diagrams#
Attack sequence#
Flaw flowchart#
Remediation#
-
Do not let a donor claim their own donation in the same transaction. Track a per-account "dividend baseline" set at share-mint time and only allow claiming dividends accrued strictly after the share was minted. The existing
payoutsTo_watermark already encodes this idea but is undermined becausedonatePool()is followed by adistribute()that bumpsprofitPerShare_for current holders including the just-donating attacker. Fix: when computing a user's claimable dividend, exclude anyprofitPerShare_delta that was funded by a donation made in the same block, or snapshotprofitPerShare_per-account at the start of each transaction. -
Vest donations over time.
donatePool()should add the amount to a vesting schedule (e.g. linear over 24h) rather than directly todividendBalance_. Only vested amounts enterdistribute(). -
Bound
distribute()per call. Cap theprofitterm so that no singledistribute()call can move more than a small fraction ofdividendBalance_intoprofitPerShare_, regardless of how largenow - lastPayoutis. This prevents the "one-block flushes the whole pool" failure mode. -
Add reentrancy / same-tx flash-loan protection. Record the caller's
tokenBalanceLedger_at function entry and forbidwithdraw()/sell()payouts that exceed what was accrued before the entry of the outermost user call. A simplenonReentrant-style guard plus a "no dividend claims within the same block as a buy" rule closes the flash path. -
Separate the dividend payout reserve from the contract's general token balance, and fund withdrawals only from accrued yield, never from freshly deposited or donated principal that has not yet vested.
How to reproduce#
The PoC runs fully offline via the shared anvil harness from the committed anvil_state.json — no RPC needed.
_shared/run_poc.sh 2025-06-SinstakeZombie_exp -vvvvv
- Fork: BNB Chain (BSC, chain id 56), fork block
51,737,496(PoC rolls forward to51,737,497and warps timestamp to1,750,350,413). - Expected result:
[PASS] testExploit()with the attacker WBNB balance going from0.000009211251839167to1.101672385330394120(profit1.101663174078554953WBNB ≈ 705 USD) [output.txt:1562,1564,1565,1784]. - The local run is confirmed passing:
1 tests passed, 0 failed, 0 skippedat the tail of output.txt.
Reference: Telegram alert — defimon_alerts/1319.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-06-SinstakeZombie_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
SinstakeZombie_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "SinstakeNetworkZombie dividend-donation flash-drain".
- 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.