Reproduced Exploit
Valkyrie — a permissionless reward notify locks staked LP
depositRewards has no caller check and no reward-token allowlist. A notifier deposits type(uint96).max * 1 weeks, which stores ratePerSec = type(uint96).max.
Chain
Other
Category
arithmetic
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: 61555-permissionless-reward-distribution-can-be-abused-to-cause-fu. Standalone Foundry PoC and full write-up: 61555-permissionless-reward-distribution-can-be-abused-to-cause-fu_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/arithmetic/overflow · vuln/dos/frozen-funds · vuln/access-control/missing-auth
Reproduction: a self-contained Foundry PoC that compiles and runs offline with only
forge-std— no fork and no RPC. Trace: output.txt. PoC: test/61555-permissionless-reward-distribution-can-be-abused-to-cause-fu_exp.sol.
Key info#
| Impact | HIGH — the staker receives 0 of 1 LP. Claim and remove both revert, so the 1 LP stays in the incentive logic |
| Protocol | Paladin Valkyrie incentive logic |
| Vulnerable code | _updateRewardState downcasts _newRewardPerToken() to uint96 |
| Bug class | Permissionless depositRewards sets ratePerSec to uint96 max, then the index update reverts |
| Finding | Cyfrin (Draiakoo) — Paladin Valkyrie, 12 March 2025 · id 61555 |
| Report | 2025-03-12-cyfrin-paladin-valkyrie-v2.0.md |
| Source | AuditVault finding |
| Status | Fixed in Valkyrie commit b95ae97 (reward-token allowlist). Reproduced here as a local synthetic. |
| Compiler | solc 0.8.27, cancun, via IR |
TL;DR#
depositRewards has no caller check and no reward-token allowlist. A notifier deposits type(uint96).max * 1 weeks, which stores ratePerSec = type(uint96).max.
The index added on the next update is
((now - lastUpdateTime) * ratePerSec * UNIT) / totalLiquidity
With UNIT and the stake both equal to 1 LP, two seconds of accrual equal 2 * uint96 max. The downcast to uint96 reverts. removeLiquidity and claim both take that path, so the staker cannot exit and is paid nothing.
nowTs replaces block.timestamp because the playground keeps one timestamp for the whole replay. The two-second gap is advance(2), the same gap as the report's skip(2).
The vulnerable code#
Pre-fix body of the index update, reduced to one pool and one reward token:
function _updateRewardState(address account) internal {
// @> VULN: permissionless uint96-max rate makes this downcast revert and brick the pool
uint96 newRewardPerToken = toUint96(_newRewardPerToken());
rewardData.rewardPerTokenStored = newRewardPerToken;
}
function _newRewardPerToken() internal view returns (uint256) {
uint256 _totalSupply = totalLiquidity;
RewardData memory _state = rewardData;
if (_totalSupply == 0) return _state.rewardPerTokenStored;
uint256 lastUpdateTimeApplicable = nowTs < _state.endTimestamp ? nowTs : _state.endTimestamp;
return _state.rewardPerTokenStored
+ ((((lastUpdateTimeApplicable - _state.lastUpdateTime) * _state.ratePerSec) * UNIT) / _totalSupply);
}
The fixed logic rejects depositRewards unless msg.sender is the reward admin, and it caps the duration at 4 weeks.
Root cause#
The stored reward index is a uint96, but the per-second rate is chosen by whoever calls depositRewards. A rate of uint96 max overflows that index as soon as the position has accrued for more than one second at 1 LP of liquidity. Every later update reverts, including the update inside remove and claim.
Preconditions#
- A staker has already deposited 1 LP.
depositRewardsis callable by anyone, with any reward token.- The notified amount is
type(uint96).max * 1 weeksand the duration is 1 week, so the rate is exactlytype(uint96).max. - At least two seconds pass before the staker updates. Here that is
advance(2).
Attack walkthrough#
- Mint 1 LP to the staker and stake it. The logic contract holds 1 LP.
- Mint
type(uint96).max * 1 weeksof a worthless reward token and calldepositRewardsfor 1 week. - Read
ratePerSec. It istype(uint96).max. - Advance
nowTsby 2 seconds. removeLiquidity(1 LP)updates the index, theuint96downcast reverts, and the LP is not transferred.claimhits the same downcast and reverts. The staker's LP balance is 0. The logic contract still holds 1 LP.
Control: depositRewards requires the reward admin and a duration of at most 4 weeks. The same max-rate notify from a non-admin reverts. The staker then withdraws 1 LP and the logic contract's LP balance is 0.
Diagrams#
Impact#
The staker cannot remove liquidity and cannot claim. They receive 0 of 1 LP, and that 1 LP remains in the incentive logic. The same revert blocks every later reward update on the pool.
Remediation#
Restrict depositRewards to an allowlisted reward token or an allowlisted caller, which is what Valkyrie shipped in commit b95ae97. A duration cap alone also stops this particular rate, but the report's fix is the allowlist.
Sources#
Taxonomy#
severity/highvuln/arithmetic/overflow·vuln/dos/frozen-funds·vuln/access-control/missing-auth
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 61555-permissionless-reward-distribution-can-be-abused-to-cause-fu_exp (evm-hack-registry mirror).
- AuditVault finding: 61555-permissionless-reward-distribution-can-be-abused-to-cause-fu.
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.