海哥 发表于 2025-3-22 00:33:24

区块链 智能合约安全 | 整型溢出漏洞

目次:
       核心概念
  溢出类型
  上溢
   原理
   案例
  下溢
   原理
   案例
  练习
  漏洞修复
   使用 SafeMath 库(旧版本)
   升级 Solidity 版本(≥0.8.0)
 所在: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

https://i-blog.csdnimg.cn/img_convert/fa215e6724d20ae32799b577ea93f0a9.png
其中运算过程是怎样的呢?
当我们定义了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: MITpragma 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.id == _productId) { return products; } } 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看看结果

https://i-blog.csdnimg.cn/img_convert/bde5f62e98153d8807639f4f508e09ee.png
在购买函数中执行 可以看到会返回对应的 success

https://i-blog.csdnimg.cn/img_convert/7690a67c1862f594be2b5457f6440b1c.png
大家可以本身把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 = 00 + 1 = 1 + 1 = 00 + 1 = 1 + 1 = 00 + 1 = 1+1 (同下, 也是进位, 后面不在赘述) = 00 + 1 = 1 + 1(这个1是进位) = 0 进一位1 + 1 = 1 进一位0 + 1 = 11 + 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 - amount >= 0); balanceOf -= amount; payable(msg.sender).transfer(amount); }}
这段代码乍看之下是没有问题的,但是我们要考虑到,balanceOf中存储的是uint256, 无符号整数,amount也是无符号整数,它们相减的结果也会是一个uint256无符号整数,假如balanceOf的值是0,amout是1,无符号整数0-1得到的结果会发生下溢,就像我们刚刚说的,uint16下 0-1 会变成uint16能表示的最大值,这里就会变成uint256能表示的最大值, 2的256次方-1,这里得到的结果永宏大于0,也就是说这段代码恒建立,可以恣意取款
那么接下来实验一下, 结果是否如我们所想 上面的代码是不全的, 无法直接测试 我们先增长一个存款函数(不然合约没钱没法转出来) 测试代码如下​​​​​​​
// SPDX-License-Identifier: MITpragma 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 - amount >= 0); balanceOf -= amount; payable(msg.sender).transfer(amount); }}
我们这里没有写往balanceOf中增长数值的方法,不管msg.sender是什么, balanceOf的值都是0,部署好之后
这里设置好发送的eth

https://i-blog.csdnimg.cn/img_convert/2e1ce9d6a2abadd9d438818fc0b4de9f.png
然后调用deposit函数,往里面先存点eth方便测试,看到合约余额增长之后

https://i-blog.csdnimg.cn/img_convert/57280a09594c9c5e47e7ee1e1fca361a.png
我们在取款函数中填入500000(这里是用wei做单元),执行函数,可以看到发生了取款操作

https://i-blog.csdnimg.cn/img_convert/c5561c7f2af8bc2da2b674430fcbabde.png
你也可以写一个函数,用来查看balanceOf是否是0,但当你取款过一次之后, balanceOf就不是0了,由于balanceOf -= amount;时会发生溢出,将balanceOf的值变成一个非常大的数
练习

最后放一个漏洞代码, 大家可以先本身练习 记得调整编译器为 0.4.21

https://i-blog.csdnimg.cn/img_convert/c25ddc74de2df388e530137817b6c8b0.png
​​​​​​​​​​​​​​​​​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 += numTokens; // 加上对应的余额 }
function sell(uint256 numTokens) public { require(balanceOf >= numTokens); // 查抄调用者的余额是否大于即是取款的余额
balanceOf -= 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

https://i-blog.csdnimg.cn/img_convert/8bacec4055bba49618af3e0f7585c40c.png
buy的参数填入 115792089237316195423570985008687907853269984665640564039458

https://i-blog.csdnimg.cn/img_convert/f3bb44d7997d45e7658f69019b2464a5.png
执行后查询本身的余额 发现已经变成了115792089237316195423570985008687907853269984665640564039458

https://i-blog.csdnimg.cn/img_convert/a674f4ad972c3255b754bb5c3fda068b.png

漏洞修复

使用 SafeMath 库(旧版本)​​​​​​​

using SafeMath for uint8; // 对uint8类型查抄是否产生溢出balances = balances.sub(_amount);
升级 Solidity 版本(≥0.8.0)

Solidity8.0版本新增了溢出回滚操作 假如产生溢出会自动回滚交易 别的, 在Solidity >= 0.8.0 时, 想要关闭溢出查抄必要使用unchecked关键字, 示例代码​​​​​​​
uint8 a = 255;unchecked { a += 1; // 允许溢出,结果归零}
申明:本账号所分享内容仅用于网络安全技能讨论,切勿用于违法途径,所有渗透都需获取授权,违者后果自行承担,与本号及作者无关   


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 区块链 智能合约安全 | 整型溢出漏洞