区块链安全:《智能合约漏洞分析》
2022-3-9 01:24:23 Author: mp.weixin.qq.com(查看原文) 阅读量:0 收藏

0x01 前言

前段时间在研究智能合约漏洞方面的内容,去年写了一半后一直没时间,最近抽空把下半部分完成。

0x02 目录

  • 假充值

  • 溢出

  • 51%攻击

  • 短地址

  • 重入

0x03 假充值

什么是“假充值”?

当我们在谈论“假充值”攻击时,我们通常谈的是攻击者利用公链的某些特性,绕过交易所的充值入账程序,进行虚假充值,并真实入账。

以太坊代币“假充值”漏洞影响面非常之广,影响对象至少包括:相关中心化交易所、中心化钱包、代币合约等。单代币合约,据相关机构的不完全统计就有 3619 多份存在“假充值”漏洞风险,其中不乏知名代币。

漏洞案例分析一:“ THORChain 跨链系统“假充值”漏洞 ”

    2021 年 6 月 29 日,去中心化跨链交易协议 THORChain 发推称发现一个针对 THORChain 的恶意攻击,THORChain 节点已作出反应并进行隔离和防御。这是一起针对跨链系统的“假充值 ”攻击。

      随着 RenVM、THORChain 等跨链服务的兴起,跨链节点充当起了交易所的角色,通过扫描另一条公链的资产转移情况,在本地公链上生成资产映射。THORChain 正是通过这种机制,将以太坊上的代币转移到其它公链。

漏洞分析:

找到 THORChain 库的漏洞文件处,可以看到2021年6月28日有2次提交,修复了此次跨链攻击 。

我们看看修复之前的代码,进入业务逻辑;首先,在处理跨链充值事件的时候,合约内调用了getAssetFromTokenAddress( ) 方法获取代币的信息,并传入了资产合约地址 depositEvt.Asset.String( )作为参数:

thornode/bifrost/pkg/chainclients/ethereum/ethereum_block_scanner.go

在 getAssetFromTokenAddress 方法里,它调用了 getTokenMeta 去获取代币元数据,此时也传入了资产的合约地址作为参数,但在此处有一个定义,在初始化代币时,默认赋予了代币符号为 ETH ,这也因此成为了漏洞的关键点之一;asset := common.ETHAsset,如果被传入的合约地址对应的代币符号为 ETH,那么此处关于 symbol 的验证就将被绕过。

当代币地址在系统中不存在时,会从ETH主链上去获取合约信息,并以得到的 symbol 构建出新的代币,此时我们看到了所有的漏洞成因:

thornode/bifrost/pkg/chainclients/ethereum/ethereum_block_scanner.go

thornode/bifrost/pkg/chainclients/ethereum/tokens_db.go

thornode/bifrost/pkg/chainclients/ethereum/ethereum_block_scanner.go

可以看到,一开始首先是因为错误的定义;如果跨链充值的 ERC20 代币符号为 ETH ,那么将会出现逻辑错误,从而导致充值的代币被识别为真正的以太币 ETH。

漏洞修复:

官方在发现攻击后迅速对代码进行了修复,首先将默认的代币类型删除,然后使用 common.EmptyAsset 对空代币进行定义,并在后续的逻辑中使用 asset.IsEmpty( ) 进行判断是否为空,过滤没有进行赋值的假充值代币。

跨链系统可能会聚集巨额的多链资金,建议在进行跨链系统设计时,充分考虑不同公链不同代币的特性,进行“假充值”测试。

0x04 溢出

相关溢出原理简介:

在区块链的编程语言 Solidity 中,变量支持的整数类型步长以8递增,支持从 uint8 到uint256,以及 int8 到 int256。一个 uint8 类型 ,只能存储在范围 0 到 2^8-1,也就是[0,255] 的数字,一个 uint256 类型 ,只能存储在范围 0到2^256-1 的数字。

在以太坊虚拟机(EVM)中为整数指定固定大小的数据类型,而且是无符号的,这意味着在以太坊虚拟机中一个整型变量只能有一定范围的数字表示,不能超过这个制定的范围。

为了说明整数溢出原理,这里以 8 (uint8) 位无符整型为例,8 位整型可表示的范围为 [0, 255],255 在内存中存储按位存储的形式为下图所示:

