其实这道题应该算是比较过时了,只有solidity 0.5.0 以前可能才会出现的漏洞,感觉主要是结构体未初始化造成的一个变量覆盖,以及程序流的劫持,有一点pwn的感觉在里面。所以通过这道题也是对solidity的存储机制有了一定的了解。
状态变量储存结构
参考登链社区的solidity中文文档,除了映射(mapping) 和 动态数组 的静态大小变量都是从位置 0 开始连续放置在存储(storage)中,如果可能的话,存储大小少于32字节的多个变量会被打包到一个存储插槽中(storage slot),(所以一个slot就是32字节的大小),规则如下:
-
slot的第一项会以向右对齐的方式存储 -
基本类型仅使用存储他们所需字节大小的存储空间 -
如果一个slot的剩余空间不足以放下接下来的基本变量,那么它会移到下一个slot -
结构体和数组的数据总是会占用一整个新的slot,但结构体或数组中的每一项还是会以上述规则打包。即不会出现一个slot里出现两个结构体或者两个数组的情况,即使一个结构体或数组也许仅仅占用了2字节。
还有更多比较细节的东西,就不再这篇文章里提出来了,感兴趣的读者可以去看看文档深入了解。
映射和动态数组
由于映射和动态数组的大小是不可预知的,所以他们使用keccak256来计算找到值得位置或数组的起始位置,映射和动态数组本身会根据上述规则在某个位置 处占满一个slot(或递归的将该规则应用到映射的映射或者数组的数组),对于动态数组,此slot会存储数组中元素的数量;对于映射,这个插槽不用,但这个茅坑还是得占,这样可以使得两个映射之后会使用不同的散列分布。
对于动态数组,数组的起始位置会位于 keccak256(p)
处,对于映射,映射中的每个键对应的值会位于 keccak256(k||p)
处,(||是连接符,代码:keccak256(abi.encodePacked(k, p))
)如果这个值不是基本类型(比如是个结构体),那么就通过加偏移来确定。
例子
// 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。
漏洞合约例子
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
好了,搞懂前面三个问题,我们就可以来看看这个题目了。
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,可以先排一下
-----------------------------------------------------
| unused (12) | owner (20) | <- slot 0
-----------------------------------------------------
| randomNumber (32) | <- slot 1
-----------------------------------------------------
| safeboxes.length (32) | <- slot 2
-----------------------------------------------------
| occupied by failedLogs but unused (32) | <- slot 3
-----------------------------------------------------
然后box的是
-----------------------------------------------------
| unused (11) | hash (12) | callback (8) | done (1) |
-----------------------------------------------------
| value (32) |
-----------------------------------------------------
所以我们可以控制callback,hash 两个变量的值,还有个done是0,不过没事,我们可以换账户么,换个末尾是0的就行。所以完全可以把原来的owner覆盖成我们自己。但是问题来了,他要求require(msg.value >= 100000000 ether)
,这个就比较过分了,好像有点难顶。
但是又发现,这个modifier里声明的 FailedAttempt info;
也是未指定位置和初始化的。那它也能改点东西啊。看看它的结构
-----------------------------------------------------
| 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
起飞!【踩坑记录就不删了,警醒一下自己属于是】
end
招新小广告
ChaMd5 Venom 招收大佬入圈
新成立组IOT+工控+样本分析 长期招新
原文始发于微信公众号(ChaMd5安全团队):区块链篇-Balsn CTF 2019 – Bank