DASP TOP 10

https://www.dasp.co/

This project is an initiative of NCC Group. It is an open and collaborative project to join efforts in discovering smart contract vulnerabilities within the security community. To get involved, join the github page.

Reentrancy

重入漏洞

也称为或与竞空、递归调用漏洞、调用未知相关

这个漏洞在审查中被许多不同的人漏掉了很多次:审查人员倾向于一次审查一个函数,并假定对安全子程序的调用会按预期安全运行。 -Phil Daian

重入攻击(Reentrancy attack)可能是最著名的以太坊漏洞,它首次被发现时让所有人都大吃一惊。它是在一次价值数百万美元的抢劫中首次被发现的,这次抢劫导致了以太坊的硬分叉。

当外部合约调用被允许在初始执行完成之前对调用合约进行新的调用时,就会出现重定向。对于函数来说,这意味着在执行过程中,由于调用了不受信任的合约或使用了带有外部地址的低级函数,合约状态可能会发生变化。

损失:估计为 350 万个以太坊(当时约合 5000 万美元)

Timeline of discovery:

Date Event
Jun 5, 2016 Christian Reitwiessner discovers an antipattern in solidity
Jun 9, 2016 More Ethereum Attacks: Race-To-Empty is the Real Deal (vessenes.com)
Jun 12, 2016 No DAO funds at risk following the Ethereum smart contract ‘recursive call’ bug discovery (blog.slock.it)
Jun 17, 2016 I think TheDAO is getting drained right now (reddit.com)
Aug 24, 2016 The History of the DAO and Lessons Learned (blog.slock.it)

著名事件

举例说明:

智能合约会跟踪多个外部地址的余额,并允许用户使用其公共 withdraw() 函数取回资金。
恶意智能合约使用 withdraw() 函数取回全部余额。
在更新恶意合约的余额之前,受害者合约会执行 call.value(amount)() 低级函数将以太币发送给恶意合约。
恶意合约有一个可支付的 fallback() 函数,用于接收资金,然后再调用受害者合约的 withdraw() 函数。
第二次执行会触发资金转移:请记住,恶意合约的余额仍未从第一次提款中更新。因此,恶意合约第二次成功提取了全部余额。

代码示例:

下面的函数包含一个容易受到重入攻击的函数。当低级 call() 函数向 msg.sender 地址发送以太坊时,该函数就会受到攻击;如果该地址是智能合约,支付就会使用剩余的交易气体触发回退函数:

1
2
3
4
5
function withdraw(uint _amount) {
require(balances[msg.sender] >= _amount);
msg.sender.call.value(_amount)();
balances[msg.sender] -= _amount;
}

Access Control

通过调用 initWallet 函数,可以将奇偶校验钱包库合约转化为普通的多重签名钱包,并成为其所有者

访问控制问题在所有程序中都很常见,不仅仅是智能合约。事实上,它在 OWASP Top 10 中排名第 5。人们通常通过合约的公共或外部函数访问合约的功能。虽然不安全的可见性设置让攻击者可以直接访问合约的私有值或逻辑,但访问控制绕过有时更为隐蔽。当合约使用已废弃的 tx.origin 来验证调用者、使用冗长的 require 来处理大型授权逻辑,以及在代理库或代理合约中肆意使用 delegatecall 时,就会出现这些漏洞。

例子:

智能合约指定初始化合约的地址为合约所有者。这是授予特殊权限(如提取合约资金的能力)的常见模式。
不幸的是,初始化函数可以被任何人调用–甚至在它已经被调用之后。这就允许任何人成为合约的所有者并提取其资金。

代码例子:

在下面的示例中,合约的初始化函数将函数的调用者设置为其所有者。然而,该逻辑与合约的构造函数是分离的,它不会跟踪它已被调用的事实。

1
2
3
function initContract() public {
owner = msg.sender;
}

在 Parity 多重签名钱包中,这个初始化函数脱离了钱包本身,而是定义在一个 “库 ”合约中。用户需要通过委托调用库函数来初始化自己的钱包。不幸的是,在我们的例子中,该函数并没有检查钱包是否已经初始化。更糟糕的是,由于库是一个智能合约,任何人都可以初始化库本身并调用销毁它。

Arithmetic Issues

