Hacking Smart Contract Solidity ブロックチェーン

[Solidity-Bug Bounty] Signature Replayの危険性を1からわかりやすく解説

かるでね

こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。

今回は「Signature Replay」という脆弱性をついた攻撃について解説していきます。

Signature Replay」は理解するが難しいものなので、この記事では噛み砕いて解説しています。

Solidityスキルを1段階あげたい方やバグ・バウンティに参加したいバグハンターを目指している方は必須の知識となるので、この記事で学んでいってください。

バグ・バウンティが何かわからない人は以下の記事を読んでください。

Signature Replayとは?

Signature Replay

Signature Replay」とは、「署名を使いまわされる攻撃」のことです。

AさんとBさんの2人がいるとします。

ある処理を行なうにはAさんとBさんの承認(署名)が必要な状況を想像してください。

承認(署名)を行なうときは、各ユーザーが所有している秘密鍵を用いて行います。

これ以降は承認ではなく、署名として説明を進めていきます。

以下の図のようにAさんとBさんそれぞれが署名をし、1ETHを預けることができるスマートコントラクトがあるとします。

しかし、この処理にはトランザクションが3回発生します。(3回コントラクトを呼び出さなければいけない)

そのため、Bさんの署名をAさんに渡し、Aさんが「AさんとBさんの署名 + 1ETH」を渡しながらコントラクトを呼び出せばどうでしょう?

以下の図のような処理であれば1回のトランザクションですみます。

トランザクションの回数が少ないと何が良いかというと、取引の時間が短くなりガス代が安くなります。

しかし、これには問題があります。

それは、Bさんの署名をAさんが再利用できてしまうことです。

例えば預けた資金を引き出すことができる関数があるとします。

この関数実行時にもAさんとBさんそれぞれが署名を必要とします。

しかし、先ほどAさんはBさんの署名を取得したため、Bさんに再度署名を求めなくても同じ署名を使用して関数を実行できてしまいます。

その結果、以下の図のようにBさんの許可なしにコントラクトが保持している全てのETHを引き出すことができてしまいます。

Signature Replayのコード

実際にコードで見ていきましょう。

今回は以下の記事のコードをほぼそのまま使用させていただきます。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract Signature {
    using ECDSA for bytes32;

    // 2人のユーザーの署名を格納。
    address[2] public owners;

    // コントラクト実行時に検証用の署名を渡す
    constructor(address[2] memory _owners) payable {
        owners = _owners;
    }

    /// @notice 資金を預ける関数。
    function deposit() external payable {}

    /// @notice 2人の署名が正しいか確認して、資金を送る関数。
    /// @param _to 資金を受け取るアドレス。
    /// @param _amount 引き出す資金。
    /// @param _sigs 2つの署名の配列。
    function transfer(address _to, uint _amount, bytes[2] memory _sigs) external {
        bytes32 txHash = getTxHash(_to, _amount);
        require(_checkSigs(_sigs, txHash), "invalid sig");

        (bool sent, ) = _to.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }

    /// @notice 受け取った情報からハッシュ値を生成する関数。
    /// @param _to 資金を受け取るアドレス。
    /// @param _amount 引き出す資金。
    /// @return _toと_amountのハッシュ値。
    function getTxHash(address _to, uint _amount) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(_to, _amount));
    }

    /// @notice 2つの署名が正しいか検証する関数。
    /// @param _sigs 2つの署名が格納された配列。
    /// @param _txHash _toと_amountのハッシュ値。
    function _checkSigs(
        bytes[2] memory _sigs,
        bytes32 _txHash
    ) private view returns (bool) {
        bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

        for (uint i = 0; i < _sigs.length; i++) {
            address signer = ethSignedHash.recover(_sigs[i]);
            bool valid = signer == owners[i];

            if (!valid) {
                return false;
            }
        }

        return true;
    }
}

constructor

コントラクトをデプロイするときに、2つの署名をあらかじめowners配列に格納しています。

deposit

資金を預けることができる関数です。

transfer

取引のハッシュが生成され、承認が必要なユーザーによって署名されているか確認したのち、_toで指定したアドレスにamount分のethを送る関数です。

getTxHash

_toに渡されたアドレスと_amountに渡された数値でハッシュを生成する関数です。

_checkSigs

2つの署名が正しいか検証する関数です。

revover関数はハッシュ化されたメッセージに署名を行ったアドレスを返します。

toEthSignedMessageHash関数は、ハッシュ化されたメッセージを返します。

危険性

このコードの危険性は先ほど解説してきたように署名が使いまわされてしまう点です。

毎回検証する署名が同じ値のため、一度でも2つの正解の署名を取得できてしまうと何回でも使いまわせてしまいます。

Signature Replayの対処法

前章まで実際のコードも用いて、Signature Replayの危険性を紹介してきました。

では対処する方法はないのでしょうか?

もちろんあります。

その方法としては以下になります。

対処法

  • txHashの生成時にnonceを引数として渡す。
  • 以前のtxHashを追跡するマッピングを作成し、現在生成したtxHashが以前も使用されたことがあるかチェックする。
  • txHashを作成するとき、資金を引き出す必要のあるコントラクトのアドレスも追加することで、さらに安全性を高めることができる。

nonceを渡す

