区块链学习笔记之2022starCTF——TreasureHunter
周末 *CTF 的一道智能合约题,合约还蛮长的
1 | pragma solidity >=0.8.0 <0.9.0; |
不过和2022realword的那道题并没有差的很多,不过由于还没有复现过,所以在比赛的时候也是从零开始,不过还是站在巨人的肩膀上啦,首先根据https://r3kapig.com/writeup/20220125-rwctf4/#treasure-hunter这份wp,把题目的整个数据结构理清楚了。
Merkle Tree
题目构建了这样一个Merkle Tree,首先每一片叶子上面的数据结构是 [address,value],
1 | struct Leaf { |
然后每一片叶子的哈希计算规则为:如果value为0,哈希则为0,如果value不为0,则将address和value进行拼接后进行哈希
1 | function calcLeaf(Leaf memory a) internal pure returns (bytes32) { |
两片叶子merge的方法:如果有一片叶子是0,则直接返回另一片叶子的哈希,否则将左叶子的哈希和右叶子的哈希拼接再哈希
1 | function merge(bytes32 l, bytes32 r) internal pure returns (bytes32) { |
根哈希的生成方式【重点】
计算根哈希的时候需要传入一个类似于opcode的 _proofs,然后计算所需的叶子节点 _leaves,以及根节点 _root
1 | function calcRoot( |
首先会检查,传入的叶子节点中的地址必须是从小到大的。
1 | require(checkGroupSorted(_leaves)); |
然后初始化两个数组模拟栈,用于存放叶子的 Keys 和 Values
1 | uint160[] memory stackKeys = new uint160[](SMT_STACK_SIZE); |
然后开始根据opcode: _proofs 对数据进行处理。
三个分支,
如果opcode是 0x4c,就类似于将一个叶子节点压栈:opcode下标加一(是一个单“slot”指令),先检查会不会溢出,然后往stackKeys存入叶子的key,往stackValues存入叶子的哈希,stackTop(栈顶)加一,leaveIndex (当前叶子下标)加一
1 | if (uint256(_proofs[proofIndex]) == 0x4c) { |
如果opcode是 0x50,是将叶子节点和opcode传入的哈希进行一个merge操作:首先proofIndex++,然后检查栈是非空的(因为要取值),然后检查proofIndex + 2 <= _proofs.length,因为这里是一个三“slot”指令,接着从指令的第二个slot获取height,从指令的第三个slot获取用于merge的哈希 currentProof,并且这个currentProof不能够等于根哈希。然后获取用于merge的叶子节点的key的在height处的比特,如果是1,那么叶子节点作为右叶子进行merge,如果是0,那么叶子节点作为左叶子进行merge。最后在stackKeys中会将该叶子节点原本的key值height处及其低位都清零,只留下高位,也就是前缀。
1 | // 获取特定位置的bit |
1 | else if (uint256(_proofs[proofIndex]) == 0x50) { |
如果opcode是0x48,会将栈里的两个叶子节点进行merge,是一个双“slot”指令,指令的第二个slot同样是 height。首先分别获取两个叶子节点key值height处的bit(设靠近栈顶的为a,靠近栈底的为b),然后更新,仅留下前缀。接着一个判断,要求前缀相同,height处的比特不同。然后获取两个叶子节点的哈希,如果a的height处的bit是1,那么a作为左叶子,否则b作为左叶子,最后merge,原本两个叶子出栈,新的叶子入栈(key是前缀,value是merge后的哈希),所以栈顶减一。
1 | else if (uint256(_proofs[proofIndex]) == 0x48) { |
最后要求,opcode走完后,每一个传入的叶子节点都用于根哈希的计算,栈里只剩下一个叶子,也就是根,然后返回value,也就是根哈希
1 | require(leaveIndex == _leaves.length); |
另外还有插入和删除函数,合并在了update里,
首先会验证插入前这棵树是不是合法的,然后进行更新,并计算相应的根节点,具体更新规则则依据传入的opcode:_proofs
1 | function update( |
如果要更新单个叶子的状态,两种,插入和删除,如果是删除,就将value=0,这样进行根哈希计算的时候它就不会被引入。如果插入,则value=1,
1 | function updateSingleTarget( |
TreasureHunter
接下来我们看到这道题,获取flag的方式需要打开宝箱
1 | function openTreasureChest() public { |
打开宝箱就需要找到宝箱和找到钥匙
找到宝箱则需要smtMode == SMT.Mode.WhiteList,这是刚开始是满足的,然后要求team的长度大于4,并通过验证SMT.verifyByMode(_proofs, team, root, smtMode) ,这个验证就是team里的叶子节点都在这颗树上。满足要求的话就会 smtMode = SMT.Mode.BlackList;
1 | function pickupTreasureChest(bytes32[] memory _proofs) public { |
然后是找到钥匙,需要满足 smtMode == SMT.Mode.BlackList,所以需要先找到宝箱再找到钥匙,同样要求team长度大于4,这次呀通过的验证SMT.verifyByMode(_proofs, team, root, smtMode),也还是这个,但是smtMode变了。所以这次的要求是team里的四片叶子都不在这颗树上。
1 | function findKey(bytes32[] memory _proofs) public { |
那么如何增加team的成员呢,有一个enter方法可以往里面加新成员,就是合约的调用者,
1 | function enter(bytes32[] memory _proofs) public { |
不过在加新成员的时候,他会检查之前的树是否合法,然后才会将你加入,而怎么检查呢?整个合约完全没有存储原来的结构,仅仅保存了一个根节点,所以全部依赖_proofs,也就是opcode了。
解题思路
所以我们整个解题步骤就是构造opcode,往原来的树上插入四个账户,因为插入的四个账户,根节点的计算肯定是跟他们有关的,所以应该可以直接找到宝箱。然后我们再去findkey,这个时候四个账户的哈希都不起作用了,所以我们还需要构造opcode,去绕过findkey这边的验证。
enter
首先我们看看如何调用enter向team添加用户,在这之前,首先题目通过init是创建好了一颗merkle True的,
根据init中的opcode画出来大概是这样子的(上面wp链接中的),运行后我们得到root为0xe2e8ebf79be9c50374592f83db1c4c82e4d97b4dae6dc26332d259289467ff8e
如果我们想调用enter,则会调用 SMT.updateSingleTarget(_proofs, msg.sender, root, SMT.Method.Delete);
1 | function updateSingleTarget( |
其中,_proofs 就是opcode了,可控;然后msg.sender就是调用合约的用户,可控;root是当前根哈希,不可控;SMT.Method.Delete是当前状态,此处为delete,因为此时msg.sender还不在树上。在updateSingleTarget的时候,首先会以msg.sender为delete的状态(这样就不会影响到原来的树了)检查原来的树,然后就会对树进行更新,而msg.sender具体插入的位置则由opcode决定。
那么我们核心的任务就是先过update里的验证先。
这里,根据先前的wp,我们拿到了生成root哈希的两个哈希,分别是0xe9f810898db8dc62342eaa122fd26525362f2b70bd462edef6e4e34093d66c17和0xba13a52ab72064627701ac75ab564f7e786d093c655849458536cc689abdf8e2
然后由于msg.sender在验证的时候是不参与计算的,所以其实我们直接传入这两个哈希,然后分别调用两次0x50就好了。opcode为
1 | ["0x000000000000000000000000000000000000000000000000000000000000004c", |
我们首先将msg.sender这个叶子压栈,然后调用0x50,根据opcode,首先会取msg.sender 在0x9c处的比特,然后决定是msg.sender的值是作为左叶子还是右叶子,不过此时是无所谓的,因为msg.sender的value是0,并不会影响merge后的哈希。然后再进行一个0x50,高度取的是msg.sender在0x9f处(也就是最高处)的比特,这里我们有进行构造,我们的msg.sender最高比特都是0,所以此时我们的0xe9f810….是作为左叶子,0xba13a…..作为右叶子,然后merge,得到我们的root,从而通过验证。
注:关于第一个高度的选取,这里我们需要使得插入的msg.sender是左叶子,我们后续会说明;第二个高度不一定非要是0x9f,只要该处比特是0,保证merge的顺序就好了。
通过验证后会更新树,得到新的hash,这里我们用的账户地址是0x0051359C4A49AE35034c693Bb423343874964bD9,所以计算出来的叶子哈希为0x9b1d5789a62aaf2914f81251c7fe468b731d0d69587149c02fa455d2f607d540,merge后得到0x423b9e9e1b3a1cfbd4c7d075391785c44def9c2884281cc63e0a78fcc3bb7f66,再次merge后得到新的根哈希0xf712cc750b9acd5fc76c58b7302bef35c4890937e08702f0d8202f289c8ae8c3
验证正确。
于是我们有新的merkle Tree
于是我们故技重施,利用0x423b9e9e1b3 和 0xba13a52ab7 ,再次插入一个用户
opcode
1 | ["0x000000000000000000000000000000000000000000000000000000000000004c", |
这一次我们用的地址是0x06F07E646D6724874e72FCE7f3AC534cfbEC754a,9a处的比特为1,叶子哈希为0xdea1d3dc0931a8116ca4692f0a3d6387eebbd4ba4e95d671b154412eade4d22c,merge后哈希为0x4e951b5440112027d135d0f1dc97bff8127bec7439f8a54ff6a6b6a164764c64,新的根哈希为0xb8bb540e2e9243c6d081375dfb652f6a87fcfb246c0a461b315e3e530bbbf0f8
验证一下
所以新的树是这个样子
继续第三个,
1 | ["0x000000000000000000000000000000000000000000000000000000000000004c", |
地址用的是0x49Bb1e658D6EE866215D11037A6De1712Ed81B83,0x9e处还是1,叶子哈希为0x111b8a732fb70d95860bf6aa916f2973cf45ab797aac6af673dd0cf1f9918c71,merge后是0x4b3f7ee71efd14747b6620f1919bcdc78724d8a862e987596a1179a6b460be05,根哈希为0xd7266ab3d5607bd49580f90162ff62ce4ac844488a07d38926c0511b687dd6e9
最后一个
1 | ["0x000000000000000000000000000000000000000000000000000000000000004c", |
地址为0x6a95Ad9945396aF307B6d0D99F5382F8C781B4f4,0x9c处为1,叶子哈希为0x34de1d313ec0366dafd9277ff42bea64676a6b426da826971d07d836ca7cf583,merge后为0x03f38c848024ba3006373acb4175da682ce9d4e1ade10e0472919f30f664b1e4,根哈希为0x1b70984cd043483aee663bdaa93a102faaadf0d7b5c429830cf3b5cc14d41429
至于为什么要弄成这样单边的,加下来找宝箱就知道了
pickupTreasureChest
现在我们已经把四个用户加入进去了,于是调用pickupTreasureChest,他会调用SMT.verifyByMode(_proofs, team, root, smtMode),我们需要构造一下opcode使得计算出来的根哈希等于0x1b70984cd043483ae
注意到calcroot的第一步就是检查叶子key的大小关系,所以我们之前是从小到大依次使用地址的,就是为了过这个检查
1 | 0x0051359C4A49AE35034c693Bb423343874964bD9 |
然后这里我们第一个就是要将team1压栈,然后是调用0x50,往opcode里塞一个0xe9f810898,这样能merge出0x423b9e9e1,接着将team2压栈,再调用0x48,将栈里的叶子和team2进行merge,注意到,先前提到,在0x48中对高度的取值比较严格,
1 | if (uint256(_proofs[proofIndex]) == 0x48) { |
height处两个地址的比特必须是不一样的,并且比hight高的比特位必须是一样的,所以我们检查一下地址0x0051359C4A49AE35034c693Bb423343874964bD9和0x06F07E646D6724874e72FCE7f3AC534cfbEC754a
可以看到,在0x9a处两个地址开始不一样,所以0x48的时候,高度要填0x9a,然后,显然,由于前面要求的地址必须从小到大,aset肯定是0,也就是意味着,先入栈的叶子必然是左叶子,这就是为什么我们之前把树往左边构造
以此类推,高度分别需要填0x9a,0x9e,0x9e,最后我们在进行一次0x50,和0xba13a52ab72 merge一下,最终opcode
1 | ["0x000000000000000000000000000000000000000000000000000000000000004c", |
成功
findkey
在findkey中,四片叶子都不参与计算,所以我们显然要构造opcode,然后利用0x50,将用于生成root的 0x03f38c848024 和 0xba13a52ab 塞进去就好了。
所以我们先把叶子压进去,与先前一样利用0x48把他们都merge了,此时stackValue里的值都还是0,然后我们调用两次0x50将 0x03f38c848024 和 0xba13a52ab 分别放入,最后就能计算出我们的目标root了。最终opcode
1 | ["0x000000000000000000000000000000000000000000000000000000000000004c", |
成功。
随后调用一下OpenTreasureChest即可
完整exp
来自天璇merak-zbr
1 | """ |
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可联系QQ 643713081,也可以邮件至 643713081@qq.com
文章标题:区块链学习笔记之2022starCTF——TreasureHunter
文章字数:5.4k
本文作者:Van1sh
发布时间:2022-04-19, 15:23:00
最后更新:2022-04-19, 19:24:02
原始链接:http://jayxv.github.io/2022/04/19/区块链学习笔记之2022starCTF/版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。