(win自带画图工具画的,丑见谅)

8 位无符整数 255 在内存中占据了 8bit 位置,若再加上 1 整体会因为进位而导致整体翻转为 0,最后导致原有的 8bit 表示的整数变为 0。

同样整数下溢也是一样,如 (uint8)0 - 1 = (uint8)255。

漏洞案例分析一:“ BEC合约整数溢出漏洞 ”

2018年4月23日中午11点30分左右,BEC代币被黑客攻击。

黑客利用溢出漏洞攻击了 BEC 的智能合约,成功地向两个地址转出了天量级别的 BEC 代币,导致市场上海量 BEC 被抛售,该数字货币价值几近归零,给 BEC 市场交易带来了毁灭性打击。

看一下这笔交易:

攻击者向两个账号转移了

57,896,044,618,658,100,000,000,000,000,000,000,000,000,000,000,000,000,000,000.792003956564819968

个BEC,相当于 BEC 凭空做出了一个非常巨大的增发,这几乎导致BEC价格瞬间归零。

BEC含有溢出漏洞的合约代码重点在 255 行至 268 行的 batchTransfer 这个函数,我们将其摘出分析:

  function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {    uint cnt = _receivers.length;    uint256 amount = uint256(cnt) * _value;    require(cnt > 0 && cnt <= 20);    require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount); for (uint i = 0; i < cnt; i++) { balances[_receivers[i]] = balances[_receivers[i]].add(_value); Transfer(msg.sender, _receivers[i], _value); } return true; }}

这个函数的作用就是批量转账,问题出在 uint256 amount = uint256(cnt) * _value 这句代码,没有使用 SafeMath 库的 mul 函数,而是直接采用乘法运算符。

通过构造 cnt 和 _value 的值,可以绕过 require(_value > 0 && balances[msg.sender] >= amount) 语句的判断,导致amount最终为0。

具体赋值为:cnt = _receivers.length = 2,_value = 2**255,这样 amount = uint256(cnt) * _value = 2**255*2 就超过了 uint256 表示的最大值,导致溢出,最终amount = 0。

溢出防护:

对于这种整数溢出漏洞,最简单的方法是采用 OpenZeppelin 提供的一套智能合约函数库中的 SafeMath 数学计算库来避免。

SafeMath 中对基础的算术操作都规定了函数,使得加减乘除不再是简单的符号,通过各个函数内部的 assert 判断机制, 来防护整数溢出问题;

如果 

uint256 amount = uint256(cnt) * _value

这句代码改为

uint256 amount = uint256(cnt).mul(_value)//或者uint256 amount = _value.mul(uint256(cnt)) 

就可以防止溢出问题。

合约编写者并不是没有安全意识,在源码中我们可以看到SafeMath已经导入了,只是在这关键的一步没有使用SafeMath中的乘法mul方法,只能说是大意了。

在合约中做加减乘除运算时,一定要做溢出检查,使用 SafeMath 可以防止溢出问题。

0x05 51%攻击

一组矿工控制超过 50% 的网络挖矿哈希率(专门用于挖掘和处理交易的所有计算能力的总和)对区块链的攻击称为 51% 攻击。

区块链是一种存储和记录数据的分类帐技术。简而言之,区块链是不断更新和审查的分布式交易列表。区块链的一个关键特征是它由去中心化的节点网络组成(确保加密货币保持去中心化和安全的关键部分)。

当一个人或一群人控制超过 50% 的区块链哈希算力时,就会发生 51% 攻击,也称为多数攻击,这通常是通过从第三方租用挖矿算力来实现的。

Bitcoin SV、Verge 和 Ethereum Classic 都是遭受 51% 攻击的项目的例子。

成功的攻击者获得了阻止新交易被确认以及改变新交易顺序的能力,它还允许恶意代理从本质上重写部分区块链并反转他们自己的交易,从而导致称为双重支出的问题。

传统上,这个问题主要是电子支付面临的一个问题,因为其中网络无法证明两个或更多人没有花费相同的数字资产。

51% 攻击的可能性:

随着区块链网络的发展,它降低了发生 51% 攻击的可能性,这是因为执行 51% 攻击的成本与网络哈希率(承诺给网络的计算能力)的同步上升。

