其实这道题应该算是比较过时了,只有solidity 0.5.0 以前可能才会出现的漏洞,感觉主要是结构体未初始化造成的一个变量覆盖,以及程序流的劫持,有一点pwn的感觉在里面。所以通过这道题也是对solidity的存储机制有了一定的了解。
状态变量储存结构
参考登链社区的solidity中文文档,除了映射(mapping) 和 动态数组 的静态大小变量都是从位置 0 开始连续放置在存储(storage)中,如果可能的话,存储大小少于32字节的多个变量会被打包到一个存储插槽中(storage slot),(所以一个slot就是32字节的大小),规则如下:
- slot的第一项会以向右对齐的方式存储
- 基本类型仅使用存储他们所需字节大小的存储空间
- 如果一个slot的剩余空间不足以放下接下来的基本变量,那么它会移到下一个slot
- 结构体和数组的数据总是会占用一整个新的slot,但结构体或数组中的每一项还是会以上述规则打包。即不会出现一个slot里出现两个结构体或者两个数组的情况,即使一个结构体或数组也许仅仅占用了2字节。
还有更多比较细节的东西,就不再这篇文章里提出来了,感兴趣的读者可以去看看文档深入了解。
映射和动态数组
由于映射和动态数组的大小是不可预知的,所以他们使用keccak256来计算找到值得位置或数组的起始位置,映射和动态数组本身会根据上述规则在某个位置 $p$ 处占满一个slot(或递归的将该规则应用到映射的映射或者数组的数组),对于动态数组,此slot会存储数组中元素的数量;对于映射,这个插槽不用,但这个茅坑还是得占,这样可以使得两个映射之后会使用不同的散列分布。
对于动态数组,数组的起始位置会位于 keccak256(p)
处,对于映射,映射中的每个键对应的值会位于 keccak256(k||p)
处,(||是连接符,代码:keccak256(abi.encodePacked(k, p))
)如果这个值不是基本类型(比如是个结构体),那么就通过加偏移来确定。
例子
1234567 |
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.4.0 <0.9.0;contract C {struct S { uint a; uint b; } uint x; mapping(uint => mapping(uint => S)) data;} |
对于上述合约,data[4][9].b
的位置为keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1))) + 1
解释一下,首先 struct S { uint a; uint b; }
只是一个结构体的定义,并没有定义变量,所以不占用slot,uint x
占用slot0,然后 mapping(uint => mapping(uint => S)) data;
会占用slot 1,所以 data[4]
的值的位置就是 keccak256(uint256(4)||uint256(1))
,然后这个地方呢不是一个基本类型,是一个 mapping(uint => S)
的映射,所以这个映射占用了slot_keccak256(uint256(4)||uint256(1)),然后再去这个映射的键值为9的值,所以这个地址就是在keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1)))
,再然后,这个地方仍然不是一个基本类型,是一个结构体,这个结构体里 uint a
会占用一个slot,uint b
会占用一个slot,所以 a 的偏移是 0 ,b 的偏移是 1,所以最后为keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1))) + 1
。
漏洞:局部变量未初始化
如果智能合约函数声明了临时的动态数组或者sturct,而没有指定“位置”(storage 还是 memory),且没有进行初始化,那么这些变量将默认为”存储指针”,且指向slot0。
漏洞合约例子
123456789101112131415161718 |
contract NameRegistrar { bool public unlocked = false;// 用来锁定注册状态 struct NameRecord { bytes32 name; address mappedAddresss; } mapping(address => NameRecord) public registeredNameRecord; mapping(bytes32 => address) public resolve; function register(bytes32 _name, address _mappedAddresss) public { //构造一个新的 NameRecord NameRecord newRecord; newRecord.name = _name; newRecord.mappedAddresss = _mappedAddresss; resolve[_name] = _mappedAddresss; registeredNameRecord[msg.sender] = newRecord; require(unlocked);//仅在智能合约处在 unlocked 状态下允许注册 }} |
注意到该合约在register中定义了一个newRecord,未指定位置,也没有初始化,所以该结构体的指针指向slot0,如果对name赋值,将修改slot0,从而覆盖unlocked变量,如果name的最后1byte为1,那么unlocked即被修改为True,从而绕过最后的限制。
Balsn CTF 2019 - Bank
好了,搞懂前面三个问题,我们就可以来看看这个题目了。
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879 |
pragma solidity ^0.4.24;contract Bank { event SendEther(address addr); event SendFlag(address addr); address public owner; uint randomNumber = 0; constructor() public { owner = msg.sender; } struct SafeBox { bool done; function(uint, bytes12) internal callback; bytes12 hash; uint value; } SafeBox[] safeboxes; struct FailedAttempt { uint idx; uint time; bytes12 triedPass; address origin; } mapping(address => FailedAttempt[]) failedLogs; modifier onlyPass(uint idx, bytes12 pass) { if (bytes12(sha3(pass)) != safeboxes[idx].hash) { FailedAttempt info; info.idx = idx; info.time = now; info.triedPass = pass; info.origin = tx.origin; failedLogs[msg.sender].push(info); } else { _; } } function deposit(bytes12 hash) payable public returns(uint) { SafeBox box; box.done = false; box.hash = hash; box.value = msg.value; if (msg.sender == owner) { box.callback = sendFlag; } else { require(msg.value >= 1 ether); box.value -= 0.01 ether; box.callback = sendEther; } safeboxes.push(box); return safeboxes.length-1; } function withdraw(uint idx, bytes12 pass) public payable { SafeBox box = safeboxes[idx]; require(!box.done); box.callback(idx, pass); box.done = true; } function sendEther(uint idx, bytes12 pass) internal onlyPass(idx, pass) { msg.sender.transfer(safeboxes[idx].value); emit SendEther(msg.sender); } function sendFlag(uint idx, bytes12 pass) internal onlyPass(idx, pass) { require(msg.value >= 100000000 ether); emit SendFlag(msg.sender); selfdestruct(owner); }} |
合约并不算长。首先我们看拿到flag的条件,不难注意到最后有一个sendFlag函数,会触发SendFlag事件,然后出题人那边部署的Listen监听到后就会给我们发送flag了。调用sendFlag的话,在deposit,如果是合约所有者调用的话,就会把box.callback 改成 sendFlag(顾名思义,猜测这玩意儿应该有点像那个回调函数叭),然后再调用withdraw就会触发这个box的callback,不过box有很多,需要指定一个,然后还得给一个pass,因为sendFlag被onlyPass修饰,要求(bytes12(sha3(pass)) != safeboxes[idx].hash)
,问题不大,hash和pass都是可控的。至于要求合约所有者调用,这个问题不大,注意到deposit里的 SafeBox box
,对box的声明并没有指定位置和初始化,所有该结构体指针是指向slot0的,而存储owner的地方正是在slot0,可以先排一下
123456789 |
-----------------------------------------------------| unused (12) | owner (20) | <- slot 0-----------------------------------------------------| randomNumber (32) | <- slot 1-----------------------------------------------------| safeboxes.length (32) | <- slot 2-----------------------------------------------------| occupied by failedLogs but unused (32) | <- slot 3----------------------------------------------------- |
然后box的是
12345 |
-----------------------------------------------------| unused (11) | hash (12) | callback (8) | done (1) |-----------------------------------------------------| value (32) |----------------------------------------------------- |
所以我们可以控制callback,hash 两个变量的值,还有个done是0,不过没事,我们可以换账户么,换个末尾是0的就行。所以完全可以把原来的owner覆盖成我们自己。但是问题来了,他要求require(msg.value >= 100000000 ether)
,这个就比较过分了,好像有点难顶。
但是又发现,这个modifier里声明的 FailedAttempt info;
也是未指定位置和初始化的。那它也能改点东西啊。看看它的结构
1234567 |
-----------------------------------------------------| idx (32) |-----------------------------------------------------| time (32) |-----------------------------------------------------| origin (20) | triedPass (12) |----------------------------------------------------- |
他占三个slot,所以它能改到slot2,也就是safeboxes的长度。safeboxes是一个动态数组,failedLogs 是一个映射,但他们都是存储在storage上的,所以有没有可能,我是说可能,他们是可以重叠的。只要safeboxes的长度比他们各自起始位置的差值的二分之一大就可以了。也就是 keccak245(msg.address()||3) -keccak256(2) < safebox.length // 2
(因为一个box占俩slot)
重叠之后能干嘛,重叠之后 failedLogs 里的 某个 info 通过修改 triedPass 就能覆盖safeboxes里某个box的callback了。把callback覆盖成sendFlag?格局小了,那不还是得要100000000eth,直接给他跳到 emit SendFlag(msg.sender)
,pwn!那我们怎么知道emit SendFlag(msg.sender)
的位置在哪儿呢?看汇编,https://ethervm.io/decompile/ropsten/0x85B0446Dc5B5f32cbB674Dc8e49Fc27Ebaff2Ee2 根据100000000eth的特征我们找到
(EVM好像只让jump到jumpdest的地方),所以我们往070F跳。
然后这个覆盖是在修饰器里造成的,所以我们需要调用一次deposit ,转进去1 eth使得safeboxes[0] 的 callback 是 sendEther 从而方便之后调用withdraw可以触发修饰器里对info的写。
解题步骤
- 计算
target = keccak256(keccak256(msg.sender||3)) + 2
,这个是 failedLogs [msg.sender].”origin+tridPasss” 的地方,我们要改这里【注意这里有两次keccak,一次是mapping的,一次是failedLogs[]的,实际部署的时候在这里踩坑了】 - 计算
base = keccak256(2)
,这个是safeboxes的起始位置 - 计算
idx = (target-base)//2
这个是要改的位置和safeboxes开始的位置之间能塞多少个box - 如果
(target-base) % 2 == 1
,说明不是正正好塞满整数个box,那么idx += 2,我们要用到下两个box,这个box和下一个box都改不到。 - 如果
(msg.sender << (12*8)) < idx
得换一个账户,因为safeboxes的长度是用 tx.origin 去覆盖的,最后的值会是tx.origin << (12*8) + Pass
- 用 1 eth 调用一下
deposit(0x000000000000000000000000)
- 调用
withdraw(0, 0x111111111111110000070f00)
,如果step4中 (target-base) % 2 == 1,那么这一步执行两次 - 最后再调用一下
withdraw(idx, 0x000000000000000000000000)
就能触发emit SendFlag(msg.sender);事件了。
程序流程
前三步应该没有什么问题,只是一个简单的距离计算,就像pwn里面的溢出你需要算填充多少字节一样。
第四步有一个分类讨论了就,如果正好被2整除,那么就是这样子的一个情况
此时我们修改failedLogs [0] 的 pass 就能够改到 safeboxes[idx] 的 callback
但如果是不被2整除,就稍微麻烦些,storage上应该是这样
我们需要修改failedLogs [1] 的 pass 才能改到 safeboxes[idx+2] 的 callback
第六步调用deposit(0x000000000000000000000000)
,转个 1eth,此时 safeboxes[0].callback = sendEther,safeboxes[0].hash = 0x000000000000000000000000,safeboxes[0].done = false,safeboxes.value = 0.99eth
第七步调用 withdraw(0, 0x111111111111110000070f00)
,此时会调用sendEther函数,进入修饰器,由于不满足 (bytes12(sha3(pass)) != safeboxes[idx].hash)
,所以开始写info,info.idx = 0,info.time = now,info.triedPass = 0x111111111111110000070f00,info.origin = tx.origin
。注意此时info的值会修改slot0,slot1,slot2的值,所以此时owner=0,randomNumber = now,safeboxes.length = tx.origin << (12*8) + 0x111111111111110000070f00
,然后把这个info推进 failedLogs [0],但推进faileLogs[0] 的同时,也把 safeboxes[idx].callback 改成了 111111110000070f
如果之前(target-base) % 2 == 1,那么再执行一次,前面的不变,不过又把一个info推进到了 failedLogs [1] ,此时会把 safeboxes[idx+2].callback 给改了。
最后调用withdraw(idx, 0x000000000000000000000000)
,执行 box.callback(idx, pass);
,此时 box.callback 已经被劫持到了 emit SendFlag(msg.sender) 的位置,触发事件,收flag。
【然而事情并非如我所愿,实际操作的时候卡在最后一步了,】
我这里给的pass是0xdeadbe00000000000008FF00(因为我的jumpdest是08ff),此时我的账户地址是0x5B38Da6a701c568545dCfcB03FcB875f56beddC4,我查的是safeboxes[23098898392122849103790042457787377065045997405586824991915591150521413904160],返回的是数组该处的hash值为0xb03fcb875f56beddc4deadbe,就是我账户地址的后半部分和我pass的前3个字节,说明我调用withdraw后,修饰器写了info,
推进failedLogs 的同时也改了safeboxes该处的值。且属于safeboxes该处结构体的callback属性的值应该是00000000000008FF,正好8个字节,然后最后的00是属于done的。那么按理说,我们withdraw数组该处box的时候,会直接执行这个box的callback,也就是0x08ff,但是,,我失败了。
【破案了,兄弟们,搞了半天之后去问zbr,才发现,是反编译的时候,把constructor code给搞进去了,所以偏移错了,不是8ff,是89a,我直接拿着input去反编译的,第一次报错,连汇编都没出来,然后我把复制的0x给删掉了之后,字节码出来了,但是伪代码没出来
然后选择性忽略了这一行小字。他说的是我可能把constructor code(不知道具体干啥的,反正应该是部署的时候给JVM看的,也许是设定了JVM部署时要用的参数啊什么什么的,不了解,也没google到,不知道哪里能搞到权威指南看)带进去了,要删掉,通常是从第一个6080(6060)删到第二个6080(6060),日,,删了之后,伪代码也出来了
寄!
pass改成0xdeadbe000000000000089a00
起飞!【踩坑记录就不删了,警醒一下自己属于是】
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可联系QQ 643713081,也可以邮件至 [email protected] - source:Van1sh的小屋
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论