Skip to content
On this page

スマートコントラクトの署名検証脆弱性:Impersonator CTFチャレンジの詳細分析

はじめに

SlockDotIt社が開発したECLockerは、IoTゲートロックとSolidityスマートコントラクトを統合した革新的な製品です。このシステムはEthereumのECDSA(Elliptic Curve Digital Signature Algorithm)を活用して認証を行い、有効な署名がロックに送信されるとOpenイベントを発行し、認可されたコントローラーがドアを開錠できる仕組みです。本記事では、このシステムのセキュリティ評価を通じて発見された重大な脆弱性と、その攻撃手法について技術的に詳細に解説します。

システムアーキテクチャの概要

ECLockerシステムは2つの主要なコントラクトで構成されています:

  1. Impersonatorコントラクト:ECLockerインスタンスのファクトリーとして機能
  2. ECLockerコントラクト:個々のロックのロジックを実装

コントラクトの初期化プロセス

ECLockerコントラクトのコンストラクターは、_lockId_signatureを受け取り、以下の処理を行います:

solidity
constructor(uint256 _lockId, bytes memory _signature) {
    // ロックIDの設定
    lockId = _lockId;

    // メッセージハッシュの計算
    bytes32 _msgHash;
    assembly {
        mstore(0x00, "\x19Ethereum Signed Message:\n32") // 28 bytes
        mstore(0x1C, _lockId) // 32 bytes
        _msgHash := keccak256(0x00, 0x3c) //28 + 32 = 60 bytes
    }
    msgHash = _msgHash;

    // 署名から初期コントローラーを回復
    address initialController = address(1);
    assembly {
        let ptr := mload(0x40)
        mstore(ptr, _msgHash) // 32 bytes
        mstore(add(ptr, 32), mload(add(_signature, 0x60))) // 32 byte v
        mstore(add(ptr, 64), mload(add(_signature, 0x20))) // 32 bytes r
        mstore(add(ptr, 96), mload(add(_signature, 0x40))) // 32 bytes s
        pop(
            staticcall(
                gas(), // トランザクションに残っているガス量
                initialController, // `ecrecover`のアドレス
                ptr, // 入力の開始位置
                0x80, // 入力のサイズ
                0x00, // 出力の開始位置
                0x20 // 出力のサイズ
            )
        )
        if iszero(returndatasize()) {
            mstore(0x00, 0x8baa579f) // `InvalidSignature()`
            revert(0x1c, 0x04)
        }
        initialController := mload(0x00)
        mstore(0x40, add(ptr, 128))
    }

    // 署名を無効化
    usedSignatures[keccak256(_signature)] = true;

    // コントローラーの設定
    controller = initialController;

    // LockInitializatedイベントの発行
    emit LockInitializated(initialController, block.timestamp);
}

脆弱性の核心:ECDSA署名の数学的特性

このシステムの根本的な脆弱性は、ECDSA署名の数学的特性に起因しています。ECDSA署名は、楕円曲線暗号に基づくデジタル署名方式で、以下の要素で構成されます:

  • r:署名の最初の部分(楕円曲線上の点のx座標)
  • s:署名の2番目の部分(署名計算の結果)
  • v:回復ID(0,1,27,28のいずれか)

署名の可塑性(Signature Malleability)

ECDSA署名には「署名の可塑性」という既知の特性があります。具体的には、ある有効な署名(r, s, v)が与えられた場合、別の有効な署名(r, s', v')を作成できる可能性があります。ここで:

s' = n - s
v' = v ^ 1 (vが27なら28、28なら27)

ただし、nは楕円曲線secp256k1の位数(曲線の位数)です:

n = 115792089237316195423570985008687907852837564279074904382605163141518161494337

攻撃シナリオの詳細分析

ステップ1:署名の回復と変換

攻撃者はまず、既存の有効な署名を入手する必要があります。このCTFチャレンジでは、以下の署名が提供されています:

solidity
bytes memory signature = abi.encode(
    [
        uint256(11397568185806560130291530949248708355673262872727946990834312389557386886033), // r
        uint256(54405834204020870944342294544757609285398723182661749830189277079337680158706), // s (v=27)
        uint256(27)                                                                              // v
    ]
);

この署名から、攻撃者は以下の手順で新しい有効な署名を生成します:

solidity
uint256 SECP256K1N = 115792089237316195423570985008687907852837564279074904382605163141518161494337;
bytes32 s28 = bytes32(SECP256K1N - uint256(s27));

これにより、(r, s28, 28)という新しい署名ペアが得られます。この署名は元の署名とは異なりますが、同じメッセージに対して数学的に有効です。

ステップ2:コントローラーの変更

攻撃者は、生成した新しい署名を使用してchangeController関数を呼び出します:

solidity
ECLocker locker0 = target.lockers(0);
locker0.changeController(28, r, s28, address(0));

_isValidSignature関数の内部ロジックを確認しましょう:

