Reproduced Exploit
Flayer protected unlocks get cheaper after the listing count is checkpointed too late
unlockProtectedListing burns the fee from the current checkpoint, then calls dropAndCheckpoint, which stores count - 1. The next protected listing in the same collection is priced off that lower count. Two listings that should each cost 0.402 cost 0.402 and 0.401. The caller keeps 0.001 of the 0.80…
Loss
0.001 collection tokens kept on the second unlock. Fair charge for both listings is 0.804. The protocol recei…
Chain
Ethereum
Category
logic
Date
Aug 2024
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: 41745-h-7-user-can-pay-less-protected-listing-fees-sherlock-flayer. Standalone Foundry PoC and full write-up: 41745-h-7-user-can-pay-less-protected-listing-fees-sherlock-flayer_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/fee-calculation · vuln/logic/incorrect-order-of-operations
Key info#
| Field | Value |
|---|---|
| Loss | 0.001 collection tokens kept on the second unlock. Fair charge for both listings is 0.804. The protocol receives 0.803 |
| Vulnerable contract | ProtectedListings.unlockProtectedListing |
| Attacker EOA | 0x1111111111111111111111111111111111111111 |
| Attack contract | Exploit at 0x15452ec016c4dc8c549e7fe6ff4b26324ea8b7a4 |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2024-08 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | Utilization checkpoint is written after listingCount has already decreased |
TL;DR#
unlockProtectedListing burns the fee from the current checkpoint, then calls dropAndCheckpoint, which stores count - 1. The next protected listing in the same collection is priced off that lower count. Two listings that should each cost 0.402 cost 0.402 and 0.401. The caller keeps 0.001 of the 0.804 they funded.
Background#
Sherlock's August 2024 Flayer contest, issue 243, reported by jsmi and dany.armstrong90. ProtectedListings.unlockProtectedListing decremented listingCount and only afterwards wrote the utilization checkpoint that unlockPrice reads. Unlocking several protected listings in one collection made every unlock after the first cheaper than the first.
The vulnerable code#
feesTaken += fee;
delete protectedListings[_tokenId];
listingCount = this.dropAndCheckpoint(listingCount);
// ...
// @> VULN: checkpointCount is stored as count - 1, the listing count after this unlock
require(msg.sender == address(this), "self");
next = count - 1;
checkpointCount = next;
unlockPrice is 0.4 ether + checkpointCount * 0.001 ether. With two listings the first call still sees checkpoint 2 and burns 0.402. The checkpoint is then stored as 1, so the second call burns 0.401.
Writing the checkpoint before the decrement, which is what ProtectedListingsFixed does, charges 0.402 twice.
Root cause#
The fee for a protected listing is a function of how many protected listings the collection still has. That count is updated, and the checkpoint that later reads consume is taken from the updated count, before the next listing is priced. Every subsequent unlock in the batch is one PER_LISTING cheaper than the listing that was just closed.
Preconditions#
- At least two protected listings exist in the same collection. This run opens exactly two, both owned by the caller.
- The caller holds 0.804 of the collection token, which is the fair fee for both unlocks at the original count.
- Auto-checkpointing uses
listingCountwith no separate utilization snapshot from before the decrement.
Attack walkthrough#
createTwosetslistingCountandcheckpointCountto 2.- The caller mints and approves 0.804 and unlocks token 0. The fee is 0.402.
dropAndCheckpointstores checkpoint 1.- Token 1 unlocks at 0.401.
- The caller still holds 0.001.
feesTakenis 0.803. - The fixed contract checkpoints first. Both fees are 0.402 and the caller keeps 0.
Diagrams#
Impact#
The collection's fee sink is underpaid by 0.001 token for every extra protected listing unlocked after the first in the same checkpoint window. Here the shortfall is 0.001 on a 0.804 fair charge. The tokens stay with the lister instead of being burned as fees.
Remediation#
Write the utilization checkpoint, then decrement listingCount. That is the order in ProtectedListingsFixed, and it makes both unlocks cost 0.402.
How to reproduce#
cd 41745-h-7-user-can-pay-less-protected-listing-fees-sherlock-flayer_exp
forge test -vv
test_exploit_second_unlock_pays_less_fee asserts fees 0.402 and 0.401 and a 0.001 balance left on the caller. test_control_checkpoint_before_decrement_charges_both asserts both fees are 0.402 and the caller keeps nothing. Captured output is output.txt.
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/41745-h-7-user-can-pay-less-protected-listing-fees-sherlock-flayer.md
- Sherlock judging issue 243: https://github.com/sherlock-audit/2024-08-flayer-judging/issues/243
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 41745-h-7-user-can-pay-less-protected-listing-fees-sherlock-flayer_exp (evm-hack-registry mirror).
- AuditVault finding: 41745-h-7-user-can-pay-less-protected-listing-fees-sherlock-flayer.
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.