Reproduced Exploit

IntentX: claim() reads pending rewards for the zero address, then wipes the caller's real balance unpaid

Jan 1970Otheruntagged3 min read

Chain

Other

Category

untagged

Date

Jan 1970

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 59427-user-pending-rewards-can-never-be-paid-out-quantstamp-intent. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.


Vulnerability classes: vuln/reward-accounting · vuln/uninitialized-read · vuln/reward-loss

Reproduction: a faithful minimal reproduction of the vulnerable finding — the vulnerable function is reproduced verbatim (marked @>) with faithful minimal doubles; local deploy, no fork.

Root cause#

claim() reads _amountOut = pendingRewards[_owner] (line 89) while _owner is still the zero address (== 0), accumulates only the per-token rewards, then sets _owner to the caller (line 92) and wipes pendingRewards[_owner] = 0 (line 93). The caller's real accrued pending balance is never included in the payout total and is zeroed — the staker permanently loses their rewards.

SOLIDITY
    function claim(uint256 _tokenId) external returns (uint256) {
        address _owner;
        uint256 _amountOut = pendingRewards[_owner]; // @> reads pendingRewards[address(0)] == 0, not the caller's balance
        // add up the rewards of the xINTX token to the (wrongly-zero) pending total

Why it's exploitable here#

  • _amountOut = pendingRewards[_owner] executes before _owner is resolved to the caller (it reads pendingRewards[address(0)] == 0).
  • The payout total therefore never includes the caller's accrued rewards.
  • pendingRewards[_owner] = 0 then wipes the caller's real balance, so the rewards can never be claimed again.

Attack path#

flowchart TD A["Staker accrues 1,000 pending rewards"] --> B["claim() reads pendingRewards[address(0)] == 0"] B --> C["_owner set to caller only after the read"] C --> D["Payout total omits the caller's pending"] D --> E["pendingRewards[caller] wiped to 0"] E --> F["Staker permanently loses 1,000 rewards"]

Marked-line walkthrough (Playground)#

The EVM Playground pins each step to the exact executed source line in StakedINTX:

  1. Line 84 — _ownerOf(_tokenId) returns msg.sender — but only used AFTER the pending balance was already read for address(0).
  2. Line 91VULN. _amountOut accumulates only tokenRewards; the caller's pendingRewards (read as 0 on line 89 for address(0)) is never included.
  3. Line 94 — rewardToken.transfer pays _amountOut, and the caller's real pending balance was wiped to 0 (line 93) — permanently lost.

PoC#

Registry (Foundry, local deploy — exploit path + a fixed-variant control):

BASH
cd 59427-user-pending-rewards-can-never-be-paid-out-quantstamp-inte_exp
forge test -vv

Expected: both tests PASS — the exploit test asserts the caller receives 0 while 1,000 was accrued and wiped; the fixed order pays the caller their full pending balance. The browser EVM Playground is served at /hacks/59427-user-pending-rewards-can-never-be-paid-out-quantstamp-inte/.

Remediation#

Resolve _owner to the caller BEFORE reading pendingRewards, include that balance in the payout, then zero it.

References#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.