Reproduced Exploit
ETN / EtnProduct Exploit — Protocol-Funded Liquidity Sent to the Caller (`addLiquidity → msg.sender`)
EtnProduct.newProduct() is the protocol's "list a product" function. For every new product it:
Loss
~$3,074 — net 3,074.53 BUSDT profit; the attacker drained ~606,091 U tokens of protocol-seeded liquidity
Chain
BNB Chain
Category
Access Control
Date
Aug 2022
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: 2022-08-EtnProduct_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/EtnProduct_exp.sol.
Vulnerability classes: vuln/access-control/broken-logic · vuln/logic/incorrect-state-transition
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 compile under a whole-project build, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: EtnProduct.sol.
Key info#
| Loss | ~$3,074 — net 3,074.53 BUSDT profit; the attacker drained ~606,091 U tokens of protocol-seeded liquidity |
| Vulnerable contract | EtnProduct — 0x1292267f726e6F313972ec4e14578735473e1649 |
| Victim asset | U token — 0xaa33085e8Fa2CB903157324603E4601299E5dA06 (sold for BUSDT via UMarket) |
| Cash-out venue | UMarket — 0xc0e8D30D2ead2C324b3f1A8386992Ba1Be534CbF |
| Attacker EOA | 0xde703797fe9219b0485fb31eda627aa182b1601e |
| Attack contract | 0x178bf96e303fb31aef1b586271a63acd33e4eaf7 |
| Attack tx | 0x72321a3b50bb68ac3b46b0ab973b0e87b6c48ab73d23c4ba2cb73527f978d995 |
| Chain / block / date | BSC / 20,147,974 / ~Aug 2, 2022 |
| Compiler | EtnProduct Solidity v0.8.1, optimizer off (runs 200) |
| Bug class | Access-controlled-but-self-grantable role + LP minted to msg.sender (protocol funds the pool, caller keeps the LP) |
TL;DR#
EtnProduct.newProduct() is the protocol's "list a product" function. For every new product it:
- creates a brand-new ERC20 "product token", and
- seeds a Uniswap/Pancake pair with
700,000 Utaken fromEtnProduct's ownUbalance plus700,000of the freshly-minted (worthless) product token.
The fatal flaw: the LP tokens minted by addLiquidity are sent to msg.sender, not to the
protocol owner (EtnProduct.sol:133 — the commented-out
// owner, directly above it betrays the original intent). So whoever calls newProduct() ends up
owning a pool that the protocol just funded with 700k of real U.
The only thing standing in the way is an authorization check (canUploadProduct), but that check is
keyed on NFT ownership the attacker can buy for themselves in the same transaction:
- mint ETN-NFT #11 (cheap) → become "owner of community 11",
EtnShop.invite(self, 11)thenEtnShop.mint(11, …)(cost 1,998 BUSDT) → become "owner of shop 1100",- which makes
canUploadProduct(self, 11, 0) == true.
The attacker then calls newProduct(11, 0, …), receives the freshly-minted LP, immediately
burn()s it back, recovers ~606,091 U + ~593,969 product token, and dumps the U into
UMarket.saleU() for BUSDT. Everything is wrapped in a 0-fee DODO flash loan to cover the up-front
swap/mint costs. Net profit: 3,074.53 BUSDT.
Background — the ETN product/shop stack#
The ETN system is a chain of NFT-gated roles culminating in a token-launch function:
ETN_NFT(source) — an ERC721 used as the "community" (commNft) registry.mintETN()is a permissionless payable mint (ETN_NFT.sol:739-751); the token id is justtotalSupply(), so the next NFT minted gets the next sequential id.EtnShop(source) — registers "shops" under a community.invite()lets a community owner whitelist an address (EtnShop.sol:128-132);mint()then mints a shop NFT (cost1998 USDT) and is the gate for product uploads (EtnShop.sol:135-163).canUploadProduct()returns true iff the caller owns the shop NFT for(commId, shopId)(EtnShop.sol:216-219).EtnProduct(source) — the launch contract. It holds a large balance ofUand, on eachnewProduct(), spendsswapAmount = 700,000 U(EtnProduct.sol:79) to bootstrap a pool for the new product token.U(source) — a deflationary ERC20 (0.6% burn-on-transfer for non-whitelisted transfers).UMarket(source) — an OTC desk that buysUback for BUSDT viasaleU()(UMarket.sol:149-164). This is where the stolenUis converted to cash.
On-chain facts at the fork block (from the trace):
| Fact | Value |
|---|---|
EtnProduct.swapAmount (U seeded per product) | 700,000 U |
EtnShop.mintCost | 1,998 BUSDT |
ETN_NFT.totalSupply() just before the attack | 11 → next minted id = 11 |
LP recipient in addLiquidity | msg.sender (the caller) |
That last row is the whole game.
The vulnerable code#
1. newProduct() — gated only by self-grantable NFT ownership#
// EtnProduct.sol:102-116
function newProduct(uint commId, uint shopId, uint price, string memory name, string memory video ) public {
bool authed = etnShop.canUploadProduct(msg.sender, commId, shopId);
require(authed, "no authed");
uint shopTokenId = etnShop.getTokenId(commId,shopId);
...
address erc20Addr = factory.createContract( name, name, bytes32(shopTokenId));
...
addLiquidity(erc20Addr); // ← seeds a pool with 700k of the PROTOCOL's U
emit NewToken(erc20Addr);
}
canUploadProduct only checks shopNft.ownerOf(getTokenId(commId,shopId)) == msg.sender
(EtnShop.sol:216-219). There is no restriction on who
can become that owner — the role is mintable on demand.
2. addLiquidity() — protocol pays, caller receives the LP#
// EtnProduct.sol:118-136
function addLiquidity(address token) private {
if(address(uniswapV2Router) == address (0)){ return; }
U.approve(address(uniswapV2Router), swapAmount); // 700,000 U of the PROTOCOL's balance
IERC20(token).approve( address(uniswapV2Router), swapAmount);
uniswapV2Router.addLiquidity(
address (U),
token,
swapAmount, // 700,000 U
swapAmount, // 700,000 product token
swapAmount,
swapAmount,
// owner, // ⚠️ intended recipient, commented out
msg.sender, // ⚠️⚠️ LP minted to the CALLER instead
block.timestamp
);
}
The U and the new product token both come from EtnProduct's own balances/approvals, but the
liquidity-provider position (the LP tokens) is handed to msg.sender. Burning that LP returns both
underlying tokens — including the protocol's 700k U — to the attacker.
3. UMarket.saleU() — converts the stolen U into BUSDT#
// UMarket.sol:149-164
function saleU(uint256 _amount) public {
require(_amount > 0, "!zero input");
uint cost = getSaleCost(_amount);
...
U.transferFrom(msg.sender,address(this), cost);
uint usdtBalanced = usdt.balanceOf(address(this));
require(usdtBalanced >= _amount, "!market balanced");
usdt.transfer( msg.sender,_amount); // pays out _amount of BUSDT
SaleU(msg.sender, _amount, cost);
}
The desk simply buys U back for BUSDT, so the recovered U is liquid.
Root cause#
Protocol-owned value is provisioned into a pool, but the claim on that value (the LP token) is assigned to the untrusted caller. Combined with an authorization gate (
canUploadProduct) whose required role is permissionlessly mintable, this lets anyone trigger a 700,000-U"donation" to a pool they exclusively own, then reclaim it viapair.burn().
Two design errors compose into the exploit:
- Wrong LP recipient (the core bug).
addLiquiditypassesmsg.senderto the router. The commented-out// owner,immediately above it shows the LP was meant to be retained by the protocol. Because the seed tokens are the protocol's, the LP token is a bearer claim on protocol funds — and it is handed to the caller. Theminamounts are set equal to the desired amounts, so the call cannot silently re-price/short the attacker; they reliably mint the full position. - Self-grantable authorization.
newProduct's only guard is shop-NFT ownership. The full role chain (community NFT → invite → shop NFT) is reachable by any address in a single transaction for1,998 BUSDT+ a cheap NFT mint. Sequential NFT ids (tokenId = totalSupply()) made it trivial to grab "community 11" — exactly the id the attacker chose forcommId.
Neither error alone is catastrophic; together, "anyone may, for a small fee, cause the protocol to
fund a pool and then walk off with the LP" is a direct drain of EtnProduct's U treasury, one
700k-U block per call.
Preconditions#
EtnProductholds ≥swapAmount(700,000)Uand has a non-zero router configured (true at the fork block — theU.transferFrom(EtnProduct → pair, 700,000)succeeds in the trace).- The attacker can obtain the shop-NFT role for some
(commId, shopId). With sequential NFT ids and a permissionlessmintETN, the attacker mints the next community NFT (id 11), self-invites, and mints the shop NFT — all permissionless given the mint fees. - Working capital for the up-front fees (NFT mint value + 1,998 BUSDT shop fee + a swap). All of it is recovered intra-transaction, so the attack is flash-loanable — the PoC borrows 9,400 BUSDT from DODO at 0 fee.
Attack walkthrough (with on-chain numbers from the trace)#
Driver: test/EtnProduct_exp.sol. All figures are taken directly from the
events / return values in output.txt. The new product token is
0x7Af5…2BD3; its pair with U is 0xc905…5a71 (token0 = product, token1 = U).
| # | Step (trace ref) | Effect | Key numbers |
|---|---|---|---|
| 0 | Flash loan 9,400 BUSDT from DODO (output.txt:1604) | Working capital | +9,400 BUSDT |
| 1 | Swap 7,380 BUSDT → WBNB (output.txt:1638) | Buys the BNB used to mint the NFT | 7,380 BUSDT → 24.16 WBNB |
| 2 | mintETN (24.15 BNB) (output.txt:1674) | Mints ETN-NFT id 11 to attacker → "owner of community 11" | NFT #11 → attacker |
| 3 | EtnShop.invite(self, 11) (output.txt:1689) | Passes commNft.ownerOf(11)==self; whitelists attacker for community 11 | inviteMap[11][self]=true |
| 4 | EtnShop.mint(11,…) (output.txt:1730) | Pays 1,998 BUSDT, mints shop NFT 1100 to attacker | −1,998 BUSDT |
| 5 | newProduct(11, 0, …) (output.txt:1749) | canUploadProduct passes; creates product token; addLiquidity mints pool from 700k protocol U + 700k product token, LP → attacker | pool: 686,000 product / 700,000 U; LP 692,964.6 → attacker |
| 6 | Pair.transfer(pair, 600,000 LP) (output.txt:1859) | Sends LP back to the pair to redeem | 600,000 LP queued |
| 7 | Pair.burn(self) (output.txt:1859) | Redeems LP: returns underlying to attacker; pool drained ~87% | +606,091.5 U and +593,969.7 product token |
| 8 | U.approve(UMarket, 9.999e24) + UMarket.saleU(11,253.73) (output.txt:1899) | Sells recovered U for BUSDT 1:1 | −11,253.73 U, +11,253.73 BUSDT |
| 9 | Repay flash loan 9,400 BUSDT (output.txt:1924) | Closes the DODO loan (0 fee) | −9,400 BUSDT |
| — | End balance (output.txt:1947) | 3,074.53 BUSDT |
Notes on the pool numbers:
- At
Pair.mintthe reserves are recorded asSync(reserve0 = 686,000 product, reserve1 = 700,000 U)(output.txt:1830) — the product token is fee-on-transfer (2% to dead), so 700,000 sent becomes 686,000 received; theUside arrives whole because the pair path isU-whitelisted (the trace showsU.transferFrom(EtnProduct → pair, 700,000)with no burn). - The attacker received 692,964.6 LP (output.txt:1831), transferred 600,000 of it
back and burned, recovering 593,969.7 product token and 606,091.5 U
(
Burn(amount0 = 593,969.7, amount1 = 606,091.5), output.txt:1886). The residual LP (~92,964) and the leftover product token are dust the attacker ignores — the prize is theU.
The entire U recovery (606,091) far exceeds what is needed; the attacker only sells 11,253.73 U
through UMarket because that is what nets the round profit after repaying the loan and fees.
Profit accounting (BUSDT)#
| Direction | Amount |
|---|---|
| Borrowed (DODO, 0 fee) | +9,400.00 |
| Spent — swap to BNB for NFT mint | −7,380.00 |
| Spent — shop mint fee | −1,998.00 |
Received — UMarket.saleU payout | +11,253.73 |
| Repaid — DODO flash loan | −9,400.00 |
| Net profit | +3,074.53 |
The 3,074.53 BUSDT end balance is confirmed by the final log_named_decimal_uint("[End] …")
returning 3074534856316884358000 (output.txt:1947). Economically the attacker
extracted ~606k U of protocol liquidity and converted the profitable slice to ~$3,074; the value
lost by the protocol is the 700k U it seeded into a pool it no longer controls.
Diagrams#
Sequence of the attack#
Where the value leaks (newProduct / addLiquidity)#
Pool lifecycle: created by the protocol, drained by the caller#
Why each magic number#
- 9,400 BUSDT flash loan — just enough to cover the 7,380-BUSDT swap (for NFT-mint BNB) plus the 1,998-BUSDT shop fee with headroom, all repaid at the end (DODO charges 0 fee here).
- 24.15 BNB into
mintETN— well abovemintPrice(1.82 BNB); the only requirement is `msg.value= mintPrice
andtotalSupply()+1 <= MAX_SUPPLY`. Overpaying is harmless; it simply mints NFT id 11. commId = 11— equalsETN_NFT.totalSupply()at the fork block, so the attacker's fresh mint is community 11;invitethen passescommNft.ownerOf(11)==self.shopId = 0— first shop in community 11;getTokenId(11,0) = 11*100+0 = 1100, the shop NFT id the attacker receives fromEtnShop.mint.- 700,000 —
EtnProduct.swapAmount, the fixedU(and product) amount seeded per pool; this is exactly the protocol value the attacker reclaims. - 600,000 LP burned — most of the 692,964.6 LP received; enough to extract the bulk of the 700k
Uwhile leaving dust behind (precision is irrelevant — the attacker only needs to recover moreUthan it sells). - 11,253.73 U sold via
saleU— sized so the BUSDT payout (1:1 at the observedsalePrice), minus the loan repayment and fees, lands the round +3,074.53 BUSDT profit.
Remediation#
- Send LP to the protocol, not the caller. The one-line fix: pass
owner(or a dedicated treasury) instead ofmsg.sendertorouter.addLiquidity(EtnProduct.sol:133). The commented// owner,above it is the intended behavior. This alone neutralizes the drain — the caller can list a product but never owns a claim on the protocol-seeded liquidity. - Don't fund pools from caller-triggerable functions, or charge for the seed. If product listing
must bootstrap liquidity from protocol funds, the listing fee should cover (or exceed) the seeded
value, and/or the seeded
Ushould come from the lister, not the protocol's treasury. - Harden the authorization chain.
newProducttrusts a role (shopNftownership) that any address can mint for a small fee. Gate product creation behind a vetted/allow-listed lister, a meaningful economic stake, or owner approval — not a freely mintable NFT. - Avoid sequential, predictable NFT ids for trust decisions. Keying "community owner" off
tokenId = totalSupply()lets an attacker grab any soon-to-be id by minting at the right moment. Decouple community membership from raw mint order. - Cap / monitor per-call treasury outflow. A single function that moves 700,000
Uof protocol funds per invocation, with no rate limit and no retained claim, is a standing liability; add a cap and emit auditable accounting of every seeded pool.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has several
unrelated PoCs that fail to compile under forge test's whole-project build):
_shared/run_poc.sh 2022-08-EtnProduct_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 20,147,974). The PoC selects the
bscendpoint fromfoundry.toml; most public BSC RPCs prune state this old and fail withheader not found/missing trie node— use an archive provider. - Result:
[PASS] testExploit()with[End] Attacker BUSDT after exploit: ~3074.53.
Expected tail (from output.txt):
├─ emit log_named_decimal_uint(key: "[End] Attacker BUSDT after exploit", val: 3074534856316884358000 [3.074e21], decimals: 18)
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 29.27s
Ran 1 test suite: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Reference: BeosinAlert — https://x.com/BeosinAlert/status/1555439220474642432 (ETN, BSC, ~$3K).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-08-EtnProduct_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
EtnProduct_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "ETN / EtnProduct 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.