溢出条件会产生不正确的结果,尤其是在没有预料到这种可能性的情况下,会危及程序的可靠性和安全性。

整数溢出和下溢并不是一类新的漏洞,但它们在智能合约中尤其危险,因为智能合约中普遍存在无符号整数,而大多数开发人员习惯于使用简单的 int 类型(通常只是有符号整数)。如果出现溢出,许多看似无害的代码路径就会成为盗窃或拒绝服务的载体。

例子:

智能合约的 withdraw() 函数允许您取回捐赠给合约的以太币,只要您的余额在操作后仍为正数。
攻击者试图提取超过其当前余额的以太币。
withdraw() 函数的检查结果总是正数,允许攻击者提取超出允许范围的金额。由此产生的余额不足溢出,变得比应有余额大一个数量级。

代码例子

最直接的例子就是一个不检查整数下溢的函数,允许你提取无限量的代币:

1
2
3
4
5
function withdraw(uint _amount) {
require(balances[msg.sender] - _amount > 0);
msg.sender.transfer(_amount);
balances[msg.sender] -= _amount;
}

第二个例子(在 “狡猾的 Solidity 编码竞赛 ”中发现的)是由于数组的长度用无符号整数表示而产生的偏差:

1
2
3
4
function popArrayOfThings() {
require(arrayOfThings.length >= 0);
arrayOfThings.length--;
}

第三个例子是第一个例子的变种,即对两个无符号整数进行运算的结果是一个无符号整数:

1
2
3
4
5
function votes(uint postId, uint upvote, uint downvotes) {
if (upvote - downvote < 0) {
deletePost(postId)
}
}

第四个示例使用了即将淘汰的 var 关键字。因为 var 将改变自身为包含赋值所需的最小类型,所以它将变成 uint8 来保存值 0。如果循环要遍历 255 次以上,它将永远不会达到这个数字,并会在执行耗尽时停止:

1
2
3
for (var i = 0; i < somethingLarge; i ++) {
// ...
}

Unchecked Return Values For Low Level Calls

应尽可能避免使用低级 “调用”。如果返回值处理不当,可能会导致意想不到的行为。

底层函数 call()、callcode()、delegatecall() 和 send() 是 Solidity 的深层特性之一。它们在处理错误时的行为与其他 Solidity 函数截然不同,因为它们不会传播(或冒泡),也不会导致当前执行的完全回退。相反,它们会返回一个设置为 false 的布尔值,代码将继续运行。这可能会让开发人员大吃一惊,而且如果不检查此类低级调用的返回值,可能会导致失败打开和其他不必要的结果。请记住,发送可能会失败!

代码例子

下面的代码就是一个例子,说明如果忘记检查 send() 的返回值可能会出现什么问题。如果调用 send() 函数向一个不接受以太币的智能合约发送以太币(例如,因为该合约没有可支付的回退函数),那么 EVM 就会将其返回值替换为 false。由于在我们的示例中没有检查返回值,因此函数对合约状态的更改将不会被还原,etherLeft 变量最终将跟踪一个不正确的值:

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);
}

Denial of Service

在以太坊的世界里,拒绝服务是致命的:其他类型的应用程序最终可以恢复,而智能合约却可能因为一次这样的攻击而永远离线。导致拒绝服务的方式有很多,包括作为交易接收方时的恶意行为、人为增加计算函数所需的气体、滥用访问控制访问智能合约的私有组件、利用混淆和疏忽等。这类攻击包括许多不同的变种,在未来几年可能会有很大的发展。

例子:

  • 拍卖合约允许用户竞拍不同的资产。
  • 要出价,用户必须调用 bid(uint object) 函数,并输入所需的以太币金额。拍卖合约会将以太币存入托管账户,直到对象的所有者接受竞价或初始竞价者取消竞价为止。这就意味着拍卖合约的余额中必须包含所有未竞价的以太币。
  • 拍卖合约还包含一个 withdraw(uint amount) 函数,允许管理员从合约中提取资金。由于该函数会将金额发送到一个硬编码地址,因此开发者决定公开该函数。
  • 攻击者看到了潜在的攻击机会,于是调用了该函数,将合约的所有资金都转给了管理员。这就破坏了托管承诺,并阻止了所有待定投标。
  • 虽然管理员可能会将托管资金返还给合约,但攻击者只需再次提取资金就能继续攻击。

