SNKMiner Referral Reward Drain
Exploit Transactions
0x0ef7ea46fcf1f5e7aac63b87f17596c835ac0459652332316b3a07e10d976e650xf6600c30ee40bfe2a24a644a2d9f7c6a9923002fd95d0b39164b796fdcddb54c0x233ef50e0b58084b12b7717cc0d19f2ce9c80000429292f7d4491ab9f34ea3380xace112925935335d0d7460a2470a612494f910467e263c7ff477221deee90a2c0x764d7b23bf39a7156758e0444be970a58c5af28e129300774d0662da3628e2330x2e505c8f35047f6d4ddcec331005e26e10aa26e4e9f997753b48708ad29d6b6f0xad428c139fc511f47935014235f7c41151e9a7edd75538cc72997f2a5eae8cc30x7394f2520ff4e913321dd78f67dd84483e396eb7a25cbb02e06fe875fc47013aVictim Addresses
0xa3f5ea945c4970f48e322f1e70f4cc08e70039eeBSC0xcde4e0d76acaa3241cc52bf23f9c5acbfeb71a51BSCLoss Breakdown
Similar Incidents
StakingDYNA Reward Backdating Drain
39%QiQi Reward Quote Override Drain
37%DBW Static-Income LP Drain
34%GGGTOKEN Treasury Drain via receive()
33%H2O helper-token reward drain from unauthorized claim loop
32%SellToken Short Oracle Manipulation
32%Root Cause Analysis
SNKMiner Referral Reward Drain
1. Incident Overview TL;DR
SNKMiner 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 197725727895542134092738 raw USDT to EOA 0x7738b2f18d994c7c8fa10e1fe456069624740f3e.
2. Key Background
SNKMiner 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.
3. Vulnerability Analysis & Root Cause Summary
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.
4. Detailed Root Cause Analysis
4.1 Code-Level Mechanism
The accounting bug requires three pieces to line up:
updateReward(msg.sender)settles only the direct caller.stakeandexitthen 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.
4.2 Exploit Preconditions
The validated root cause requires only public, on-chain conditions:
- A parent account must have at least
10e18SNK staked sodynamicEarnedis enabled. - Some time must pass after the parent's last checkpoint so
rewardPerToken() - userRewardPerTokenPaid[parent]becomes positive. - A fresh child must be bindable to that parent through
SNKMiner.bindParent. - SNKMiner must still hold enough SNK to pay the computed reward.
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.
4.3 Why the Binding Model Matters
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:
- bind itself to the seeded parent,
- stake a large SNK balance,
- let the parent call
getReward, - exit and return the principal.
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.
5. Adversary Flow Analysis
5.1 Deployment and Priming
The attacker EOA 0x6b67f9e79180e08cb42132754bc9f88558b1e535 deployed orchestration contract 0xc04c4914d14225345f1678a705a69aa5636b3a33 in tx 0xfc84bdc1b2bebbc533a974d4f499d626499d9ffbf938a9d81b389d5af26c046c, then funded it with SNK. The priming sequence used four cp(...) batches:
- tx
0x0ef7ea46fcf1f5e7aac63b87f17596c835ac0459652332316b3a07e10d976e65:cp(38) - tx
0xf6600c30ee40bfe2a24a644a2d9f7c6a9923002fd95d0b39164b796fdcddb54c:cp(54) - tx
0x233ef50e0b58084b12b7717cc0d19f2ce9c80000429292f7d4491ab9f34ea338:cp(54) - tx
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.
5.2 Harvesting Loop
After reward time accrued, the attacker executed four harvesting transactions:
0x764d7b23bf39a7156758e0444be970a58c5af28e129300774d0662da3628e2330x2e505c8f35047f6d4ddcec331005e26e10aa26e4e9f997753b48708ad29d6b6f0xad428c139fc511f47935014235f7c41151e9a7edd75538cc72997f2a5eae8cc30x7394f2520ff4e913321dd78f67dd84483e396eb7a25cbb02e06fe875fc47013a
The 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.
5.3 Cash-Out and Realized Profit
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:
- SNKMiner SNK delta:
-242062661194891883274528 - Attacker contract SNK delta:
-310827797095510532629035 - USDT delta for
0x7738b2f18d994c7c8fa10e1fe456069624740f3e:+197725727895542134092738
The 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.
6. Impact & Losses
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.
7. References
- Verified SNKMiner source:
0xa3f5ea945c4970f48e322f1e70f4cc08e70039ee - Verified Invite source:
0xcde4e0d76acaa3241cc52bf23f9c5acbfeb71a51 - Verified SNK token source:
0x05e2899179003d7c328de3c224e9df2827406509 - Attacker deployment tx:
0xfc84bdc1b2bebbc533a974d4f499d626499d9ffbf938a9d81b389d5af26c046c - Final priming tx:
0xace112925935335d0d7460a2470a612494f910467e263c7ff477221deee90a2c - Harvest txs:
0x764d7b23bf39a7156758e0444be970a58c5af28e129300774d0662da3628e233,0x2e505c8f35047f6d4ddcec331005e26e10aa26e4e9f997753b48708ad29d6b6f,0xad428c139fc511f47935014235f7c41151e9a7edd75538cc72997f2a5eae8cc3,0x7394f2520ff4e913321dd78f67dd84483e396eb7a25cbb02e06fe875fc47013a - Collected evidence used in validation: decoded traces and balance diffs for tx
0xace11292...and tx0x7394f252..., plus the root-cause and oracle artifacts produced in this session.