合约崩盘的导火索?聊聊整型溢出的“前世今生”
整型溢出?听起来像是编译器才会care的底层bug,但它在智能合约里,绝对是能让你一夜回到解放前的超级雷区。想象一下,你的代码里藏着个定时炸弹,随时可能因为一个简单的加减法,把你的智能合约炸得面目全非,资产归零,权限全乱…… 这可不是危言耸听,而是血淋淋的教训堆出来的经验!
简单来说,整型溢出就是数值运算超出了数据类型能表示的范围。这就像你用一个只能装8升水的水桶,硬要装9升,结果就是水漫金山。在Solidity里,你定义一个uint8
,它就只能存0到255之间的整数。
uint a; // 相当于 uint256 a,土豪级的容量
溢出?溢出!上溢下溢,都是“要命”的溢出!
数值类型的存储空间是有限的,比如uint8
,最多存255。所以,溢出分两种:
- 上溢(Overflow): 数值太大,超过了最大值。
- 下溢(Underflow): 数值太小,低于了最小值。
这两种溢出,都能让你的合约逻辑彻底跑偏。
上溢:从“富得流油”到“一贫如洗”的魔幻之旅
上溢的“底层逻辑”:二进制的“丢盔弃甲”
还是拿uint8
说事儿。它的范围是0到255,也就是二进制的0000 0000
到1111 1111
。如果你让一个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;
}
}
猜猜结果?不是256,而是0!
为啥?因为255的二进制是1111 1111
,加1就变成了1 0000 0000
。但是uint8
只有8个bit,最左边的1
直接被扔掉了,只剩下0000 0000
,也就是0。
如果加2呢?那就是1 0000 0001
,丢掉最高位,结果就是1。
所以,上溢会让一个很大的数,瞬间变成一个很小的数,甚至直接归零!
电商平台的“血泪史”:上溢引发的“0元购”
想象一下,你开了一个区块链网店,代码大致如下(别吐槽我用uint8
,只是为了方便演示):
// 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)); // 2 ETH
products.push(Product(2, "Laptop", 3)); // 3 ETH
products.push(Product(3, "Headphones", 1)); // 1 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;
}
}
如果有人一次性购买128个单价2 ETH的商品,totalPrice
会是多少? 理论上是256,但由于uint8
的限制,结果会变成0! 也就是说,用户可以不花一分钱,就能买走128个商品! 这酸爽,老板估计要哭晕在厕所。
友情提示: 上面的例子为了方便理解,用了uint8
。实际开发中,一定要用uint256
,但是,这并不意味着你就安全了。只要数值足够大,超过了2的256次方-1,照样会溢出!
下溢:从“一无所有”到“富可敌国”的惊天逆转
下溢的“数学原理”:补码的“乾坤大挪移”
下溢比上溢更隐蔽,更难发现。它涉及到计算机的补码运算。简单来说,计算机用补码来表示负数。
比如,我们要计算2-1,计算机会先把-1转换成补码。 假设是8位二进制:
1的二进制:0000 0001
取反:1111 1110
加1:1111 1111
这就是-1的补码
然后,把2的二进制和-1的补码相加:
0000 0010 + 1111 1111 = 1 0000 0001
去掉进位,结果就是0000 0001
,也就是1。
如果是0-1呢?
0的二进制:0000 0000
1的二进制:0000 0001
取反:1111 1110
加1:1111 1111
这就是-1的补码
0-1 -> 0000 0000 + 1111 1111 = 1111 1111
结果是1111 1111
,也就是255!
所以,下溢会让一个很小的数,瞬间变成一个很大的数!
银行的“惊天漏洞”:下溢引发的“无限提款”
想象一下,你写了一个银行合约,代码如下:
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);
}
}
这段代码看起来没啥问题,对吧? 提款前会检查余额是否足够。但是,如果你的余额是0,你想取1个ETH,会发生什么?
balanceOf[msg.sender] - amount
会发生下溢,变成一个非常大的数(2的256次方-1),导致require
检查永远通过。 也就是说,你可以无限提款,把银行的钱全部掏空!
实战演练:攻破TokenSaleChallenge合约
来,练练手。看看你能否攻破下面这个合约:
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
函数里。 正常情况下,你发送1个ETH,numTokens
也应该是1。但是,如果我购买2**256 // 10**18 + 1
个代币,会发生什么?
(2**256 // 10**18 + 1) * 10**18
的结果会大于2的256次方,导致上溢。溢出后的结果会变得很小,你可以用很少的ETH,买到大量的代币!
亡羊补牢:如何防范整型溢出?
SafeMath:老版本的“救命稻草”
在Solidity 0.8.0之前,最常用的方法是使用SafeMath库。它会在运算前后检查是否发生溢出,如果发生,就直接回滚交易。
using SafeMath for uint8; // 对uint8类型检查是否产生溢出
balances[msg.sender] = balances[msg.sender].sub(_amount);
Solidity 0.8.0:自带“金钟罩”
Solidity 0.8.0 以后,编译器默认开启了溢出检查。也就是说,只要发生溢出,交易就会自动回滚。
如果你想关闭溢出检查,可以使用unchecked
关键字:
uint8 a = 255;
unchecked {
a += 1; // 允许溢出,结果归零
}
但是,强烈不建议这样做!除非你非常清楚自己在做什么,并且有充分的理由要关闭溢出检查。
总结: 整型溢出是智能合约安全中一个非常重要的风险。理解它的原理,掌握防范方法,是每一个区块链开发者必备的技能。 不要让你的合约,成为黑客的提款机!
```
黑客/网络安全学习包
资料目录
-
成长路线图&学习规划
-
配套视频教程
-
SRC&黑客文籍
-
护网行动资料
-
黑客必读书单
-
面试题合集
因篇幅有限,仅展示部分资料,需要点击下方链接即可前往获取
*************************************CSDN大礼包:《黑客&网络安全入门&进阶学习资源包》免费分享*************************************
1.成长路线图&学习规划
要学习一门新的技术,作为新手一定要先学习成长路线图,方向不对,努力白费。
对于从来没有接触过网络安全的同学,我们帮你准备了详细的学习成长路线图&学习规划。可以说是最科学最系统的学习路线,大家跟着这个大的方向学习准没问题。
因篇幅有限,仅展示部分资料,需要点击下方链接即可前往获取
*************************************CSDN大礼包:《黑客&网络安全入门&进阶学习资源包》免费分享*************************************
2.视频教程
很多朋友都不喜欢晦涩的文字,我也为大家准备了视频教程,其中一共有21个章节,每个章节都是当前板块的精华浓缩。
因篇幅有限,仅展示部分资料,需要点击下方链接即可前往获取
*************************************CSDN大礼包:《黑客&网络安全入门&进阶学习资源包》免费分享*************************************
3.SRC&黑客文籍
大家最喜欢也是最关心的SRC技术文籍&黑客技术也有收录
SRC技术文籍:
黑客资料由于是敏感资源,这里不能直接展示哦!
4.护网行动资料
其中关于HW护网行动,也准备了对应的资料,这些内容可相当于比赛的金手指!
5.黑客必读书单
**
**
6.面试题合集
当你自学到这里,你就要开始思考找工作的事情了,而工作绕不开的就是真题和面试题。
更多内容为防止和谐,可以扫描获取~
因篇幅有限,仅展示部分资料,需要点击下方链接即可前往获取
*************************************CSDN大礼包:《黑客&网络安全入门&进阶学习资源包》免费分享*********************************