Reproduced Exploit
Ouroboros: permanent `buildsPOL` flag locks staked positions forever
Chain
Other
Category
untagged
Date
Jan 1970
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: Ouroboros-security-review_2025-06-30. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.
Vulnerability classes: vuln/dos · vuln/logic
Reproduction: a faithful minimal reproduction of the vulnerable finding — the
require(!... .buildsPOL, 'E024')exit guards are reproduced verbatim (marked@>) with faithful minimal doubles (real ERC721 custody, real owner/stake accounting); local deploy, no fork.
Root cause#
Once a staked position is flagged buildsPOL (protocol-owned liquidity) the flag is permanent — no code path ever resets it to false. Both exit paths, decreaseLiquidity and withdrawToken, hard-require(!buildsPOL, 'E024'), so a POL position's underlying liquidity and its NFT are locked forever. The vulnerable lines, reproduced verbatim from the finding:
function decreaseLiquidity(
uint256 tokenId,
uint128 liquidity,
uint256 amount0Min,
uint256 amount1Min,
uint256 deadline
) external override returns (uint256 amount0, uint256 amount1) {
// Only the position's owner can decrease liquidity.
require(deposits[tokenId].owner == msg.sender, 'E020');
@> require(!deposits[tokenId].buildsPOL, 'E024');
}
function withdrawToken(
uint256 tokenId,
address to,
bytes memory data
) external override {
// Tokens with POL cannot be withdrawn.
@> require(!deposit.buildsPOL, 'E024');
}
function _calculateAndDistributeRewards(
IncentiveKey memory key,
Deposit memory deposit,
bytes32 incentiveId,
uint160 secondsPerLiquidityInsideInitialX128,
uint32 secondsInsideInitial,
uint128 liquidity
) private returns (uint256) {
Incentive storage incentive = incentives[incentiveId];
(, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside) =
key.pool.snapshotCumulativesInside(deposit.tickLower, deposit.tickUpper);
...
}
Why it's exploitable here#
A staker deposits a Uniswap v3 position NFT worth 1000e18 of underlying liquidity, then joins a POL-building incentive:
- Deposit —
onERC721Receivedrecords the staker as owner withbuildsPOL = false. - Stake —
stakeTokensetsbuildsPOL = true; this flag is never cleared anywhere in the contract. - Unstake —
unstakeTokendecrementsnumberOfStakesback to0but leavesbuildsPOLattrue. - Exit attempt —
decreaseLiquidityrevertsE024, andwithdrawTokenrevertsE024. - Harm — the
1000e18position NFT is stuck in the staker permanently. A control position that never staked (buildsPOLstillfalse) withdraws normally, proving the two verbatim guards — not the test setup — are the cause of the lock.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xce01759b…:
- L120 — Position NFT enters custody: Setup: depositing the Uniswap v3 position NFT triggers onERC721Received, recording the staker as owner with buildsPOL still false.
- L135 — Staker joins POL incentive: Setup: stakeToken checks the caller owns the position, then joins the POL incentive that permanently sets buildsPOL = true.
- L142 — Staker unstakes, flag stays set: Setup: unstakeToken drops numberOfStakes back to zero but never resets buildsPOL, so the position stays permanently marked.
- L150 — Owner tries to decrease liquidity: Setup: the staker calls decreaseLiquidity to pull the underlying tokens back out of the still-marked position.
- L160 — Owner tries to withdraw the NFT: Setup: the staker instead calls withdrawToken to reclaim the position NFT itself now that numberOfStakes is zero.
- L170 — Permanent buildsPOL guard blocks exit: Root cause: require(!deposit.buildsPOL, 'E024') reverts because buildsPOL is set once at stake and never cleared, so no exit ever succeeds.
- L172 — NFT return path never reached: The delete and safeTransferFrom that would return the NFT sit just past the revert, so the position and its liquidity are locked forever.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 63445-h-01-stakers-may-fail-to-claim-all-incentives-pashov-audit-g_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: a POL-flagged position reverts E024 on both decreaseLiquidity and withdrawToken, locking the 1000e18 position NFT forever while a control non-POL position withdraws fine. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- No executable Forge reproduction is claimed; the historical source/toolchain was unavailable for this finding.
- AuditVault finding: Ouroboros-security-review_2025-06-30.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Ouroboros: permanent
buildsPOLflag locks staked positions forever". - Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
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.