從一起“盜幣”事件再談合約安全問題
本來是受到從一起“盜幣”事件看以太坊存儲 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方式直接部署即可.
初次調用newRecords
應該說絕大多數時候newRecords肯定是無法直接調用成功,那就先來一次失敗調用吧.
我們就傳入參數
0,"0x3131313131313131313131313131313131313131313131313131313131313131","0x692a70d2e424a56d2c6c27aa97d1a86395877b3a"
可以看到調用失敗了,失敗結果如下:
從失敗中找到正確方法
常言說,失敗乃成功之母,我們就從失敗中尋找成功吧.
Debug去找尋存儲地址hash(records_slot)
單擊Debug開始找尋地址的旅程吧.
這整個過程只有最后的records[index]=newRecord
會在storage空間存儲內容,因此我們只需快進到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())
}