Reproduced Exploit
Paladin Valkyrie — uncapped poolRewards array makes claim run out of gas
addReward pushes any address onto poolRewards. claim loops that array and updates storage for every entry. A balance-change notification forwards 160000 gas, which is enough for the one real reward and not enough for six extra entries. The inner call runs out of gas, claim reverts, and 1,000 RWD st…
Loss
1,000 ether of reward tokens stay in the pool. The LP's claim reverts and pays 0
Chain
Ethereum
Category
dos
Date
Mar 2025
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 61554-poolrewards-arrays-can-grow-without-bounds-resulting-in-dos. Standalone Foundry PoC and full write-up: 61554-poolrewards-arrays-can-grow-without-bounds-resulting-in-dos_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/dos/unbounded-loop · vuln/dos/frozen-funds
Key info#
| Field | Value |
|---|---|
| Loss | 1,000 ether of reward tokens stay in the pool. The LP's claim reverts and pays 0 |
| Vulnerable contract | PoolRewards._updateAllUserState |
| Attacker EOA | 0x1111111111111111111111111111111111111111 |
| Attack contract | Exploit |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2025-03 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | Permissionless push into an array that claim loops |
TL;DR#
addReward pushes any address onto poolRewards. claim loops that array and updates storage for every entry. A balance-change notification forwards 160000 gas, which is enough for the one real reward and not enough for six extra entries. The inner call runs out of gas, claim reverts, and 1,000 RWD stay in the pool. Capping the array at one token rejects the second push, and the same 160000 gas stipend pays the LP the 1,000 RWD.
Background#
Cyfrin reviewed Paladin Valkyrie on 2025-03-12. Reward distribution is permissionless, so poolRewards[id] can grow without a bound. _updateAllUserState walks the whole array and is used by claim and by notifyBalanceChange / notifyTransfer. Those notifications are what Uniswap hooks call. A long array makes the notification run out of gas, which reverts the hook and blocks the claim. This reduction uses six spam entries, not thousands, and a 160000 gas stipend standing in for the gas a notifier forwards. The loop itself is the quoted one.
The vulnerable code#
function _updateAllUserState(address account) internal {
address[] memory _tokens = poolRewards;
uint256 _length = _tokens.length;
// @> VULN: poolRewards has no cap, so this loop exhausts the notifier's gas
for (uint256 i; i < _length; i++) {
_updateRewardState(_tokens[i], account);
}
}
function addReward(address rewardToken) external {
+ if (poolRewards.length >= CAP) revert RewardCap(CAP);
poolRewards.push(rewardToken);
}
Root cause#
The array is append-only and permissionless. Every claim and every transfer notification iterates it and writes three storage slots per entry. Gas therefore grows with the number of spam tokens. Once that cost exceeds the gas the caller forwards, the call reverts and the reward balance cannot be transferred.
Preconditions#
- One real reward token is registered and the pool holds 1,000 RWD.
- The attacker pushes six further addresses. No token deployment is required, because each iteration only writes storage keyed by the address.
claimforwardsHOOK_GAS = 160000into the loop. That budget pays a single-token claim and reverts on seven entries.
Attack walkthrough#
- The real reward token is pushed and 1,000 RWD are transferred to the pool.
- The attacker pushes addresses
1through6.rewardCountis 7. claimcalls the inner loop with 160000 gas.- The loop starts updating every entry and runs out of gas before the transfer.
- The outer claim reverts. The pool still holds 1,000 RWD. The LP holds 0.
Diagrams#
Impact#
The LP cannot claim. 1,000 RWD remain in the pool after the reverting call. With the array capped at one token, the attacker's second addReward reverts and claim transfers the 1,000 RWD to the LP.
PoC#
cd 61554-poolrewards-arrays-can-grow-without-bounds-resulting-in-dos_exp
forge test -vv
test_exploit_six_spam_rewards_revert_claim asserts seven entries, a reverted claim, and a pool balance of 1,000 ether. test_control_cap_lets_claim_pay asserts the second push reverts with RewardCap(1) and the LP receives 1,000 ether.
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/61554-poolrewards-arrays-can-grow-without-bounds-resulting-in-dos.md
- Cyfrin report: https://github.com/solodit/solodit_content/blob/main/reports/Cyfrin/2025-03-12-cyfrin-paladin-valkyrie-v2.0.md
- Fix noted by the report: reward-token allowlist in commit
b95ae97a67eca4e1e33cafc636ae3d3d3c1af088on PaladinFinance/Valkyrie
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 61554-poolrewards-arrays-can-grow-without-bounds-resulting-in-dos_exp (evm-hack-registry mirror).
- AuditVault finding: 61554-poolrewards-arrays-can-grow-without-bounds-resulting-in-dos.
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.