Appearance
L3-8: Multisig Extension(マルチシグ拡張)
1. 問題
単一の秘密鍵で大量の資金を管理することは極めて危険です。秘密鍵の盗難、紛失、または保持者による悪用は、不可逆的な資金損失につながります。マルチシグウォレットは、N 人の署名者のうち M 人の承認をトランザクション実行の条件とすることで、リスクを分散します。しかし、マルチシグウォレット自体も設計上の課題に直面します。署名はどのように収集・検証するのか?同じ署名が異なるトランザクションにリプレイされるのをどう防ぐのか?マルチシグウォレット自体をどのようにアップグレード(署名者の追加・削除など)するのか?
Gnosis Safe(現在の Safe)はプロダクションレベルのソリューションであり、数百億ドルの資産を保護しています。このチャレンジで構築するのは簡易版です。EIP-712 の完全な構造に依存せず、eth_sign 互換の ECDSA 署名検証を直接使用します。マルチシグの実装を理解することは、チームのトレジャリー、DAO の財務管理、および機関レベルのカストディツールを構築するための基礎となります。
2. 理由
マルチシグは Web3 組織の資金管理のためのインフラです。ほぼすべての DAO トレジャリーがマルチシグ(通常は Safe 経由)を使用しており、コアチームの資金管理も同様にマルチシグに依存しています。署名者セットの管理から、トランザクションハッシュの構築、リプレイ防止のための署名ソートに至るまで、その内部メカニズムを理解することは、コントラクト開発のスキルであるだけでなく、協調的なセキュリティ管理を理解するための重要な知識でもあります。
単一署名と比較して、マルチシグは以下のシナリオで重要なセキュリティ強化を提供します。(1) 単一の悪意行為を防止(共謀が必要)、(2) 秘密鍵の紛失を防止(残りの署名者が引き続き操作可能)、(3) 階層的な承認(異なる閾値が異なる操作の重要度に対応)。このチャレンジでは、ECDSA 署名の復元(ecrecover)の高度な応用として、署名者のアドレスでソートして重複署名を防止するテクニックも紹介します。
3. 解決策
MultiSigWallet.sol は N-of-M マルチシグウォレットを実装しており、主要な機能は以下の通りです。
コンストラクタ:初期オーナーリストと閾値(threshold)を受け取ります。検証:閾値 > 0 かつ <= オーナー数、各アドレスはゼロアドレスでなく重複もなし。
トランザクション実行(execTransaction):マルチシグの中核関数です。フローは以下の通りです。
- トランザクションハッシュの構築:
keccak256(abi.encode(to, value, keccak256(data), nonce))— eth_sign 互換フォーマットを使用(EIP-712 プレフィックスなし) - 署名の検証:
_verifySignatures()が連結された署名バイト(各署名は65バイト = r(32) + s(32) + v(1))を解析し、署名者アドレスを1つずつ復元する - 署名順序の検証:各復元された署名者アドレスは前のものより厳密に大きくする必要がある(
uint160(signer) > uint160(lastSigner))。重複署名を防止する - 実行:
callでトランザクションを送信し、成功後に nonce をインクリメント
オーナー管理:addOwner() と removeOwner() はマルチシグ自体を通じてのみ実行可能です(onlyMultiSig 修飾子が msg.sender == address(this) を要求)。つまり、署名者の追加や削除自体がマルチシグの承認を必要とする操作であり、自己管理・自己ガバナンスとなります。
署名検証(_verifySignatures):65バイトの署名から r、s、v を抽出します。v 値の正規化:v < 27 の場合、27 を加算(イーサリアムの v 値エンコーディングとの互換性)。ecrecover(hash, v, r, s) で署名者を復元します。
参考ソースファイル:src/level3/MultiSigWallet.sol
4. 遭遇した落とし穴
- 署名順序の境界ケース:2つの署名者アドレスの
uint160値が非常に近い場合、比較ロジックは依然として有効か? - ecrecover の無効な署名リスク:
ecrecoverは署名が無効な場合に revert せずaddress(0)を返す。コントラクトがsigner != address(0)をチェックしない場合、偽造されたゼロアドレス署名を受け入れる可能性がある - nonce 管理によるリプレイ防止:nonce は成功実行のたびにインクリメントされる。署名者が現在の nonce と将来の nonce の両方のトランザクションに署名していた場合、実行順序は決定的か?
- 署名フォーマットと外部ツールの非互換性:eth_sign(
vm.sign)は bytes32 ハッシュに直接署名するが、MetaMask のpersonal_signは\x19Ethereum Signed Message:\n32プレフィックスを追加する。両者で復元される署名者は異なる - タイムロックや有効期限の仕組みがない:署名者が署名したトランザクションは理論上永遠に有効(nonce が消費されるまで)。署名が漏洩してもトランザクションが未提出の場合、リスクが存在する
5. 落とし穴の原因
ecrecover の奇妙な動作は、Solidity における最も古典的な落とし穴の一つです。EVM プリコンパイル ecrecover は以下の場合に address(0) を返します。(1) 署名形式は正しいが署名者が期待されるアドレスではない、(2) v 値が 27 または 28 ではない、(3) r または s が secp256k1 曲線の有効範囲外である。コントラクトコードが recovered != address(0) をチェックしない場合、攻撃者は ecrecover がゼロアドレスを返すように細工された署名を渡す可能性があり、ゼロアドレスが偶然にもコントラクト内の正当な署名者である可能性もあります(ゼロアドレスは通常除外されるため可能性は低いですが)。
MultiSigWallet.sol では、_recoverSigner() 内部で require(v == 27 || v == 28, "Invalid v") チェックを実行していますが、復元されたアドレスがゼロかどうかはチェックしていません。_verifySignatures() の isOwner[signer] チェックがゼロアドレスを捕捉します(コンストラクタがゼロアドレスをオーナーとして許可しないため)。したがって、この特定の脆弱性は間接的に緩和されていますが、isOwner マッピングの完全性に依存しています。
署名フォーマットの非互換性の問題は、イーサリアム署名エコシステムの断片化に起因します。eth_sign は bytes32 に直接署名し(Foundry の vm.sign はこのフォーマットを使用)、personal_sign はプレフィックスを追加し、EIP-712 はドメインセパレータを追加します。コントラクトで使用されるハッシュ構築方法は、フロントエンドの署名方法と完全に一致しなければなりません。MultiSigWallet.sol では、getTransactionHash() が keccak256(abi.encode(to, value, keccak256(data), _nonce)) を返し、フロントエンドは同じエンコーディング方法でハッシュを計算し、Foundry の vm.sign で署名する必要があります。
6. 落とし穴の解決方法
ecrecover の安全なラッパー:
solidity
function _recoverSigner(bytes32 hash, bytes memory sig) internal pure returns (address) {
require(sig.length == 65, "Invalid sig len");
// ... r, s, v の抽出 ...
address signer = ecrecover(hash, v, r, s);
require(signer != address(0), "Invalid signature");
return signer;
}
署名者ソートによるリプレイ防止:現在のソート設計(アドレスによる厳密な昇順)は、重複署名と署名者順序の変更を効果的に防止できます。重要なのは、JavaScript 側でも署名を signer アドレスでソートすることです:
javascript
signatures.sort((a, b) =>
BigInt(a.signer) < BigInt(b.signer) ? -1 : 1
);
nonce ロック:トランザクション署名時に現在の nonce を明示的に指定します。フロントエンドは multisig.nonce() で最新の nonce を照会します。トランザクションが正常に実行されると nonce がインクリメントされ、古い nonce を対象とするすべての署名は無効になります。
有効期限の追加:プロダクションレベルの実装では、トランザクションハッシュに validUntil タイムスタンプを含め、指定時間後に署名が無効になるようにすべきです。
7. 技術ポイント
| 技術ポイント | 説明 |
|---|---|
| N-of-M 閾値 | threshold 個のオーナー署名で実行可能 |
| 署名ソート | uint160(signer) の昇順、重複防止 |
| 署名フォーマット | 各署名は65バイト:r(32) + s(32) + v(1) |
| ecrecover | EVM プリコンパイル。ECDSA 署名者アドレスを復元 |
| nonce リプレイ防止 | 成功実行ごとにインクリメント、古い署名を無効化 |
| onlyMultiSig | 管理操作自体がマルチシグ承認を必要とする |
| eth_sign 互換 | bytes32 ハッシュに直接署名、Foundry vm.sign と一致 |
| swap-and-pop | owners 配列からメンバーを削除する古典的パターン |