Skip to content

Fix: ERC-4494 permit() does not consume the token nonce (signature replay / unrevocable approvals)#295

Open
thistehneisen wants to merge 1 commit into
immutable:mainfrom
thistehneisen:fix/erc4494-permit-nonce
Open

Fix: ERC-4494 permit() does not consume the token nonce (signature replay / unrevocable approvals)#295
thistehneisen wants to merge 1 commit into
immutable:mainfrom
thistehneisen:fix/erc4494-permit-nonce

Conversation

@thistehneisen
Copy link
Copy Markdown

Fix: ERC-4494 permit() does not consume the token nonce — signatures are replayable and approvals unrevocable

Summary

ERC721Permit._permit() (and its ERC721HybridPermit / ERC721HybridPermitV2 copies) never increments the per-token nonce. The nonce is only incremented in _transfer(). Because _buildPermitDigest() derives the EIP-712 digest from _nonces[tokenId], a permit signature stays valid for as long as the token is not transferred.

Consequences:

  1. Replayable signatures. A single permit signature can be submitted any number of times.
  2. Unrevocable approvals. After an owner signs a permit, calling approve(address(0), tokenId) does not durably revoke access: anyone can replay the original (still-valid) signature to re-instate the approval. The owner's only way to invalidate the signature is to transfer the token (the sole nonce bump), which is not a revocation mechanism any user or wallet UI expects.

This deviates from ERC-4494, which states the permit function MUST increment the nonce of tokenId. The bug is present in the V1 contracts and persists unchanged into V2.

Severity

High — integrity/confidentiality of asset ownership. An owner who uses the contract's advertised gasless-permit feature once, then later "revokes" the approval, can have the NFT transferred out from under them. No privileged roles and no atypical configuration are required; the only mitigating factor is a short permit deadline, while long/maximum deadlines are common in real integrations (and in this repo's own tests).

Affected code

  • contracts/token/erc721/abstract/ERC721Permit.sol_permit() / _buildPermitDigest() (root cause)
  • contracts/token/erc721/abstract/ERC721HybridPermit.sol — same logic
  • contracts/token/erc721/abstract/ERC721HybridPermitV2.sol — same logic (issue carried into V2)

Inherited by the shipped presets ImmutableERC721, ImmutableERC721MintByID, ImmutableERC721V2.

Note: contracts/token/erc1155/abstract/ERC1155Permit.sol is not affected — it correctly increments its nonce inside the digest builder and checks the owner. The ERC-721 path should match that behaviour.

Root cause

ERC721Permit.sol:

// nonce is incremented ONLY on transfer
function _transfer(address from, address to, uint256 tokenId) internal virtual override(ERC721) {
    _nonces[tokenId]++;
    super._transfer(from, to, tokenId);
}

function _permit(address spender, uint256 tokenId, uint256 deadline, bytes memory sig) internal virtual {
    if (deadline < block.timestamp) revert PermitExpired();
    bytes32 digest = _buildPermitDigest(spender, tokenId, deadline);
    // ... signature checks ...
    // _approve(spender, tokenId) is called on success
    // <-- nonce is NEVER incremented here
}

function _buildPermitDigest(...) internal view returns (bytes32) {
    return _hashTypedDataV4(
        keccak256(abi.encode(_PERMIT_TYPEHASH, spender, tokenId, _nonces[tokenId], deadline))
    );
}

Proof of concept

Verified against the real presets using the repository's own test harness (ERC721BaseTest). The test mints to an owner, has the owner sign a normal ERC-4494 permit, has the owner revoke via approve(address(0), …) repeatedly, shows each revocation is undone by replaying the original signature, and finally transfers the NFT away from the owner.

Result:

[PASS] test_PoC_IrrevocablePermit_NFTTheft()
Suite result: ok. 1 passed; 0 failed; 0 skipped

(PoC test reproduced in the security report; not included in this PR.)

Proposed fix

Increment the nonce on every successful permit, in both the ERC-1271 and EOA branches, before/at the point the approval is granted — matching ERC-4494 and the existing ERC1155Permit behaviour. Apply the equivalent change in all three files.

 function _permit(address spender, uint256 tokenId, uint256 deadline, bytes memory sig) internal virtual {
     if (deadline < block.timestamp) {
         revert PermitExpired();
     }

     bytes32 digest = _buildPermitDigest(spender, tokenId, deadline);

     // smart contract signature validation
     if (_isValidERC1271Signature(ownerOf(tokenId), digest, sig)) {
+        _nonces[tokenId]++;
         _approve(spender, tokenId);
         return;
     }

     address recoveredSigner = address(0);

     // EOA signature validation
     if (sig.length == 64) {
         recoveredSigner = ECDSA.recover(digest, bytes32(BytesLib.slice(sig, 0, 32)), bytes32(BytesLib.slice(sig, 32, 64)));
     } else if (sig.length == 65) {
         recoveredSigner = ECDSA.recover(digest, sig);
     } else {
         revert InvalidSignature();
     }

     if (_isValidEOASignature(recoveredSigner, tokenId)) {
+        _nonces[tokenId]++;
         _approve(spender, tokenId);
     } else {
         revert InvalidSignature();
     }
 }

Optional hardening (separate, spec-alignment): change _isValidEOASignature to require recoveredSigner == ownerOf(tokenId) instead of _isApprovedOrOwner(recoveredSigner, tokenId), matching ERC-4494 and the ERC-1271 branch. This is not required to close the replay/revocation issue but tightens the accepted signer set.

Backwards compatibility

  • Each permit now consumes one nonce, so a given signature is single-use (as ERC-4494 intends). Off-chain signing flows already include the current nonces(tokenId) value in the digest and read it fresh per signature, so correctly-implemented integrations are unaffected.
  • nonces(tokenId) will advance on permits in addition to transfers; any system that assumed the nonce only moved on transfer should read nonces() rather than infer it.

Test plan

  • Existing permit tests updated to expect the nonce to advance after permit().
  • New regression test: a permit signature cannot be replayed after a successful permit(); approve(address(0), tokenId) durably revokes (replay of the prior signature reverts with InvalidSignature).
  • Apply and test the identical change in ERC721Permit.sol, ERC721HybridPermit.sol, and ERC721HybridPermitV2.sol.

Nils Putnins / OffSeq Cybersecurity
npu@offseq.com / https://offseq.com / https://radar.offseq.com

permit() never incremented the per-token nonce (only _transfer did), so a
permit signature stayed valid until the token moved: signatures were
replayable and approve(address(0)) did not durably revoke an approval.

Increment _nonces[tokenId] on every successful permit, in both the
ERC-1271 and EOA branches, per ERC-4494, in ERC721Permit,
ERC721HybridPermit and ERC721HybridPermitV2. Add a regression test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant