Skip to content

Latest commit

 

History

History
182 lines (145 loc) · 6.67 KB

File metadata and controls

182 lines (145 loc) · 6.67 KB

Dry Garnet Cobra

High

_countValidSignatures Does Not Reject Duplicate Signatures

Summary

A single valid signature can be repeated multiple times to bypass the threshold.

Root Cause

To verify that a transaction can be executed, a sufficient number of signers must have signed the transaction hash:

// get the tx hash
bytes32 txHash = safe.getTransactionHash(
      to,
      value,
      data,
      operation,
      safeTxGas,
      baseGas,
      gasPrice,
      gasToken,
      refundReceiver,
      // We subtract 1 since nonce was just incremented in the parent function call
      safe.nonce() - 1
);

// count the number of valid signatures and revert if there aren't enough
if (_countValidSignatures(txHash, signatures, threshold) < threshold) revert InsufficientValidSignatures();

https://github.com/sherlock-audit/2024-11-hats-protocol/blob/49de29508904e95b3cfaaf27d2e76c527429c019/hats-zodiac/src/HatsSignerGate.sol#L489C1-L505C112

Digging into _countValidSignatures:

function _countValidSignatures(bytes32 dataHash, bytes memory signatures, uint256 sigCount)
    internal
    view
    returns (uint256 validSigCount)
{
    // There cannot be an owner with address 0.
    address currentOwner;
    uint8 v;
    bytes32 r;
    bytes32 s;
    uint256 i;

    for (i; i < sigCount; ++i) {
      (v, r, s) = signatureSplit(signatures, i);
      if (v == 0) {
        // If v is 0 then it is a contract signature
        // When handling contract signatures the address of the contract is encoded into r
        currentOwner = address(uint160(uint256(r)));
      } else if (v == 1) {
        // If v is 1 then it is an approved hash
        // When handling approved hashes the address of the approver is encoded into r
        currentOwner = address(uint160(uint256(r)));
      } else if (v > 30) {
        // If v > 30 then default va (27,28) has been adjusted for eth_sign flow
        // To support eth_sign and similar we adjust v and hash the messageHash with the Ethereum message prefix before
        // applying ecrecover
        currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s);
      } else {
        // Default is the ecrecover flow with the provided data hash
        // Use ecrecover with the messageHash for EOA signatures
        currentOwner = ecrecover(dataHash, v, r, s);
      }

      if (isValidSigner(currentOwner)) {
        // shouldn't overflow given reasonable sigCount
        unchecked {
          ++validSigCount;
        }
      }
    }
}

https://github.com/sherlock-audit/2024-11-hats-protocol/blob/49de29508904e95b3cfaaf27d2e76c527429c019/hats-zodiac/src/HatsSignerGate.sol#L644C3-L684C4

Here, we see that _countValidSignatures iterates through the signatures data to determine the address of each signer, and will verify whether they are valid using a call to isValidSigner:

function isValidSigner(address _account) public view returns (bool valid) {
    /// @dev existing `registeredSignerHats` are always valid, since `_validSignerHats` is append-only
    /// We don't need a special case for `_account == address(0)` because the 0 hat id does not exist
    valid = HATS.isWearerOfHat(_account, registeredSignerHats[_account]);
}

https://github.com/sherlock-audit/2024-11-hats-protocol/blob/49de29508904e95b3cfaaf27d2e76c527429c019/hats-zodiac/src/HatsSignerGate.sol#L544C3-L548C4

To summarize:

  1. The hash of the transaction intended to be executed is signed by a number of signers to create signatures.
  2. The signatures are verified to check that each individual signer corresponds to the address of a valid signer, and they have authorized the transaction hash.
  3. The number of valid signer addresses are counted, and if this count exceeds the execution threshold, the transaction is executed.

Now, consider the case where the signatures consists of a valid signature repeated multiple times.

Since this logic fails to ignore signatures from addresses that have already contributed to the validSigCount, a single valid signature can be repeated multiple times to exceed the threshold for execution.

Internal pre-conditions

No response

External pre-conditions

  1. A valid signer for the aafe exists (either a traditional owner or hat wearer).

Attack Path

  1. A single valid signer creates a valid signature for a transaction.
  2. The signer maliciously duplicates the valid signature multiple times until they exceed the execution threshold for the safe.
  3. The signer submits the transaction.

Impact

A single signer can forcibly execute transactions from the safe, irrespective of the execution threshold.

PoC

No response

Mitigation

Enforce that the array of signatures to be specified in ascending order of addresses, and make sure that the currentOwner for each signature is greater than the previous:

  function _countValidSignatures(bytes32 dataHash, bytes memory signatures, uint256 sigCount)
    internal
    view
    returns (uint256 validSigCount)
  {
    // There cannot be an owner with address 0.
    address currentOwner;
+   address lastOwner;
    uint8 v;
    bytes32 r;
    bytes32 s;
    uint256 i;

    for (i; i < sigCount; ++i) {
      (v, r, s) = signatureSplit(signatures, i);
      if (v == 0) {
        // If v is 0 then it is a contract signature
        // When handling contract signatures the address of the contract is encoded into r
        currentOwner = address(uint160(uint256(r)));
      } else if (v == 1) {
        // If v is 1 then it is an approved hash
        // When handling approved hashes the address of the approver is encoded into r
        currentOwner = address(uint160(uint256(r)));
      } else if (v > 30) {
        // If v > 30 then default va (27,28) has been adjusted for eth_sign flow
        // To support eth_sign and similar we adjust v and hash the messageHash with the Ethereum message prefix before
        // applying ecrecover
        currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s);
      } else {
        // Default is the ecrecover flow with the provided data hash
        // Use ecrecover with the messageHash for EOA signatures
        currentOwner = ecrecover(dataHash, v, r, s);
      }
+
+     /// @notice prevent sybil attacks
+     require(currentOwner > lastOwner);
+     lastOwner = currentOwner;

      if (isValidSigner(currentOwner)) {
        // shouldn't overflow given reasonable sigCount
        unchecked {
          ++validSigCount;
        }
      }
    }
  }

This will also prevent against the ecrecover signature malleability vulnerability currently present in the implementation of _countValidSignatures.