solidty智能合约漏洞总结

1,317 阅读18分钟

简单漏洞

这块漏洞主要是由于solidty语法没有深入理解而产生的使用错误,或者接连一些逻辑错误。

1.tx.origin身份认证漏洞

有开发者不知道tx.origin和msg.sender两者区别而进行混用或者是没理解tx.origin的用法,导致攻击者可以通过再写一个合约达成tx.origion!=msg.sender条件,而将余额转向自己的私人账户。

tx.origin 表示的是触发当前函数执行的原始的交易发送者
msg.sender 表示的是调用当前函数的直接消息发送者

如果通过攻击合约调用函数,这个时候调用栈就是 你的账户—你的攻击合约—函数,所以攻击合约是msg.sender,tx.origin是这条交易线最开始的那个地址,也就是你的账户

2.利用transfer夺权漏洞

solidity里有三种转账方法:

  • address.transfer()
  • address.send()
  • address.call.value(转账的金额).gas(转账最大允许支付的gas)(调用的ABI编码参数)

transfer 没有返回值,出错抛出异常,send、call出错不抛出异常,返回true或false

tansfer相对send更安全,发生错误会回滚状态(比如执行 匿名函数的时候),但是send只会返回false,另外这2个函数都需要花费2300gas

可能出现利用transfer执行失败的条件,让合约报废。
例如:

receive() external payable {
require(msg.value >= prize || msg.sender == owner);
king.transfer(msg.value);
king = msg.sender;
prize = msg.value;
}

这个合约收到足够钱会先把钱转给上一个king,再把转钱的这个地址设为king

未有考虑调用者为另一智能合约的情况
基本上只要king的地址为一智能合约,而该智能合约又并未定义fallback或是receive,transfer就会失败。 未定义fallback或是receive的智能合约收到transfer传送的以太币会以exception处理。

因此只要一个未定义fallback或是receive的智能合约占用king,合约就会在transfer时出现错误,令king的地址永远属于该智能合约。

3.private数据泄露漏洞

很多人会在合约privte里存放很重要的数据,殊不知,他们并不安全。

在区块链中private的数据并不是真正的private,他只限制在合约层面的不可见,在区块链浏览器上是可见的。 也就是说即使变量是private我们也能看到具体数据。

只要我们知道该变量的存储位置,就可以直接通过web3查询
比如,查询合约第五个插槽的数据:

slot = await web3.eth.getStorageAt("合约地址", 5);

会得到一个返回:

0x41df771c1ea3a15b991385eb8aa950ba5bd195fa46f5e8807307612c000d92a9

4.extcodesize合约代码量绕过

这个漏洞涉及到汇编。
以太坊账户分为合约账户和外部账户,两者区别之一就是合约账户有代码而外部账户没有。
extcodesize则是回来对应地址的合约代码的size

modifier gateTwo() {
uint x; assembly { x := extcodesize(caller()) }
require(x == 0);
_;
}

caller()函数会来call sender,也便是call的发起者。假设extcodesize的参数是用户地址则会回来0,是合约地址则回来了调用合约的代码size。

用这段代码的本意也许是为了让调用者必须是外部账户,但是!!合约也可以 绕过。
一个特性:当合约正在履行结构函数constructor并部署时,其extcodesize为0。 也就是说我们在constructor里调用这个函数的话,extcodesize(caller())会是0.因此就绕过了查看。

5.delegatecall和call错误使用漏洞

在智能合约开发过程中,合约的相互调用是经常发生的。由于三种外部调用函数的相似性,区别容易混淆,导致滥用。

delegatecall

delegatecall不当使用可能会造成数据覆盖,数据被恶意篡改等。

solidity中有三种合约间的调用方式:call、 delegatecall、 callcode
call: 最常用的调用方式,调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境(合约的 storage)。
delegatecall: 调用后内置变量 msg 的值不会修改为调用者,但执行环境为调用者的运行环境。
callcode: 调用后内置变量 msg 的值会修改为调用者,但执行环境为调用者的运行环境。

这是一个讲解链接: (26条消息) 图文并茂详细介绍Solidity的三种合约间的调用方式 call、delegatecall 和 callcode_powervip的博客-CSDN博客

Delegatecall的问题,也就在于此。调用Delegatecall并不切换上下文,直接运行新的合约代码。合约A Delegatecall 合约B的方法,如果该方法存在变量的改变,那么改变的就是A合约的变量值。

并且!!他修改的数据不是修改相同名字的,而是相同存储位置的数据。

6.create2地址构造漏洞

1.一些合约会对调用者地址做一些要求,但是攻击者可以用create2去构造合约地址从而拿到权限。
2.可以做到在同一个地址上部署不同的合约来pass不同的校验。

以太坊有两种创建地址的方法:create 和create2

CREATE

如果利用外部账户或者使用 CREATE 操作码的合约账户创建一个合约,那么很容易就能确定被创建合约的地址。
每个账户都有一个与之关联的 nonce:对外部账户而言,每发送一个交易,nonce 就会随之 +1;对合约账户而言,每创建一个合约,nonce 就会随之 +1。新合约的地址由创建合约交易的发送者账户地址及其 nonce 值计算得到,其具体公式如下:

keccak256(rlp.encode(address, nonce))[12:]

CREATE2

不同于CREATE操作吗,CREATE2不再依赖于账户的nonce,而是对以下参数进行哈希计算,得到新的地址:

  • 合约创建者的地址
  • 作为参数的混淆值(salt)
  • 合约创建代码 计算公式如下:

keccak256(0xff ++ address ++ salt ++ keccak256(init_code))[12:]

注意:计算合约地址最后一个参数并非合约代码而是创建代码,该代码是用来创建合约,合约创建完成后将返回运行时字节码。

1.控制salt可以构造出我们想要地址
2.保持合约创建代码不变,控制合约构造函数返回的运行时的字节码,那么很容易做到在同一个地址上,反复部署不同的合约。

7.block.blockhash使用漏洞

有些合约会拿block.blockhash来做一些运算或者验证,但是忽略了block.blockhash的特性。

image.png

也就是说只有最新的256个区块才会返回hash,其他的都返回0.

那么攻击者等到他过了256个区块,直接输入x0000000000000000000000000000000000000000000000000000000000000000就可以破解该值,从而控制一些运算。

8.结构体覆盖漏洞

这是一个比较常见的漏洞,问题在于结构体只声明并没有初始化,就没有赋予存储空间。

结构体在函数内非显式地初始化的时候会使用storage存储而不是memory,所以就可以达到变量覆盖的效果

如果一个合约的变量是这样声明的:

struct Donation {
uint256 timestamp;
uint256 etherAmount;
}
Donation[] public donations;
address public owner;

如果在函数中直接新另一个Donation的话,就会直接覆盖slot[0]和slot[1]也就是donations的长度和owner会被覆盖
像这样:

Donation donation;
donation.timestamp = now;
donation.etherAmount = etherAmount;
donations.push(donation);

如果在函数中是新另一个donations[i]就不会覆盖,会按照动态数组的方式存储元素。但是这个时候也可以通过计算出数组起始位置,算出slot[1]存储位置的插槽,然后赋值覆盖。

经典漏洞

此部分漏洞是发生过真实攻击事件并且造成很多重大损失的漏洞

1.不安全的外部调用漏洞

一些合约会调其他合约的外部调用,但是被调合约可以控制每次的返回值,如果被调合约是恶意的就会造成损失。 例如:

