# Chained signature with checkpoint usage disabled can bypass all checkpointer validation

Bug Deep Dive #29 · 25 December 2025

Bug Deep Dives · [The Contest Academy](https://0xsimao.com/the-contest-academy) · 0xSimao

---

| Title | Chained signature with checkpoint usage disabled can bypass all checkpointer validation |
| Reward | $19609, Unique |
| Contest | Sequence - 7 October 2025 on Code4rena |
| Author | hgrano |
| Context | Sig 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**](https://code4rena.com/reports/2025-10-sequence#h-01-chained-signature-with-checkpoint-usage-disabled-can-bypass-all-checkpointer-validation)\
[**Codebase**](https://github.com/code-423n4/2025-10-sequence/tree/b0e5fb15bf6735ec9aaba02f5eca28a7882d815d?tab=readme-ov-file)

---

Newer: [Partial signature replay/frontrunning attack on session calls](https://0xsimao.com/the-contest-academy/bug-deep-dive-30) · Older: [The collateral that liquidators receive is valued below their initial expectations as a result of the price fall](https://0xsimao.com/the-contest-academy/bug-deep-dive-28)
