Inugami Staking Reward Debt Miscalculation Drain
On March 3, 2026, the Inugami staking contract on BNB Chain (0x2001144a0485b0b3748a167848cdd73837345d73) was exploited via a logic error in reward-debt initialization. The attacker staked a small amount of LP, sent 1 wei WBNB to reactivate the reward window, and then claimed legacy rewards that should not have been attributable to the new stake. The bug is that stakeFor() always calls _updateUserDebt(), but _updateUserDebt() silently skips initialization when endRewardTimestamp < block.timestamp. Because accRewardPerShare kept a large historical value, claim() computed a full payout with zero debt offset and transferred 13.904954155454625145 WBNB from staking reserves (about $8.75K at incident-time pricing).
Root Cause
Vulnerable Contract
- Contract:
InugamiStaking - Address:
0x2001144a0485b0b3748a167848cdd73837345d73 - Proxy: No
- Source type: Verified (
contracts/InugamiStaking.sol)
Vulnerable Function
- Primary bug site:
_updateUserDebt(address) - Reachable via:
stake(uint256)/stakeFor(address,uint256)andclaim(address,uint256[]) - Attack-path selectors:
stake(uint256)=0xa694fc3astreamReward()=0xf1603e30claim(address,uint256[])=0x45718278
Vulnerable Code
// contracts/InugamiStaking.sol
function stakeFor(address _user, uint256 _amount) public nonReentrant {
uint256 amountBefore = stakeAsset.balanceOf(address(this));
stakeAsset.safeTransferFrom(msg.sender, address(this), _amount);
_amount = stakeAsset.balanceOf(address(this)) - amountBefore;
require(_amount != 0, "zero amount");
_updatePools(true, _user);
balanceOf[_user] += _amount;
totalSupply += _amount;
_updateUserDebt(_user); // stake always attempts debt sync
emit Stake(_user, _amount);
}
function _updateUserDebt(address user) internal {
uint256 _rewardTokensCount = rewardTokensCount;
for (uint256 i = 0; i < _rewardTokensCount; ++i) {
if (
rewardInfos[i].rewardToken != address(0) &&
rewardInfos[i].endRewardTimestamp >= block.timestamp // <-- VULNERABILITY: inactive periods skip debt init
) {
_userDebt[i][user] =
(balanceOf[user] * rewardInfos[i].accRewardPerShare) /
1e36;
}
}
}
// contracts/InugamiStaking.sol
function _updatePools(bool unstakeReward, address user) internal {
for (uint256 i = 0; i < rewardTokensCount; ++i) {
RewardInfo storage rewardInfo = rewardInfos[i];
if (
rewardInfo.rewardToken == address(0) ||
rewardInfo.endRewardTimestamp < block.timestamp
) {
continue;
}
uint256 newReward = _calcNewReward(rewardInfo);
if (totalSupply == 0) {
rewardInfo.accRewardPerShare = block.timestamp;
} else {
rewardInfo.accRewardPerShare += (newReward * (1e36)) / totalSupply;
}
if (unstakeReward) {
uint256 pending = ((balanceOf[user] * rewardInfo.accRewardPerShare) / 1e36) - _userDebt[i][user];
if (pending > 0) {
_userPending[i][user] += pending;
}
}
}
lastRewardTimestamp = block.timestamp;
}
Why It’s Vulnerable
- Expected behavior: when a user stakes, their
rewardDebtshould be initialized against currentaccRewardPerShare, so old emissions are not claimable by the new stake. - Actual behavior: during inactive reward periods (
endRewardTimestamp < block.timestamp),_updateUserDebt()does nothing. The new staker keeps_userDebt = 0. - Then
streamReward()reactivates reward windows without resettingaccRewardPerShare;claim()calls_updatePools(true, user)and computes pending asbalance * accRewardPerShare - debt. - Since debt stayed zero, the attacker claims historical accumulator value.
On-chain proof from this tx:
rewardInfos[0].accRewardPerShareslot (keccak256(0,6)+1 = 0x...b4f9) was already high and unchanged across blocks 84377205 -> 84377206:0x000...02ad891524ee4c9bf476a40d06b4d6cd36.rewardInfos[0].endRewardTimestamppre-state was stale (0x6250bfbc= 2022-04-08T23:05:32Z), far before attack block timestamp (2026-03-03T05:30:10Z).- After attacker sent 1 wei and called
streamReward(),rewardRatebecame exactly1e36 / 7 days = 1653439153439153439153439153439(slot0x...b4fapost value).
Deterministic payout equation (matches trace exactly):
- Staked LP:
0.015259494452769803 accRewardPerShare:911.232950638852218698941779818023669046(scaled by1e36)- Pending:
amount * acc / 1e36 - debt = 13.904954155454625145 WBNB(withdebt = 0) - Actual claim transfer in trace index 51:
13.904954155454625145 WBNB.
Attack Execution
High-Level Flow
- Attacker EOA deploys attack contract with
0.03 BNBseed. - Attack contract wraps BNB to WBNB and swaps into INUGAMI.
- It adds INUGAMI/WBNB liquidity to receive LP tokens.
- It stakes LP into
InugamiStakingwhile reward window is inactive. - It transfers
1 weiWBNB to staking and callsstreamReward()to reactivate reward accounting. - It calls
claim(attacker, [0])and receives13.904954155454625145 WBNB. - It unstakes LP, unwraps WBNB, and sends BNB profit back to the attacker EOA.
Detailed Call Trace
From trace_callTracer.json / decoded_calls.json:
idx 0CREATE:0x43c2...33f8->0x20a8...f989(attacker contract)idx 1CALLdeposit()(0xd0e30db0): attacker contract -> WBNBidx 6CALLswapExactTokensForTokensSupportingFeeOnTransferTokens(...)(0x5c11d795): attacker contract -> Pancake Routeridx 17CALLaddLiquidity(...)(0xe8e33700): attacker contract -> Pancake Routeridx 42CALLstake(uint256)(0xa694fc3a): attacker contract -> InugamiStaking, amount0x0036366dea4f340bidx 46CALLtransfer(address,uint256)(0xa9059cbb): attacker contract -> WBNB, send1 weito stakingidx 47CALLstreamReward()(0xf1603e30): attacker contract -> InugamiStakingidx 50CALLclaim(address,uint256[])(0x45718278): attacker contract -> InugamiStaking, args(_user=0x20a8...f989, pids=[0])idx 51CALLtransfer(address,uint256)(0xa9059cbb): staking -> WBNB transfer to attacker contract, amount0xc0f85221fdf6f579idx 52CALLunstake(uint256)(0x2e17de78): attacker contract -> InugamiStaking, same LP amount as stakeidx 55CALLwithdraw(uint256)(0x2e1a7d4d): attacker contract -> WBNBidx 57CALLvalue transfer: attacker contract -> attacker EOA
Financial Impact
Primary evidence source: funds_flow.json.
- WBNB drained from staking reserve via claim transfer:
13.904954155454625145 WBNB - Staking contract net WBNB change:
-13.904954155454625144 - Attacker net gain:
13.895262645782173309 BNB(after seed capital and in-tx flows) - Loss valuation from incident context: approximately
$8.75K
Who lost funds: staking reward reserve depositors (WBNB reward pool in InugamiStaking).
Evidence
- Receipt status:
0x1(success) - Block:
84377206(2026-03-03T05:30:10Z) - Transfer logs:
logIndex 0x46: attacker -> staking,1 weiWBNBlogIndex 0x47: staking -> attacker,13.904954155454625145 WBNB
- Pre/post storage (
trace_prestateTracer.json) forrewardInfos[0]:reservesslot0x...b4fb:13.904954155454625823->679endRewardTimestampslot0x...b4fc:1649459132->1773120610rewardRateslot0x...b4fa: updated to exact1e36 / 604800after 1 wei stream
- Consistency check:
pre_reserves + 1 wei - claim = 679 wei
Related URLs
- Transaction: https://bscscan.com/tx/0xa487da310b02dfa3e6da2d9b3c797b656957924f4b08e38ed256cfeed48dbbca
- Vulnerable staking: https://bscscan.com/address/0x2001144a0485b0b3748a167848cdd73837345d73
- Attacker contract: https://bscscan.com/address/0x20a8047fd8b23db7446041c32c442a77eb46f989
- WBNB: https://bscscan.com/address/0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c