Reproduced Exploit
NST Swap Exploit — Dangling `approve()` lets the buyer drain the swap contract's USDT reserves
Milktech's NST swap contract is a simple fixed-price exchange between USDT (6 decimals) and the company token NST (4 decimals), holding a USDT float to pay out sellers.
Loss
29,195.083207 USDT (~$29,195) stolen from the swap contract
Chain
Polygon
Category
Other
Date
Jun 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-06-NST_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/NST_exp.sol.
Vulnerability classes: vuln/logic/missing-allowance · vuln/logic/state-update · vuln/logic/incorrect-order-of-operations
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 all compile, so this one was extracted into a standalone project). Full verbose trace: output.txt. Verified token source: NSTToken.sol. Caveat: the actually-vulnerable contract is the swap contract
0x9D10…aa88, which was never verified on-chain — so the snippets below for itsbuyNST/sellNSTlogic are reconstructed from the execution trace, not copied from verified source. The NST token itself (verified) is benign.
Key info#
| Loss | 29,195.083207 USDT (~$29,195) stolen from the swap contract |
| Vulnerable contract | NST Swap contract 0x9D101E71064971165Cd801E39c6B07234B65aa88 (unverified) — bscscan/polygonscan |
| Tokens involved | USDT 0xc2132D05D31c914a87C6611C10748AEb04B58e8F (6 dp) ↔ NST 0x83eE54ccf462255ea3Ec56Fa8dE6797d679276e7 (4 dp) |
| Attacker EOA | 0xcb3585f3e09f0238a3f61838502590a23f15bb5b |
| Attacker contract | 0x3bb7a0f2fe88aba35408c64f588345481490fe93 |
| Attack tx | 0xa1f2377fc6c24d7cd9ca084cafec29e5d5c8442a10aae4e7e304a4fbf548be6d |
| Chain / block / date | Polygon / fork 43,430,814 / ~June 2023 |
| Compiler | NST token: Solidity v0.8.9, optimizer 1000 runs (swap contract unverified) |
| Bug class | Leftover/dangling ERC-20 approve() over the contract's own reserves → reuse via transferFrom |
TL;DR#
Milktech's NST swap contract is a simple fixed-price exchange between USDT (6 decimals) and the company token NST (4 decimals), holding a USDT float to pay out sellers.
When you call sellNST, the contract sends you your USDT payout, but it does so by first
approve()-ing you for that payout amount and then paying you with a plain transfer() — instead
of letting you pull it. The transfer() moves the money, but the approve() is never consumed and
never reset. The result is a dangling allowance: after a sell, the attacker holds an ERC-20
allowance over the swap contract's own USDT balance equal to the payout it just received.
The attacker then simply calls usdt.transferFrom(swapContract, attacker, …) and walks off with the
swap contract's remaining USDT reserves — the company's deposited liquidity.
Concretely (all numbers from output.txt):
- Buy: deposit
40,000 USDT; after a 3% fee the contract sends back388,000.0000 NST(3,880,000,000raw NST units). - Sell: sell those
388,000 NSTback; after a 3% fee the contract pays out37,636 USDT(37,636,000,000raw units) — andapprove(attacker, 37,636,000,000)on USDT, thentransfers the same amount. - Drain: the attacker uses that dangling
37,636USDT allowance totransferFrom(swapContract, attacker, 31,559.083207 USDT)— emptying the swap contract's USDT float down to ~0.
Net: started with 40,000 USDT, ended with 69,195.083207 USDT → +29,195.083207 USDT profit, all
of it pulled out of the swap contract's reserves. The PoC mocks the attacker's 40k working capital
with deal (the real attacker flash-loaned it from Balancer).
Background — what the NST system is#
- NST token (NSTToken.sol) is a verified, fairly vanilla
OpenZeppelin-style ERC-20 with
Pausable,Ownable, and a customMinterrole. It has 4 decimals (NSTToken.sol:1146-1148) and forbids transfers to the token contract itself (NSTToken.sol:1190-1193). The token is not the bug. - USDT on Polygon is a 6-decimal proxy token
(UChildERC20Proxy.sol). In the trace its
logic lives behind a
delegatecallto0x7FFB…c1e2. - Swap contract
0x9D10…aa88(unverified) is the heart of the system: it offersbuyNST(usdtIn)andsellNST(nstIn)at a fixed USD price, takes a 3% fee to a fee wallet (0xbb5a92c69355Dd75480e66Db8D07cEA4443CbEa1), and keeps a USDT float so sellers can be paid. Because it was never verified, the attacker had to reverse the selectors:buyNST=0x6e41592csellNST=0x7cd0599b
From the trace, the swap held ~69,195 USDT of float just before the attack
(USDT.balanceOf(swapper) = 69,195,083,207, output.txt:118-121) — this is the prize.
The fixed exchange rate is visible in the raw units: in both directions the contract uses a constant ×10 between raw USDT units and raw NST units (after the 3% fee):
- buy:
38,800,000,000effective USDT units →3,880,000,000NST units (÷10) - sell:
3,763,600,000effective NST units →37,636,000,000USDT units (×10)
(USDT 6 dp vs NST 4 dp differ by 100×; the contract's ×10 constant is the protocol's chosen price of
0.1 USD/NST baked into raw-unit math. The decimal handling is not the vulnerability — the
round-trip is value-neutral apart from fees; the theft comes entirely from the dangling approval.)
The vulnerable code (reconstructed from the trace)#
The swap contract is unverified. The following is the behaviour observed in the trace, written as the equivalent Solidity. The two damning facts are: (a) the payout is delivered with
token.transfer(msg.sender, …), and (b) it is immediately preceded bytoken.approve(msg.sender, …)for the same amount — an allowance that is therefore never spent and never cleared.
sellNST — the leaky payout#
// selector 0x7cd0599b — RECONSTRUCTED from output.txt, NOT verified source
function sellNST(uint256 nstAmount) external returns (uint256 usdtOut) {
uint256 fee = nstAmount * 3 / 100; // 116,400,000 NST units in the trace
uint256 net = nstAmount - fee; // 3,763,600,000 NST units
nst.transferFrom(msg.sender, address(this), net); // [output.txt:125-130]
nst.transferFrom(msg.sender, feeWallet, fee); // [output.txt:131-136]
usdtOut = net * 10; // 37,636,000,000 USDT units (fixed price ×10)
usdt.approve(msg.sender, usdtOut); // ⚠️ [output.txt:137-143] DANGLING APPROVAL over the float
usdt.transfer(msg.sender, usdtOut); // [output.txt:144-151] payout actually delivered here
return usdtOut; // the approve() above is never consumed / reset
}
buyNST — same anti-pattern (less harmful here)#
// selector 0x6e41592c — RECONSTRUCTED from output.txt
function buyNST(uint256 usdtAmount) external returns (uint256 nstOut) {
uint256 fee = usdtAmount * 3 / 100; // 1,200,000,000 USDT units
uint256 net = usdtAmount - fee; // 38,800,000,000 USDT units
usdt.transferFrom(msg.sender, address(this), net); // [output.txt:83-92]
usdt.transferFrom(msg.sender, feeWallet, fee); // [output.txt:93-102]
nstOut = net / 10; // 3,880,000,000 NST units
nst.approve(msg.sender, nstOut); // ⚠️ [output.txt:103-107] dangling NST approval
nst.transfer(msg.sender, nstOut); // [output.txt:108-113] payout delivered
return nstOut;
}
The benign NST token code (for contrast) — a textbook ERC-20 with nothing wrong in transfer /
transferFrom / approve (NSTToken.sol:804-858).
The token did exactly what it was told; the swap contract is what mis-issued the approval.
Root cause — why it was possible#
A correct exchange contract that holds a reserve must never grant an external caller an ERC-20
allowance over its own reserve balance. The only legitimate way to pay a user out of a held balance
is a direct transfer(user, amount) — transfer itself is the authorization. An approve(user, amount) on top of that is not just redundant; it is a standing license for user to take that much
again from the contract's balance whenever they want, via transferFrom(contract, user, amount),
until the allowance is exhausted or reset.
The swap contract does exactly the wrong thing:
In
sellNSTit callsusdt.approve(msg.sender, usdtOut)andusdt.transfer(msg.sender, usdtOut). Thetransferpays the seller correctly. Theapprovethen leaves the seller with a liveusdtOut-sized allowance over the contract's remaining float. Because the float is shared across all users, the seller cantransferFromit out — that float is other people's (and the company's) money.
Three design facts compose into the theft:
approve+transferinstead oftransferalone. The redundantapprovecreates a dangling allowance equal to the payout, scoped to the contract's own balance.- The allowance is never consumed. Since payout is delivered with
transfer(nottransferFrom), theapproveis not spent. A correct "pull" design (approvethen have the usertransferFrom) would have consumed it; this hybrid leaves it dangling. - A shared reserve to steal from. The contract holds a USDT float (~69k USDT) so it can pay sellers. The dangling allowance points straight at that shared pot, so the attacker drains everyone's liquidity, not just their own deposit.
The 3% fees and the fixed ×10 price are economically irrelevant to the exploit — they merely shave a
small amount off a value-neutral round trip. 100% of the attacker's profit comes from re-using the
dangling approval to transferFrom the contract's float.
Preconditions#
- The swap contract holds a USDT reserve large enough to be worth stealing (it held ~69,195 USDT).
sellNSTissues a USDTapprove(msg.sender, payout)to the caller (the dangling approval). This is unconditional — every seller gets it.- The attacker needs working capital ≥ the round-trip size to generate a large dangling allowance.
Here
40,000 USDTwas enough to mint a37,636 USDTallowance; the real attacker flash-loaned the 40k from Balancer (the PoC mocks this withdeal, test/NST_exp.sol:49-51). - No timing, no privileged role, no oracle — it is permissionless and single-transaction.
Attack walkthrough (with on-chain numbers from the trace)#
All raw units below are from output.txt. USDT has 6 decimals (40_000_000_000 =
40,000 USDT); NST has 4 decimals (3_880_000_000 = 388,000 NST).
| # | Step (trace ref) | Attacker USDT | Attacker NST | Swap contract USDT float | Effect |
|---|---|---|---|---|---|
| 0 | Start — deal 40k USDT to attacker (test:51) | 40,000.000000 | 0 | ~69,195.083207 | Attacker funded; swap holds the float. |
| 1 | buyNST(40,000 USDT) (output.txt:73-114): 38,800 USDT → swap, 1,200 USDT → fee wallet, swap approves + transfers 388,000 NST | 0 | 388,000.0000 | ~107,995.083207 | Attacker holds NST + a dangling 388,000 NST allowance (harmless side-effect). |
| 2 | sellNST(388,000 NST) (output.txt:115-152): 3,763,600,000 NST → swap, fee → fee wallet; swap approve(attacker, 37,636 USDT) then transfer(attacker, 37,636 USDT) | 37,636.000000 | 0 | ~69,195.083207 | ⚠️ Attacker now holds a dangling 37,636 USDT allowance over the swap's float. |
| 3 | usdt.transferFrom(swap → attacker, 31,559.083207) (output.txt:153-162) | 69,195.083207 | 0 | 0 | Float drained using the dangling allowance. Remaining allowance 6,076.916793 left unused. |
Why the final pull is exactly 31,559.083207: after step 2 the swap contract's USDT balance is
69,195,083,207 units. The attacker's dangling allowance is 37,636,000,000 units, but the contract
only has 69,195,083,207 − 37,636,000,000 = 31,559,083,207 units left after paying the sell, so the
attacker pulls all of it (transferFrom reverts if it exceeds the balance, so the attacker pulls the
balance, not the full allowance). The trace confirms the residual allowance afterwards:
Approval(swapper → test, 6,076,916,793) (output.txt:156), and
37,636,000,000 − 31,559,083,207 = 6,076,916,793 ✓.
Profit / loss accounting (USDT)#
| Direction | Amount (USDT) |
|---|---|
In — initial capital (flash-loaned, mocked via deal) | 40,000.000000 |
Out — buyNST cost (38,800 to pool + 1,200 fee) | −40,000.000000 |
In — sellNST payout | +37,636.000000 |
| In — dangling-allowance drain | +31,559.083207 |
| Net profit | +29,195.083207 |
The PoC asserts this exactly: console.log("USDT Theft", 29195083207) (output.txt:5-7)
and the final balance is 69,195,083,207 (output.txt:163-166). After repaying the 40k
flash loan, the attacker keeps the 29,195.08 USDT.
Diagrams#
Sequence of the attack#
Pool / float state evolution#
Why the approval is the bug — correct vs. actual payout#
Remediation#
- Pay out with
transferonly — neverapproveyour own reserve to a user. Delete theusdt.approve(msg.sender, …)(and the symmetricnst.approve(msg.sender, …)inbuyNST). Thetransferalone correctly and safely delivers the payout. This single change eliminates the bug. - If a pull pattern is genuinely required, make it consistent:
approve(user, amount)and let the user calltransferFrom(swap, user, amount)— and never alsotransferthe same funds. Mixing push (transfer) and pull (approve) is what creates the dangling allowance. - Reset allowances defensively. Any function that issues an allowance over the contract's own
balance should zero it again before returning (
approve(user, 0)), so no allowance survives the call. - Don't co-mingle a shared reserve with per-user allowances. Treat the swap float as protected capital; never expose it through an allowance to arbitrary callers.
- Verify and audit the contract that holds the money. The token was verified and clean; the swap contract — the one custodying the float — was unverified and unaudited. The asset-holding contract is exactly where review matters most.
How to reproduce#
The PoC was extracted into a standalone Foundry project:
_shared/run_poc.sh 2023-06-NST_exp --mt testExploit -vvvvv
- Fork: Polygon at block
43,430,814(foundry.tomluseshttps://polygon.drpc.org; any Polygon archive RPC that serves state at this block works). The swap contract is on-chain at that block, so no source is needed to run the PoC — it calls the live bytecode via raw selectors (0x6e41592cbuyNST,0x7cd0599bsellNST, test/NST_exp.sol:53-57). - Capital: the attacker's 40k USDT working capital is mocked with
dealinstead of the original Balancer flash loan (test/NST_exp.sol:49-51). - Result:
[PASS] testExploit()withUSDT Theft 29195083207(≈ 29,195.08 USDT).
Expected tail:
Ran 1 test for test/NST_exp.sol:NstExploitTest
[PASS] testExploit() (gas: 415500)
Logs:
USDT Theft 29195083207
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.07s
References: write-up by @eugenioclrc; attack tx
0xa1f2377f…548be6d.
NST = Milktech "Instante" token, Polygon, ~$29.2K loss.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-06-NST_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
NST_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "NST Swap 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.