从一起“盗币”事件再谈合约安全问题
本来是受到从一起“盗币”事件看以太坊存储 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())
}