# πŸ” Security Review β€” AughtNaughts ## Scope | | | |--------------------------|--------------------------------------------------------------------| | **Target** | `AughtNaughts.sol` β€” 1/1 onchain-art NFT + embedded reserve auction | | **Source origin** | Contract source pasted directly in the client's job description (not a git repo); saved to `src/AughtNaughts.sol`, 441 lines, `pragma ^0.8.24` | | **Dependencies** | OpenZeppelin `ERC721`/`ERC2981`/`Ownable`/`ReentrancyGuard`/`Base64`/`Strings` (v5-shaped API) and `./SSTORE2.sol` β€” **neither is present in the supplied source**; both are treated as trusted, already-audited external dependencies and are out of scope for this review | | **Methodology** | Three-phase audit: context mapping (protocol map + access-control inventory + threat catalog) β†’ breadth pass (5 checklist domains: general, precision-math, erc721, access-control, dos) β†’ depth pass (12 independent attacker-mindset agents, run blind to the breadth pass) β†’ cross-phase reconciliation | | **Confidence threshold** | 50 (findings below this bar are listed as Leads, not scored findings) | **Note for the client**: this target has been submitted for audit before; per our engagement policy every job runs a fully independent audit from scratch β€” the findings below come entirely from this run's own three-phase analysis, not from reuse of any prior report. --- ## Reconciliation Summary - **Breadth-phase findings**: 10 items (1 High, 2 Medium, 4 Low, 3 Informational) before cross-phase merge. - **Depth-phase findings**: 4 promoted findings (1 High, 2 Medium, 1 Low) from 12 independent agents, plus a shared set of confirmed-non-issues. - **Overlap**: 6 Β· **Breadth-only**: 2 (stranded-token rescue gap, returndata gas-griefing) Β· **Depth-only**: 1 (`setHiResData` mid-auction mutation) Β· **Re-examined leads kept**: 2 (mimeType injection escalated Lowβ†’Medium; timeBuffer/increment issue escalated to include a distinct overflow-DoS mechanism) Β· **Demoted**: 0. - **Coverage gate**: 17 external/public state-changing entrypoints in the access-control inventory, 17 addressed (by a finding or an explicit "examined, no issue" note). 8 threat-catalog rows, 8 answered. **Coverage holes closed this pass: 0** β€” both phases independently reached full coverage of the entrypoint/threat surface; reconciliation combined and cross-verified rather than filling gaps. - **Confidence floor**: 50. Three items below that bar are listed under **Leads**, not as scored findings. --- ## Access-Control Inventory | Function | Guard | Caller | Writes / moves | |---|---|---|---| | `writeChunks(batchId, chunks)` | `onlyOwner` (L155) | owner | deploys SSTORE2 chunk contracts, no `AughtNaughts` storage | | `mint(pointers, name, description, mimeType)` | `onlyOwner` (L174) | owner | `nextTokenId`, `_pieces[id]`, mints NFT to `owner()` | | `setHiResData(id, pointers)` | `onlyOwner` (L192) + append-once check (L195) | owner | `_pieces[id].hiResPointers` | | `startAuction(id, reserve, duration)` | `onlyOwner` (L282), `nonReentrant` | owner | `auctions[id]` (full overwrite), escrows token to `address(this)` | | `createBid(id)` | `nonReentrant` only β€” **no caller check** (L306) | **anyone** | `auctions[id].amount/bidder/endTime`, payable, refunds prior bidder | | `settleAuction(id)` | `nonReentrant` only β€” **no caller check** (L337) | **anyone** | `auctions[id].settled`, transfers NFT, pays ETH | | `cancelAuction(id)` | `onlyOwner` (L359), `nonReentrant`, only if zero bids | owner | `auctions[id].settled`, returns NFT | | `setTimeBuffer` / `setMinBidIncrementPercentage` | `onlyOwner`, **unbounded** | owner | globals read live by open auctions | | `setDefaultRoyalty` | `onlyOwner` | owner | ERC2981 default royalty | | views (`imageData`, `hiResData`, `imagePointers`, `hiResPointers`, `pieceInfo`, `tokenURI`, `tokenJSON`, `supportsInterface`) | none / existence check | anyone | β€” | | Inherited `transferOwnership` / `renounceOwnership` | `onlyOwner`, **one-step** (plain `Ownable`, not `Ownable2Step`), not overridden | owner | `Ownable._owner` | | Inherited `approve` / `setApprovalForAll` / `transferFrom` / `safeTransferFrom`Γ—2 | OZ internal auth checks | token owner/approved | ERC721 custody state | **Roles**: exactly one β€” `owner()`. Granted at deploy (`Ownable(msg.sender)`), transferable/renounceable one-step, unlocks all 9 owner-gated functions above and is the live-read destination for auction proceeds/token-returns (see Finding #1). **Unguarded (permissionless) state-changing entrypoints**: `createBid`, `settleAuction`, plus inherited `approve`/`setApprovalForAll`/`transferFrom`/`safeTransferFrom` (each scoped to tokens the caller owns/is approved for). No `receive()`/`fallback()`/withdraw function exists anywhere in the contract. --- ## Threat Model | Actor | Reaches | Could gain | Status | |---|---|---|---| | `owner()` | `renounceOwnership`/`transferOwnership` mid-auction | Redirect or permanently burn auction proceeds/token | **Finding #1 (High)** | | `owner()` | `setTimeBuffer`/`setMinBidIncrementPercentage` mid-auction | Neutralize anti-snipe protection or freeze out a bidder; or brick bidding entirely via overflow | **Finding #2 (Medium)** | | `owner()` | `mint`'s `mimeType` argument | Inject arbitrary JSON keys into token metadata rendered by marketplaces | **Finding #3 (Medium)** | | `owner()` | `setHiResData` while a token is escrowed/sold | Mutate token data after bidders have committed funds | **Finding #4 (Low)** | | Any address | plain `transferFrom` a token directly to the contract | Accidentally/adversarially strand a token permanently | **Finding #5 (Low)** | | WETH contract / misconfigured `weth` | uncapped `deposit`/`transfer` calls in the refund fallback | Block or silently drop a refund/payout | **Finding #6 (Low)**, invariant holds under a canonical WETH9 | | Bidder-controlled contract | outbid-refund call | Force refund down the WETH path; impose bounded extra gas cost on the next bidder | **Finding #7 (Low)** β€” refund path itself is not blockable | | Any address (well-funded) | repeated self-bidding | Delay settlement via anti-snipe re-extension | invariant holds β€” cost compounds 5%/round, self-limiting (see Leads) | | Arbitrary caller | `settleAuction` | Nothing beyond gas cost β€” permissionless by design | invariant holds | --- ## Findings [92] **1. `owner()` is read live at settlement β€” `renounceOwnership`/`transferOwnership` permanently burns proceeds or locks the NFT** `AughtNaughts.settleAuction` / `AughtNaughts.cancelAuction` Β· Confidence: 92 Β· **[both phases, corroborated by 8 independent agents]** **Description** Every payout/return path calls `owner()` live instead of using a value snapshotted when the auction started, so a standard, unmodified `renounceOwnership()` (or a routine `transferOwnership()`) made while an auction is open silently redirects or destroys its outcome β€” with no revert, no event indicating loss, and no recovery function anywhere in the contract. ```solidity // settleAuction() β€” no-bid path if (a.bidder == address(0)) { _transfer(address(this), owner(), tokenId); // L346 β€” reverts forever if owner()==address(0) emit AuctionSettled(tokenId, address(0), 0); } else { _transfer(address(this), a.bidder, tokenId); // L352 _safeTransferETHWithFallback(owner(), a.amount); // L353 β€” succeeds trivially against address(0), burning funds emit AuctionSettled(tokenId, a.bidder, a.amount); } ``` **Proof of Concept** 1. Owner starts an auction on `tokenId=7` (zero bids). Owner calls the inherited `renounceOwnership()`. `owner()` becomes `address(0)`. After `endTime`, anyone calls `settleAuction(7)`: L346's `_transfer(address(this), address(0), 7)` reverts every time (OZ v5 `ERC721._transfer` explicitly rejects `to==address(0)` with `ERC721InvalidReceiver`), and `cancelAuction` is `onlyOwner`-gated so no caller can ever satisfy it again β€” `tokenId=7` is permanently stuck in the contract with no rescue path. 2. Same setup but with a winning bid of `X` ETH. After the same `renounceOwnership()` call, `settleAuction` succeeds in transferring the NFT to the winner (L352, `a.bidder != address(0)`), but L353's `_safeTransferETHWithFallback(address(0), X)` executes `address(0).call{value: X, gas: 30_000}("")` β€” a plain value-call to a codeless address always returns `success = true` β€” so the entire winning bid is silently and irrecoverably burned, while `AuctionSettled` still emits as if the sale completed normally. 3. A non-renounce variant is equally real: owner calls plain, unguarded `transferOwnership(newAddr)` (routine key rotation, or a typo) while an auction is open β€” the next `settleAuction`/`cancelAuction` call sends the proceeds/token-return to `newAddr`, not to the address that actually ran the auction. **Fix** ```diff struct Auction { uint96 amount; uint96 reservePrice; uint40 startTime; uint40 endTime; address bidder; bool settled; + address beneficiary; } ... function startAuction(uint256 tokenId, uint96 reservePrice, uint40 duration) external onlyOwner nonReentrant { ... auctions[tokenId] = Auction({ amount: 0, reservePrice: reservePrice, startTime: start, endTime: start + duration, bidder: address(0), - settled: false + settled: false, + beneficiary: owner() }); ... } // settleAuction / cancelAuction: replace every owner() read in a transfer/payout with a.beneficiary ``` Optionally also override `renounceOwnership()` to revert while any `auctions[id]` is started-and-unsettled, and/or adopt `Ownable2Step` for `transferOwnership`. --- [78] **2. Global `timeBuffer`/`minBidIncrementPercentage` are not snapshotted per-auction β€” retroactively alter or brick in-flight auctions** `AughtNaughts.createBid` Β· Confidence: 78 Β· **[both phases]** **Description** Unlike `reservePrice`/`endTime` (fixed into the `Auction` struct at `startAuction`), `timeBuffer` and `minBidIncrementPercentage` are global variables read live by every currently-open auction, with no upper bound on either setter. Two distinct mechanisms follow from this one gap: - **(a) Fairness defeat**: the owner can set `minBidIncrementPercentage` to an extreme value (e.g. 255, the `uint8` max) mid-auction to price out a specific disfavored bidder, or set `timeBuffer=0` right before a bid lands inside the advertised anti-snipe window so the extension silently never fires β€” the exact interference `cancelAuction`'s `AuctionHasBids` guard exists to prevent, achieved via a side door those guards don't cover. - **(b) Overflow DoS**: the owner (maliciously, or via a unit mistake β€” e.g. entering raw seconds where minutes were intended) sets `timeBuffer` to a large-but-legal `uint40` value. The very next bid that lands inside the (now enormous) anti-snipe window computes `uint40(block.timestamp) + timeBuffer`, which β€” because Solidity ^0.8.x applies checked arithmetic even to fixed-width types β€” overflows `uint40` and reverts, permanently blocking every future bid on that auction until the owner lowers `timeBuffer` again. ```solidity bool extended = a.endTime - block.timestamp < timeBuffer; // L322 β€” live global read if (extended) { a.endTime = uint40(block.timestamp) + timeBuffer; // L324 β€” checked-arithmetic revert if timeBuffer is large emit AuctionExtended(tokenId, a.endTime); } ``` **Proof of Concept** `type(uint40).max = 1,099,511,627,775`. At `block.timestamp β‰ˆ 1,785,283,200` (2026-07-29), owner calls `setTimeBuffer(1_099_000_000_000)` β€” inside the legal `uint40` range, no bounds check exists in `setTimeBuffer`. On a normal 7-day auction (`duration=604800`), the very first bid satisfies `604800 < 1,099,000,000,000` (anti-snipe triggers), then computes `1,785,283,200 + 1,099,000,000,000 = 1,100,785,283,200`, which exceeds `type(uint40).max` by `1,273,655,425` β€” the addition reverts, the bid never lands, and the auction silently expires with zero bids/zero revenue. **Fix** ```diff function setTimeBuffer(uint40 newTimeBuffer) external onlyOwner { + require(newTimeBuffer <= 1 days, "timeBuffer too large"); timeBuffer = newTimeBuffer; emit TimeBufferUpdated(newTimeBuffer); } ``` Additionally, snapshot both `timeBuffer` and `minBidIncrementPercentage` into the `Auction` struct at `startAuction` time and reference the stored per-auction values in `createBid`, so a global parameter change cannot retroactively alter the terms of an already-running auction. (This mechanism is recoverable by the owner lowering the parameter again, unlike Finding #1's `renounceOwnership` path β€” hence Medium rather than High.) --- [75] **3. `mimeType` is interpolated unescaped in `tokenJSON` β€” arbitrary JSON-key injection into token metadata** `AughtNaughts.tokenJSON` Β· Confidence: 75 Β· **[both phases; escalated from an initial Low "malformed JSON" framing to a working injection PoC]** **Description** `name` and `description` are passed through `_escapeJSON` before being interpolated into the metadata JSON, but `mimeType` is spliced in raw: ```solidity '","image":"data:', p.mimeType, // L267 β€” not escaped, unlike name/description above ";base64,", Base64.encode(SSTORE2.readAll(p.pointers)), '"}' ``` Because the base64 alphabet (`A-Za-z0-9+/=`) contains no characters requiring JSON escaping, a `mimeType` value can close the `"image"` string early and inject additional, attacker-chosen top-level keys that survive to the end of the object β€” a genuine metadata-injection primitive, not just JSON breakage. **Proof of Concept** Owner mints a token with `mimeType = 'image/png","external_url":"https://evil.example/claim","x":"y'`. `tokenJSON` renders: ```json {"name":"...","description":"...","image":"data:image/png","external_url":"https://evil.example/claim","x":"y;base64,"} ``` This is valid JSON containing an injected `external_url` key that marketplaces render as a clickable link. The same technique via an `animation_url` key set to a `data:text/html,