Polygonのブリッジに潜む二重使用バグ
概要
ホワイトハットハッカーの Gerhard Wagner 氏は、Polygon ネットワーク上で潜在的にコストのかかる「二重使用」バグの解決策を報告しました。
2021 年 10 月 21 日、セキュリティサービス Immunefi のブログ記事によると、Polygon ネットワークの Plasma Bridge にあった脆弱性によってブリッジから最大 223 回までバーントランザクションを終了することができ、攻撃を仕掛けるのに 10 万ドルあれば、2230 万ドルの損失が発生させることが可能でした。これは、Plasma Bridge の DepositManager が枯渇する可能性が十分にあることを意味します。
Immunefi の報告によると、二重使用はまず Plasma Bridge を通じて Ether(ETH)を入金し、トランザクションが確認された後に出金プロセスを開始することで機能します。ハッカーはその後 1 週間待ち、 「ブランチ・マスクの 1 バイト目を変更したもの」を除いて同じ出金を再送信することができます。
ブリッジとは?
稼働している代表的なブロックチェーンとして、Ethereum、Binance Smart Chain(BSC)、Polygon、Bitcoin などが挙げられます。
DeFi の台頭により、多くの資産が上記のいずれかのチェーンで作成されています。しかし、あるチェーンから別のチェーンに NFT やトークンを移動させたいとしたらどうでしょうか。
そこで登場するのがブロックチェーンブリッジという概念です。ブロックチェーンブリッジとは、2 つの異なるブロックチェーンを接続し、その間の通信を可能にする方法です。Polygon は、Polygon と Ethereum の間で信頼性の高い双方向の取引チャネルを提供しています。Polygon は 2 つのブリッジを提供しています。1 つは Plasma ブリッジと PoS ブリッジです。Plasmaは、その出金メカニズムから、より安全なブリッジと考えられています。
Plasma Bridgeでのアセットの流れ
ユーザはルートチェーン(Ethereum)上の Polygon コントラクトにトークンを預けます。
トークン入金トランザクションが Ethereum 上で確認されると、対応するトークンが Polygon チェーン上で生成されます。これらのトークンは、Polygon ネットワーク上で使用できるようになります。
ユーザが子チェーン(Polygon)から引き出す準備ができたら、Polygon からのイニシエーションによって引き出すことができます。
最後のチェックポイントからすべてのブロックが検証されるチェックポイント間隔を経過する必要があります(約30分)。
チェックポイントは、ルートチェーンのコントラクトに提出されます。
ユーザが引き出したい値の EXIT NFT トークンが生成されます。
待ち時間が初期化され、ユーザーは資金を引き出すことができるまで 7 日間待つ必要があります。
process-exit 手続きを使って、待機期間が終了すると、ユーザーは自分の Ethereum アカウントに資金を戻すことができます。
しかし、この出金手順には脆弱性が存在していました。
出金プロセス
出金処理は、子チェーン上のトークンを燃やす(バーンする)ことから始まります。Polygon Plasma クライアントは、getERC20TokenContract の withdraw 関数を呼び出すために startWithdraw メソッドを公開します(この関数は、トークンを燃やします)。
バーンが確認された後、ユーザは erc20Predicate コントラクトの startExitWithBurntTokens 関数を呼び出すことができます。それは、最初のチェックポイント(30分)を追っている時間です。さらに、exit ペイロードを関数に渡す必要があります。exit ペイロードには、L2->L1 から転送される資金に関するすべての重要な情報が含まれています。
exit を続行するには、バーントランザクションが成功し、有効なものである必要があります。ここで非常に重要なことは、exit はチェックポイントがバーントランザクションと一緒にルートチェーンに含まれた後にのみ呼び出すことができることです。ユーザは、withlowManager コントラクトの processExits 関数を呼び出して、バーン証明を提出する必要があります。
脆弱性は、Polygon の WithdrawManager が以前のブロックにバーントランザクションが含まれ、一意であることを検証する方法にあります。
脆弱性
WithdrawManager.sol は、verifyInclusion 関数(以下参照)を実装しています。
function verifyInclusion(
bytes calldata data,
uint8 offset,
bool verifyTxInclusion
)
external
view
returns (
uint256 /* ageOfInput */
)
{
ExitPayloadReader.ExitPayload memory payload = data.toExitPayload();
VerifyInclusionVars memory vars;
vars.headerNumber = payload.getHeaderNumber();
vars.branchMaskBytes = payload.getBranchMaskAsBytes();
vars.txRoot = payload.getTxRoot();
vars.receiptRoot = payload.getReceiptRoot();
require(
MerklePatriciaProof.verify(
payload.getReceipt().toBytes(),
vars.branchMaskBytes,
payload.getReceiptProof(),
vars.receiptRoot
),
"INVALID_RECEIPT_MERKLE_PROOF"
);
if (verifyTxInclusion) {
require(
MerklePatriciaProof.verify(
payload.getTx(),
vars.branchMaskBytes,
payload.getTxProof(),
vars.txRoot
),
"INVALID_TX_MERKLE_PROOF"
);
}
vars.blockNumber = payload.getBlockNumber();
vars.createdAt = checkBlockMembershipInCheckpoint(
vars.blockNumber,
payload.getBlockTime(),
vars.txRoot,
vars.receiptRoot,
vars.headerNumber,
payload.getBlockProof()
);
vars.branchMask = payload.getBranchMaskAsUint();
require(
vars.branchMask & 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000 == 0,
"Branch mask should be 32 bits"
);
// ageOfInput is denoted as
// 1 reserve bit (see last 2 lines in comment)
// 128 bits for exitableAt timestamp
// 95 bits for child block number
// 32 bits for receiptPos + logIndex * MAX_LOGS + oIndex
// In predicates, the exitId will be evaluated by shifting the ageOfInput left by 1 bit
// (Only in erc20Predicate) Last bit is to differentiate whether the sender or receiver of the in-flight tx is starting an exit
return (getExitableAt(vars.createdAt) << 127) | (vars.blockNumber << 32) | vars.branchMask;
}
この関数の目的は、チェックポイント中にバーントランザクションのレシートが含まれていることを確認することです。これは、Merkle Proof をチェックして、レシートとトランザクション自体を確認することによって行われます。上記のすべての情報は、exit ペイロードの中に含まれています。
exit 証明に含まれる重要なパラメータの 1 つに、レシートの Merkle Proof の branchMask というものがあります。branchMask は、システムを安全に保つために不可欠なセキュリティガードです。そのため、exit ID の生成に使用されるため、branchMask は一意でなければなりません。
注目すべきプロパティは、1 つの exitトランザクションが 1 つの exit ID と同値であることです。しかし、ホワイトハットハッカーが発見したように、それは必ずしもそうではありません。nibbles 配列へのデコードでは値の一部を無視し、無視された部分の差分は uint256 のデコードで拒否されないため、MerklePatriciaProof でデコードされた同じ値が uint256 として多くのエンコーディングを持つことがあります。uint256 はリプレイを避けるためのデコードであるため、デコードの違いにより同じ証明がリプレイされる可能性があります。
MerklePatriciaProof のデコーディングをより深く掘り下げると、1 つの値が複数のエンコーディングを持つ可能性がある理由がわかります。HP エンコードされた値の最初の nibble が 1 または 3 であれば、2 番目の nibble を解釈することです。しかし、最初の nibble が 1 または 3 でない場合、最初のバイトはすべて破棄されます。2 番目の nibble が解釈される値を除くと、同じパスをエンコードする方法が 14*16==224 通りあることがわかる。悪意のあるユーザは、同じ exit トランザクションに対して異なる exit ID を作成することができます。
攻撃のステップは以下の通りです。
Plasma Bridge を通じて Polygon に大量の ETH トークンを入金する。
Polygon で資金が利用可能になったことを確認後、出金処理を開始する。
exit が有効になるまで 7 日間待つ。
branchMask の 1 バイト目を変更して、exit ペイロードを再送信する。
同じ有効なトランザクションを、HP エンコードされたパスの 1 バイト目の値を変えて最大 223 回まで再送信することができる。
利益獲得!
この脆弱性の影響は甚大でした。バグ情報が提出された時点で、約 8 億 5 千万ドルが DepositManagerProxy の中に入っていたのです。
バグの修正
エンコードされた branchMask の最初のバイトは常に 0x00 であるはずです。バグの修正内容として、エンコードされた branchMask の最初のバイトが 0x00 であるかどうかをチェックし、不正なマスクとして無視しないようにすることです。
参考文献
この記事が気に入ったらサポートをしてみませんか?