Reproduced Exploit
DTXT Exploit — 1-Wei USDT Donation Misclassifies a Sell as a Liquidity Add (Fee Bypass + Stale-Reserve Drain)
1. DTXT is a fee-on-transfer token. On a sell (a transfer to the AMM pair) it skims a 5% destroy/dividend fee in _transfer (DTXT.sol:933-940). But before charging any fee it asks _isLiquidity(from, to) (DTXT.sol:948-962) whether the transfer is actually a
Loss
~35,041.11 USDT drained from the DTXT/USDT PancakeSwap pair (the pool's entire ~35,637 USDT reserve, net of t…
Chain
BNB Chain
Category
Oracle Manipulation
Date
Jun 2026
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: 2026-06-DTXT_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/DTXT_exp.sol.
Vulnerability classes: vuln/logic/wrong-condition · vuln/oracle/price-manipulation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains several unrelated PoCs that do not all compile together, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: DTXT.sol; victim AMM: PancakePair.sol.
Key info#
| Loss | ~35,041.11 USDT drained from the DTXT/USDT PancakeSwap pair (the pool's entire ~35,637 USDT reserve, net of the swap math) |
| Vulnerable contract | DTXT token — 0xAc9Bf7C320d4cE2D0ac978B83955Dd67351897D2 |
| Victim pool | DTXT/USDT PancakeV2 pair — 0x90BfC1dBc878bA54858bA8A635B3DAebd2aC6c01 |
| Flash-loan source | Moolah USDT flash loan — 0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C |
| Attacker EOA | 0xd304ea1592f733e0A46436A01fe54bD504009526 |
| Attacker contract | 0x3065bc8ed8bd53bdc3fd4633c3097c40726b5f5f |
| Attack tx | 0x07ba2ccf2b5c1aaca4c017af4fe87762a73ef7177f6ea8bb569367e908a0671d |
| Chain / block / date | BSC / 102,432,239 / June 2026 |
| Compiler | DTXT: Solidity v0.8.18, optimizer enabled, 200 runs; pair: v0.5.16, optimizer disabled |
| Bug class | Fee-bypass via liquidity-add misclassification — a 1-wei USDT donation makes DTXT mis-read a large sell into the pair as a liquidity addition, skipping the sell fee, then swapping the (untaxed) DTXT against stale reserves |
TL;DR#
-
DTXTis a fee-on-transfer token. On a sell (a transfer to the AMM pair) it skims a 5% destroy/dividend fee in_transfer(DTXT.sol:933-940). But before charging any fee it asks_isLiquidity(from, to)(DTXT.sol:948-962) whether the transfer is actually a liquidity add — and if so, it forwards the full amount fee-free (DTXT.sol:895-899). -
The add-liquidity heuristic is trivially spoofable. The pair's
token0is USDT, not DTXT, so_isLiquiditydecides "this is an add" purely by checking whether the pair's USDT balance is greater than its cachedreserve0(bal0 > r0). It never looks at the DTXT side at all. -
The attacker pre-positions the pool with a Moolah USDT flash loan: add DTXT/USDT liquidity, then immediately
removeLiquidityso DTXT's del-liquidity branch hands back the LP'd DTXT, leaving the attack contract holding ~350.14M DTXT and the pool shrunk back to ~35,637 USDT / ~5.94M DTXT. -
The attacker then sends 1 wei of USDT straight to the pair (DTXT_exp.sol:142). Now
bal0 (USDT) = r0 + 1 > r0, so the next DTXT transfer into the pair is classified as an "add." -
The attacker dumps its entire ~350.14M DTXT into the pair (DTXT_exp.sol:143). Because
_isLiquidityreturnsisAdd = true, DTXT moves the full amount with zero sell fee — the pair now physically holds ~356.08M DTXT but its cachedreserve1is still only ~5.94M DTXT. -
Finally the attacker calls the raw pair
swap()(DTXT_exp.sol:146-149), claiming the donated DTXT as swap input against the stale low reserves.getAmountOut(~350.14M DTXT, r1≈5.94M, r0≈35,637)returns 35,041.11 USDT, which the attacker withdraws, repays the ~1.077M USDT flash loan, and keeps the difference. Net profit = 35,041.11 USDT (output.txt:1857-1859).
Background — what DTXT does#
DTXT (source) is a fixed-supply (671,000,000) BEP-20 "tax token"
paired against USDT on PancakeSwap. Like most BSC tax tokens it overrides _transfer
(DTXT.sol:860-946) to apply fees on swaps while letting
liquidity operations pass untaxed:
- Buy (transfer from the pair): 2% destroy + 1% dividend (
buyDesFee=20,buyEnvFee=10) (DTXT.sol:915-922). - Sell (transfer to the pair): 4% destroy + 1% dividend (
sellDesFee=40,sellEnvFee=10) (DTXT.sol:933-940). - Add liquidity: forwarded fee-free (DTXT.sol:895-899).
- Remove liquidity: 5%
delFeeskimmed (DTXT.sol:901-907).
Add vs. remove vs. plain swap are distinguished entirely by _isLiquidity
(DTXT.sol:948-962), a balance-vs-reserve heuristic. That
heuristic is the bug.
On-chain parameters at the fork block (read from the trace at block 102,432,239):
| Parameter | Value | Note |
|---|---|---|
pair token0 | 0x55d3…7955 (USDT) | output.txt:1654 — DTXT is not token0 |
pair token1 | 0xAc9B…97D2 (DTXT) | output.txt:1656 |
reserve0 (USDT) | 35,637,000,000,000,000,000,000 (~35,637 USDT) | output.txt:1652 |
reserve1 (DTXT) | 5,939,500,000,000,000,000,000,000 (~5,939,500 DTXT) | output.txt:1652 |
sellDesFee / sellEnvFee | 40 / 10 (4% + 1% = 5% sell tax) | DTXT.sol:714-715 |
delFee | 50 (5% remove-liquidity tax) | DTXT.sol:716 |
automatedMarketMakerPairs[pair] | true | set in constructor DTXT.sol:777 |
| seed DTXT held by historical helper | 359,122,000,007,000,000,000,000,000 (~359.12M DTXT) | output.txt:1609 |
The single fact that makes the exploit possible: token0 == USDT, so DTXT's add-liquidity
detector inspects the USDT balance, a quantity the attacker can move by 1 wei with a free
transfer.
The vulnerable code#
1. The sell path is skipped entirely if the transfer "looks like" an add#
(isAddLiquidity, isDelLiquidity) = _isLiquidity(from,to);
if (!_isExcludedFromFees[from] && !_isExcludedFromFees[to]) {
if(!automatedMarketMakerPairs[from] && !automatedMarketMakerPairs[to]){
super._transfer(from, to, amount);
return;
}
if(isAddLiquidity){
super._transfer(from, to, amount); // ⚠️ full amount, NO sell fee
return ;
}
if(isDelLiquidity){
uint256 _del = amount.mul(delFee).div(1000);
delAmount += _del;
super._transfer(from, address(this), _del);
super._transfer(from, to, amount - _del);
return ;
}
swapFee(from);
swapDelFee(from);
require(block.timestamp >= startSwapTime, "not start");
...
} else if (automatedMarketMakerPairs[to]) { // a SELL — only reached if NOT isAddLiquidity
uint256 _des = amount.mul(sellDesFee).div(1000); // 4%
super._transfer(from, address(0xdead), _des);
uint256 _dividend = amount.mul(sellEnvFee).div(1000); // 1%
super._transfer(from, address(this), _dividend);
fees = _des + _dividend;
}
amount = amount.sub(fees);
}
super._transfer(from, to, amount);
When to is the AMM pair, the code is supposed to charge the 5% sell tax. But the
if(isAddLiquidity){ … return; } short-circuit fires first and forwards the entire amount with no
fee. So whoever can make _isLiquidity return isAdd = true while sending DTXT into the pair sells
fee-free.
2. The add-liquidity heuristic only looks at the token0 (USDT) balance#
function _isLiquidity(address from,address to)internal view returns(bool isAdd,bool isDel){
address token0 = IUniswapV2Pair(address(uniswapV2Pair)).token0();
(uint r0,,) = IUniswapV2Pair(address(uniswapV2Pair)).getReserves();
uint bal0 = IERC20(token0).balanceOf(address(uniswapV2Pair));
if( automatedMarketMakerPairs[to] ){
if( token0 != address(this) && bal0 > r0 ){
isAdd = bal0 - r0 > 0; // ⚠️ "add" iff USDT balance > cached USDT reserve
}
}
if( automatedMarketMakerPairs[from] ){
if( token0 != address(this) && bal0 < r0 ){
isDel = r0 - bal0 > 0;
}
}
}
token0 is USDT, so token0 != address(this) is always true. The "is this an add?" decision reduces
to pair's USDT balance > pair's cached USDT reserve — i.e. "has someone deposited USDT that
hasn't been sync()ed yet?" A genuine liquidity add deposits both tokens before mint(), so this
heuristic is a cheap proxy for it. But it can be satisfied by a free, 1-wei USDT transfer to the
pair, with no DTXT side and no mint() — which is exactly a sell in disguise.
3. The PancakeSwap pair prices a swap from its cached reserves, not its live balance#
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
require(amount0Out > 0 || amount1Out > 0, 'Pancake: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
...
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
...
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(10000**2), 'Pancake: K');
}
The pair credits as amount1In everything the attacker pre-deposited above the stale reserve, and the
constant-product K check is evaluated against the stale _reserve0/_reserve1. Because DTXT let
~350M DTXT into the pair fee-free while reserve1 was still ~5.94M, the K check passes and the
attacker can pull out almost the entire USDT reserve.
Root cause — why it was possible#
The loss is the composition of two flaws:
-
A spoofable liquidity-add heuristic.
_isLiquidityinfers "liquidity add" from a one-sided, un-syncedtoken0(USDT) balance increase. Because USDT is a normal ERC-20, anyone can makebal0 > r0true by transferring 1 wei of USDT to the pair for free — no real liquidity, no DTXT, nomint(). Once that flag is set, DTXT applies the add-liquidity branch (no fee) to a transfer that is functionally a sell. -
Fee logic that gates on the heuristic before it gates on direction. In
_transfer, theisAddLiquidityshort-circuit (DTXT.sol:895-899) runs ahead of theautomatedMarketMakerPairs[to]sell branch (DTXT.sol:933-940). So a "sell that looks like an add" is forwarded at full amount. The 5% sell tax — the only mechanism that would have made the attacker's round trip unprofitable — never fires.
The downstream amplifier is generic AMM mechanics: PancakeSwap prices swap() from cached reserves
(PancakePair.sol:454-475). DTXT having let the
DTXT into the pair fee-free without any sync() means the attacker can immediately claim that DTXT as
swap input against stale, tiny reserves and walk out with the USDT side.
Note this is not a reserve-burn/sync() attack (BY/AROS style). Here the pair's reserves are never
desynced by the token; instead the token's fee waiver lets the attacker stuff the pair with DTXT
that the AMM will happily treat as swap input.
Preconditions#
- The pair's
token0must be the counter-asset (USDT), not DTXT — true here (output.txt:1654-1656), which is why_isLiquiditykeys off the USDT balance. (DTXT's constructor evenrequire(USDT < address(this))to force USDT as token0, DTXT.sol:765.) - The attacker (and its helper) must not be fee-excluded — if either side were in
_isExcludedFromFees,_transferwould early-return without ever consulting_isLiquidity(DTXT.sol:872-875). The attack contract is a fresh, non-excluded address. - A DTXT inventory to dump. The PoC seeds its local helper with the same ~359.12M DTXT that the
historical on-chain helper
0xd245…5428held (output.txt:1608-1609, DTXT_exp.sol:57-58); on-chain the attacker had pre-funded that DTXT. - Working capital in USDT to seed/withdraw liquidity. Peak outlay was the Moolah flash loan of 1,077,367 USDT (output.txt:1657), fully repaid intra-transaction — hence flash-loanable.
Attack walkthrough (with on-chain numbers from the trace)#
The pair's token0 = USDT, token1 = DTXT, so reserve0 = USDT, reserve1 = DTXT. All figures are
read directly from the Sync / Swap / getReserves / Transfer lines in output.txt.
Amounts are raw 18-decimal wei; human approximations in parentheses.
| # | Step | USDT reserve (r0) | DTXT reserve (r1) | Pair's live DTXT balance | Effect |
|---|---|---|---|---|---|
| 0 | Initial getReserves (output.txt:1652) | 35,637,000,000,000,000,000,000 (~35,637) | 5,939,500,000,000,000,000,000,000 (~5,939,500) | = r1 | Honest pool. |
| 1 | Moolah flash loan 1,077,367,000,021,000,000,000,000 USDT (~1,077,367) to attacker (output.txt:1657-1661) | 35,637 (unchanged) | 5,939,500 (unchanged) | = r1 | Working capital acquired. |
| 2 | addLiquidity — deposit 1,077,366,000,021,000,000,000,000 USDT (~1,077,366) + 179,561,000,003,500,000,000,000,000 DTXT (~179,561,000); mint Sync (output.txt:1683,1718-1719) | 1,113,003,000,021,000,000,000,000 (~1,113,003) | 185,500,500,003,500,000,000,000,000 (~185,500,500) | = r1 | Pool ballooned; attacker gets LP. |
| 3 | removeLiquidity — burn 13,908,735,252,838,108,500,522,252 LP; DTXT's del branch skims 5% to the token, returns 170,582,950,003,325,000,000,000,000 DTXT (~170,582,950) + 1,077,366 USDT to attacker; burn Sync (output.txt:1749,1777-1778,1789) | 35,637,000,000,000,000,000,001 (~35,637) | 5,939,500,000,000,000,000,000,001 (~5,939,500) | = r1 | Pool restored; attacker now holds ~350.14M DTXT. |
| 4 | Donate 1 wei USDT to the pair (output.txt:1798-1799) | 35,637 (cached, unchanged) | 5,939,500 (cached) | USDT bal now = r0 + 1 | bal0 > r0 ⇒ _isLiquidity will say isAdd for the next DTXT-in. |
| 5 | Dump ~350.14M DTXT into the pair — attacker balance 350,143,770,445,824,996,500,000,000 (~350,143,770) sent; DTXT classifies it as an add → no sell fee; pair receives 350,143,420,302,054,550,675,003,500 (~350,143,420) DTXT (output.txt:1805-1806,1813,1821) | 35,637 (cached) | 5,939,500 (stale) | 356,082,920,302,054,550,675,003,501 (~356,082,920) ⚠️ | Pair physically holds ~356M DTXT; reserve1 still ~5.94M. |
| 6 | Raw swap(usdtOut, 0) — getAmountOut(350,143,420,302,054,550,675,003,500, r1=5,939,500…, r0=35,637…) = 35,041,106,262,669,601,832,717 (~35,041.11 USDT) pulled out; Sync (output.txt:1822-1823,1835-1836) | 595,893,737,330,398,167,285 (~595.89) | 356,082,920,302,054,550,675,003,501 (~356,082,920) | = r1 | USDT side drained to ~596 USDT. |
| 7 | Repay flash loan 1,077,367,000,021,000,000,000,000 USDT to Moolah (output.txt:1846-1847) | — | — | — | Loan closed. |
| 8 | Forward profit 35,041,106,262,669,601,832,715 USDT (~35,041.11) to attacker EOA (output.txt:1857-1859) | — | — | — | Profit realized. |
The fee-bypassed dump (step 5) is the whole game: the attacker injected ~350M DTXT into the pool for free, then in step 6 the AMM priced that DTXT as legitimate swap input against the ~5.94M / ~35,637 stale reserves and paid out essentially the pool's full USDT side.
Profit / loss accounting (USDT, raw wei)#
| Item | Amount (wei) | ~Human |
|---|---|---|
| Attacker USDT before attack | 27,112,978,940,964,773,437 | ~27.11 |
| Attacker USDT after attack | 35,068,219,241,610,566,606,152 | ~35,068.22 |
| Net profit (forwarded by attack contract) | 35,041,106,262,669,601,832,715 | ~35,041.11 |
| Pair USDT reserve before swap (step 0/3) | 35,637,000,000,000,000,000,001 | ~35,637.00 |
| Pair USDT reserve after swap (step 6) | 595,893,737,330,398,167,285 | ~595.89 |
| Pair USDT drained | 35,041,106,262,669,601,832,716 | ~35,041.11 |
The attacker round-trips the 1,077,367 USDT flash loan at no net cost (deposit → withdraw → repay) and
keeps the ~35,041.11 USDT the fee-free dump + stale-reserve swap let it pull out of the pool. The PoC
asserts profit > 35,000 ether (DTXT_exp.sol:70); realized profit is
35,041.11 USDT (output.txt:1565).
Diagrams#
Sequence of the attack#
Pool state evolution#
The flaw inside _transfer / _isLiquidity#
Why it is theft: pair invariant before vs. after the fee-free dump#
Why each magic number#
- Moolah flash loan
1,077,367,000,021,000,000,000,000USDT (~1,077,367): computed in_flashAmountForSeedasusdtForLiquidity + 1 ether, whereusdtForLiquidity = (seedDtxt/2) * r0 / r1(DTXT_exp.sol:154-164). It is exactly the USDT needed to addseedDtxt/2(~179.56M) DTXT of liquidity at the current price, plus 1 USDT of slack. usdtRetained = 1 etherinaddLiquidityAndReturnRemainder(DTXT_exp.sol:133): incidental slack so thetransferFromofbalanceOf(msg.sender) - usdtRetainednever underflows; not load-bearing.seedDtxt / 2for liquidity (DTXT_exp.sol:84,161): only half the ~359.12M DTXT seed is LP'd; the other half is handed back (DTXT_exp.sol:91). Combined with the DTXT returned by the del-liquidity branch onremoveLiquidity, the attacker ends step 3 holding ~350.14M DTXT to dump.- The
1(1 wei USDT) transfer (DTXT_exp.sol:142): the entire exploit pivot. It makes the pair's USDT balance exceed its cachedreserve0by 1, so_isLiquidityreturnsisAdd = truefor the very next DTXT transfer into the pair, waiving the 5% sell fee. dtxtIn = balanceOf(pair) - reserve1(DTXT_exp.sol:146-147): the amount of DTXT the pair physically received above its stale reserve — the value the AMM will credit asamount1In. Feeding it togetAmountOut(dtxtIn, reserve1, reserve0)yields the exactusdtOut = 35,041,106,262,669,601,832,717the attacker requests fromswap()(output.txt:1822-1824).- PoC assertion
profit > 35_000 ether(DTXT_exp.sol:70): a conservative floor; realized profit is ~35,041.11 USDT.
Remediation#
- Do not gate fees on a balance-vs-reserve "liquidity add" heuristic. Any check of the form
pairTokenBalance > cachedReserveis satisfiable by a free 1-wei donation of the counter-asset and is therefore not a reliable add-liquidity signal. Remove theisAddLiquidityfee-waiver entirely, or detect liquidity operations by gating on the router/mintcall path (e.g. an authorized LP manager), not by inspecting reserve drift. - Charge the sell tax based on transfer direction, not on a spoofable flag. A transfer to an AMM pair from a non-excluded address is a sell and should be taxed regardless of any add/remove heuristic. Resolve direction first, then apply add/remove exemptions only for whitelisted liquidity routers.
- If a liquidity-detection heuristic is unavoidable, require both sides. A real add deposits
both token0 and token1 before
mint(); checking only thetoken0(USDT) side lets a one-sided USDT donation impersonate an add. At minimum require a matching, proportional increase in both reserves. - Never trust raw, un-synced reserves for value decisions. The token forwarded ~350M DTXT into
the pair fee-free; the pair then priced that DTXT against stale reserves. Tax tokens that interpose
on swaps must not create a state where the pair's live balance diverges from its reserve without a
corresponding
sync()/skim()they control.
How to reproduce#
The PoC was extracted into a standalone Foundry project and runs offline against a local anvil
fork served from anvil_state.json (the setUp() calls
vm.createSelectFork("http://127.0.0.1:8546", 102_432_239),
DTXT_exp.sol:42-43):
_shared/run_poc.sh 2026-06-DTXT_exp --mt testExploit -vvvvv
- The harness boots a local anvil from the captured BSC state at block 102,432,239 and points the
fork URL at
127.0.0.1:8546; no public RPC is required. foundry.tomlsetsevm_version = 'cancun'.- Result:
[PASS] testExploit()withAttacker Final USDT Balance: 35068.219241610566606152(~35,041.11 USDT net profit over the ~27.11 USDT starting balance).
Expected tail:
Ran 1 test for test/DTXT_exp.sol:ContractTest
[PASS] testExploit() (gas: 2509304)
Logs:
Attacker Before exploit USDT Balance: 27.112978940964773437
Attacker Final USDT Balance: 35068.219241610566606152
Attacker After exploit USDT Balance: 35068.219241610566606152
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 35.62s (33.51s CPU time)
Reference: audit_911 — https://x.com/audit_911/status/2063793931138347015 (DTXT, BSC, ~35,041 USDT).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-06-DTXT_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
DTXT_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "DTXT 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.