こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。
このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。
https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58
今回は「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のコード
実際にコードで見ていきましょう。
今回は以下の記事のコードをほぼそのまま使用させていただきます。
https://solidity-by-example.org/hacks/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では気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!
参考
Signature Replay Vulnerabilities in Smart Contracts
https://coinsbench.com/signature-replay-hack-solidity-13-735997ad02e5
https://docs.openzeppelin.com/contracts/2.x/utilities
// 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;
}
}