毎回の取引ごとに異なる値であるnonceを渡し、その値も含めてtxHashを生成することで、取引ごとにtxHashが同じになることがなくなります。

txHashの追跡

txHashを生成した時に、配列に生成したtxHashを格納します。

上記を行うことで、既に過去に同じtxHashが使用されたことがないか確認することができるようになり、もし過去に使用されていた場合は取引を無効にすることができます。

コントラクトのアドレスもハッシュに含める

txHashを生成する時に、資金を引き出す関数を実行されているコントラクトのアドレスを含めることでさらに安全性を高めることができます。

コードの確認

実際にコードを見てみましょう。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract Signature {
    using ECDSA for bytes32;

    // 2人のユーザーの署名を格納。
    address[2] public owners;

    // txHashが使用されたか格納する配列。
    mapping(bytes32 => bool) public executed;

    // コントラクト実行時に検証用の署名を渡す
    constructor(address[2] memory _owners) payable {
        owners = _owners;
    }

    /// @notice 資金を預ける関数。
    function deposit() external payable {}

    /// @notice 2人の署名が正しいか確認して、資金を送る関数。
    /// @param _to 資金を受け取るアドレス。
    /// @param _amount 引き出す資金。
    /// @param _sigs 2つの署名の配列。
    function transfer(address _to, uint _amount, bytes[2] memory _sigs) external {
        bytes32 txHash = getTxHash(_to, _amount);
				// 既に使用されたtxHashではないかチェック。
				require(!executed[txHash], "tx executed");
        require(_checkSigs(_sigs, txHash), "invalid sig");

        executed[txHash] = true;

        (bool sent, ) = _to.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }

    /// @notice 受け取った情報からハッシュ値を生成する関数。
    /// @param _to 資金を受け取るアドレス。
    /// @param _amount 引き出す資金。
    /// @param _nonce 毎回の取引で異なる値。
    /// @return _toと_amountのハッシュ値。
    function getTxHash(
        address _to, 
        uint _amount,
        uint _nonce
    ) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(address(this), _to, _amount, _nonce));
    }

    /// @notice 2つの署名が正しいか検証する関数。
    /// @param _sigs 2つの署名が格納された配列。
    /// @param _txHash _toと_amountのハッシュ値。
    function _checkSigs(
        bytes[2] memory _sigs,
        bytes32 _txHash
    ) private view returns (bool) {
        bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

        for (uint i = 0; i < _sigs.length; i++) {
            address signer = ethSignedHash.recover(_sigs[i]);
            bool valid = signer == owners[i];

            if (!valid) {
                return false;
            }
        }

        return true;
    }
}

13行目にtxHashが使用されたか格納する配列を定義しています。

49行目でコントラクトアドレスとnonceを含めてtxHashを生成しています。

30行目でtxHashが過去に使用されていないか確認しています。

最後に

今回は「Signature Replay」という脆弱性をついた攻撃について解説してきました!

いかがだったでしょうか?

ポイント

  • Signature Replayが何か理解できた!
  • Signature Replayの危険性を理解できた!
  • Signature Replayの対処法を理解できた!

上記が当てはまっていたら嬉しいです。

もし何か質問などがあれば以下のTwitterなどから連絡ください!

普段はSolidityやブロックチェーン、Web3についての情報発信をしています。

Twiiterでは気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!

参考

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract Signature {
    using ECDSA for bytes32;

    // 2人のユーザーの署名を格納。
    address[2] public owners;

    // txHashが使用されたか格納する配列。
    mapping(bytes32 => bool) public executed;

    // コントラクト実行時に検証用の署名を渡す
    constructor(address[2] memory _owners) payable {
        owners = _owners;
    }

    /// @notice 資金を預ける関数。
    function deposit() external payable {}

    /// @notice 2人の署名が正しいか確認して、資金を送る関数。
    /// @param _to 資金を受け取るアドレス。
    /// @param _amount 引き出す資金。
    /// @param _sigs 2つの署名の配列。
    function transfer(address _to, uint _amount, bytes[2] memory _sigs) external {
        bytes32 txHash = getTxHash(_to, _amount);
        // 既に使用されたtxHashではないかチェック。
        require(!executed[txHash], "tx executed");
        require(_checkSigs(_sigs, txHash), "invalid sig");

        executed[txHash] = true;

        (bool sent, ) = _to.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }

    /// @notice 受け取った情報からハッシュ値を生成する関数。
    /// @param _to 資金を受け取るアドレス。
    /// @param _amount 引き出す資金。
    /// @param _nonce 毎回の取引で異なる値。
    /// @return _toと_amountのハッシュ値。
    function getTxHash(
        address _to, 
        uint _amount,
        uint _nonce
    ) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(address(this), _to, _amount, _nonce));
    }

    /// @notice 2つの署名が正しいか検証する関数。
    /// @param _sigs 2つの署名が格納された配列。
    /// @param _txHash _toと_amountのハッシュ値。
    function _checkSigs(
        bytes[2] memory _sigs,
        bytes32 _txHash
    ) private view returns (bool) {
        bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

        for (uint i = 0; i < _sigs.length; i++) {
            address signer = ethSignedHash.recover(_sigs[i]);
            bool valid = signer == owners[i];

            if (!valid) {
                return false;
            }
        }

        return true;
    }
}

-Hacking, Smart Contract, Solidity, ブロックチェーン
-, ,