从一次盗币事件再谈合约安全问题

从一起“盗币”事件再谈合约安全问题

本来是受到从一起“盗币”事件看以太坊存储 hash 碰撞问题一文启发,但是我并不太认同文中的观点.并且文中有一些技术性错误.

一. 起因

今日某安全厂商在以太坊上发布一份让大家来"盗币"的合约,就是希望大家能够意识到不好的合约设计会存在严重安全隐患.下面是这份合约源码.

pragma solidity ^0.4.21;
contract DVPgame {
    ERC20 public token;
    uint256[] map;
    using SafeERC20 for ERC20;
    using SafeMath for uint256;
    constructor(address addr) payable{
        token = ERC20(addr);
    }
    function (){
        if(map.length>=uint256(msg.sender)){
            require(map[uint256(msg.sender)]!=1);
        }
        if(token.balanceOf(this)==0){
            //airdrop is over
            selfdestruct(msg.sender);
        }else{
            token.safeTransfer(msg.sender,100);

            if (map.length <= uint256(msg.sender)) {
                map.length = uint256(msg.sender) + 1;
            }
            map[uint256(msg.sender)] = 1;  

        }
    }
    //Guess the value(param:x) of the keccak256 value modulo 10000 of the future block (param:blockNum)
    function guess(uint256 x,uint256 blockNum) public payable {
        require(msg.value == 0.001 ether || token.allowance(msg.sender,address(this))>=1*(10**18));
        require(blockNum>block.number);
        if(token.allowance(msg.sender,address(this))>0){
            token.safeTransferFrom(msg.sender,address(this),1*(10**18));
        }
        if (map.length <= uint256(msg.sender)+x) {
            map.length = uint256(msg.sender)+x + 1;
        }

        map[uint256(msg.sender)+x] = blockNum;
    }
    //Run a lottery
    function lottery(uint256 x) public {
        require(map[uint256(msg.sender)+x]!=0);
        require(block.number > map[uint256(msg.sender)+x]);
        require(block.blockhash(map[uint256(msg.sender)+x])!=0);
        uint256 answer = uint256(keccak256(block.blockhash(map[uint256(msg.sender)+x])))%10000;
        if (x == answer) {
            token.safeTransfer(msg.sender,token.balanceOf(address(this)));
            selfdestruct(msg.sender);
        }
    }
}

上述文中提到这里面安全问题是因为solidity在存储map时候的地址计算方式,存在hash碰撞问题,所以导致币被盗走. 但是显然并不是因为hash碰撞问题. 确实不好的设计会导致hash碰撞问题,但是这里确实不是hash碰撞引起的问题.

二. solidity复杂变量的地址计算问题

一个示例

开始之前,我们先找一个兼具各种元素

pragma solidity ^0.4.23; 
contract Locked {
    bool public unlocked = false;    
    struct NameRecord { 
        bytes32 name;
        address mappedAddress;
    }
    mapping(address => NameRecord) public registeredNameRecord; 
    mapping(bytes32 => address) public resolve;
    NameRecord []records;
    function register(bytes32 _name, address _mappedAddress) public {
        NameRecord newRecord;
        newRecord.name = _name;
        newRecord.mappedAddress = _mappedAddress; 
        resolve[_name] = _mappedAddress;
        registeredNameRecord[msg.sender] = newRecord; 
        require(unlocked); 
    }
    function newRecords(uint256 index,bytes32 _name, address _mappedAddress) public{
        NameRecord memory newRecord;
        newRecord.name = _name;
        newRecord.mappedAddress = _mappedAddress; 
        if(recor)
        records[index]=newRecord;
        require(unlocked);
    }
}

简单变量的地址

每个合约都会有自己独立的存储空间(storage),运行时的Memory空间.storage和memory空间都是从0开始.
因为EVM是一个256位的虚拟机,因此Storage空间有2*256256位这么大.
作为Locked这份合约中第一个简单变量unlcoked的地址就是0.
基本类型int,string,bytes32,固定大小的数组等都是简单类型,他们有固定的长度. 很容易算出来占用多少字节空间,因此只需依次累加即可.
比如registeredNameRecord的地址是1,resolve的地址是2,records地址就是3
另外就是要注意空间对齐问题

动态数组以及Map的地址

Array计算问题

因为动态数组,比如这里的records事先无法预知大小,他的地址计算就会用到hash. 简单来说,这里records中元素的起始地址就是hash(slot),这里的slot是3,因为records是第四个变量.
这个hash(slot)就是这个数组的起始地址,真正存储的变量地址则是hash(slot)+offset,offset的计算方式就和其他所有语言的offset计算方式都一样i*sizeof(NameRecord).

这种方式的好处就在于节省Gas,虽然定义了records对象,但是在你没存储任何对象之前,不会浪费一点Gas,要知道存储一个字就是20000Gas,成本昂贵.

而slot3,也就是3这个地址存的是数组的长度.

Map地址计算问题

