Reproduced Exploit
AI SPACE (AIS) Exploit — Permissionless `PendingMint` Inflation + Unprotected Vault Drain
The AIS token bolts a "market reward" mint mechanism onto a standard OZ ERC20. Every transfer that touches a registered AMM pair bumps a global counter PendingMint by 4–8 % of the transferred amount (AISPACE.sol:973-998). A separate, permissionless function harvestMarket() then mints PendingMint −…
Loss
~$60.7k — 60,686.88 USDT extracted from the AIS/USDT PancakeSwap-V2 pair
Chain
BNB Chain
Category
Access Control
Date
Nov 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-11-AIS_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/AIS_exp.sol.
Vulnerability classes: vuln/access-control/missing-modifier · vuln/arithmetic/overflow
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 compile together, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: sources/AISPACE_6844Ef/AISPACE.sol. PoC: test/AIS_exp.sol.
Key info#
| Loss | ~$60.7k — 60,686.88 USDT extracted from the AIS/USDT PancakeSwap-V2 pair |
| Vulnerable contract | AISPACE (AIS token) — 0x6844Ef18012A383c14E9a76a93602616EE9d6132 |
| Secondary vulnerable contract | "MarketVault" — 0xFFAc2Ed69D61CF4a92347dCd394D36E32443D9d7 (unverified; has a permissionless setAdmin + transferToken) |
| Victim pool | AIS/USDT PancakeSwap-V2 pair — 0x1219F2699893BD05FE03559aA78e0923559CF0cf |
| Flash-loan source | PancakeSwap-V3 USDT pool — 0x4f31Fa980a675570939B737Ebdde0471a4Be40Eb |
| Attacker EOA | 0x84f37F6cC75cCde5fE9bA99093824A11CfDc329D (frontrun by 0x7cb74265e3e2d2b707122bf45aea66137c6c8891) |
| Attacker contract | 0xf6f60b0e83d9837c1f247c575c8583b1d085d351 (frontrunner: 0x15ffd1d02b3918c9e56f75e30d23786d3ef2b5bc) |
| Attack tx | 0x0be817b6a522a111e06293435c233dab6576d7437d0e148b45efcf7ab8a10de0 |
| Chain / block / date | BSC / 33,916,687 (PoC fork) / Nov 2023 |
| Compiler | Solidity v0.8.20, optimizer 200 runs |
| Bug class | Permissionless mint-accounting inflation + missing access control on setSwapPairs / setAdmin |
TL;DR#
The AIS token bolts a "market reward" mint mechanism onto a standard OZ ERC20. Every transfer that
touches a registered AMM pair bumps a global counter PendingMint by 4–8 % of the transferred
amount (AISPACE.sol:973-998). A separate,
permissionless function harvestMarket() then mints PendingMint − MintPosition brand-new AIS
to the MarketAddress vault (:968-972).
Two design flaws make this catastrophic:
setSwapPairs(address)lost itsonlyOwnermodifier — the//onlyOwneris commented out (:949-951). Anyone can register any address as a "pair", giving it the inflationary transfer semantics.PendingMintaccrues with no backing. Because it grows on every pair-touching transfer (including zero-net round-trips), an attacker can pump it arbitrarily high by shuttling AIS in and out of the pair viapair.skim()— thenharvestMarket()mints that inflation as real AIS.
The vault (MarketAddress = 0xFFAc2Ed…) is itself broken: it exposes a permissionless setAdmin()
and an admin-only transferToken(). So the attacker makes themselves admin and walks the freshly-minted
AIS out of the vault.
The full attack (one atomic tx, financed by a 3,000,000 USDT PancakeV3 flash loan):
- Buy 142,406 AIS from the AIS/USDT pair with the flash-loaned 3M USDT.
- Pump
PendingMint— 100 rounds oftransfer(pair, 128,165 AIS)+pair.skim(self)round-trips. Each leg bumpsPendingMintby 4–8 % with zero net AIS cost (skim returns what was sent). harvestMarket()— mints 1,543,728 AIS to the vault.- Steal the mint —
vault.setAdmin(self)thenvault.transferToken(AIS, self, 90 % = 1,389,357 AIS). setSwapPairs(self)so the attacker's own sell into the pair takes the buy (untaxed) path.- Dump ~1.53M AIS into the pair → receive 3,062,186 USDT.
- Repay 3,001,500 USDT (3M + 0.05 % fee). Net +60,686.88 USDT.
Background — what AISPACE does#
AISPACE (source) is an OZ-5 ERC20 (with
ERC20Burnable, ERC20Pausable, Ownable) plus a custom "market reward" layer:
- Registered pairs —
Pairs[addr]marks AMM pairs. Set viasetSwapPairs(). - Custom transfer routing —
transfer/transferFromdelegate to_transferAIS, which branches on whether the counterparty is a pair (a "buy" vs a "sell"). Both branches accruePendingMintandPendingBrun. PendingMint/MintPosition— a global accumulator of "rewards owed";harvestMarket()mints the delta toMarketAddress.PendingBrun— a per-account pending-burn ledger; crucially, the overriddenbalanceOfsubtractsPendingBrun[account](:1008-1010).
Total supply at the fork block was ~1.0e9 AIS (minted to the vault holder in the constructor, :939). The AIS/USDT pair held only 3,271 AIS and 3,068,743 USDT of effective reserve liquidity — the prize.
The vulnerable code#
1. setSwapPairs is permissionless (access control commented out)#
function setSwapPairs(address _address) public { //onlyOwner {
Pairs[_address] = true;
}
AISPACE.sol:949-951 — the onlyOwner guard is
commented out. Anyone can flag any address as a pair, switching its transfer accounting into the
inflationary branch.
2. The pair-touching transfer paths inflate PendingMint for free#
function _transferAIS(address from, address to, uint256 value) private returns (bool) {
if (Pairs[from]){ // a "buy" (tokens leaving the pair)
_transfer(from, to, value);
PendingBrun[to] = value*5/100;
PendingMint += value*4/100; // ⚠️ +4% accrual, unbacked
IMarketVault(MarketAddress).addMarketValue(value*4/100);
return true;
}
if (Pairs[to]){ // a "sell" (tokens entering the pair)
require(balanceOf(from) > value*111/100, "insufficient funds for burn!");
_transfer(from, to, value);
PendingBrun[from] = value*10/100;
PendingMint += value*8/100; // ⚠️ +8% accrual, unbacked
IMarketVault(MarketAddress).addMarketValue(value*8/100);
return true;
} else { ... }
}
AISPACE.sol:973-998. PendingMint rises every time
AIS moves to/from a pair, regardless of whether any net value changed hands.
3. harvestMarket mints the accrued inflation, permissionlessly#
function harvestMarket() public {
require(PendingMint>MintPosition, "No Pending available");
_mint(MarketAddress, PendingMint-MintPosition); // ⚠️ mints unbacked AIS to the vault
MintPosition = PendingMint;
}
AISPACE.sol:968-972 — no access control, no rate limit.
4. The balanceOf override + skim make the pump cost-free#
function balanceOf(address account) public override view virtual returns (uint256) {
return ERC20.balanceOf(account) - PendingBrun[account];
}
AISPACE.sol:1008-1010. The attacker shuttles AIS
through pair.skim() (which pays out realBalance − reserve) so the AIS round-trips back at no net
cost, while each leg keeps bumping PendingMint.
5. The vault's setAdmin is also permissionless#
The MarketAddress vault (0xFFAc2Ed…, unverified) exposes setAdmin(address) with no access
control and an admin-gated transferToken(token, to, amount). In the trace
(output.txt:5916-5928) the attacker calls setAdmin(self) then
transferToken(AIS, self, 1,389,357 AIS) — straight out of the vault.
Root cause — why it was possible#
The protocol mints "rewards" off an accumulator that anyone can inflate, and nothing ties that accumulator to real economic activity:
- No access control on
setSwapPairslets an attacker pick which addresses get inflationary transfer semantics — including, at the end, themselves, so they can dump AIS through the untaxed buy path. PendingMintaccrues on gross transfer volume, not net value. A wash trade (send AIS to the pair,skimit right back) generates fee-like accrual with zero capital at risk. The skim round-trip is the engine: the attacker's AIS balance is conserved whilePendingMintratchets up.harvestMarket()is permissionless and unbounded — it mints the entire accrued delta of an attacker-controlled number into a single address.- The reward sink (
MarketAddressvault) has a permissionlesssetAdmin, so even though the inflation lands in the vault rather than the attacker's wallet, the attacker simply seizes admin and sweeps it.
Each flaw alone is bad; composed, they let an attacker conjure ~1.54M AIS from nothing and sell it into the pool's real USDT reserves.
Preconditions#
- An AIS/USDT AMM pair exists with real USDT liquidity (≈3.07M USDT here).
setSwapPairsis reachable without ownership (it is — guard commented out).harvestMarketis reachable without ownership (it is).- The
MarketAddressvault has a permissionlesssetAdmin+ admintransferToken(it does). - Working capital to buy AIS and prime the pump — fully flash-loanable: the PoC borrows 3,000,000 USDT from a PancakeV3 pool and repays it in the same tx.
Attack walkthrough (with on-chain numbers from the trace)#
Pair token0 = USDT, token1 = AIS, so reserve0 = USDT, reserve1 = AIS. All figures are taken
directly from the Sync/Transfer events and addMarketValue calls in output.txt.
| # | Step | Trace ref | Concrete numbers |
|---|---|---|---|
| 0 | Flash-loan 3,000,000 USDT from PancakeV3 pool | :37-49 | borrow 3,000,000 USDT; fee 1,500 USDT |
| 1 | Buy AIS — swap 3,000,000 USDT → AIS to self | :51-92 | received 142,406.16 AIS; pair reserve AIS → 3,271.32 |
| 2 | Pump loop ×100 — transfer(pair, 128,165.54 AIS) then skim(self) ×2 | :93-5908 | each leg: PendingMint += value*8/100 ≈ 10,253 AIS (send-in) and += value*4/100 ≈ 5,126 AIS (skim-out); net AIS cost = 0 (skim returns the 128,165 sent) |
| 3 | harvestMarket() — mint accrued PendingMint to vault | :5909-5915 | mints 1,543,728.29 AIS to vault 0xFFAc2Ed… |
| 4 | vault.setAdmin(self) — seize the vault | :5916-5919 | admin slot → attacker |
| 5 | vault.transferToken(AIS, self, 90%) | :5922-5929 | pulls 1,389,357.51 AIS to attacker |
| 6 | setSwapPairs(self) — flag attacker as a "pair" | :5930-5933 | makes the upcoming sell take the untaxed Pairs[from] path |
| 7 | Dump AIS — transfer(pair, 1,531,763.68 AIS) + buy-path swap → USDT | :5936-5985 | receive 3,062,186.88 USDT; pair USDT reserve → 6,556 |
| 8 | Repay flash loan — transfer 3,001,500 USDT to V3 pool | :5986-5991 | 3,000,000 + 1,500 fee |
| 9 | Profit | :6004-6008 | balance 60,713.39 USDT, logged profit 60,686.88 USDT |
Why the skim loop is free: pair.skim(to) transfers out realBalance − recordedReserve. The
attacker sends 128,165.54 AIS to the pair (real balance becomes 3,271.32 + 128,165.54 = 131,436.87,
seen at :137), then skim pays exactly 131,436.87 − 3,271.32 = 128,165.54 AIS back
to the attacker. Net AIS = 0, but the two AIS transfers (in via Pairs[to], out via Pairs[from])
have already bumped PendingMint by 8% + 4% = 12% of 128,165.54 ≈ 15,380 AIS per cycle. Over 100
cycles plus the initial buy, PendingMint reaches the 1,543,728 AIS that harvestMarket then mints.
Profit accounting (USDT)#
| Direction | Amount (USDT) |
|---|---|
| Flash-loan in | +3,000,000.00 |
| Spent — buy AIS | −3,000,000.00 |
| Received — dump 1.53M AIS | +3,062,186.88 |
| Repay flash loan (3M + 0.05 %) | −3,001,500.00 |
| Pre-existing balance (gas float) | +26.51 |
| Final balance | 60,713.39 |
| Logged profit | 60,686.88 |
The ~1.54M AIS sold for 3,062,186 USDT was minted from thin air; the 60,686.88 USDT profit is real USDT drained out of the pair's liquidity (and ultimately from AIS holders, whose supply was diluted by the unbacked mint).
Diagrams#
Sequence of the attack#
PendingMint inflation and value flow#
Pair reserve evolution#
Why each magic number#
- 3,000,000 USDT flash loan — sized to buy enough AIS (142,406) so that 90 % of it (128,165) can be used as the per-cycle skim amount. Bigger buy ⇒ bigger per-cycle accrual ⇒ bigger final mint.
balance * 90 / 100per skim leg — leaves a 10 % buffer so thePairs[to]sell guardbalanceOf(from) > value*111/100never reverts (:982) and the attacker never strands AIS in the pair.- 100 iterations — purely a knob: each cycle adds ~12 % of 128,165 ≈ 15,380 AIS to
PendingMint; 100 cycles compound it to the ~1.54M AIS eventually minted. balanceOf(vault) * 90 / 100transferred out — the attacker leaves 10 % in the vault, plausibly to avoid edge-case reverts; 90 % (1,389,357 AIS) is more than enough to drain the pool.
Remediation#
- Restore access control on
setSwapPairs. Re-addonlyOwner(or a managed allow-list). An attacker must never be able to declare arbitrary addresses — least of all themselves — as pairs. - Do not accrue mintable rewards on gross transfer volume.
PendingMintmust reflect real, non-reversible economic activity, not wash-trade round-trips. At minimum, exclude pair↔pair and self-directed transfers, and make accrual depend on net value, not gross amount. - Gate
harvestMarket()and bound it. Restrict to a trusted keeper/role, cap the per-call and cumulative mint, and reconcile against an off-chain or oracle-verified reward figure. - Never override
balanceOfto diverge from the real ERC20 ledger. ThebalanceOf − PendingBruntrick desynchronizes the token's view from AMM reserves and enables theskimround-trip; it is a classic source of pair-accounting exploits. - Fix the vault.
setAdmin()must have access control;transferToken()must only move tokens the vault is entitled to, under a trusted role. - Treat the token + vault as one trust boundary. The vault blindly trusts AIS's
addMarketValueaccounting and exposes an open admin setter — either side alone breaks the system.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile under a single forge build):
_shared/run_poc.sh 2023-11-AIS_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 33,916,687).
foundry.tomluseshttps://bsc-mainnet.public.blastapi.io, which serves historical state at that block. Public BSC RPCs that prune old state fail withheader not found/missing trie node; if blastapi returns a transienthistory index only covers up to …pipeline-sync error, simply re-run. - Result:
[PASS] testExploit()withUSDT profit: 60686.884783691370295991.
Expected tail:
Ran 1 test for test/AIS_exp.sol:AISExploit
[PASS] testExploit() (gas: 7954032)
Logs:
USDT profit: 60686.884783691370295991
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: Phalcon analysis — https://twitter.com/Phalcon_xyz/status/1729861048004391306 ; SlowMist Hacked — https://hacked.slowmist.io/ (AIS, BSC, ~$61K).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-11-AIS_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
AIS_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "AI SPACE (AIS) 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.