Skip to content
Request an audit

Chained signature with checkpoint usage disabled can bypass all checkpointer validation

TitleChained signature with checkpoint usage disabled can bypass all checkpointer validation
Reward$19609, Unique
ContestSequence - 7 October 2025 on Code4rena
Authorhgrano
ContextSig Replay

Consider a scenario where (1) the wallet is behind the checkpointer and (2) a chained signature is used; however, bit 6 (0x40 - the checkpointer usage flag) is zero. As a result, when BaseSig.recover is called the below if-block on BaseSig.sol:88-106 will be skipped:

solidity
if (signatureFlag & 0x40 == 0x40 && _checkpointer == address(0)) {
  // Override the checkpointer
  // not ideal, but we don't have much room in the stack
  (_checkpointer, rindex) = _signature.readAddress(rindex);

if (!_ignoreCheckpointer) {
    // Next 3 bytes determine the checkpointer data size
    uint256 checkpointerDataSize;
    (checkpointerDataSize, rindex) = _signature.readUint24(rindex);

// Read the checkpointer data
    bytes memory checkpointerData = _signature[rindex:rindex + checkpointerDataSize];

// Call the middleware
    snapshot = ICheckpointer(_checkpointer).snapshotFor(address(this), checkpointerData);

rindex += checkpointerDataSize;
  }

We can see that this will leave the following variables unset (zero-valued):

  • _checkpointer
  • snapshot.checkpoint
  • snapshot.imageHash

We then enter recoverChained with those unset variables passed in:

solidity
function recoverChained(
  Payload.Decoded memory _payload,
  address _checkpointer,
  Snapshot memory _snapshot,
  bytes calldata _signature
) internal view returns (uint256 threshold, uint256 weight, bytes32 imageHash, uint256 checkpoint, bytes32 opHash) {
  Payload.Decoded memory linkedPayload;
  linkedPayload.kind = Payload.KIND_CONFIG_UPDATE;

  uint256 rindex;
  uint256 prevCheckpoint = type(uint256).max;

  while (rindex < _signature.length) {
    uint256 nrindex;

    {
      uint256 sigSize;
      (sigSize, rindex) = _signature.readUint24(rindex);
      nrindex = sigSize + rindex;
    }

    address checkpointer = nrindex == _signature.length ? _checkpointer : address(0);

    if (prevCheckpoint == type(uint256).max) {
      (threshold, weight, imageHash, checkpoint, opHash) =
        recover(_payload, _signature[rindex:nrindex], true, checkpointer);
    } else {
      // [ ... ]
    }

  // [ ... ]

Assume the signature chain contains one signature. In the first loop iteration nrindex == _signature.length, and checkpointer is set to _checkpointer, which is address(0).

We then enter recover again, this time with _ignoreCheckpointer == true and _checkpointer == address(0). Bit 6 (0x40) can be set to 1 for the signature inside the chain, so we enter the if-block shown earlier but ignore the checkpointer:

solidity
if (signatureFlag & 0x40 == 0x40 && _checkpointer == address(0)) {
  // Override the checkpointer
  // not ideal, but we don't have much room in the stack
  (_checkpointer, rindex) = _signature.readAddress(rindex);

  if (!_ignoreCheckpointer) {
    // [ ... ]
  }
}

To make signature validation succeed, it is easy enough to provide the necessary checkpointer, checkpoint and threshold. Assuming the non-chained signature is valid, with respect to the wallet configuration, the recovered imageHash will be valid. The final validation of the non-chained signature (BaseSig.sol:138-140) will succeed because snapshot.imageHash == bytes32(0) as the checkpointer has been ignored:

solidity
if (snapshot.imageHash != bytes32(0) && snapshot.imageHash != imageHash && checkpoint <= snapshot.checkpoint) {
  revert UnusedSnapshot(snapshot);
}

Finally, we end up back in recoverChained (BaseSig.sol:179-193) and all the validations will pass (comments added below for explanation):

solidity
if (_snapshot.imageHash == imageHash) {
  // [ will not enter: _snapshot.imageHash is still zero and imageHash is non-zero ]
  _snapshot.imageHash = bytes32(0);
}

if (checkpoint >= prevCheckpoint) {
  // [ will not enter: prevCheckpoint is type(uint256).max ]
  revert WrongChainedCheckpointOrder(checkpoint, prevCheckpoint);
}

linkedPayload.imageHash = imageHash;
prevCheckpoint = checkpoint;
} // [ loop ends here ]

if (_snapshot.imageHash != bytes32(0) && checkpoint <= _snapshot.checkpoint) {
  // [ will not enter: _snapshot.imageHash is still zero ]
  revert UnusedSnapshot(_snapshot);
}

After all this, the signature recovery doesn’t revert and returns an image hash; which is valid for the wallet’s current configuration. The checkpointer has been ignored completely.

Alpha: check for inconsistent states in the flags used, what happens if a flag is off but the functionality is still used?

Conclusion

This finding would earn you $19609, requiring an understanding of the flags used and the wallet structure, mixed with the attack vector of trying to do actions on behalf of the wallet skipping the regular checks.

Full Report
Codebase

Message on Telegram All 62 posts