代码例子:

在下面的例子中(灵感来自《以太之王》),如果公开贿赂前任总统,游戏合约的一个功能就可以让你成为总统。不幸的是,如果上一任总统是一个智能合约,并在付款时导致还原,那么权力转移就会失败,恶意智能合约将永远担任总统。听起来像是独裁:

1
2
3
4
5
6
function becomePresident() payable {
require(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 = price * 2; // we double the price to become president
}

在第二个例子中,调用者可以决定下一次函数调用将奖励给谁。由于 for 循环中的指令很昂贵,攻击者可以引入一个大到无法迭代的数字(由于以太坊中的气体块限制),这将有效阻止函数的运行。

1
2
3
4
5
6
function selectNextWinners(uint256 _largestWinner) {
for(uint256 i = 0; i < largestWinner, i++) {
// heavy code
}
largestWinner = _largestWinner;
}

Bad Randomness

该合约对区块.编号年龄的验证不足,导致 400 ETH 被一名未知玩家抢走,该玩家在等待了 256 个区块后才揭晓了可预测的中奖号码。

随机性在以太坊中很难实现。虽然 Solidity 提供的函数和变量可以访问明显难以预测的值,但它们通常要么比看上去更公开,要么受到矿工的影响。由于这些随机性来源在一定程度上是可预测的,恶意用户一般可以复制它,并依靠其不可预测性攻击函数。

例子:

  • 智能合约使用区块号作为游戏的随机性来源。
  • 攻击者会创建一个恶意合约,检查当前区块编号是否是赢家。如果是,它就会调用第一个智能合约来获胜;由于调用是同一交易的一部分,因此两个合约上的区块链号码将保持不变。
  • 攻击者只需调用她的恶意合约,直到它获胜为止。

代码例子:

在第一个例子中,私人种子与迭代次数和 keccak256 哈希函数结合使用,以确定调用者是否获胜。尽管种子是私有的,但它一定是在某个时间点通过交易设置的,因此在区块链上是可见的。

1
2
3
4
5
6
7
8
9
10
uint256 private seed;

function play() public payable {
require(msg.value >= 1 ether);
iteration++;
uint randomNumber = uint(keccak256(seed + iteration));
if (randomNumber % 2 == 0) {
msg.sender.transfer(this.balance);
}
}

在第二个示例中,block.blockhash 被用来生成一个随机数。如果 blockNumber 被设置为当前的 block.number,那么这个哈希值就是未知的(原因显而易见),因此会被设置为 0。如果 blockNumber 被设置为过去 256 个以上的区块,那么它将始终为 0。最后,如果它被设置为一个不算太旧的前一个区块编号,另一个智能合约就可以访问相同的编号,并调用游戏合约作为同一交易的一部分。

1
2
3
4
5
6
function play() public payable {
require(msg.value >= 1 ether);
if (block.blockhash(blockNumber) % 2 == 0) {
msg.sender.transfer(this.balance);
}
}

Front-Running

事实证明,只需大约 150 行 Python 代码,就能获得一个有效的前端运行算法。

由于矿工代表外部拥有的地址(EOA)运行代码总能通过气体费获得奖励,因此用户可以指定更高的费用,让自己的交易更快被挖掘出来。由于以太坊区块链是公开的,因此每个人都可以看到其他人的待处理交易内容。这就意味着,如果某个用户透露了谜题的答案或其他有价值的秘密,恶意用户就可以窃取答案,并以更高的费用复制他们的交易,抢占原始答案的先机。如果智能合约的开发者稍有不慎,这种情况就会导致实际的破坏性前置攻击。

例子:

  • 智能合约发布一个 RSA 数字(N = prime1 x prime2)。
  • 调用其带有正确质数 1 和质数 2 的 submitSolution() 公共函数,就能获得奖励。
  • 爱丽丝成功计算出 RSA 数字并提交了解决方案。
  • 网络上有人看到 Alice 的交易(包含解决方案)正在等待挖矿,于是以更高的天然气价格提交了该交易。
  • 由于支付的费用较高,第二笔交易首先被矿工选中。攻击者赢得了奖金。

Time manipulation

如果矿工持有合约的股份,他就可以通过为正在开采的区块选择合适的时间戳来获得优势。

从锁定代币销售到在游戏的特定时间解锁资金,合约有时需要依赖当前时间。在 Solidity 中,这通常是通过 block.timestamp 或其别名来实现的。但这个值从何而来?来自矿工!由于交易的矿工在报告挖矿发生的时间上有一定的回旋余地,因此好的智能合约会避免强烈依赖所公布的时间。请注意,block.timestamp 有时也会被(误)用于生成随机数,这在 #6 中讨论过。随机性差。

例子:

  • 一个游戏在今天午夜向第一个玩家支付奖金。
  • 一个恶意矿工试图赢得游戏,并将时间戳设置为午夜。
  • 在午夜前一点,矿工最终挖出了区块。真实的当前时间与午夜(当前设置的区块时间戳)“足够接近”,网络上的其他节点决定接受该区块。

代码例子:

以下函数只接受特定日期之后的调用。由于矿工可以影响其区块的时间戳(在一定程度上),他们可以尝试挖掘一个包含其交易的区块,并将区块时间戳设置在未来。如果时间足够接近,就会被网络接受,在其他玩家试图赢得游戏之前,这笔交易就会给矿工以太币:

1
2
3
4
5
function play() public {
require(now > 1521763200 && neverPlayed == true);
neverPlayed = false;
msg.sender.transfer(1500 ether);
}

Short Address Attack

为令牌传输准备数据的服务部门假定用户将输入 20 字节长的地址,但实际上并没有检查地址的长度。

短地址攻击是 EVM 本身接受错误填充参数的副作用。攻击者可以利用这一点,使用特制的地址使编码不良的客户端在将参数纳入事务之前对参数进行错误编码。这是 EVM 的问题还是客户端的问题?是否应该在智能合约中解决?虽然每个人都有不同的看法,但事实是,大量以太币可能会受到这个问题的直接影响。虽然这个漏洞尚未被广泛利用,但它很好地展示了客户端与以太坊区块链之间的交互所产生的问题。还存在其他链外问题:其中一个重要问题是以太坊生态系统对特定 Javascript 前端、浏览器插件和公共节点的深度信任。Coindash ICO 遭黑客攻击时使用了一个臭名昭著的链外漏洞,该漏洞修改了该公司网页上的以太坊地址,诱骗参与者向攻击者的地址发送以太坊。

例子:

  • 一个交易所应用程序接口(API)有一个交易函数,它接收一个收件人地址和一个金额。
  • 然后,API 与智能合约 transfer(address _to, uint256 _amount)函数交互,并填充参数:在地址(预期长度为 20 字节)前加上 12 个 0 字节,使其长度达到 32 字节。
  • 鲍勃(0x3bdde1e9fbaef2579dd63e2abbf0be445ab93f00)要求爱丽丝给他转 20 个代币。他恶意地将自己的地址截短,去掉了尾部的 0。
  • Alice 使用交换 API 获取了 Bob 较短的 19 字节地址(0x3bdde1e9fbaef2579dd63e2abbf0be445ab93f)。
  • API 在地址中填充了 12 个零字节,使地址从 32 字节变为 31 字节。这实际上是从下面的 _amount 参数中窃取了一个字节。
  • 最终,执行智能合约代码的 EVM 会指出数据未正确填充,并在 _amount 参数末尾添加丢失的字节。这样,转移的代币数量实际上比想象的多 256 倍。

Unknown Unknowns

我们认为,更多的安全审计或更多的测试不会有什么影响。主要问题是审查人员不知道应该注意什么。

以太坊仍处于起步阶段。用于开发智能合约的主要语言 Solidity 还未达到稳定版本,生态系统的工具仍处于试验阶段。一些最具破坏性的智能合约漏洞让所有人都大吃一惊,没有理由相信不会再出现同样出人意料或同样具有破坏性的漏洞。只要投资者决定将大量资金投入到复杂但未经严格审核的代码上,我们就会不断看到新的发现,从而导致可怕的后果。对智能合约进行正式验证的方法尚未成熟,但它们似乎大有希望摆脱目前摇摇欲坠的现状。随着新的漏洞不断被发现,开发人员需要保持警惕,并开发新的工具,在坏人发现之前找到它们。在智能合约开发达到稳定和成熟的状态之前,这十大漏洞可能会迅速演变。