地址:zkanzz
整型溢出漏洞 (Integer Overflow/Underflow Vulnerability)是计算机程序中因数值运算超出数据类型范围而导致的异常行为。可能导致的危害: 在智能合约中,这种漏洞可能导致资产计算错误 、权限绕过 , 合约逻辑失控,是区块链安全领域的高危风险之一。
在Solidity中, 当我们定义一个整型数据类型时通常需要声明这个整型的长度,在学习Solidity的过程中, 我们学习到整型有无符号整型uint与整型int类型 ,如果直接使用uint声明
uint a; // 这里等效于声明 uint256 a
核心概念
数据类型的有限性 ,计算机中所有数值类型都有固定的存储空间(例如 uint256 用 256 位二进制存储),其取值范围被严格限制
uint8:0 ~ 255(2的8次方-1)
int8:-128 ~ 127 (-1 * 2的7次方 到 2的7次方-1)
溢出类型
上溢(Overflow):数值超过类型最大值 下溢(Underflow):数值低于类型最小值
上溢
原理
下面我们用uint8举例,uint8就是使用了8个比特位,其值的范围是0-2的八次方,也就是 0 - 255,255是uint8数据类型可以存储的最大值,那么如果我们设置一个uint8变量等于255,对其加1会发生什么呢,测试代码如下
// SPDX-License-Identifier: GPL-3.0
pragma solidity = 0.7.6;
contract Test {
function test() public pure returns(uint8) {`` uint8 a = 255;`` return a + 1;`` }
}
运行可以看到如下结果, 最后的值是0

其中运算过程是怎样的呢?
当我们定义了uint8 a = 255 之后,系统为我们分配了一个大小为8bit的内存空间,随后设置其值为255, 换成2进制也就是 1111 1111(为了方便查看这里加上空格),不论是传统的系统还是区块链网络,其底层都是2进制,当我们执行a+1时,也就是二进制的 1111 1111 + 1
最后结果是
1 0000 0000
因为存储结果的变量只有 8 bit,最高位的1会被丢弃,最后只剩下
0000 0000
换算回10进制也就是0
同理, 如果是+2,那么就是
1 0000 0001
最高位被丢弃, 结果值为1
这就是整型上溢,上溢会使得原本很大的值变得很小
案例
假设我们有一个区块链网店业务,示例代码如下, 这种代码理论上是要用 uint256 的, 但是这里为了方便理解我将uint256都修改为了uint8, 实际利用中只需要增加对应的值使其超过2^256-1即可
这里当我们购买128个商品1时就会触发漏洞
// SPDX-License-Identifier: MIT``pragma solidity = 0.7.6;
contract OnlineStore {`` // 商品结构体`` struct Product {`` uint256 id;`` string name;`` uint8 price; // 单位 eth `` }
// 商品存储不定长数组`` Product[] private products;
// 初始化函数`` constructor() {`` // 创建三个示例商品`` products.push(Product(1, "Phone", 2)); // 1 ETH`` products.push(Product(2, "Laptop", 3)); // 3 ETH`` products.push(Product(3, "Headphones", 1)); // 0.5 ETH`` }`` // 价格计算函数`` function calculatePrice(uint8 _productId, uint8 _quantity) `` public`` view`` returns (uint8)`` {`` Product memory product = getProductById(_productId);`` require(product.id != 0, "Product not found");`` require(_quantity > 0, "Quantity must be greater than 0");
return product.price * _quantity;`` }`` // 购买函数`` function purchase(uint8 _productId, uint8 _quantity) public payable returns(string memory){`` uint8 totalPrice = calculatePrice(_productId, _quantity);`` // 检查支付金额`` require(msg.value == totalPrice * 10 ** 18, "Incorrect ETH amount");`` return "success";`` }
// 根据ID查询商品(内部函数)`` function getProductById(uint256 _productId)`` internal`` view`` returns (Product memory)`` {`` for (uint256 i = 0; i < products.length; i++) {`` if (products[i].id == _productId) {`` return products[i];`` }`` }`` return Product(0, "", 0); // 返回空商品`` }`` function test(uint8 a) public pure returns(uint8) {`` return a + 1;`` }``}
接下来来看一下漏洞的产生过程 当我们输入了购买id 1 和购买数量128之后 会先从商品列表中找到对应的商品, 取出其价格 计算 totalPrice = 2 * 128 = 256 因为使用uint8存储 只有8位bit 256的2进制为 1 0000 0000 存储时最高位被丢弃 最后只剩下了 0000 0000
我们部署好代码后运行 大家也可以换成127, 129看看结果

