【実務・中級編】 tx.originによる認証の脆弱性 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

現場のエンジニアへ:tx.originは「誰が呼び出したか」ではなく「誰が起源か」に過ぎない

現場でスマートコントラクトを触っていると、たまに「なぜ認証に tx.origin を使ってはいけないのか?」という質問を受ける。教科書的な回答は「フィッシング耐性がないから」だが、これでは実務の緊張感が伝わらない。

結論から言おう。tx.origin を認証に使うことは、「身元確認のために、相手の親の顔色を見ている」ようなものだ。サイバー攻撃者はその「親(起源)」を偽装し、あなたのコントラクトを完全に支配下に置く。今日はこの、Web3の黎明期から存在するが、未だに撲滅されない「古典的かつ致命的な脆弱性」について、深掘りしていく。

—

1. なぜ tx.origin は罠なのか?

まず、Ethereumの仕様を復習しよう。

  • msg.sender: 関数を直接呼び出したアドレス。コントラクトを経由した場合、その直前のコントラクトアドレスを指す。
  • tx.origin: トランザクション全体を開始した最初のアドレス(EOA:外部所有アカウント)。

攻撃者はこれを利用する。あなたが開発したコントラクトが tx.origin で認証を行っている場合、攻撃者は「悪意のあるコントラクト」を作成し、被害者にそのコントラクト内の関数を呼び出させる。被害者がボタンを押した瞬間、tx.origin は「被害者のアドレス」を指すが、実行の制御権は「攻撃者のコントラクト」に移る。

これが、コントラクトを介したフィッシング攻撃の正体だ。

攻撃のロジック(PoC)

被害者は、一見正規のコントラクトに見えるUIを操作しているつもりだが、裏側では攻撃者が仕込んだコントラクトが「被害者の資産を奪う関数」を呼び出している。

// 脆弱なコントラクト例
contract VulnerableBank {
    address public owner;

    constructor() { owner = msg.sender; }

    // tx.originを使っているため、攻撃者のコントラクト経由で呼び出されると
    // 攻撃者が owner と誤認される
    function withdrawAll() public {
        require(tx.origin == owner, "お前はオーナーじゃない");
        payable(msg.sender).transfer(address(this).balance);
    }
}

攻撃者のコントラクトは、VulnerableBank の withdrawAll を呼び出すだけでいい。tx.origin はオリジナルの署名者であるため、チェックをパスしてしまうのだ。

—

2. セキュアな実装:msg.sender への回帰

この脆弱性を修正するのは極めて簡単だ。単に msg.sender を使うだけでいい。msg.sender は関数を呼び出した「直近のコントラクト」を指すため、攻撃者のコントラクトを経由した場合、msg.sender は「攻撃者のコントラクトアドレス」になる。これなら、不正なコントラクトからの呼び出しを遮断できる。

推奨されるセキュアな実装例

// セキュアな実装
contract SecureBank {
    address public owner;

    constructor() { owner = msg.sender; }

    // 認証には必ず msg.sender を使用する
    modifier onlyOwner() {
        require(msg.sender == owner, "未承認のアクセスです。");
        _;
    }

    // これにより、攻撃者が他コントラクトを経由して呼び出しても
    // msg.sender は攻撃者のコントラクトアドレスとなり、拒否される
    function withdrawAll() public onlyOwner {
        payable(msg.sender).transfer(address(this).balance);
    }
}

—

3. 実務的なベストプラクティス:コントラクト間認証

もし、どうしても「トランザクションの起源」を知る必要がある特殊なユースケース(例えば、特定のEOAのみが操作できるプロキシなど)がある場合でも、認証ロジックには tx.origin を使ってはならない。

その代わりに、以下の設計パターンを採用すること。

1. アクセス制御の委譲(Ownableパターン):
OpenZeppelin の Ownable を使い、msg.sender による権限管理を徹底する。
2. マルチシグ(Multi-sig)の導入:
単一のEOAに依存せず、Gnosis Safeのようなマルチシグウォレットを権限の主体にする。
3. オラクルや署名検証(EIP-712):
トランザクションの改ざんを防ぐため、データに署名を持たせ、コントラクト側で ecrecover を用いて真正性を検証する。

実務で使える署名検証のヒント(Pythonでの署名生成例)

バックエンド(Python/Web3.py)からコントラクトへ指示を送る際は、以下のように署名を生成・検証するのが定石だ。

from web3 import Web3
from eth_account.messages import encode_defunct

# 署名生成のイメージ(バックエンド側)
message = "Authorize_Withdrawal_ID_12345"
encoded_msg = encode_defunct(text=message)
signed_msg = w3.eth.account.sign_message(encoded_msg, private_key=PRIVATE_KEY)

# コントラクト側では ecrecover を使用して、
# 送信者が正しい署名を持っているか(=許可されたユーザーか)を確認する

—

まとめ:防御の心得

エンジニアとして覚えておいてほしいのは、「便利そうな機能(tx.origin)には、必ず相応の代償(脆弱性)が隠されている」ということだ。

  • 認証は常に「直近の呼び出し元」である msg.sender に行わせる。
  • コントラクトの設計段階で「この関数は誰が呼び出せるべきか?」を常に問い直す。
  • 外部入力を鵜呑みにせず、署名検証を実装して「本当の意志」を確認する。

セキュリティは「魔法のツール」を導入すれば終わるものではない。泥臭い実装の積み重ねこそが、最も強固な防御壁になる。今日から君たちのリポジトリにある tx.origin をすべて検索し、もし認証に使われていたら、即座にリファクタリングしてほしい。

健闘を祈る。

コメント

タイトルとURLをコピーしました