1. 程式人生 > >從一起“盜幣”事件再談合約安全問題

從一起“盜幣”事件再談合約安全問題

目錄

從一起“盜幣”事件再談合約安全問題

本來是受到從一起“盜幣”事件看以太坊儲存 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())
}