Calculated from recorded token losses using historical USD prices at the incident time.
0xa3f5ea945c4970f48e322f1e70f4cc08e70039eeBSC0xcde4e0d76acaa3241cc52bf23f9c5acbfeb71a51BSCSNKMiner on BNB Smart Chain exposed a permissionless reward-drain path caused by stale inviter checkpoints. The attacker first created 195 inviter accounts that each staked the minimum 10e18 SNK required to qualify for dynamic rewards, then let time pass, then reused a large temporary child stake across those inviters to claim retroactively backdated rewards. The observed sequence spans attacker deployment tx 0xfc84bdc1b2bebbc533a974d4f499d626499d9ffbf938a9d81b389d5af26c046c, four priming batches (0x0ef7ea46..., 0xf6600c30..., 0x233ef50e..., 0xace11292...), and four harvesting batches (0x764d7b23..., 0x2e505c8f..., 0xad428c13..., 0x7394f252...).
The root cause is in SNKMiner: updateReward checkpoints only the direct caller, but stake and exit immediately change inviter-linked balances for ancestors without settling those ancestors first. Because dynamicEarned later multiplies the current child-balance graph by the entire stale reward-per-token delta, a child stake that exists only for the current callback is rewarded as if it had existed throughout the whole elapsed interval. In the final harvesting transaction, the attacker sold the drained SNK into the SNK/USDT Pancake pair and sent raw USDT to EOA .
0x233ef50e0b58084b12b7717cc0d19f2ce9c80000429292f7d4491ab9f34ea3380xace112925935335d0d7460a2470a612494f910467e263c7ff477221deee90a2c0x764d7b23bf39a7156758e0444be970a58c5af28e129300774d0662da3628e2330x2e505c8f35047f6d4ddcec331005e26e10aa26e4e9f997753b48708ad29d6b6f0xad428c139fc511f47935014235f7c41151e9a7edd75538cc72997f2a5eae8cc30x7394f2520ff4e913321dd78f67dd84483e396eb7a25cbb02e06fe875fc47013a1977257278955421340927380x7738b2f18d994c7c8fa10e1fe456069624740f3eSNKMiner pays two reward streams:
privateEarned(account): reward on the account's own staked SNK.dynamicEarned(account): reward on the current balances of the account's direct children.Dynamic rewards are gated only by the parent's own stake size. If balanceOf(account) < 10e18, dynamicEarned returns zero; otherwise the parent is eligible. That means a parent can be activated with a minimal 10 SNK stake and does not need to keep large principal at risk.
Invite relationships are permanent once set. The verified Invite contract allows the SNKMiner contract, acting as an enabled miner, to bind any fresh address to any non-zero parent exactly once:
// Verified Invite source on BscScan
function invite(address user, address parent) external returns (bool) {
address miner_ = msg.sender;
require(miners[miner_].enable, "Invite: miner permission denied");
require(inviter[user] == address(0), "Invite: user has invited");
require(parent != address(0), "Invite: parent is zero address");
inviter[user] = parent;
inviterSuns[parent].push(user);
emit Bind(user, parent);
return true;
}
The critical accounting design is that dynamic rewards are derived from the current child set, not from a child-balance snapshot taken when the last reward checkpoint was written.
The vulnerability is a reward-accounting bug in SNKMiner, not a routing or token bug. updateReward(account) snapshots prewards, drewards, and userRewardPerTokenPaid only for the supplied account. Immediately after that checkpoint, stake and exit walk up the inviter chain and mutate each ancestor's communityBalances and node state, but they never checkpoint those ancestors before changing the reward basis. Later, when an ancestor calls getReward, dynamicEarned(account) reads the current child balances from Invite.getInviterSuns(account) and multiplies them by the entire rewardPerToken() - userRewardPerTokenPaid[account] interval. The result is backdating: a child balance added moments ago inherits all reward accrual since the parent's previous checkpoint.
The verified SNKMiner source shows the flaw directly:
// Verified SNKMiner source on BscScan
modifier updateReward(address account) {
rewardPerTokenStored = rewardPerToken();
lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
prewards[account] = privateEarned(account);
drewards[account] = dynamicEarned(account);
userRewardPerTokenPaid[account] = rewardPerTokenStored;
}
_;
}
function stake(uint256 amount) public updateReward(msg.sender) checkStop {
super.stake(amount, 0);
address parent = msg.sender;
for (uint256 i = 0; i < 20; i++) {
parent = inv.getInviter(parent);
if (parent == address(0)) break;
communityBalances[parent] = communityBalances[parent].add(amount);
}
}
function dynamicEarned(address account) public view returns (uint256) {
if (balanceOf(account) < 10e18) return 0;
return _getMyChildersBalanceOf(account)
.mul(rewardPerToken().sub(userRewardPerTokenPaid[account]))
.mul(45)
.div(precision)
.div(100)
.add(drewards[account]);
}
The invariant that should hold is: whenever a child stake change modifies an inviter's reward basis, that inviter must be settled and checkpointed before the new child balance becomes effective. SNKMiner violates that invariant in both stake and exit, which makes the exploit deterministic for any unprivileged actor who can fund a minimal parent, wait for time to pass, and briefly provide a large child stake.
The accounting bug requires three pieces to line up:
updateReward(msg.sender) settles only the direct caller.stake and exit then modify ancestor-linked state for up to 20 inviter levels.dynamicEarned(parent) later values rewards from the parent's live child balances, not from balances snapshotted at the time of the parent's last checkpoint.That means the parent's userRewardPerTokenPaid[parent] can remain stale for days while the child graph is empty. When the attacker adds a child with a large temporary stake and calls getReward on the parent immediately afterward, the parent is paid as if that child had been present for the whole stale interval.
The validated root cause requires only public, on-chain conditions:
10e18 SNK staked so dynamicEarned is enabled.rewardPerToken() - userRewardPerTokenPaid[parent] becomes positive.SNKMiner.bindParent.All of these conditions were satisfied on-chain. The final balance-diff artifact for tx 0x7394f2520ff4e913321dd78f67dd84483e396eb7a25cbb02e06fe875fc47013a shows SNKMiner still held 1823619504165333317431575 raw SNK before the last harvest batch and lost 242062661194891883274528 raw SNK during that transaction.
The Invite contract makes the exploit scalable because every fresh child can be permanently attached to any seeded parent without privileged access. The child only needs to exist long enough to:
getReward,The principal can then be reused for the next parent. This is why the attacker could amortize one large working balance over a large seeded inviter set.
The attacker EOA 0x6b67f9e79180e08cb42132754bc9f88558b1e535 deployed orchestration contract 0xc04c4914d14225345f1678a705a69aa5636b3a33 in tx 0xfc84bdc1b2bebbc533a974d4f499d626499d9ffbf938a9d81b389d5af26c046c, then funded it with SNK. The priming sequence used four cp(...) batches:
0x0ef7ea46fcf1f5e7aac63b87f17596c835ac0459652332316b3a07e10d976e65: cp(38)0xf6600c30ee40bfe2a24a644a2d9f7c6a9923002fd95d0b39164b796fdcddb54c: cp(54)0x233ef50e0b58084b12b7717cc0d19f2ce9c80000429292f7d4491ab9f34ea338: cp(54)0xace112925935335d0d7460a2470a612494f910467e263c7ff477221deee90a2c: cp(49)The decoded cp(49) trace shows the helper repeatedly deploying fresh worker contracts, transferring each one 10e18 SNK, and having each worker call SNKMiner::stake(10e18):
Decoded trace of the final priming batch
0xC04c4914...::cp(49)
-> new worker 0x2876B55d...
-> SNKToken::transfer(worker, 10000000000000000000)
-> worker::stakeAll(...)
-> SNKMiner::stake(10000000000000000000)
-> emit Staked(user: 0x2876B55d..., amount: 10000000000000000000, feeAmount: 0)
The balance-diff artifact for the same transaction confirms the result: the attacker contract lost exactly 490000000000000000000 raw SNK and SNKMiner gained the same amount, matching 49 new parents at 10 SNK each.
After reward time accrued, the attacker executed four harvesting transactions:
0x764d7b23bf39a7156758e0444be970a58c5af28e129300774d0662da3628e2330x2e505c8f35047f6d4ddcec331005e26e10aa26e4e9f997753b48708ad29d6b6f0xad428c139fc511f47935014235f7c41151e9a7edd75538cc72997f2a5eae8cc30x7394f2520ff4e913321dd78f67dd84483e396eb7a25cbb02e06fe875fc47013aThe final batch shows the exploit loop in full. The trace begins with a Pancake pair flash-swap, then for each seeded parent it deploys a fresh child, binds the child, stakes the borrowed SNK, claims the parent's reward, exits the child, and moves on:
Decoded trace of the final harvest batch
PancakePair::swap(..., to = 0xC04c4914..., data = 0x00)
-> 0xC04c4914...::pancakeCall(...)
-> SNKMiner::bindParent(0x9A50A16757A26DDe9002822a7b3D70dc5BE02d18)
-> SNKMiner::stake(396264020618767698414035)
-> SNKMiner::getReward()
-> emit RewardPaid(user: 0x9A50A167..., reward: 4220704689613521621992)
-> SNKMiner::exit()
That same pattern repeats for parent after parent in the same transaction. The important point is that each parent receives a positive reward immediately after the child is added, even though the child stake only exists inside the current exploit callback. The stake is then unwound, so the principal can be reused for the next parent.
At the end of the final harvesting batch, the attacker converted the accumulated SNK into USDT through the SNK/USDT Pancake route and sent the proceeds to EOA 0x7738b2f18d994c7c8fa10e1fe456069624740f3e. The collected balance diff for tx 0x7394f252... records:
-242062661194891883274528-3108277970955105326290350x7738b2f18d994c7c8fa10e1fe456069624740f3e: +197725727895542134092738The root-cause artifact also quotes aggregate gas paid across the observed nonce range as 419870972603061097794 raw USDT, leaving a gas-adjusted profit delta of 197305856922939072994944 raw USDT.
The exploit drained reward inventory from SNKMiner and converted the realized value into USDT. The directly observed payout in the final harvesting transaction was 197725727895542134092738 raw USDT, which is 197725.727895542134092738 USDT at 18 decimals. That amount is recorded as the validated loss figure for this incident.
The protocol-side inventory loss is also visible in-kind. In the last harvesting batch alone, SNKMiner's SNK balance fell from 1823619504165333317431575 to 1581556842970441434157047, a delta of 242062661194891883274528 raw SNK. Because the exploit is permissionless and depends only on public state plus ordinary transaction submission, the same stale-checkpoint condition was an ACT opportunity until the miner's reward inventory or the relevant parent set was exhausted.
0xa3f5ea945c4970f48e322f1e70f4cc08e70039ee0xcde4e0d76acaa3241cc52bf23f9c5acbfeb71a510x05e2899179003d7c328de3c224e9df28274065090xfc84bdc1b2bebbc533a974d4f499d626499d9ffbf938a9d81b389d5af26c046c0xace112925935335d0d7460a2470a612494f910467e263c7ff477221deee90a2c0x764d7b23bf39a7156758e0444be970a58c5af28e129300774d0662da3628e233, 0x2e505c8f35047f6d4ddcec331005e26e10aa26e4e9f997753b48708ad29d6b6f, 0xad428c139fc511f47935014235f7c41151e9a7edd75538cc72997f2a5eae8cc3, 0x7394f2520ff4e913321dd78f67dd84483e396eb7a25cbb02e06fe875fc47013a0xace11292... and tx 0x7394f252..., plus the root-cause and oracle artifacts produced in this session.