function goTo(uint _floor) public {
Building building = Building(msg.sender);
if (! building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}

该合约两次调用了bulding合约的isLastFloor,我们可以让isLastFloor两次返回值不一样,就能达到控制top的值。
攻击合约可以这样写:

 bool  public sign =false;
 function goTofloor()public{
   elevator=Elevator(0xB55F1ea35AFd58aa006ec0C7296462Abdbd2d46D);
   elevator.goTo(10);
 }
 function isLastFloor(uint)external returns (bool){
   sign=!sign;
   return sign;

 }

2.溢出漏洞

溢出分为:整数溢出、动态数组溢出、

  • 整数溢出 【0.8以后的版本已经被修复了】

整数溢出很好理解,基本上接触solidity的人都会知道。

由于计算机底层是二进制,任何十进制数字都会被编码到二进制。溢出会丢弃最高位,导致数值不正确。

如:八位无符号整数类型的最大值是 255,翻译到二进制是 1111 1111;当再加一时,当前所有的 1 都会变成 0,并向上进位。但由于该整数类型所能容纳的位置已经全部是 1 了,再向上进位,最高位会被丢弃,于是二进制就变成了 0000 0000

注:有符号的整数类型,其二进制最高位代表正负。所以该类型的正数溢出会变成负数,而不是零。

  • 数组溢出

动态数组溢出涉及到以太坊的存储,动态数组对应的slot位不是数组元素的内容而是数组的长度,数组元素的存储是:会按序存储在keccak256(bytes(1))+x的插槽内,其中,x就是数组的下标。

而solidity一共只有256个插槽。

如果数组长度发生下溢,就可以覆盖整个solidty插槽,从而修改其他变量的数据。

3. 拒绝服务攻击(DOS)

拒绝服务攻击就是攻击者想办法让目标机器停止提供服务,在智能合约中攻击者可以通过消耗合约Gas、恶意调用等手段实现。

拒绝服务攻击分为三种:基于外部调用的进展、通过外部操纵映射或数组、所有者操作

transfer函数:具体讲解看简单漏洞模板里的<利用transfer夺权漏洞>
revert函数:revert()可以用来标记错误并回退当前调用,剩余的gas会返回给调用者。
Gas耗尽:gaslimit是一次交易的gas的可用上限,由于交易复杂程度不同,确切的gas消耗量等完成交易后才知道。因此在提交交易之前,需要设定一个gas用量的上限。

基于外部调用的进展

就类似于简单漏洞模板里的<利用transfer夺权漏洞>,利用fallback写入revert()/没有fallback导致合约无法使用。

通过外部操纵映射或数组

1.需要遍历数组(用了for循环),但同时数组的长度是可控的。攻击者就可以在数组中加尽量多的内容,那么此时遍历运行,就会消耗大量的gas,如果gas到极限,那么合约就无法正常运行。

2.外部调用fallback时,我们也可以利用在fallback中反复执行之前的需要外部调用的方法,直至gas耗尽。

所有者操作

多数智能合约所有者拥有相当高的权限,如果有一天所有者的私钥泄露/丢失,那么该合约就无法使用/被人利用。

4.代币交换漏洞

1.利用汇率

不同的token之间,如果想要交换,就需要有一个计算汇率的准则。大多数计算方式都是根据两个币池里的token数量的比例来作为汇率。
但是这样可能出现一些问题,当在某一时刻给token1注入大量,这个时候该token1价格就会变得很低,然后再去用另一种token2价购入很多token1,这样token1的价格又会极增。这样套利就完成了。

一个类似的例子(dex): token转换Dex,DexTwo - 掘金 (juejin.cn)

目前会采用延迟改变汇率的策略去避免这种情况。

2.token未限制

有些合约不检查交换是否必然介于 token1和token2 之间,于是可以自己构造一个新的 IERC20 的 token 参与进来,大量铸币approve后随便换就行。

一个类似的例子( Dextwo) token转换Dex,DexTwo - 掘金 (juejin.cn)

5.私钥泄露

1.公钥的计算

想得到一个地址的公钥,直接反推是不行的。有消息+签名是可以恢复出公钥的。知识涉及到以太坊上公钥的生成和交易的签名。

在对交易进行签名后,我们只要知道r、s、v和hash就可以计算出公钥,然而这些值都可以在交易内进行读取。

交易信息:

image.png

利用这些已知的交易信息来使用ethereumjs-tx库创建一个交易从而利用里面封装的getSenderAddress得到公钥

image.png

2.私钥泄露

只要在某地址有两个交易的签名r值相同就可以,解析出私钥来。

image.png
首先拿到消息的哈希:

image.png

根据算法进行一个私钥的计算

image.png

6.短地址漏洞

在0.5.0已被修复,这里就做个科普。
当将参数传递给智能合约时,参数将根据 ABI 规范进行编码。第三方合约可以发送比预期参数长度短的编码参数(例如,发送一个只有 38 个十六进制字符,即19 个字节长度,而不是标准的 20个字节长度的地址时)。在这种情况下,EVM 将在编码参数的末尾填充 0,以弥补预期的长度。

如果传入的是末端缺省的短地址,EVM会将后面字节补足地址,而最后的值不足则用0填充,导致实际转出的代币数值倍增。

例如:
假设接收地址:0xc3Bb35818d58FCA0C4943bA98938cb6F46A91700

如果去掉末尾的两个 0 (一个十六进制字节等于0) EVM 会接受它,但在打包的功能参数的末尾添加额外的零。从给出一个短参数的点开始,传递函数参数将移动一个字节,将移动传递的令牌数量。

如果这个时候去调transfer方法的话,前4字节为函数选择器,中间32字节为地址,后32字节为金额。然后地址我们少输了1个字节,就会拿金额前面的0去填上,再在金额的后面补0,这样金额就从100变成了10000。

7.假充值漏洞

该漏洞是由于在transfer函数中使用if else判断方式,返回状态结果为true和false,并未出现异常。

以太坊代币交易回执中status字段是0x1(true)还是 0x0(false),取决于交易事务执行过程中是否抛出了异常(比如使用了 require/assert/revert/throw等机制),当用户调用代币合约的transfer函数进行转账时,如果transfer函数正常运行未抛出异常,该交易的 status即是0x1(true)

举个例子:一个用户要从账户上取出大于存储金额的金钱,由于取出总数大于存储总量,正常情况下是取不了的,但是如果因为代码使用if else逻辑判断,该操作并未出现异常,返回状态结果为success,导致用户可以给自己转更多的钱。

8.重入漏洞

这是一个比较出名的漏洞。

合约有外部调用或者是触发fallback,外部合约的函数调用早于状态变量的修改,这样攻击者就可以反复回调该函数。直至合约余额为0,或者gas耗尽。

在以太坊中,智能合约能够调用其他外部合约的代码,由于智能合约可以调用外部合约或者发送以太币,这些操作需要合约提交外部的调用,这些外部调用可以被攻击者劫持,从而迫使合约执行更多的代码(即通过 fallback 回退函数),包括回调原合约本身。所以,合约代码执行过程中将可以“重入”该合约重入攻击本质上与编程里的递归调用类似.

这是之前写的一篇讲重入攻击的博客:重入攻击 - 掘金 (juejin.cn)

9.条件竞争漏洞

该漏洞发生的主要原因,相关的两笔的交易,如果后一笔交易的gasprice更高而优先打包,就会导致安全问题发生。

例子:某合约授权第三方100个以太币,在相关联的两笔交易中使用更高的gsPrie打包后一笔交易时, 正常的两次交易打包顺序会发生改变,如果后者在该合约所有者修改授权以太币之前取走原本授权的以太币,就会导致安全问题的的发生。

具体场景下可能发生的安全问题:
●A用户授权B用户100个以太币用以转账或进行其他操作。
●第二天,B用户的100个以太币还未使用,A用户就将授予的100个以太币修改 为50个以太币。
●B用户发现了A用户修改以太币的交易,然后B用户使用更高的gasPrice发出一 笔提取100个以太币的交易。
●当B用户的交易优先被打包后,B用户就得到100个以太币。
●当A用户将授予的100个以太币修改为50个以太币的交易打包成功后,B用户还会 有对50个以太币的使用权,至此,B用户得到了更多的以太币,从而出现安全问题。 下面我们用一一个较为具体的例子来理解交易顺序依赖漏洞问题。

奇葩漏洞

此部分漏洞来自于马马虎虎和奇奇怪怪的合约开发者

1.ERC20继承合约漏洞

这个漏洞发生有自己特殊的情景,在合约有自己逻辑需要重写erc20的方法的时候,可能会存在漏写。

比如:只重写了erc20的transfer方法没有重写transferfrom方法,根据 ERC20 的标准我们也知道,转账有两个函数,一个transfer一个transferFrom,题目中代码只重写了transfer函数,那重写transferFrom就是一个可利用的点了。

2.地址恢复+权限漏设

前面我们也讲过create出的地址是可以被计算出来的,如果有一个工厂合约(可以每次用它部署一个新的合约),同时他的模板合约有一些方法没有受到限制(比如说destory函数)。

那么我们就可以把部署过的合约地址算出来,调用他的方法。

计算合约地址

合同地址是确定性计算的。新帐户的地址被定义为仅包含发送方和帐户随机数的结构的 RLP 编码的 Keccak 哈希的最右边的 160 位。 表示此函数的更简单方法是:

address = rightmost_20_bytes(keccak(RLP(sender address, nonce)))
  • sender address:是创建此新合约的合约或钱包地址
  • nonce:是从 OR 发送的交易数量,如果发送者是工厂合同, 则是此帐户创建的合同数量。
  • RLP:是数据结构上的编码器,是序列化以太坊中对象的默认编码器。
  • keccak:是一个加密原语,用于计算任何输入的以太坊-SHA-3 (Keccak-256) 哈希值。

计算
1.工厂合约地址 2.第一次部署地址的nonce为1

nonce 0 is always the smart contract’s own creation event
nonce 0始终是智能合约自己的创建事件。

3.20 字节地址的 RLP 编码为:0xd6,0x94。对于所有小于 0x7f 的整数,其编码只是它自己的字节值。因此,1 的 RLP 是0x01。

计算在remix里我们可以这样写: address public a = address(keccak256(0xd6, 0x94, YOUR_ADDR, 0x01));

还有一种方法:直接用Etherscan去查

3.构造函数错误漏洞

solidity的0.4.22之前,构造函数就是和合约同名的函数。然后出现了什么问题呢?

有的开发人员把名字打错了。。也就是说构造函数变成了普通函数,没有按预期执行,造成安全漏洞。