从本质上讲,网络越大,参与的节点越多,控制超过 50% 的网络所需的算力就越多,但即使攻击者达到 50% 以上的哈希率,区块链的大小仍然可以提供安全性。这是由于区块在链中链接在一起,因此只有在消除所有后续确认的区块后才能更改区块。

尽管有这种可能,但这样做对攻击者来说代价太高,原因有两个:

1. 攻击者必须花费大量的计算能力(电力成本)才能达到 51% 的哈希率,尤其是在更大更成熟的网络上。

2. 由于矿工没有以适当的参与方式行事,他们将不再获得挖矿带来的区块链奖励。

因此交易数量越多,链上的区块就越多,更改区块的难度就越大。

虽然像比特币这样的大型区块链仍然存在 51% 攻击的威胁(尽管极不可能),但财务成本将远远超过收益。

即使攻击者花费其所有资源来攻击区块链,不断向链中添加块也只会为攻击者更改的大量交易提供一个相对较小的窗口。

0x06 短地址

ERC20 Token标准(ERC20 Token Standard)是通过以太坊创建Token时的一种规范,按照 ERC20 的规范可以编写一个智能合约,创建“可互换Token”(也可称为“代币”)。

它并非强制要求,但遵循这个标准,所创建的Token可以与众多交易所、钱包等进行交互,它现在已被行业普遍接受。

在ERC20中实现了很多基础函数,其中在进行Token转账时,最常用到的是 transfer(),第一参数是发送代币的目的地址,第二个参数是发送token的数量。

短地址攻击原理:

利用 EVM 在参数长度不够时自动在右方补 0 的特性,通过去除钱包地址末位的 0,达到将转账金额左移放大的效果。

示例:

具体代币功能的合约 TokenDemo,当 A 账户向 B 账户转代币时调用 transfer() 函数,例如

A 账户(0x14723a09acff6d2a60dcdf7aa4aff308fddc160c)向 B 账户(0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db)转 8 个 TokenDemo,msg.data 数据为:

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函数签名0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2db  -> B 账户地址(前补 0 补齐 32 字节)0000000000000000000000000000000000000000000000000000000000000008  -> 0x8(前补 0 补齐 32 字节)(这里的0xa9059cbb是method的hash值、第二个是地址,第三个是amount参数)

那么短地址攻击是怎么做的呢,攻击者找到一个末尾是 00 账户地址,假设为 0x4b0897b0513fdc7c541b6d9d7e929c4e5364d200,那么正常情况下整个调用的 msg.data 应该为:

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函数签名0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d200  -> B 账户地址(注意末尾 00)0000000000000000000000000000000000000000000000000000000000000008  -> 0

但是如果我们将 B 地址的 00 吃掉,不进行传递,也就是说我们少传递 1 个字节变成 4+31+32:

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函数签名0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2  -> B 地址(31 字节)0000000000000000000000000000000000000000000000000000000000000008  -> 0x8(前补 0 补齐 32 字节)

当上面数据进入 EVM 进行处理时,对参数进行编码对齐后补 00 变为:

0xa9059cbb0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2000000000000000000000000000000000000000000000000000000000000000800

也就是说,恶意构造的 msg.data 通过 EVM 解析补 0 操作,导致原本 0x8 = 8 变为了 0x800 = 2048;本来仅仅转账8个TOEKN,经过攻击后转账2048个TOKEN。

上述 EVM 对畸形字节的 msg.data 进行补位操作的行为其实就是短地址攻击的原理,目前主要依靠客户端主动检查地址长度,web3层面的参数格式校验来避免该问题。

外部应用程序中的所有输入参数都应在发送到区块链之前进行验证。

0x07 重入

重入:

以太坊智能合约的特点之一是它们能够调用和利用来自其他外部合约的代码,合约通常还处理以太币,因此经常将以太币发送到各种外部用户地址。

这些操作需要合约提交外部调用,这些外部调用可能被攻击者劫持,攻击者可以强制合约执行进一步的代码(通过 Fallback 函数),包括对自身的调用。

当合约将以太币发送到未知地址时,可能会发生这种类型的攻击,攻击者可以在回退函数中“用包含恶意代码的外部地址”构造合约。