Map的存储设计方式类似于Array,一样为了节省Gas,采用hash计算地址.和Array不一样的是,他是Hash(key,slot)而不是简单的slot. 以resolve这个map为例,"arandname"存储地址就是hash(bytes32("arandnme"),uint256(2).

如果存储对象比较复杂,不止占用一个字的存储空间,按照顺序递增即可.

三. 先来玩demo

newRecords函数成功调用,必须要求unlocked为true,但是unlocked并没有可以修改的地方. 这是一个棘手的问题,实际上这个是最前面合约问题的简化.

首先我们知道unlocked的存储地址为0
其次我们已经知道了动态数组的地址计算规则,那么是否可以让records[index]计算结果是0呢?

这个地址我们已经知道是hash(records_slot)+index*sizeof(NameRecord).
有了这个公式其实已经比较容易算出来了.

让我们来一步一步计算这个地址吧.

部署合约

这一步比较容易在Remix中选择Javascript VM方式直接部署即可.

部署Locked

初次调用newRecords

应该说绝大多数时候newRecords肯定是无法直接调用成功,那就先来一次失败调用吧.
我们就传入参数
0,"0x3131313131313131313131313131313131313131313131313131313131313131","0x692a70d2e424a56d2c6c27aa97d1a86395877b3a"
可以看到调用失败了,失败结果如下:

失败调用

从失败中找到正确方法

常言说,失败乃成功之母,我们就从失败中寻找成功吧.

Debug去找寻存储地址hash(records_slot)

单击Debug开始找寻地址的旅程吧.

这整个过程只有最后的records[index]=newRecord会在storage空间存储内容,因此我们只需快进到sstore指令即可.

找寻sstore

从Stack中可以看到0元素的起始地址是0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b,要在这个地址上存储的对象是就是0x3131313131313131313131313131313131313131313131313131313131313131,恰好就是name的值.

0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b就是Sha3(3).

构造成功的调用

首先起始地址是0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b,而sizeof(NameRecord)是2,注意不是64,因为EVM单位是32字节而不是字节
就很容易推算出来Index是

(0x10000000000000000000000000000000000000000000000000000000000000000-
0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b)/2
=0x1ed452f8b0d361ff8353039b6876926bcb1e6352e27d7e97d0ed74ddc84703d2

那么我们的调用参数就是

0x1ed452f8b0d361ff8353039b6876926bcb1e6352e27d7e97d0ed74ddc84703d2,"0x3131313131313131313131313131313131313131313131313131313131313131","0x692a70d2e424a56d2c6c27aa97d1a86395877b3a"

下图可以看到成功调用.
![调用结果]

四. 再一起来玩DVPgame

有了上面的思路相信大家就不会想着想法设法猜测lottery的x是多少了,直奔我们的fallback函数即可.

覆盖token

先通过guess函数把token设置为你自己事先部署的一份ERC20 Token,当然DVPgame就不会有任何这种新Token.

让hash(1)+msg.sender+x大于2**256,这个很容易满足吧.
然后把blockNum指定为你的token地址,相信他肯定会比当前的block.number大的.

悄悄告诉你hash(1)就是0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,方便您试试.

随便转点以太坊给DVPgame

正常转账给DVPgame这个合约地址,无论多少都无所谓,反正最后都是会回到我们自己的账户上. 不过还是不要太多,万一有人捷足先登了呢.

看看别人怎么玩的

到底怎么玩我就不做了,因为已经有人玩过了,我也是事后诸葛亮. 链上直播看这里


## 五. 剩下的问题
如果你细心,就会发现我的例子中还有一个register函数没说.如果你自己尝试调用了,就会发现无论怎么调用都会成功,是不是颠覆了三观啊.
其实原因很简单,solidity中结构体默认是分配在storage空间中的(我也不知道为什么这么做,确实有点坑),而且这时候结构体的地址的起始地址就是0. 也就是说newRecord.name = _name;这句话在你不知不觉中就覆盖了unlocked.
说到这里,我还想说的是:如果你在写合约,请把solidity怎么工作的,搞清楚再动手

如果你够细心,至少register中的这个bug是可以避免的,因为solidity都警示你了.

来自solidity的warn

solidity的任何warning都请不要忽略

六. hash碰撞问题

经过上面的

六. 小测试工具

计算hash值的小工具

//Sha3 is short for Keccak256Hash
func Sha3(data ...[]byte) common.Hash {
	return crypto.Keccak256Hash(data...)
}

//BigIntTo32Bytes convert a big int to bytes
func BigIntTo32Bytes(i *big.Int) []byte {
	data := i.Bytes()
	buf := make([]byte, 32)
	for i := 0; i < 32-len(data); i++ {
		buf[i] = 0
	}
	for i := 32 - len(data); i < 32; i++ {
		buf[i] = data[i-32+len(data)]
	}
	return buf
}

func TestCalcHashSlot(t *testing.T) {
	i := big.NewInt(3)
	hash := Sha3(BigIntTo32Bytes(i))
	t.Logf("hash=%s", hash.String()) //0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b
	addr := common.Address{}
	fix := [32]byte{}
	copy(fix[12:], addr[:])
	hash = Sha3(addr[:])
	//addr=0x0000000000000000000000000000000000000000,it's hash=0x5380c7b7ae81a58eb98d9c78de4a1fd7fd9535fc953ed2be602daaa41767312a
	t.Logf("addr=%s,it's hash=%s", addr.String(), hash.String())
}