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


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

    本来是受到从一起“盗币”事件看以太坊存储 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**256*256位这么大.
    作为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] (https://img2018.cnblogs.com/blog/124391/201811/124391-20181115120330641-1178298347.png)

    solidity的任何warning都请不要忽略

    六. 小测试工具

    计算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())
    }
    
  • 相关阅读:
    设计模式--总结
    设计模式--行为型模式--解释器模式
    设计模式--行为型模式--备忘录模式
    设计模式--行为型模式--访问者模式(Visitor模式)
    设计模式--行为型模式--迭代器模式
    设计模式--行为型模式--中介者模式
    js常用方法集合
    CSS 每隔4行显示不同样式的表格
    常用正则验证
    wIndow 强制关闭被占用的端口
  • 原文地址:https://www.cnblogs.com/baizx/p/9962754.html
Copyright © 2020-2023  润新知