所以当合约将以太币发送到该地址时,它将调用恶意代码,然后恶意代码会在易受攻击的合约上执行一个功能,执行开发者预料之外的操作。

概念:回退函数是合约里的特殊无名函数,它在合约调用没有匹配到函数签名,或者调用没有带任何数据时被自动调用。

触发场景:

  • address.send(ether_to_send)

  • address.call().value(ether_to_send)

漏洞示例:

首先创建一份涉及金额操作的合约 EthStore.sol

该合约有两个作用,depositFunds()和 withdrawFunds(),depositFunds()函数只是增加发起人的余额。

withdrawFunds()允许发送方指定提取的 wei 数量,此功能仅在请求的提款金额小于 1 以太币且上周未发生提款时才会成功。

漏洞位于第 17 行,合约向用户发送他们请求的以太币数量。

接下来我们再创建一个攻击者构造的合约 Attack.sol:

漏洞利用:

首先,攻击者创建恶意合约( 假设地址是0x0...123 ),并将EthStore的合约地址作为构造函数参数,这会初始化变量并将其指向要攻击的合约地址。

然后攻击者会调用该attackEtherStore()函数,并使用一定数量的大于或等于 1 的以太币

我们假设许多其他用户已将以太币存入EthStore合约,因此其当前余额为10 ether,然后会出现以下情况:

    1. Attack.sol 第15行:EthStore合约的 depositFunds()函数被调用,并传入形参 "1 ether"。调用者(msg.sender)是攻击者创建的恶意合约(0x0...123),所以 EthStore.sol合约的 balances[0x0.. 123] = 1 ether。

    2. Attack.sol 第17行:恶意合约会调用参数为"1 ether"的EthStore合约的withdrawFunds函数。所有的 require(EthStore合约的第12-16行)都为true,因为之前没有进行过取款。 

    3. EthStore.sol 第17行:合约发回1个以太币给恶意合约。

    4. Attck.sol 第25行:支付给恶意合约的款项将执行回退(fallback)函数。

    5. EthStore.sol 第26行: EthStore 合约的总余额是10个以太币,现在是9个,所以这个if 语句为true。

    6. Attck.sol 第27行:回退(fallback)函数再次调用EthStore 合约的 withdrawFunds()函数,并“reenters(重入)”EtherStore合约。

    7. EthStore.sol 第11行:在第二次对 withdrawFunds 的调用中,恶意合约的余额仍然是1 以太币,因为EthStore.sol的第18行还没有执行,所以 EthStore.sol 里的 balances[0x0.. 123] 依然= 1 ether,lastWithdrawTime 变量也一样,最后再次通过了所有的 require()。  

    8. EthStore.sol 第17行:Attck.sol 合约又再次收回了 1 以太币。

    9. 重复步骤  4-8,直到 Attck.sol 合约的第26行为 false,也就是 EthStore.sol 合约里的余额不大于1。

    10. Attck.sol 第26行:在 EthStore.sol 合约中剩下1个 (或更少的) 以太币时,这个if语句就会false;EthStore.sol 合约的 withdrawFunds()函数在被调用时就会正常的执行第18行和第19行,最后执行结束。  

最终结果是攻击者在一次交易中从 EthStore.sol 合约中提取了除1个以太币以外的所有以太币。

防重入:

    1. 在将 Ether 发送给外部合约时使用内置的 transfer() 函数 ,因为 transfer()转账功能只发送 2300 的 gas, 不足以使合约调用另一份合约(即重入)。

    2. 引入互斥锁,也就是说,要添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用。

    3. 使用官方推荐的 “Checks-Effects-Interactions” 模式。

0x08 end

除此之外,智能合约还存在越权、DDOS、随机数...等等类型的漏洞,其中大部分都是逻辑漏洞,时间原因就不在这里陈述了。

由于智能合约开源的特殊性,在开发合约时需要多注意安全问题,不可粗心大意。

谢谢~


文章来源: https://mp.weixin.qq.com/s?__biz=Mzg5MzU4NTgwNQ==&mid=2247484977&idx=1&sn=87c8904801a9d136ec253f97c0fa6ce9&chksm=c02dd6d3f75a5fc5cdad68b0aa288e9c6703e902799a364755f9d9bb83b082a4cf63a304d114&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh