Reproduced Exploit
SellToken Exploit — Spot-Price Oracle Manipulation of a Leveraged "Short" Exchange
SellToken is a self-described "decentralized short-trading exchange." A user opens a short on a token through ShortStart(), and later closes it through withdraw(). The size of the payout on close is computed entirely from PancakeRouter.getAmountsOut() — i.e. the instantaneous spot price read straig…
Loss
~3.11 WBNB profit per cycle (≈ $1,000 at the time); attacker repeated this across the SellToken platform. Thi…
Chain
BNB Chain
Category
Oracle Manipulation
Date
May 2023
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: 2023-05-SellToken_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/SellToken_exp.sol.
Vulnerability classes: vuln/oracle/spot-price · vuln/oracle/price-manipulation · vuln/defi/slippage
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 was extracted). Full verbose trace: output.txt. Verified vulnerable source: SellToken.sol.
Key info#
| Loss | ~3.11 WBNB profit per cycle (≈ $1,000 at the time); attacker repeated this across the SellToken platform. This PoC reproduces a single ~3.11 WBNB cycle. |
| Vulnerable contract | SellToken exchange — 0x57Db19127617B77c8abd9420b5a35502b59870D6 |
| Vulnerable helper | Minerals (the "vault") — 0x8D190C70937493046a464440d28f126A4E42eF7f (deployed by SellToken in its constructor) |
Shorted token (SELLC) | 0xa645995e9801F2ca6e2361eDF4c2A138362BADe4 |
| Manipulated pool | PancakeSwap SELLC/WBNB pair — 0x358EfC593134f99833C66894cCeCD41F550051b6 |
| Flash-loan source | DODO DPPOracle (WBNB private pool) — 0xFeAFe253802b77456B4627F8c2306a9CeBb5d681 |
| Attacker contract | SellTokenExp (the PoC test contract; on-chain the attacker used an equivalent helper) |
| Attack tx | 0x7d04e953dad4c880ad72b655a9f56bc5638bf4908213ee9e74360e56fa8d7c6a |
| Chain / block / date | BSC / 28,168,034 / ~May 2023 |
| Compiler | SellToken v0.8.19, optimizer 1 run · SELLC v0.8.16 |
| Bug class | Price-oracle manipulation — using Router.getAmountsOut() (instantaneous AMM spot price) as the trade price for a leveraged product, manipulable by a flash loan |
TL;DR#
SellToken is a self-described "decentralized short-trading exchange." A user opens a short on a token
through ShortStart(), and later closes it through withdraw(). The size of the payout on close is
computed entirely from PancakeRouter.getAmountsOut() — i.e. the instantaneous spot price read
straight out of the token's PancakeSwap pool
(SellToken.sol:563-580,
:581-599). There is no TWAP, no oracle, and no
sanity bound; whatever the AMM reserves say in this block is taken as truth.
Because that price is read from a thin PancakeSwap pool, an attacker can move it at will inside a single transaction. The attacker:
- Flash-borrows 418.5 WBNB from a DODO
DPPOracleprivate pool. - Pumps the
SELLC/WBNBpool by swapping ~414 WBNB into SELLC, inflating the recorded SELLC price ~2× (setTokenPricesnapshots the pumped price). - Opens a short via
ShortStart()while the price is high — the position's reference price is locked at the inflated value, andSellTokenroutes the user's BNB through itsMineralsvault to buy SELLC at the top. - Dumps all the SELLC it bought back into the pool, crashing the price below where the short was opened.
- Closes the short via
withdraw(). The settlement math (getMyShort, :619-623) sees "price went down → short profited," and theMineralsvault pays the attacker SELLC sized by the manipulated ratio, which the attacker immediately sells for WBNB. - Repays the flash loan and keeps the difference — 3.11 WBNB.
The whole position is opened and closed inside one block at prices the attacker himself dictated, so the "short" always wins. The protocol's vault funds the payout.
Background — what SellToken does#
SellToken (source) is two contracts working together:
SellToken(L429-700) — the user-facing exchange. It holds the short-position bookkeeping (Short[user][token]) and the price snapshots (tokenPrice[user][token]).Minerals(L234-428) — a "vault"/liquidity contract theSellTokenconstructor deploys (:460). It actually holds the tokens and BNB, executes the buys/sells against PancakeSwap on behalf of the exchange, and tracks per-token reserves inbalanceOf[token].
The intended flow for a "short":
setTokenPrice(token)— snapshot the token's current price (tokenPrice[user][token]) and start a 30-second window (:471-476).ShortStart(token, user, terrace)— pay BNB to open the short.SellTokenforwards 97% of the BNB toMinerals, which buys the token on Pancake; the position records the entry price (:477-518).withdraw(token)— close the short. The payout isbnb × nowPrice / oldPrice; if the token's price fell, the short "made money," andMineralssells token → BNB and forwards it to the user (:519-561).
Every "price" in the above is PancakeRouter.getAmountsOut() of the live pool. That is the entire bug.
The vulnerable code#
1. Price = raw AMM spot price (no TWAP, no bounds)#
// SellToken.sol — getToken2Price (used by setTokenPrice / ShortStart)
function getToken2Price(address token,address bnbOrUsdt,uint bnb) view public returns(uint){
...
if(bnbOrUsdt == _WBNB){
address[] memory routePath = new address[](https://github.com/sanbir/evm-hack-registry/tree/main/2023-05-SellToken_exp/2);
routePath[0] = token;
routePath[1] = isbnb; // [token, WBNB]
return IRouter(_router).getAmountsOut(bnb,routePath)[1]; // ⚠️ spot price out of the pool
}
...
}
(SellToken.sol:581-599; the symmetric
getTokenPrice at :563-580 does the same in the
WBNB→token direction.)
2. The snapshot captures whatever the pool says right now#
function setTokenPrice(address _token) public {
address bnbOrUsdt=mkt.getPair(_token);
require(bnbOrUsdt == _WBNB || bnbOrUsdt==_USDT);
tokenPrice[_msgSender()][_token]=getToken2Price(_token,bnbOrUsdt,1 ether); // ⚠️ pumpable
tokenPriceTime[_msgSender()][_token]=block.timestamp+30;
}
3. Settlement scales the payout by nowPrice / oldPrice#
function getMyShort(address _tokens,address bnbOrUsdt,uint bnb,uint oldPrice) view private returns(uint){
uint nowPrice = getTokenPrice(_tokens,bnbOrUsdt,bnb); // ⚠️ spot price again, now crashed
uint zt = nowPrice * 1 ether / oldPrice; // ratio attacker controls both ends of
return bnb*zt/1 ether;
}
In withdraw, that result drives how much token the vault sells back to the user:
uint tokens = mkt.balanceOf(Short[..].coin)/10;
uint getBNB = getMyShort(token, Short[..].token, Short[..].bnb, Short[..].tokenPrice);
uint getTokens= getTokenPrice(token, Short[..].token, getBNB);
if(getTokens >= tokens){
mkt.sell(token, Short[..].token, tokens, _msgSender()); // vault pays out token → BNB
mkt.setPools(token, tokens, false);
}else {
mkt.sell(token, Short[..].token, getTokens, _msgSender());
mkt.setPools(token, getTokens, false);
}
mkt.sell() (:309-325) swaps the vault's token for
BNB and sends it directly to the caller — so the manipulated ratio turns straight into the attacker's
withdrawal.
Root cause — why it was possible#
A constant-product AMM (PancakeSwap) prices a token purely from its current reserves. getAmountsOut()
returns the marginal price for this block and is, by construction, manipulable for the duration of a
single transaction: borrow → swap to move reserves → read the moved price → swap back.
SellToken builds a leveraged product (shorts that pay out a multiple of the price move) on top of this
manipulable number, with three compounding mistakes:
- Spot price as the oracle. Both the entry snapshot (
setTokenPrice→getToken2Price) and the exit settlement (withdraw→getMyShort→getTokenPrice) readgetAmountsOut()of the same thin pool. The attacker controls both numbers in thenowPrice/oldPriceratio. - Open and close in the same block. Nothing forces the short to be held across blocks/oracle
updates.
setTokenPriceeven setstokenPriceTime = block.timestamp + 30, but the PoC simply does the open in transaction #1 andvm.warp(+100)then closes in transaction #2 — both still inside the attacker's flash-loan-funded sequence (the second flash loan is a fresh borrow, see walkthrough). - No bound on the payout vs. the protocol's actual liquidity. The payout is computed from price
ratios and capped only by
mkt.balanceOf(coin)/10, not by the BNB the user actually deposited or by what the trade really earned. The vault therefore pays "profit" that never existed — it is the protocol's own liquidity walking out the door.
In short: the protocol trusts a number anyone can set, to decide how much of its own money to hand out.
Preconditions#
- The shorted token has a PancakeSwap pool thin enough (here
SELLC/WBNBheld ~420 WBNB) that a few hundred WBNB of swap meaningfully moves the price. - The
Mineralsvault holds enough of the token / BNB to fund a payout (it does — the platform pre-funds per-token reserves viasetPool/buy). - Flash-loanable working capital in WBNB. The attack borrows 418.5 WBNB from a DODO
DPPOracleprivate pool (SellToken_exp.sol:40, :43) and repays it in full at the end of each callback — so the only capital truly at risk is gas.
Attack walkthrough (with on-chain numbers from the trace)#
The SELLC/WBNB pair 0x358EfC…51b6 has token0 = SELLC, token1 = WBNB, so reserve0 = SELLC,
reserve1 = WBNB. All figures are taken from the Sync / Swap events and getReserves() returns in
output.txt.
The PoC runs the attack as two DODO flash loans of 418.5 WBNB each (SellToken_exp.sol:39-45):
- Loan A (
datalength > 20 → "setup" branch): pump the pool and snapshot the inflated price viasetTokenPrice. The position is not opened yet; the loan is repaid. - Loan B (
data="abc", short): pump again, open the short viaShortStartat the top, dump SELLC to crash the price, close viawithdrawto extract the vault's payout, repay the loan, keep the profit.
| # | Step (trace line) | SELLC reserve | WBNB reserve | Effect |
|---|---|---|---|---|
| 0 | Initial pool (:1635) | 10,131,261 | 419.96 | Honest pool. ~SELLC price ≈ 4.14e-5 WBNB. |
| A1 | Loan A borrow 418.5 WBNB; swap 414.32 WBNB → 5,025,066 SELLC (:1633-1659) | 5,106,195 | 834.28 | Pool half-drained of SELLC → SELLC price doubles. |
| A2 | setTokenPrice(SELLC) snapshots price: getAmountsOut(1e18 SELLC)= 1.629e14 WBNB (:1661-1672) | 5,106,195 | 834.28 | Inflated entry price locked in tokenPrice[attacker][SELLC]. |
| A3 | Dump 5,025,066 SELLC → 413.28 WBNB; repay loan A (:1674-1699) | ~10,131,261 | ~420 | Pool restored; loan A net cost ≈ 1.04 WBNB (Pancake fees). |
| B1 | Loan B borrow 418.5 WBNB; swap 423.19 WBNB → 5,072,405 SELLC (:1758-1779) | 5,058,856 | 844.19 | Pump again — same setup as A1. |
| B2 | ShortStart{value:4.27 WBNB}(SELLC,…) opens the short at the pumped price; routes BNB through Minerals.buy to buy ~24,664 SELLC at the top (:1786-1873) | 5,034,191 | 848.34 | Short entry recorded against the inflated tokenPrice. |
| B3 | Dump 5,072,405 SELLC → 425.24 WBNB (:1875-1900) | ~10,106,597 | ~423.09 | Price crashes back down — far below the recorded entry price. |
| B4 | withdraw(SELLC): getMyShort sees price-down ⇒ big payout ratio; vault sells 381,017 SELLC → 15.33 WBNB to attacker (:1907-1966) | 10,487,615 | 407.76 | Vault funds the "profit"; attacker receives 15.33 WBNB. |
| B5 | Repay loan B (transfer 427.46 WBNB back to DPPOracle) (:1980) | — | — | Loan repaid in full. |
After both loans settle, the attacker contract holds 13.11 WBNB vs. the 10 WBNB it seeded itself with
in setUp → net +3.11 WBNB (:final, emit log_named_decimal_uint("WBNB total profit", 3112948098078880127)).
Why the short always "wins"#
The settlement ratio is zt = nowPrice / oldPrice:
oldPricewas snapshotted in step A2 while the attacker had pumped the pool → artificially high.nowPriceis read in step B4 after the attacker dumped the pool → artificially low.
For a short, "price fell" = profit, and the payout scales with how far it "fell." The attacker
manufactured the entire price round-trip, so the recorded drop is whatever he chose. The Minerals vault
pays out real WBNB against an entirely synthetic price move.
Profit accounting (WBNB, single PoC run)#
| Direction | Amount |
|---|---|
Seed capital (own WBNB in setUp) | 10.00 |
| Loan A — pump cost (fees, round-trip) | ≈ −1.04 |
| Loan B — short open BNB outlay (recovered via withdraw) | netted |
Vault payout received at withdraw (mkt.sell → attacker) | +15.33 |
| Flash-loan principal A + B | borrowed & repaid (net 0) |
| Ending balance | 13.11 |
| Net profit | +3.11 |
Diagrams#
Sequence of one extraction cycle#
How the price round-trip drives the payout#
The oracle flaw inside SellToken#
Remediation#
- Do not price a leveraged product off
getAmountsOut(). A constant-product AMM spot price is manipulable within a single transaction. Use a manipulation-resistant oracle (Chainlink, or a sufficiently long Uniswap/Pancake TWAP), and reject prices that deviate from it beyond a tolerance. - Separate the open and close across time/oracle epochs. A short whose entry and exit prices can both be set in the same transaction is not a short — it is a free option the attacker exercises against the vault. Enforce a minimum holding period measured in blocks, and re-read the oracle (not the cached pool) at settlement.
- Bound the payout by economic reality. The withdrawal should never exceed what the underlying trade
actually earned (token actually bought at open, sold at close, minus fees). Capping by
mkt.balanceOf(coin)/10ties the loss to the vault's liquidity, not to the user's stake — exactly the wrong invariant. - Snapshot using a volume-aware quote, not
1 ether. Quoting price with a fixed tiny notional (getAmountsOut(1 ether,…)) ignores depth and is trivially moved; even a depth-aware quote is still spot and must not be the sole oracle. - Add deviation/circuit-breaker guards. Reject
ShortStart/withdrawwhen the pool price has moved more than a few percent from a trusted reference within the same block, which is the on-chain fingerprint of this attack.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many unrelated
PoCs that fail under forge test's whole-project build):
_shared/run_poc.sh 2023-05-SellToken_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 28,168,034 is long pruned by most public RPCs;
foundry.tomlis configured with an endpoint that serves historical state). - Result:
[PASS] testExp()withWBNB total profit: 3.112….
Expected tail:
├─ emit log_named_decimal_uint(key: "WBNB total profit", val: 3112948098078880127 [3.112e18], decimals: 18)
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 15.24s
Ran 1 test suite: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Reference: BlockSec — https://twitter.com/BlockSecTeam/status/1657324561577435136 (SellToken, BSC, May 2023). Phalcon tx: 0x7d04e953dad4c880ad72b655a9f56bc5638bf4908213ee9e74360e56fa8d7c6a.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-05-SellToken_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
SellToken_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "SellToken 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.