在购买函数中执行 可以看到会返回对应的 success

大家可以自己把uint8改成uint256再测试 另外这里有一个问题 即购买商品3我们无法产生溢出 原因是我们把购买数量设置为了uint8 当我们输入大于255的数字时会直接报错输入类型不匹配, uint8装不下这么大的数字 这里其实可以把uint8改更大, 不影响结果 大家自己多试一试, 也能增加一些理解
下溢
原理
我们继续用uint8类型举例
uint8 a = 0;``return a-1;
这段代码的运行结果我们思考一下会是什么 这里需要一些二进制的基础 在计算机中,减法是通过补码(Two's Complement)实现的。具体步骤如下:例如我们要计算2-1, -1的二进制表示就是1的补码 首先我们要计算 1 的补码:1 的二进制表示:0000 0001 1 的补码(取反加 1):首先进行取反 得到 1111 1110 加1得到 11111111 减法操作也就是加上对应数字的补码执行加法操作:2 的二进制表示是 0000 0010 那么 2 - 1就是 0000 0010 + 1111 1111 得到 1 0000 0001 去掉进位(就是比原本多出来的位数) 得到 0000 0001 也就是十进制的1
看的不是很明白? 我们再来一个案例 5-2 首先计算2的补码 取反: 1111 1101 加一得到: 1111 1110 5的二进制表示是: 0000 0101 0000 0101 + 1111 1110 = 1 0000 0011 或者换一种方法展示(记得从下往上看)
= 1(进位进上来的)``0 + 1 = 1 + 1 = 0``0 + 1 = 1 + 1 = 0``0 + 1 = 1 + 1 = 0``0 + 1 = 1+1 (同下, 也是进位, 后面不在赘述) = 0``0 + 1 = 1 + 1(这个1是进位) = 0 进一位``1 + 1 = 1 进一位``0 + 1 = 1``1 + 0 = 1
最后结果就是 1 0000 0011 去掉进位 得到 0000 0011 也就是2的0次方+2的1次方= 1+2 = 3
我们继续, 如果是0-1呢 8位bit下0的二进制表示 0000 0000 1的二进制表示 0000 0001 取反: 1111 1110 加一: 1111 1111 -1就是 1111 1111 0-1 -> 0000 0000 + 1111 1111 = 1111 1111 没有进位 最终值是256 拿uint8举例是8个bit表示一组 如果是uint16 那0就是 0000 0000 0000 0000 1就是 0000 0000 0000 0001 -1就是(同样是取反再加一) 1111 1111 1111 1111 0-1就是 0000 0000 0000 0000 + 1111 1111 1111 1111 最后得到 1111 1111 1111 1111 = 2的16次方 - 1 等于65535 大家可以在Solidity或者C语言中试验一下如下代码查看结果 这里就不再演示
uint16 a = 0;``return a-1;
大家也可以自行尝试计算uint16下的5 - 2的计算 这里就不再浪费篇幅
案例
整型下溢的案例比如说存款合约取款 大家可以先查看这段代码 尝试下是否能看出问题
contract Bank {`` ....`` mapping(address => uint256) public balanceOf;`` function withdraw(uint256 amount) public {`` require(balanceOf[msg.sender] - amount >= 0);`` balanceOf[msg.sender] -= amount;`` payable(msg.sender).transfer(amount);`` }``}
这段代码乍看之下是没有问题的,但是我们要考虑到,balanceOf中存储的是uint256, 无符号整数,amount也是无符号整数,它们相减的结果也会是一个uint256无符号整数,如果balanceOf[msg.sender]的值是0,amout是1,无符号整数0-1得到的结果会发生下溢,就像我们刚刚说的,uint16下 0-1 会变成uint16能表示的最大值,这里就会变成uint256能表示的最大值, 2的256次方-1,这里得到的结果永远大于0,也就是说这段代码恒成立,可以任意取款
那么接下来实验一下, 结果是否如我们所想 上面的代码是不全的, 无法直接测试 我们先增加一个存款函数(不然合约没钱没法转出来) 测试代码如下
// SPDX-License-Identifier: MIT``pragma solidity = 0.7.6;
contract Bank {`` mapping(address => uint256) public balanceOf;
function deposit() public payable { // Deposit Ether`` }
function withdraw(uint256 amount) public payable {`` require(balanceOf[msg.sender] - amount >= 0);`` balanceOf[msg.sender] -= amount;`` payable(msg.sender).transfer(amount);`` }``}
我们这里没有写往balanceOf中增加数值的方法,不管msg.sender是什么, balanceOf[msg.sender]的值都是0,部署好之后
这里设置好发送的eth

然后调用deposit函数,往里面先存点eth方便测试,看到合约余额增加之后

我们在取款函数中填入500000(这里是用wei做单位),执行函数,可以看到发生了取款操作

你也可以写一个函数,用来查看balanceOf[msg.sender]是否是0,但当你取款过一次之后, balanceOf[msg.sender]就不是0了,因为balanceOf[msg.sender] -= amount;时会发生溢出,将balanceOf[msg.sender]的值变成一个非常大的数
练习
最后放一个漏洞代码, 大家可以先自己练习 记得调整编译器为 0.4.21

pragma solidity ^0.4.21;
contract TokenSaleChallenge {`` mapping(address => uint256) public balanceOf; // 存款`` uint256 constant PRICE_PER_TOKEN = 1 ether; // 单位
function TokenSaleChallenge(address _player) public payable {`` require(msg.value == 1 ether); // 创建时 写入一个地址, 然后需要发送1 eth 存进来`` }
function isComplete() public view returns (bool) {`` return address(this).balance < 1 ether; // 返回此合约内的 eth 余额是否小于 1 eth`` }
function buy(uint256 numTokens) public payable {`` require(msg.value == numTokens * PRICE_PER_TOKEN); // 检查发送的eth 与标记发送的是否一致
balanceOf[msg.sender] += numTokens; // 加上对应的余额`` }
function sell(uint256 numTokens) public {`` require(balanceOf[msg.sender] >= numTokens); // 检查调用者的余额是否大于等于取款的余额
balanceOf[msg.sender] -= numTokens; // 记账`` msg.sender.transfer(numTokens * PRICE_PER_TOKEN); //取款 `` }``}
漏洞主要发生在 buy 函数中 我们首先分析如果正常存入 正常想要存入 1eth 我们首先传入参数 numTokens = 1 同时传入 1 eth 这时候 msg.value = 1 eth PRICE_PER_TOKEN = 1eth 1 * 1eth = 1eth 余额值会增加 1, 向其中存入1eth
那么漏洞是如何触发的? 首先我们要明确, msg.value的值是以wei做单位的 1eth = 10的18次方 msg.value实际上是一个整型 当我们传入1eth实际上 msg.value=1000000000000000000 我们传入的代币数量会乘以10^18 那么如果我们购买2的256次方 // 10的18次方 + 1 个代币时 最后的numTokens * PRICE_PER_TOKEN的值就是
(2^256 // 10^18 + 1) * 10^18`` (115792089237316195423570985008687907853269984665640564039457584007913129639936 // 1000000000000000000 + 1) * 1000000000000000000`` = 115792089237316195423570985008687907853269984665640564039458000000000000000000
最后得到的值大于2的256次方 会产生上溢 溢出之后的结果就是 415992086870360064 也就是 msg.value == 415992086870360064 即可存入 6432893846517566412420610278260439325181665814757809113303199111550729424441(这个值是 2的256次方整除 10的18次方 + 1)个代币 一个代币等价于1eth 415992086870360064约等于0.4eth 也就是说花费了0.4eth就能得到6432893846517566412420610278260439325181665814757809113303199111550729424441 eth的存款 实验: 发送415992086870360064 wei

buy的参数填入 115792089237316195423570985008687907853269984665640564039458

执行后查询自己的余额 发现已经变成了115792089237316195423570985008687907853269984665640564039458

漏洞修复
使用 SafeMath 库(旧版本)
using SafeMath for uint8; // 对uint8类型检查是否产生溢出``balances[msg.sender] = balances[msg.sender].sub(_amount);
升级 Solidity 版本(≥0.8.0)
Solidity8.0版本新增了溢出回滚操作 如果产生溢出会自动回滚交易 另外, 在Solidity >= 0.8.0 时, 想要关闭溢出检查需要使用unchecked关键字, 示例代码
uint8 a = 255;``unchecked {`` a += 1; // 允许溢出,结果归零``}