Reproduced Exploit
ONTR Token Zero-Owner `onlyOwner` Free Mint → Pancake WETH Drain
1. ONTR ships a custom Ownable whose onlyOwner is: When ownership has been renounced (owner == 0), any caller is treated as owner.
Loss
49.480100697512152261 WETH (exact wei: 49480100697512152261)
Chain
Ethereum
Category
access-control
Date
May 2026
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Original research reproduction (not from DeFiHackLabs). Standalone Foundry PoC, offline
anvil_state.json, and full write-up: 2026-05-ONTR_exp in theevm-hack-registrymirror.
Vulnerability classes: vuln/access-control/missing-owner-check · vuln/access-control/broken-logic · vuln/access-control/uninitialized-owner · vuln/logic/incorrect-state-transition
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. The fork is served offline from the bundled
anvil_state.json(local anvil replays Ethereum state at block25193099), so no public RPC is required. Full verbose trace: output.txt. Verified vulnerable source: Token.sol, Ownable.sol, IERC20Metadata.sol (obfuscated custom ERC-20 stack verified on Etherscan as contractToken).
Key info#
| Loss | 49.480100697512152261 WETH (exact wei: 49480100697512152261) |
| Vulnerable contract | ONTR (OpenTrade) — 0xf074865358b0dd039beee075831f8a2ae6b1f3f3 |
| Attacker EOA | 0xE806B37A9F965bd9D54AaDf9560C78957550b760 |
| Attack contract | 0xD7A33e89aBC1Ac5b2497D9589c81784A2BC52491 (CREATE in attack tx, nonce 0) |
| Victim pair | 0xd46D89f4675bc96328fBDEB443842cdB5Fcd83FD (PancakeSwap V2 WETH/ONTR on Ethereum) |
| Attack tx | 0x98f80eff… (entire drain in constructor) |
| Chain / block / date | Ethereum mainnet / fork 25193099 (attack in 25193100) / ~2026-05-29 |
| Compiler | Solidity 0.8.20 |
| Bug class | Custom onlyOwner treats owner == address(0) as authorized → free ownership seize → free balance inflate |
| Alert | SlowMist TI |
Chain note. Early intake listed BSC. On-chain verification shows Ethereum: pair
token0is mainnet WETH0xC02a…, factory is Pancake on ETH0x1097053Fd2ea711dad45caCcc45EfF7548fCB362. Do not conflate with2026-05-SEAToken_exp(different MetaSea protocol on Arbitrum).
TL;DR#
-
ONTR ships a custom Ownable whose
onlyOwneris:require(hazeDeer == address(0) || hazeDeer == _msgSender());When ownership has been renounced (
owner == 0), any caller is treated as owner. -
Pre-attack
owner()wasaddress(0). -
Attacker CREATE (nonce 0) constructor:
transferOwnership(this)— seize ownershipdesertJasper(this, 1e30)— queue a hidden balance creditglenFlash()→ashBud—balance[this] += 1e30without bumpingtotalSupplytransfer(pair, 1e30)+swap→ 49.4801 WETH to the EOA
Background#
Token (ONTR / OpenTrade) is an obfuscated ERC-20 with a custom ownership /
allowlist / “pending balance” subsystem nested under a fake OpenZeppelin path
(openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol is not
the real OZ interface — it is the attack surface).
Relevant pieces:
| Symbol (obfuscated) | Role |
|---|---|
hazeDeer | owner storage |
hawkDeck | balances mapping |
loftQuilt | totalSupply |
desertJasper | onlyOwner: queue (recipient, amount) into moorSouth |
glenFlash | permissionless: apply all queued credits via ashBud |
ashBud | hawkDeck[account] += amount (no supply update) |
The vulnerable code#
Broken onlyOwner (root cause)#
modifier onlyOwner() {
require(hazeDeer == address(0) || hazeDeer == _msgSender());
_;
}
Correct Ownable is require(owner == msg.sender). The extra
hazeDeer == address(0) branch turns renounced ownership into open admin.
transferOwnership is gated only by this modifier:
function transferOwnership(address tideFlint) public virtual onlyOwner {
require(tideFlint != address(0));
smokeAxe(tideFlint);
}
Free balance inflate path#
function desertJasper(address starField, uint256 meadowWood) public onlyOwner {
require(roseBanner(_msgSender()));
// ...
marchTree[harborCoil] = bufferMyrtle(address(0), starField, meadowWood, block.timestamp);
if (meadowWood > 0) {
moorSouth.push(harborCoil);
}
vortexMesa.push(harborCoil);
harborCoil++;
}
function glenFlash() external {
uint256 sandFern = moorSouth.length;
for (uint256 storkWind = 0; storkWind < sandFern; storkWind++) {
uint256 riverDawn = moorSouth[storkWind];
bufferMyrtle storage mapleTusk = marchTree[riverDawn];
ashBud(mapleTusk.starField, mapleTusk.meadowWood);
}
delete moorSouth;
}
function ashBud(address depthDock, uint256 meadowWood) internal virtual override {
hawkDeck[depthDock] += meadowWood;
}
ashBud is not a mint: it never increments loftQuilt (totalSupply). The
pair still prices the inflated ERC-20 balance as real supply when swapping.
Root cause#
Two bugs compose:
- Auth hole:
onlyOwnersucceeds for everyone after renounce. - Hidden mint: owner-gated
desertJasper+ publicglenFlash/ashBudincreases spendable balance without updatingtotalSupplyor emitting a proper mint event fromaddress(0)via the normaldeltaPlumepath.
Either alone is dangerous; together they let an unprivileged CREATE drain the only liquid market (WETH/ONTR Pancake pair).
Preconditions#
- ONTR
owner() == address(0)(confirmed at block25193099). - Live Pancake pair with ~50.03 WETH reserves.
- Attacker EOA with nonce 0 (historical CREATE address) and gas.
Attack walkthrough#
Historical attack is a single CREATE — no separate attack call.
On-chain log highlights (attack tx 0x98f80eff…, block 25193100):
OwnershipTransferred(0x0 → 0xD7A33e89…)Transfer(0xD7A33e89… → pair, 1e30)(inflated ONTR)Transfer(pair → EOA, 49480100697512152261)(WETH)- Pair Sync: WETH reserve collapses from ~50.03 → ~0.55
PoC assertions (offline output.txt):
- Attacker WETH profit =
49480100697512152261 totalSupplyunchanged at1e27- New owner = attack contract
Remediation#
- Fix
onlyOwner:require(owner() == msg.sender)only. Never treataddress(0)as authorized. - If renouncing, burn admin paths: after renounce,
transferOwnership,desertJasper,recover, etc. must permanently revert. - No silent balance credits: any balance increase must go through a real
mint that updates
totalSupplyand is access-controlled with a non-zero owner or a timelocked governance role. - Do not leave permissionless appliers (
glenFlash) sitting on top of owner-gated queues after ownership is gone.
How to reproduce#
# Offline (no RPC):
_shared/run_poc.sh 2026-05-ONTR_exp -vvvvv
# Expect: [PASS] testExploit — Attacker WETH profit 49.480100697512152261
Historical CREATE initcode is embedded in test/ONTR_exp.sol and replayed at
fork block 25193099. A clean synthetic path lives in test/2026-05-ONTR.sol
for the site playground.
Reference: SlowMist TI alert
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-05-ONTR_exp (evm-hack-registry mirror).
- Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- 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.