Reproduced Exploit
SlurpyCoin Exploit — Attacker-Timed Token-Owned `BuyOrSell` Pool Manipulation
SlurpyCoin is a "reflection + auto-liquidity" meme token. Its _transfer hook contains a BuyOrSell() routine (SlurpyCoin.sol:1123-1138) that fires automatically whenever the token contract's own SLURPY balance reaches numTokensToSell (100,000 SLURPY). For the first timeToSlurp = 200 firings it sells…
Loss
~$3K (reported). PoC nets 7.4118 BNB of pool WBNB, intra-transaction, off a 40 WBNB flash loan
Chain
BNB Chain
Category
Oracle Manipulation
Date
Dec 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-12-SlurpyCoin_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/SlurpyCoin_exp.sol.
Vulnerability classes: vuln/reentrancy/single-function · vuln/oracle/price-manipulation
One-liner: SLURPY's tokenomics contract performs its own swaps against the SLURPY/WBNB pool on every transfer once it holds enough SLURPY; an attacker fed the contract SLURPY to control when that self-swap fires, wrapped the contract's reentrant "buy-back-and-burn" inside their own sell, and walked off with the pool's WBNB.
Reproduction: the PoC compiles & runs in this isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains several unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: sources/SlurpyCoin_72c114/SlurpyCoin.sol. PoC test: test/SlurpyCoin_exp.sol.
Key info#
| Loss | ~$3K (reported). PoC nets 7.4118 BNB of pool WBNB, intra-transaction, off a 40 WBNB flash loan |
| Vulnerable contract | SlurpyCoin — 0x72c114A1A4abC65BE2Be3E356eEde296Dbb8ba4c |
| Victim pool | SLURPY/WBNB PancakeSwap V2 pair — 0x76A5a2Ef4AE2DdEAD0c8D5b704808637B414113C |
| Flash-loan source | DODO DPP pool — 0x6098A5638d8D7e9Ed2f952d35B2b67c34EC6B476 |
| Attacker EOA | 0x132d9bbdbe718365af6cc9e43bac109a9a53b138 |
| Attacker contract | 0x051e057ea275caf9a73578a97af6e8965e5a2349 |
| Attack tx | 0x6c729ee778332244de099ba0cb68808fcd7be4a667303fcdf2f54dd4b3d29051 |
| Chain / block / date | BSC / fork at 44,990,634 (attack at 44,990,635) / ~Dec 18, 2024 |
| Compiler | Solidity v0.6.12, optimizer 200 runs |
| Bug class | Token-owned AMM self-swap with attacker-controlled timing + reentrant reserve manipulation |
TL;DR#
SlurpyCoin is a "reflection + auto-liquidity" meme token. Its _transfer hook contains a
BuyOrSell() routine (SlurpyCoin.sol:1123-1138)
that fires automatically whenever the token contract's own SLURPY balance reaches numTokensToSell
(100,000 SLURPY). For the first timeToSlurp = 200 firings it sells 100k SLURPY into the pool for BNB
(swapTokensForEth); on the 200th firing it flips to buying SLURPY back with the accumulated BNB and
burning it (swapEthForTokens).
Two design facts make this exploitable:
- The attacker controls when
BuyOrSellfires — anyone can credit the contract with SLURPY simply by callingslurpy.transfer(SLURPY_ADDR, amount); the next transfer then trips thecontractTokenBalance >= numTokensToSellgate and runs the self-swap. - The self-swap runs inside the caller's transaction, against live reserves — so the attacker can sandwich their own trades around it, and can force the expensive "buy-back-and-burn" to fire reentrantly in the middle of their own sell, inflating the pool's WBNB reserve exactly when they are selling.
The attacker:
- Flash-borrows 40 WBNB from DODO's
DPPpool. - Repeatedly buys 1.3M SLURPY from the pool (16 rounds) while, between buys, feeding the contract 100k
SLURPY at a time so its
BuyOrSellkeeps selling 100k SLURPY into the pool for BNB — done 194 times, driving the internalcounterup to thetimeToSlurp = 200threshold. The contract quietly accrues 28.66 BNB. - When
counterhits 200, the nextBuyOrSellflips toswapEthForTokensand spends the contract's 28.66 BNB to buy 2.78M SLURPY (sent to the owner) and burn 2.64M of it — and this fires reentrantly while the attacker's own 574k-SLURPY sell is in flight, pumping the pool's WBNB reserve from 32.67 → 61.33 right under the attacker's sell. - Dumps all remaining SLURPY (its own + 8 helper contracts' holdings) into the WBNB-rich pool.
- Repays the 40 WBNB loan, unwraps the rest → +7.4118 BNB.
Background — what SlurpyCoin does#
SlurpyCoin (source) is a Solidity-0.6 SafeMoon-style token:
- Reflection accounting (
_rOwned/_tOwned,_getRate) with a configurable tax + liquidity fee. maxTxAmount/whaleCapanti-whale limits (:784-789).- An "auto-liquidity" engine (
BuyOrSell) bolted into_transfer. This is the vulnerable part.
The on-chain state at the fork block:
| Item | Value |
|---|---|
numTokensToSell | 100,000 SLURPY — the contract-balance trigger for BuyOrSell |
_maxTxAmount | 5,000,000 SLURPY |
timeToSlurp | 200 — # of sell-firings before a buy-back-and-burn |
_taxFee / _liquidityFee (after endPresale) | 3% / 2% ⇒ 5% fee-on-transfer |
swapAndLiquifyEnabled | true |
Pool token0 = SLURPY, token1 = WBNB | reserves below |
| Initial pool reserves | 16,796,897.21 SLURPY / 10.9657 WBNB (output.txt:33) |
Because the 5% liquidity fee on every pool sell sends SLURPY to the token contract
(_takeLiquidity, :1030-1036), the contract naturally
accumulates SLURPY during trading — but the attacker doesn't need to wait: a direct
transfer(SLURPY_ADDR, 100_000e18) credits the contract instantly.
The vulnerable code#
1. _transfer auto-fires BuyOrSell on the contract's own balance#
function _transfer(address from, address to, uint256 amount) private {
...
uint256 contractTokenBalance = balanceOf(address(this)); // ← contract's own SLURPY
if (contractTokenBalance >= _maxTxAmount) contractTokenBalance = _maxTxAmount;
bool overMinTokenBalance = contractTokenBalance >= numTokensToSell; // ≥ 100k SLURPY ?
if (
overMinTokenBalance &&
!inSwapAndLiquify &&
from != uniswapV2Pair && // ← only blocks pair-as-sender; attacker is the sender
swapAndLiquifyEnabled
) {
contractTokenBalance = numTokensToSell;
BuyOrSell(contractTokenBalance); // ⚠️ fires on a balance the attacker can set
}
...
_tokenTransfer(from, to, amount, takeFee);
}
2. BuyOrSell — sells into the pool, then buys-and-burns#
function BuyOrSell(uint256 contractTokenBalance) private lockTheSwap {
if (counter < timeToSlurp) { // first 200 firings
counter++;
swapTokensForEth(contractTokenBalance); // ⚠️ contract dumps 100k SLURPY into pool for BNB
} else { // 200th firing
counter = 0;
uint256 bal = address(this).balance;
removeAllFee();
swapEthForTokens(bal); // ⚠️ contract spends ALL its BNB to buy SLURPY + burn
restoreAllFee();
}
}
function swapTokensForEth(uint256 tokenAmount) private {
address[] memory path = new address[](https://github.com/sanbir/evm-hack-registry/tree/main/2024-12-SlurpyCoin_exp/2);
path[0] = address(this); path[1] = uniswapV2Router.WETH();
_approve(address(this), address(uniswapV2Router), tokenAmount);
uniswapV2Router.swapExactTokensForETHSupportingFeeOnTransferTokens(
tokenAmount, 0 /* any */, path, address(this), block.timestamp); // ⚠️ minOut = 0
}
function swapEthForTokens(uint256 ethAmount) private {
address[] memory path = new address[](https://github.com/sanbir/evm-hack-registry/tree/main/2024-12-SlurpyCoin_exp/2);
path[0] = uniswapV2Router.WETH(); path[1] = address(this);
uint256 ownerBal = balanceOf(owner());
uniswapV2Router.swapExactETHForTokens{value: ethAmount}(
0 /* any */, path, owner(), block.timestamp); // ⚠️ minOut = 0, buys at any price
uint256 amountBurn = balanceOf(owner()).sub(ownerBal);
_transferStandard(owner(), address(0), amountBurn); // burn the bought SLURPY
}
Both swaps pass 0 as amountOutMin, so the contract trades against the pool at whatever price the
attacker has manipulated it to — there is no slippage protection, and the trade is executed at a moment of
the attacker's choosing inside the attacker's own transaction.
Root cause — why it was possible#
A token must not perform unbounded, externally-timed swaps against its own liquidity pool using the pool's price. SlurpyCoin does exactly that, and hands the trigger to anyone:
- Attacker-chosen trigger.
BuyOrSellfires whenbalanceOf(address(this)) >= numTokensToSell. Anyone can satisfy that with a singletransfer(SLURPY_ADDR, 100_000e18). The only "guard" (from != uniswapV2Pair) blocks the pair from being the sender, not the attacker. - Self-swap runs against live, manipulable reserves with
minOut = 0.swapTokensForEthandswapEthForTokensroute through PancakeSwap at the current price. The attacker first skews that price (by cornering SLURPY) and then lets the contract trade at the skewed price. counter/timeToSlurpis a free reserve pump the attacker can schedule. By firing the cheap "sell" path 194 times the attacker (a) loads the contract with 28.66 BNB and (b) windscounterup to 200, so the next fire is the expensive buy-back-and-burn. The attacker then triggers that fire reentrantly, in the middle of its own large SLURPY→WBNB sell, so the contract dumps 28.66 BNB into the pool — pushing the WBNB reserve from 32.67 → 61.33 — exactly while the attacker is selling SLURPY for that WBNB.- No reentrancy isolation around the pool.
lockTheSwaponly setsinSwapAndLiquifyto prevent recursiveBuyOrSell; it does nothing to stop the self-swap from executing within an attacker's PancakeSwap call and altering the reserves the attacker is mid-trade against.
In short, the attacker turned SlurpyCoin's own "auto-liquidity" engine into a programmable price-pump that they could fire at the precise instant it benefited them.
Preconditions#
swapAndLiquifyEnabled == trueand presale ended (both true on-chain afterendPresale).- The attacker can credit the token contract with ≥
numTokensToSellSLURPY (trivially, viatransfer), and can buy SLURPY from the pool to corner it. - Working WBNB capital to corner the pool and feed the cycles; it is fully recovered intra-transaction,
hence flash-loanable (the PoC borrows 40 WBNB from DODO
DPP). - The 5% fee-on-transfer and the
counter/timeToSlurpcadence are exploited as designed — no admin action is required.
Attack walkthrough (with on-chain numbers from the trace)#
Pair token0 = SLURPY (reserve0), token1 = WBNB (reserve1). All figures below come from the Sync
events in output.txt.
| # | Step | SLURPY reserve | WBNB reserve | Effect |
|---|---|---|---|---|
| 0 | Initial (output.txt:33) | 16,796,897.21 | 10.9657 | Honest pool. |
| 1 | Flash loan: borrow 40 WBNB from DODO DPP | 16,796,897.21 | 10.9657 | Working capital acquired. |
| 2 | Cycle loop (16× buy 1.3M SLURPY + 194× contract self-sells of 100k SLURPY) | ~5,957,273 | ~32.67 (output.txt:12117) | Attacker corners SLURPY; contract self-sells dump SLURPY in & pull BNB out → contract holds 28.66 BNB; counter reaches 200. |
| 3 | Attacker sells 574,574 SLURPY → triggers reentrant BuyOrSell (now buy-back path) | — | — | Sell of SLURPY also trips the contract-balance gate mid-swap. |
| 3a | ↳ contract swapEthForTokens(28.66 BNB): buys 2,779,946 SLURPY → owner, burns 2,640,949 (output.txt:12153-12171) | 3,177,326 | 61.33 (output.txt:12165) | ⚠️ Contract pumps 28.66 BNB into the pool, WBNB reserve nearly doubles, mid-attacker-sell. |
| 3b | ↳ attacker's sell completes: receives 9.2113 WBNB (output.txt:12191-12203) | 3,740,246 | 52.12 | Attacker sells at the inflated WBNB reserve. |
| 4 | Helper drain loop: 8 helper contracts (each pre-bought 1.3M SLURPY) widthdraw() to attacker, who sells each batch | rising | falling | Each ~1.2M-SLURPY sell pulls ~1.35 WBNB; reserves walk toward SLURPY-heavy. |
| 5 | Final sells of remaining SLURPY (output.txt:12856-12911) | 13,799,811 | 14.1698 (output.txt:12910) | Pool left SLURPY-heavy, WBNB drained to the attacker. |
| 6 | Repay 40 WBNB to DODO (output.txt:12923); unwrap remainder | 13,799,811 | 14.1698 | Contract holds 47.4118 WBNB → repay 40 → 7.4118 WBNB kept → unwrap to BNB. |
The attacker's per-round corner-buy cost rose from 0.922 WBNB → 5.875 WBNB for the same 1.3M SLURPY (output.txt buy inputs) as SLURPY became scarce — confirming the pool was being progressively cornered before the reentrant pump.
Profit accounting (WBNB)#
| Item | Amount (WBNB) |
|---|---|
| Flash loan in (DODO) | 40.0000 |
| Token-contract balance at end (pre-repay) | 47.4118 |
| Flash loan repaid | −40.0000 |
| Net profit (unwrapped to BNB) | +7.4118 |
testExploit balance log: attacker BNB 0 → 7.411804202305118343 (output.txt:5-7).
Diagrams#
Sequence of the attack#
Pool state evolution#
The flaw inside _transfer / BuyOrSell#
Why each magic number#
- 40 WBNB flash loan — working capital to corner the pool and cover the cycle; the pool only had ~11 WBNB initially, so the attacker also injects WBNB by buying, then recovers it.
- 16 outer × ~15 inner cycles — sized so the contract self-sells fire 194 times, pushing
counterfrom its on-chain start to exactlytimeToSlurp = 200, so the nextBuyOrSellis the buy-back-and-burn. - 100,000 SLURPY per transfer — exactly
numTokensToSell, the minimum to trip theBuyOrSellgate each cycle while loading the contract with BNB. - 1.3M SLURPY corner buys — large enough to make SLURPY scarce in the pool (corner-buy cost climbs 0.92 → 5.87 WBNB) so the contract's self-sells return more BNB and the later pump is more violent.
- 574,574 SLURPY first sell — the sell that, combined with the fee-accrued contract balance, trips the
200th
BuyOrSell, firing the 28.66-BNB buy-back-and-burn while the attacker is selling.
Remediation#
- Do not let a token swap against its own pool on user-triggered transfers. Remove the
BuyOrSellhook from_transfer, or restrict the auto-liquidity engine to a trusted keeper/owner call that runs in its own transaction (never reentrantly inside a user's PancakeSwap call). - Never use
amountOutMin = 0for protocol-owned swaps.swapTokensForEth/swapEthForTokensmust set a slippage bound derived from a manipulation-resistant price (TWAP/oracle), so the engine cannot be made to trade at an attacker-skewed spot price. - Don't key the trigger on the contract's own, externally-creditable balance.
balanceOf(address(this)) >= numTokensToSellis satisfiable by anyone via a directtransfer. Track the swappable amount via internal accounting that only increases through fee accrual, and/or gate the engine behind time/role checks the attacker cannot schedule. - Isolate the engine from reentrancy into the AMM. A real
nonReentrantguard on the whole transfer path (not just aninSwapAndLiquifyrecursion flag) prevents the self-swap from executing within an attacker's in-flight swap and mutating the reserves they are trading against. - Cap single-operation reserve impact. Any protocol action that can move a pool reserve by more than a few percent in one call should revert — a 28.66-BNB pump into a ~33-WBNB pool is a clear red flag.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has several unrelated
PoCs that fail under a whole-project forge build):
_shared/run_poc.sh 2024-12-SlurpyCoin_exp --match-test testExploit -vvvvv
- RPC: a BSC archive endpoint is required (fork block 44,990,634).
foundry.tomluseshttps://bsc-mainnet.public.blastapi.io, which serves historical state at that block; most public BSC RPCs prune it and fail withheader not found/429rate limits (the rotatedonfinalitydefault was swapped forblastapihere). - Result:
[PASS] testExploit()with attacker BNB0 → 7.4118.
Expected tail:
Ran 1 test for test/SlurpyCoin_exp.sol:SlurpyCoin
[PASS] testExploit() (gas: 33791644)
Logs:
Attacker Before exploit BNB Balance: 0.000000000000000000
Attacker After exploit BNB Balance: 7.411804202305118343
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: CertiK Alert — https://x.com/CertiKAlert/status/1869580379675590731 (SLURPY, BSC, ~$3K).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2024-12-SlurpyCoin_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
SlurpyCoin_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "SlurpyCoin 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.