solidity
function _isValidSignature(uint8 v, bytes32 r, bytes32 s) internal returns (address) {
    address _address = ecrecover(msgHash, v, r, s);
    require (_address == controller, InvalidController());

    bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));
    require (!usedSignatures[signatureHash], SignatureAlreadyUsed());

    usedSignatures[signatureHash] = true;

    return _address;
}

重要な点は、usedSignaturesマッピングが署名のハッシュをキーとして使用していることです。元の署名(r, s27, 27)と新しい署名(r, s28, 28)は異なるハッシュ値を生成するため、システムは新しい署名を「未使用」として認識します。

ステップ3:ロックの開錠

コントローラーをaddress(0)に変更した後、攻撃者は任意の署名(この場合は(0, 0, 0))でopen関数を呼び出すことができます:

solidity
locker0.open(0, 0, 0);

open関数は_isValidSignatureを呼び出しますが、この時点でcontrolleraddress(0)に設定されています。ecrecover関数は無効な署名に対してaddress(0)を返すことがあるため、このチェックを通過します:

solidity
address _address = ecrecover(msgHash, v, r, s);
require (_address == controller, InvalidController()); // address(0) == address(0) で通過

根本原因の技術的詳細

この脆弱性の根本原因は、以下の3つの要素が組み合わさったものです:

  1. 署名の可塑性に対する保護の欠如:システムは署名の可塑性を防ぐためのチェックを実装していません。

  2. ecrecoverのエッジケース処理ecrecover関数は無効な署名に対してaddress(0)を返す可能性がありますが、このケースが適切に処理されていません。

  3. コントローラーアドレスの検証不足address(0)が有効なコントローラーとして設定されることを防ぐチェックがありません。

修正方法とベストプラクティス

修正案1:署名の可塑性に対する保護

solidity
function _isValidSignature(uint8 v, bytes32 r, bytes32 s) internal returns (address) {
    // s値が低いことを確認(署名の可塑性防止)
    uint256 sValue = uint256(s);
    require(sValue <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0, 
            "Invalid signature: s value too high");
    
    address _address = ecrecover(msgHash, v, r, s);
    require(_address != address(0), "Invalid signature: ecrecover returned zero");
    require(_address == controller, InvalidController());

    bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));
    require(!usedSignatures[signatureHash], SignatureAlreadyUsed());

    usedSignatures[signatureHash] = true;

    return _address;
}

修正案2:OpenZeppelinのECDSAライブラリの使用

solidity
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract ECLocker {
    using ECDSA for bytes32;
    
    function _isValidSignature(bytes memory signature) internal returns (address) {
        address recoveredAddress = msgHash.recover(signature);
        require(recoveredAddress != address(0), "Invalid signature");
        require(recoveredAddress == controller, InvalidController());
        
        bytes32 signatureHash = keccak256(signature);
        require(!usedSignatures[signatureHash], SignatureAlreadyUsed());
        
        usedSignatures[signatureHash] = true;
        
        return recoveredAddress;
    }
}

修正案3:ゼロアドレスの防止

solidity
function changeController(uint8 v, bytes32 r, bytes32 s, address newController) external {
    require(newController != address(0), "Invalid controller address");
    _isValidSignature(v, r, s);
    controller = newController;
    emit ControllerChanged(newController, block.timestamp);
}

セキュリティ教訓

このCTFチャレンジから得られる重要なセキュリティ教訓は以下の通りです:

  1. 暗号プリミティブの理解:ECDSAなどの暗号プリミティブを使用する際は、その数学的特性と既知の脆弱性を完全に理解する必要があります。

  2. エッジケースの考慮address(0)や境界値などのエッジケースを常に考慮し、適切に処理する必要があります。

  3. 既存の監査済みライブラリの使用:OpenZeppelinなどの監査済みライブラリを使用することで、一般的な脆弱性を回避できます。

  4. 署名の一意性の確保:署名の可塑性を防ぐために、s値が低い範囲にあることを確認する必要があります。

  5. 多層防御の実装:単一のセキュリティメカニズムに依存せず、多層的な防御を実装することが重要です。

結論

Impersonator CTFチャレンジは、スマートコントラクト開発における署名検証の複雑さと潜在的な危険性を明確に示しています。ECDSA署名の数学的特性、特に署名の可塑性を理解することは、安全なスマートコントラクトを開発する上で不可欠です。開発者は常に最新のセキュリティベストプラクティスに従い、監査済みのライブラリを使用し、すべてのエッジケースを考慮した堅牢なコードを書くことが求められます。

この脆弱性の分析を通じて、単なるコードの実装だけでなく、基礎となる暗号理論の理解がセキュリティにおいてどれほど重要であるかを再認識することができます。ブロックチェーンとスマートコントラクトの世界では、一度デプロイされたコードは変更が困難であるため、初期段階での徹底的なセキュリティ評価が極めて重要です。

Built with AiAda