現場のエンジニアへ: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 をすべて検索し、もし認証に使われていたら、即座にリファクタリングしてほしい。
健闘を祈る。
コメント