M-04 _approve Permits Recording Approvals for Non-existent Tokens, Breaking Downstream Circuits
Severity: Medium
Source: Midnight Foundation #2 — OpenZeppelin Compact Contracts Audit, Release 0.3.0-alpha.1 (2026-08-18)
The exported _approve circuit of NonFungibleToken performs its approver validation only when the auth argument is non-zero: the token-existence check sits inside the guarded branch, while the approval write executes unconditionally. The zero-auth path is documented as a deliberate escape hatch, and its requirements list no condition that tokenId exist.
The mock contract MockNonFungibleTokenexports the _approve circuit, bypassing the generation of auth through computeAccountId as implemented in approve circuit. Consequently, following the mock contract implementation, an approval can be recorded for a token that does not exist, and the invariant that a populated approval implies an existing token no longer holds. That invariant is exactly what the module relies upon elsewhere: a comment in _unsafeTransferFrom states that supplying an auth argument verifies token existence and that the returned previous owner therefore need not be checked against zero. Two exploits follow. In the first, an approval is planted on an unminted identifier and the approvee calls the public transferFrom or _unsafeTransferFrom with the zero account as the source: the update circuit reads the owner as zero, passes authorization on the stale approval, skips the balance decrement, credits the recipient and writes the owner entry, minting the token without mint authorization and permanently blocking the composer's own gated mint of that identifier. In the second, an approval is planted on an identifier about to be minted legitimately; because the update circuit clears approvals only when the source is non-zero, a mint never clears the approval, so the planted approval survives and the planter transfers the freshly minted token away from its owner.
Consider enforcing token existence independently of approval state. For example, make _checkAuthorized revert for owner == Utils_zeroAccount() unconditionally (before evaluating approvals), and explicitly reject fromAddress == Utils_zeroAccount() in transferFrom / _unsafeTransferFrom. In addition, consider updating the mock contracts by removing all internal circuits that are not supposed to be exported to avoid integration issues for composers extending the contract.
M-04
_approvePermits Recording Approvals for Non-existent Tokens, Breaking Downstream CircuitsSeverity: Medium
Source: Midnight Foundation #2 — OpenZeppelin Compact Contracts Audit, Release 0.3.0-alpha.1 (2026-08-18)
The exported
_approvecircuit ofNonFungibleTokenperforms its approver validation only when theauthargument is non-zero: the token-existence check sits inside the guarded branch, while the approval write executes unconditionally. The zero-authpath is documented as a deliberate escape hatch, and its requirements list no condition thattokenIdexist.The mock contract
MockNonFungibleTokenexports the_approvecircuit, bypassing the generation ofauththroughcomputeAccountIdas implemented in approve circuit. Consequently, following the mock contract implementation, an approval can be recorded for a token that does not exist, and the invariant that a populated approval implies an existing token no longer holds. That invariant is exactly what the module relies upon elsewhere: a comment in_unsafeTransferFromstates that supplying anauthargument verifies token existence and that the returned previous owner therefore need not be checked against zero. Two exploits follow. In the first, an approval is planted on an unminted identifier and the approvee calls the publictransferFromor_unsafeTransferFromwith the zero account as the source: the update circuit reads the owner as zero, passes authorization on the stale approval, skips the balance decrement, credits the recipient and writes the owner entry, minting the token without mint authorization and permanently blocking the composer's own gated mint of that identifier. In the second, an approval is planted on an identifier about to be minted legitimately; because the update circuit clears approvals only when the source is non-zero, a mint never clears the approval, so the planted approval survives and the planter transfers the freshly minted token away from its owner.Consider enforcing token existence independently of approval state. For example, make _checkAuthorized revert for owner == Utils_zeroAccount() unconditionally (before evaluating approvals), and explicitly reject fromAddress == Utils_zeroAccount() in transferFrom / _unsafeTransferFrom. In addition, consider updating the mock contracts by removing all internal circuits that are not supposed to be exported to avoid integration issues for composers extending the contract.