Grego AI | Permanent ERC-4337 DoS for Safe v1.5.0 passkey accounts via Safe4337Module

Safe

Safe4337Module

Permanent ERC-4337 DoS for Safe v1.5.0 passkey accounts via Safe4337Module

A high-severity Safe v1.5.0 finding where passkey signatures failed ERC-4337 validation because dynamic bytes were undercounted.

Failure path

The module counted dynamic bytes for contract signatures but omitted the 128 dynamic bytes used by P256 signatures. Every valid P256 operation in this configuration exceeded the accepted maximum and returned SIG_VALIDATION_FAILED.

Impact and conditions

Affected accounts lost gasless, sponsored, and bundled execution until migration; direct Safe transactions still worked.

Our powerful (but humble) Grego AI searches for bugs when the devs are fast asleep … trawling repos like a Chinese fishing vessel until it caught another interesting bug … this time in @safe v1.5.0 that permanently denies ERC-4337 service to all Safe v1.5.0 accounts that use native P256 (passkey) owners.

This finding was discovered in the Safe modules repo:

https://github.com/safe-fndn/safe-modules

Team has acknowledged and fixed below:

https://github.com/safe-fndn/safe-modules/issues/522

If you would like to engage our AI security services for your protocol contact @0xriptide or @0xitsgreg to experience what our AI security solution can offer:

  • Extremely low false positive rate
  • “A-Tier” quality findings
  • 24 hour turnaround

Bug report follows …

Summary

The _checkSignaturesLength function in Safe4337Module fails to account for the 128-byte dynamic data portion of native secp256r1 (v == 2) signatures introduced in Safe v1.5.0. Because the function only recognizes signatureType == 0 (contract signatures) as having dynamic data, it systematically underestimates the expected length for any user operation containing a v == 2 signature, causing _validateSignatures to unconditionally return SIG_VALIDATION_FAILED. This permanently denies ERC-4337 service to all Safe v1.5.0 accounts that use native P256 (passkey) owners.

Description

Safe v1.5.0 introduced native secp256r1 signature support in checkNSignatures, using signature type v == 2. This signature type, like the existing contract signature type (v == 0), uses the s field as a data pointer to dynamic data stored after the static signature slots. Specifically, v == 2 signatures require exactly 128 bytes of fixed-size dynamic data containing the P256 signature components (r, s) and public key coordinates (qx, qy). The Safe’s checkNSignatures validates this explicitly:

