ERC2771・Multicall併用時の脆弱性について
この記事は、Ethereumアドベントカレンダー2023 10日目の投稿です。
前置き
先日thirdwebによってOpenZeppelinの脆弱性が報告され、界隈は一時騒然としていました。
Xでは解説ポストも流れていましたし、OpenZeppelinから脆弱性の解説もありましたが、いまいち自分の知識不足で完全に理解しきれなかったため、調べ直した際の備忘録になります。
OpenZeppelin、thirdwebからの解説は以下になります。
ERC2771はどのようにしてMetaTxを実現しているのか?
今回の脆弱性を理解するためにはERC2771の理解が必須なので、まずはここからです。
以下がERC2771の仕様とそのフローです。各フローを少しずつ追っていきます。
①TransactionSigner -> GasRelay(Signs&Send Request)
まずはユーザ側(TransactionSigner)がトランザクションの内容に署名を行い、
運営側(GasRelay)に送信します。
トランザクションを発行するわけではないため、ユーザ側にガス代はかかりません。
署名する内容は以下からSignatureを除いたものです。
変数 | 説明 |
---|---|
from | トランザクションを送信したい人のアドレス。今回の場合ユーザ。 |
to | 呼び出したいコントラクトのアドレス |
value | 送信するETH量 |
gas | 使用していいガス代 |
deadline | このトランザクションの締切 |
data | 実行したい関数と引数の情報 |
signature | 上のデータに署名をしたもの |
上のデータ+上のデータに署名をしたもの(signature)をGasRelayに送信することで、MetaTxを要求することができます。
以後、ここで送信したものをrequestと呼びます。
②GasRelay -> Trusted Forwarder(sendAndVerify(request))
運営側がTrusted Forwarderコントラクトをコールします。このタイミングで運営側はガス代を支払う必要があります。
ここで重要なのはTrusted Forwarderコントラクト内で行われているVerify処理です。
request内の情報を利用して、署名者のアドレスを割り出します。
このアドレスが①でrequest内のfromと一致することを確認し、不一致であればrevertします。
つまり、from = 署名者のアドレスが絶対です(めちゃくちゃ当たり前ですが)。
ポイント1:別の人がトランザクションを送ったようにしてVerifyを通せないの?
できません。
その人の秘密鍵がないとsignatureが用意できないため、Verifyを通すことができません。
逆に言えば、秘密鍵が漏れていないなら、そのrequestの内容(特にfrom)は信頼できます。
③Trusted Forwarder -> Recipient(execute with Client addr)
Verifyの結果に問題がなければ、Trusted Forwarderは実際に実行したいコントラクト(Recipient)をコールします。
この時、Trusted Forwarderはcall dataの末尾にfromのアドレスを付与します。
Recipient側はここで付与された末尾の部分を読み取ることで、本来の署名者(上の図ではTransactionSigner)のアドレスを取得することができます。
具体的には_msgSender()を通して取得でき、このアドレスを本来の署名者として扱います。
補足:_msgSender()の仕組みがない場合
_msgSender()の仕組みがない場合、Recipient側は本来の署名者のアドレスを知ることができないため、ユーザのトランザクションを運営が代わりに実行する、ことはできません。
ポイント2: 末尾に付与するアドレスを別の人のものに偽装できないの?
できません。
Trusted Forwarderは①のfromのアドレスを末尾に付与します。
②で説明した通り、from = 署名者のアドレスが絶対のため、秘密鍵を持っていないユーザのアドレスを付与することはできません。
以上がMetaTxを実現している仕組みになります。
ポイント1, 2でも説明した通り、別の人になりすますことは難しいように思えます。
脆弱性
今回の脆弱性の原因は、Multicallで他関数を呼び出す時、call dataの末尾にfromのアドレスを付与していなかったためとなります。
以下が修正が入る前のコードです。dataをそのまま入れてるだけなのが分かると思います。
これによって何が起きてしまうのか、上述のポイントと合わせてハッカーの手法を見ていきます。
ポイント1:別の人がトランザクションを送ったようにしてVerifyを通せないの?
結論から言うと、ハッカーはこの偽装は行っていません。
request内のfromに自分のアドレスを記載し、自分で署名を行っています。
このため、Trusted Forwarderでの認証は問題なく通り、dataに設定されたmulticallがコールされることになります。
const data = {
from: ataccker.address,
to: token.target,
value: 0n,
gas: 100_000n,
nonce: await forwarder.getNonce(atacker),
data: token.interface...,
}
// Atacker sign the message
const signature = await atacker.signTypedData(domain, types, data);
ポイント2: 末尾に付与するアドレスを別の人のものに偽装できないの?
ここが今回の脆弱性と大きく関わっており、今回ハッカーが利用した部分です。
先ほども説明した通り、脆弱性によってMulticallを利用して関数をコールする時は末尾に署名者のアドレス(from)は付与されません。このため、上のポイント1でfromがハッカーのアドレスになっていますが、関数コール時に付与されることはありません。
ここで、代わりに別の人のアドレスを付与できるとしたらどうなるでしょう?
_msgSender()はcall dataの末尾を読み取り、本来の署名者として扱うため、別の人になりすますことが可能になってしまいます。
やっていることはTrusted Forwarderと同等なので、形としても異常はありません。
また、②では偽装できないと記載しましたが、今回の場合、
- Trusted ForwarderでのVerifyは既に通っているため、from = 署名者のアドレスでなくても良い
- 末尾に署名者のアドレスを付与する処理がないため、ハッカーが付与したアドレスが末尾となる
ことから、偽装ができてしまいます。
OpenZeppelinのサイトでは以下のようなコードが紹介されていました。
data: token.interface.encodeFunctionData('multicall', [[
// Here, we poison the call by appending the victim address to the encoded call
ethers.concat([
token.interface.encodeFunctionData('transfer', [atacker.address, balance], victim)
])
]])
OpenZeppelinの修正内容
重要な修正箇所は以下になります。
脆弱性の箇所で説明した通り、今回の問題はMulticallで他関数を呼び出す時、call dataの末尾にfromのアドレスを付与していなかったためです。
そこで、Multicall時にもちゃんとfromのアドレスが付与されるよう修正が行われました。
これによって、悪意のあるアドレスが付与された場合でも、正しいアドレスが取得できるようになります。
イメージ(雑ですが。。):
[関数の情報...、引数の情報...、悪意のあるアドレス...、Multicallで付与された正しいアドレス]
(悪意のあるアドレスは末尾にならず、正しいアドレスが末尾になる)
おまけ
なぜかFoundryで今回の件を試したリポジトリです。
後書き
組み合わせた場合の脆弱性はほんと難しいなと思いました…
誤字脱字や誤り等あれば教えていただけると大変幸いです。
Discussion
有益な記事ありがとうございます!!
お役に立てたなら大変良かったですありがとうございますー!