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) and claim(address,uint256[])
  • Attack-path selectors:
    • stake(uint256) = 0xa694fc3a
    • streamReward() = 0xf1603e30
    • claim(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 rewardDebt should be initialized against current accRewardPerShare, 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 resetting accRewardPerShare; claim() calls _updatePools(true, user) and computes pending as balance * accRewardPerShare - debt.
  • Since debt stayed zero, the attacker claims historical accumulator value.

On-chain proof from this tx:

  • rewardInfos[0].accRewardPerShare slot (keccak256(0,6)+1 = 0x...b4f9) was already high and unchanged across blocks 84377205 -> 84377206: 0x000...02ad891524ee4c9bf476a40d06b4d6cd36.
  • rewardInfos[0].endRewardTimestamp pre-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(), rewardRate became exactly 1e36 / 7 days = 1653439153439153439153439153439 (slot 0x...b4fa post value).

Deterministic payout equation (matches trace exactly):

  • Staked LP: 0.015259494452769803
  • accRewardPerShare: 911.232950638852218698941779818023669046 (scaled by 1e36)
  • Pending: amount * acc / 1e36 - debt = 13.904954155454625145 WBNB (with debt = 0)
  • Actual claim transfer in trace index 51: 13.904954155454625145 WBNB.

Attack Execution

High-Level Flow

  1. Attacker EOA deploys attack contract with 0.03 BNB seed.
  2. Attack contract wraps BNB to WBNB and swaps into INUGAMI.
  3. It adds INUGAMI/WBNB liquidity to receive LP tokens.
  4. It stakes LP into InugamiStaking while reward window is inactive.
  5. It transfers 1 wei WBNB to staking and calls streamReward() to reactivate reward accounting.
  6. It calls claim(attacker, [0]) and receives 13.904954155454625145 WBNB.
  7. 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 0 CREATE: 0x43c2...33f8 -> 0x20a8...f989 (attacker contract)
  • idx 1 CALL deposit() (0xd0e30db0): attacker contract -> WBNB
  • idx 6 CALL swapExactTokensForTokensSupportingFeeOnTransferTokens(...) (0x5c11d795): attacker contract -> Pancake Router
  • idx 17 CALL addLiquidity(...) (0xe8e33700): attacker contract -> Pancake Router
  • idx 42 CALL stake(uint256) (0xa694fc3a): attacker contract -> InugamiStaking, amount 0x0036366dea4f340b
  • idx 46 CALL transfer(address,uint256) (0xa9059cbb): attacker contract -> WBNB, send 1 wei to staking
  • idx 47 CALL streamReward() (0xf1603e30): attacker contract -> InugamiStaking
  • idx 50 CALL claim(address,uint256[]) (0x45718278): attacker contract -> InugamiStaking, args (_user=0x20a8...f989, pids=[0])
  • idx 51 CALL transfer(address,uint256) (0xa9059cbb): staking -> WBNB transfer to attacker contract, amount 0xc0f85221fdf6f579
  • idx 52 CALL unstake(uint256) (0x2e17de78): attacker contract -> InugamiStaking, same LP amount as stake
  • idx 55 CALL withdraw(uint256) (0x2e1a7d4d): attacker contract -> WBNB
  • idx 57 CALL value 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 wei WBNB
    • logIndex 0x47: staking -> attacker, 13.904954155454625145 WBNB
  • Pre/post storage (trace_prestateTracer.json) for rewardInfos[0]:
    • reserves slot 0x...b4fb: 13.904954155454625823 -> 679
    • endRewardTimestamp slot 0x...b4fc: 1649459132 -> 1773120610
    • rewardRate slot 0x...b4fa: updated to exact 1e36 / 604800 after 1 wei stream
  • Consistency check: pre_reserves + 1 wei - claim = 679 wei