合约漏洞
Last updated on 21 hours ago
重入攻击
重入攻击是指攻击者通过在合约的回调函数中重新调用原合约,造成意外的行为或重入漏洞。
重入攻击的一个攻击点就是合约转账ETH的地方:转账ETH的目标地址如果是合约,会触发对方合约的fallback(回退)
函数,从而造成循环调用的可能。
攻击流程大致为:
- 攻击者合约调用被攻击合约
- 被攻击合约向调用者转账(msg.sender.call{value:amount}(“”)),此时msg.sender为攻击者合约地址
- 攻击者合约收到被攻击者合约的转账,触发fallback函数,再fallback中再次调用被攻击合约
- 2,3步骤循环
解决方案就是使用重入锁:在进行转账前修改状态变量,转账完成后,再修改回来。
1 |
|
溢出攻击
每个数据类型都有它的取值范围,如果计算范围超出最大值或小于最小值,则会出现上溢和下溢。比如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
通过某些手段,对服务进行干涉,使得目标可用性降低或失去可用性而拒绝服务,称为拒绝服务攻击。
拒绝服务攻击包括但不限于以下:
基于代码逻辑的攻击:
这种一般是由于合约不严谨导致的,比如:当合约未对传入的映射或数组做限制时,攻击者可以通过输入超长映射或数组 消耗大量的Gas,使得这笔交易的Gas溢出,最后使该合约暂时或永久不可用基于外部调用的攻击:
一般是合约对外部调用处理不严谨导致的基于运营管理的攻击:
比如合约的管理者被盗号
其中最常用的就是基于外部调用的攻击,例如:
有个合约A用于竞选最富有的人,其定义了一个函数用于接收以太币,然后将接收的以太币与当前最富有者发送的以太币对比,如果产生了新富豪,则将上一个富豪发送的以太币用address.call{value:account}()
原路返回,如果返还以太币失败则会使用require
退出。
这个合约的攻击点在于返回以太币与接收以太币都在同一个函数中,现在有一个攻击合约B,B中只有一个函数,接收以太币并调用A的竞选函数。
当B成为富豪后,其他人想竞选将会失败,这是因为B合约中没有payable
修饰的fallback
函数,使得B无法接收B.call{value: account}()
这样的空调用转账,所以A对B的转账会一直失败,使得B成为了永久的富豪。
解决方案:
定义新函数单独处理退款,用取回模式代替发送模式,这样就算有人恶意拒收也只能影响到自己。
tx.origin钓鱼攻击
tx.origin
和msg.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 |
|
1 |
|
攻击流程:
- 攻击合约调用
hackMe.doSomething
并传入自己的地址 hackMe.doSomething
通过delegatecall
调用lib.doSomething
- 由于
delegatecall
的特性,lib中someNumber
状态变量的修改会同步到hackMe中的lib
状态变量,lib指向了攻击合约 - 攻击合约继续调用
hackMe.doSomething
,由于hackMe.lib
被修改,故hackMe.doSomething
将会调用攻击合约中的doSomething
,将owner修改为攻击者
解决方案:
- 在使用 delegatecall 时应注意被调用合约的地址不能是可控的
- 在较为复杂的合约环境下需要注意变量的声明顺序以及存储位置。因为使用 delegatecall 进行外部调用时会根据被调用合约的数据结构来修改本合约相应 slot 中存储的数据
选择器碰撞
solidity在调用函数时,会通过函数选择器找到对应函数,函数选择器就是函数签名哈希值的前4个字节
函数签名是一个字符串,包含了函数名和参数类型形如"functionName(uint256)"
,选择器就是bytes4(keccak256(bytes("functionName(type1,type2)")))
选择器碰撞是指两个不同的函数具有相同的选择器,比如LOCK8605463013()
和uWjK9(uint256)
,他们的选择器都是0xffffffff
选择器碰撞可以通过这个网址查询“Ethereum Signature Database”
参考资料:
“登链-慢雾科技” 智能合约安全审计入门篇 各大篇