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 callsstakingToken.transfer(msg.sender, _balances[msg.sender]), but the entire function body contains zero SSTORE instructions. The balance mapping is never modified. AfterforceExit()returns,_balances[msg.sender]still holds the original staked amount.Why this matters: Because
_balances[msg.sender]is never zeroed, every subsequent call toforceExit()by the same address transfers the same amount again. An attacker can callforceExit()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:
| Step | Normal (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. Transfer | transfer(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
- Attacker EOA (
0x734e1bda) deploys a main attack contract (0xcabba5f0) which immediately deploys a helper contract (0x0dc0c0e0). - The main contract calls the helper’s trigger function (
0x682aa435). - 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). - Inside the flash swap callback (
pancakeCall), the helper approves the staking proxy and callsstake(1,197,945 HPAY). After the 2% stake tax,_balances[helper]is set to ~1,173,986 HPAY. - 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. - The helper swaps the accumulated HPAY for WBNB via PancakeSwap Router V2 using
swapExactTokensForETHSupportingFeeOnTransferTokens(HPAY -> BTCB -> WBNB). - The helper repays the flash swap by transferring HPAY back to the pair contract.
- 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):
| Address | Role | HPAY Net Change |
|---|---|---|
0x6e30c17d (Staking Proxy) | Victim | -57,525,318 HPAY |
0xf603ae6e (HPAY/BTCB Pair) | DEX pair | +55,103,473 HPAY |
0x50720e10 | Fee recipient 1 | +1,204,652 HPAY |
0xc405c35c | Fee recipient 2 | +1,204,652 HPAY |
0x6ff5a4e3 | Stake fee address | +23,959 HPAY |
0x0dc0c0e0 (Attacker Helper) | Attacker contract | 0 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 aprocessFee()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()=0x67acc704stake(uint256)=0xa694fc3atransfer(address,uint256)=0xa9059cbbstakingToken()=0x72f702f3pancakeCall(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.
Related URLs
- Transaction: https://bscscan.com/tx/0x5f2ea6cb43d14986188fa2f474d9e22502fa95cc76cab72cd6ba1ba146ed137f
- Staking Proxy: https://bscscan.com/address/0x6e30c17d2554dca5a1ac178939764c6bf61ab95a
- Staking Implementation: https://bscscan.com/address/0xbe189fe9f84ca531cd979630e1f14757b88dd80d
- HPAY Token: https://bscscan.com/address/0xc75aa1fa199eac5adabc832ea4522cff6dfd521a
- Attacker EOA: https://bscscan.com/address/0x734e1bda62e779878f6c6f9f42d793badf247244