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

目录:

核心概念

 溢出类型

  上溢

   原理

   案例

 下溢

 原理

 案例

  练习

 漏洞修复

 使用 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

其中运算过程是怎样的呢?

当我们定义了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; // 允许溢出,结果归零``}

申明:本账号所分享内容仅用于网络安全技术讨论,切勿用于违法途径,所有渗透都需获取授权,违者后果自行承担,与本号及作者无关

相关推荐
漏洞谷6 小时前
白帽子为什么几乎都绕不开 httpx:一款 HTTP 资产探测工具的技术价值
web安全·漏洞挖掘·安全工具
木西10 小时前
深度拆解 Web3 预测市场:基于 Solidity 0.8.24 与 UMA 乐观预言机的核心实现
web3·智能合约·solidity
用户962377954483 天前
VulnHub DC-3 靶机渗透测试笔记
安全
叶落阁主4 天前
Tailscale 完全指南:从入门到私有 DERP 部署
运维·安全·远程工作
用户962377954486 天前
DVWA 靶场实验报告 (High Level)
安全
数据智能老司机6 天前
用于进攻性网络安全的智能体 AI——在 n8n 中构建你的第一个 AI 工作流
人工智能·安全·agent
数据智能老司机6 天前
用于进攻性网络安全的智能体 AI——智能体 AI 入门
人工智能·安全·agent
用户962377954487 天前
DVWA 靶场实验报告 (Medium Level)
安全
red1giant_star7 天前
S2-067 漏洞复现:Struts2 S2-067 文件上传路径穿越漏洞
安全
用户962377954487 天前
DVWA Weak Session IDs High 的 Cookie dvwaSession 为什么刷新不出来?
安全