// File: contracts/Safe.sol
} else if (v == 2) {
    currentOwner = address(uint160(uint256(r)));
    if (uint256(s) < requiredSignatures.mul(65)) revertWithError("GS021");
    if (uint256(s).add(128) > signatures.length) revertWithError("GS027");

The _checkSignaturesLength function in the Safe4337Module was designed to prevent malicious bundlers from padding extra bytes to signatures to waste gas. It iterates through each signer’s 65-byte slot, reads the signature type byte, and only adds dynamic data to maxLength when signatureType == 0. There is no handling for signatureType == 2:

// File: safe-modules-main/modules/4337/contracts/Safe4337Module.sol
uint8 signatureType = uint8(signatures[signaturePos + 0x40]);
if (signatureType == 0) {
    uint256 signatureOffset = uint256(bytes32(signatures[signaturePos + 0x20:]));
    uint256 signatureLength = uint256(bytes32(signatures[signatureOffset:]));
    maxLength += 0x20 + signatureLength;
}

For a threshold-1 Safe with a single secp256r1 owner, the valid signature is 193 bytes (65 static + 128 dynamic P256 data). However, _checkSignaturesLength computes maxLength = 1 * 0x41 = 65, sees signatureType == 2, skips the dynamic data accounting entirely, and evaluates 193 <= 65 which returns false.

The return value of _checkSignaturesLength directly controls the validation outcome in _validateSignatures. When the function returns false, validSignature is set to false. Critically, the subsequent try/catch block calling the Safe’s checkSignatures has an empty success handler — it does NOT set validSignature back to true on success:

// File: safe-modules-main/modules/4337/contracts/Safe4337Module.sol
bool validSignature = _checkSignaturesLength(signatures, ISafe(payable(userOp.sender)).getThreshold());
try ISafe(payable(userOp.sender)).checkSignatures(keccak256(operationData), operationData, signatures) {} catch {
    validSignature = false;
}

Even though the Safe’s checkSignatures succeeds (the P256 signature is cryptographically valid), validSignature remains false. This is then packed into the validation data with sigFailed = true:

// File: safe-modules-main/modules/4337/contracts/Safe4337Module.sol
validationData = _packValidationData(!validSignature, validUntil, validAfter);

The _packValidationData helper from the account-abstraction package encodes sigFailed = true as 1 in the lowest 160 bits, which the EntryPoint interprets as address(1) — the SIG_VALIDATION_FAILED sentinel. The EntryPoint then compares this against the expected address(0) and reverts the entire handleOps transaction with “AA24 signature error”.

// File: @account-abstraction/contracts/core/Helpers.sol
function _packValidationData(bool sigFailed, uint48 validUntil, uint48 validAfter) pure returns (uint256) {
    return (sigFailed ? 1 : 0) | (uint256(validUntil) << 160) | (uint256(validAfter) << (160 + 48));
}

The module’s README states compatibility with “Safe 1.4.1 or newer,” explicitly including Safe v1.5.0. The formal verification (@Certora) was performed with _checkSignaturesLength stubbed to ALWAYS(true) in both Safe4337Module.spec and ValidationDataLastBitOne.spec, so the function’s logic was never verified against v == 2 signatures. Similarly, the Certora harness canonicalSignature function in Account.sol only models v == 0 contract signatures. The test suite uses a Safe4337Mock with a different _validateSignatures implementation that does not call _checkSignaturesLength at all, so integration tests would not detect this failure either.

Attack Path

  1. A user creates a Safe v1.5.0 account with a native secp256r1 (P256/passkey) owner at an address derived from keccak256(qx, qy), enables the Safe4337Module as module and fallback handler, and deposits ETH for ERC-4337 prefunding.
  2. The user submits a valid ERC-4337 user operation through a bundler, with userOp.signature containing abi.encodePacked(validAfter, validUntil, signatures) where signatures is 193 bytes: {32-byte owner address}{32-byte offset=65}{1-byte v=2}{32-byte p256_r}{32-byte p256_s}{32-byte qx}{32-byte qy}.
  3. The EntryPoint calls validateUserOp on the Safe proxy, which forwards to Safe4337Module._validateSignatures, which calls _checkSignaturesLength(signatures, 1) computing maxLength = 65 and evaluating 193 <= 65 = false, returning isValid = false.
  4. _validateSignatures sets validSignature = false, and even though ISafe.checkSignatures succeeds for the valid P256 signature, the empty try {} block does not update validSignature, so _packValidationData(true, validUntil, validAfter) encodes SIG_VALIDATION_FAILED.
  5. The EntryPoint sees aggregator = address(1) != address(0) and reverts the entire handleOps transaction with “AA24 signature error”, preventing execution of the user operation.

Likelihood (high)

This vulnerability triggers deterministically on every ERC-4337 user operation that includes a native secp256r1 (v == 2) signature component. There is no randomness, timing window, or edge case — the _checkSignaturesLength function simply does not handle signatureType == 2, causing a 100% failure rate. The three preconditions — Safe v1.5.0 singleton, native P256 owner, and Safe4337Module enabled — all represent normal, intended protocol configurations that require no privileged roles, user mistakes, or unusual conditions. Safe v1.5.0 is the latest version, native P256 owners are a flagship feature for passkey integration, and the 4337 module is the standard ERC-4337 compatibility path. No attacker action is needed; any legitimate user matching this configuration experiences the failure when submitting user operations.

Impact (high)

All Safe v1.5.0 accounts using native secp256r1 owners with the Safe4337Module are completely unable to execute ERC-4337 user operations. This is a permanent condition on every deployed instance of the module — which is immutable with no admin functions or upgrade path — until a new version is deployed and users migrate. The impact is a total denial of ERC-4337 service, including gasless transactions, paymaster-sponsored operations, and bundled execution, for the affected user class. Users retain the ability to execute transactions via the Safe’s direct execTransaction function (which correctly handles v == 2), so funds are not permanently locked. However, users who were onboarded through gasless ERC-4337 flows using passkeys — the exact target demographic for this feature combination — may not hold ETH needed for direct transaction gas, creating a practical barrier to recovery until they acquire gas tokens through alternative means. The migration to a patched module also requires a direct transaction, compounding this difficulty for the affected user persona.

PoC

Add to safe-modules/modules/4337/test/erc4337/Safe4337Module.spec.ts under describe('validateUserOp', () => {:
it('test_POC_CheckSignaturesLengthIgnoresSecp256r1', async () => {
      const { user, safeModule, validator, entryPoint } = await setupTests()
      const validAfter = BigInt(ethers.hexlify(ethers.randomBytes(3)))
      const validUntil = validAfter + BigInt(ethers.hexlify(ethers.randomBytes(3)))
      const safeOp = buildSafeUserOpTransaction(
        await safeModule.getAddress(),
        user.address,
        0,
        '0x',
        '0',
        await entryPoint.getAddress(),
        false,
        false,
        { validAfter, validUntil },
      )
      // Native secp256r1 (v==2) encoding: 65 static (owner, offset=65, v=2) + 128 dynamic (r,s,qx,qy)
      const v2Static = ethers.concat([
        ethers.zeroPadValue(user.address, 32),
        ethers.toBeHex(65, 32),
        '0x02',
      ])
      const v2Signatures = ethers.concat([v2Static, '0x' + '00'.repeat(128)])
      const userOp = buildPackedUserOperationFromSafeUserOperation({ safeOp, signature: v2Signatures })
      const entryPointImpersonator = await ethers.getSigner(await entryPoint.getAddress())
      const safeFromEntryPoint = safeModule.connect(entryPointImpersonator)
      const validationData = await safeFromEntryPoint.validateUserOp.staticCall(userOp, ethers.ZeroHash, 0)
      expect(validationData & 1n).to.eq(1n)
    })