区块链篇-Balsn CTF 2019 - Bank

admin 2022年4月25日02:28:43CTF专场评论10 views9624字阅读32分4秒阅读模式

其实这道题应该算是比较过时了,只有solidity 0.5.0 以前可能才会出现的漏洞,感觉主要是结构体未初始化造成的一个变量覆盖,以及程序流的劫持,有一点pwn的感觉在里面。所以通过这道题也是对solidity的存储机制有了一定的了解。

状态变量储存结构

参考登链社区的solidity中文文档,除了映射(mapping) 和 动态数组 的静态大小变量都是从位置 0 开始连续放置在存储(storage)中,如果可能的话,存储大小少于32字节的多个变量会被打包到一个存储插槽中(storage slot),(所以一个slot就是32字节的大小),规则如下:

  1. slot的第一项会以向右对齐的方式存储
  2. 基本类型仅使用存储他们所需字节大小的存储空间
  3. 如果一个slot的剩余空间不足以放下接下来的基本变量,那么它会移到下一个slot
  4. 结构体和数组的数据总是会占用一整个新的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 =&gt; NameRecord) public registeredNameRecord;
    mapping(bytes32 =&gt; 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的特征我们找到

区块链篇-Balsn CTF 2019 - Bank

(EVM好像只让jump到jumpdest的地方),所以我们往070F跳。

然后这个覆盖是在修饰器里造成的,所以我们需要调用一次deposit ,转进去1 eth使得safeboxes[0] 的 callback 是 sendEther 从而方便之后调用withdraw可以触发修饰器里对info的写。

解题步骤

  1. 计算target = keccak256(keccak256(msg.sender||3)) + 2,这个是 failedLogs [msg.sender].”origin+tridPasss” 的地方,我们要改这里【注意这里有两次keccak,一次是mapping的,一次是failedLogs[]的,实际部署的时候在这里踩坑了】
  2. 计算base = keccak256(2),这个是safeboxes的起始位置
  3. 计算idx = (target-base)//2 这个是要改的位置和safeboxes开始的位置之间能塞多少个box
  4. 如果 (target-base) % 2 == 1,说明不是正正好塞满整数个box,那么idx += 2,我们要用到下两个box,这个box和下一个box都改不到。
  5. 如果 (msg.sender << (12*8)) < idx 得换一个账户,因为safeboxes的长度是用 tx.origin 去覆盖的,最后的值会是 tx.origin << (12*8) + Pass
  6. 用 1 eth 调用一下 deposit(0x000000000000000000000000)
  7. 调用 withdraw(0, 0x111111111111110000070f00),如果step4中 (target-base) % 2 == 1,那么这一步执行两次
  8. 最后再调用一下withdraw(idx, 0x000000000000000000000000) 就能触发emit SendFlag(msg.sender);事件了。

程序流程

前三步应该没有什么问题,只是一个简单的距离计算,就像pwn里面的溢出你需要算填充多少字节一样。

第四步有一个分类讨论了就,如果正好被2整除,那么就是这样子的一个情况

区块链篇-Balsn CTF 2019 - Bank

image-20220218173428749

此时我们修改failedLogs [0] 的 pass 就能够改到 safeboxes[idx] 的 callback

但如果是不被2整除,就稍微麻烦些,storage上应该是这样

区块链篇-Balsn CTF 2019 - Bank

image-20220218173330674

我们需要修改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。

【然而事情并非如我所愿,实际操作的时候卡在最后一步了,】

区块链篇-Balsn CTF 2019 - Bank

image-20220218213341705

我这里给的pass是0xdeadbe00000000000008FF00(因为我的jumpdest是08ff),此时我的账户地址是0x5B38Da6a701c568545dCfcB03FcB875f56beddC4,我查的是safeboxes[23098898392122849103790042457787377065045997405586824991915591150521413904160],返回的是数组该处的hash值为0xb03fcb875f56beddc4deadbe,就是我账户地址的后半部分和我pass的前3个字节,说明我调用withdraw后,修饰器写了info,

区块链篇-Balsn CTF 2019 - Bank

img

推进failedLogs 的同时也改了safeboxes该处的值。且属于safeboxes该处结构体的callback属性的值应该是00000000000008FF,正好8个字节,然后最后的00是属于done的。那么按理说,我们withdraw数组该处box的时候,会直接执行这个box的callback,也就是0x08ff,但是,,我失败了。

区块链篇-Balsn CTF 2019 - Bank

image-20220218213234763

【破案了,兄弟们,搞了半天之后去问zbr,才发现,是反编译的时候,把constructor code给搞进去了,所以偏移错了,不是8ff,是89a,我直接拿着input去反编译的,第一次报错,连汇编都没出来,然后我把复制的0x给删掉了之后,字节码出来了,但是伪代码没出来

区块链篇-Balsn CTF 2019 - Bank

然后选择性忽略了这一行小字。他说的是我可能把constructor code(不知道具体干啥的,反正应该是部署的时候给JVM看的,也许是设定了JVM部署时要用的参数啊什么什么的,不了解,也没google到,不知道哪里能搞到权威指南看)带进去了,要删掉,通常是从第一个6080(6060)删到第二个6080(6060),日,,删了之后,伪代码也出来了

区块链篇-Balsn CTF 2019 - Bank

image-20220218215830157

寄!

pass改成0xdeadbe000000000000089a00

区块链篇-Balsn CTF 2019 - Bank

image-20220218215925025

起飞!【踩坑记录就不删了,警醒一下自己属于是】




end


招新小广告

ChaMd5 Venom 招收大佬入圈

新成立组IOT+工控+样本分析 长期招新

欢迎联系[email protected]



区块链篇-Balsn CTF 2019 - Bank

原文始发于微信公众号(ChaMd5安全团队):区块链篇-Balsn CTF 2019 - Bank

特别标注: 本站(CN-SEC.COM)所有文章仅供技术研究,若将其信息做其他用途,由用户承担全部法律及连带责任,本站不承担任何法律及连带责任,请遵守中华人民共和国安全法.
  • 我的微信
  • 微信扫一扫
  • weinxin
  • 我的微信公众号
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年4月25日02:28:43
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                  区块链篇-Balsn CTF 2019 - Bank http://cn-sec.com/archives/939165.html

发表评论

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: