2. Solidity基础安全规范:变量作用域与可见性、整数溢出与SafeMath、require与assert的正确使用
各位同学,今天我们来聊聊Solidity开发中最基础、但也最容易踩坑的三个安全点。说实话,我见过太多合约因为这几个小问题被攻破,损失动辄几百万美元。咱们一个一个说清楚。
2.1 变量作用域与可见性:别让你的数据裸奔
先问个问题:你写的合约里,哪些数据是外人能看到的?哪些是能改的?
Solidity里变量的可见性分四种:public、internal、private、external。很多人以为private就是真的私密了——其实不是。链上数据都是公开的,private只是不让别的合约读,但链下随便查。
private修饰符。我在审计中见过有人把管理员私钥哈希存成private变量,以为安全了——结果链上数据一查就出来了。
我个人习惯这样分配可见性:
- 状态变量:能用
internal就别用public。除非你确定需要对外暴露读取接口。 - 函数:能用
external就用external,省gas。内部调用用internal。 - 映射和数组:
public会自动生成getter,但小心——如果映射的key是敏感数据,别人可以遍历。
来看个反面教材:
// ❌ 错误示例
contract BadVisibility {
uint256 private secretNumber = 42; // 以为别人看不到?
function guess(uint256 _num) public view returns (bool) {
return _num == secretNumber;
}
}
// ✅ 正确做法
contract GoodVisibility {
uint256 internal secretNumber; // 至少不让外部合约直接读
// 用事件来记录,而不是直接暴露
event NumberSet(uint256 indexed number);
function setNumber(uint256 _num) internal {
secretNumber = _num;
emit NumberSet(_num);
}
}
keccak256哈希后存储,或者用链下存储+链上验证的模式。我在一个DeFi项目里就用过这种方案,效果不错。
2.2 整数溢出与SafeMath:老生常谈但年年出事
整数溢出这个问题,说白了就是:数字太大装不下了,结果绕回最小值。比如uint8最大是255,你加1就变成0了。
在Solidity 0.8版本之前,这是个大坑。我记得2018年有个著名的交易所合约,就是因为溢出漏洞,被人薅走了几万个ETH。从那以后,SafeMath库就成了标配。
现在Solidity 0.8+已经内置了溢出检查,但别高兴太早——有几个场景你还是要小心:
| 场景 | 风险等级 | 说明 |
|---|---|---|
| 普通加减乘除(0.8+) | 低 | 编译器自动检查,溢出会revert |
unchecked块内运算 |
高 | 你手动关闭了检查,必须自己保证安全 |
| 类型转换(如uint256转uint8) | 中 | 截断时可能丢失高位数据 |
| 减法运算(0.8以下) | 极高 | 下溢会导致余额暴增 |
我曾经审计过一个项目,他们在unchecked块里做循环累加,结果循环次数一多就溢出了。嗯,这个漏洞差点让项目方损失几百万。
来看看怎么正确使用:
// ✅ 推荐做法(Solidity 0.8+)
contract SafeMathDemo {
uint256 public totalSupply;
function mint(uint256 _amount) external {
// 编译器自动检查溢出
totalSupply += _amount;
}
// 如果你确实需要节省gas,用unchecked但要小心
function batchMint(uint256 _count) external {
uint256 start = totalSupply;
unchecked {
// 这里要确保_count不会导致溢出
require(_count < 1e18, "too many");
totalSupply = start + _count;
}
}
}
// ❌ 错误做法(0.8以下版本必须用SafeMath)
contract UnsafeDemo {
using SafeMath for uint256;
function transfer(address to, uint256 amount) external {
// 0.8以下版本,直接加减可能溢出
balances[msg.sender] -= amount; // 如果余额不够,会下溢!
balances[to] += amount;
}
}
unchecked,一定要在注释里写清楚为什么安全,并且加个边界检查。
2.3 require与assert的正确使用:别搞混了
这两个函数看起来差不多,但用途天差地别。我见过太多人把assert当require用,结果出事了都不知道怎么查。
简单说:
- require:用于检查输入条件、权限、余额等。失败了会退还剩余gas。
- assert:用于检查不应该发生的内部错误。失败了会消耗所有gas。
为什么会这样?因为assert是用来抓bug的——如果它失败了,说明你的代码逻辑有严重问题,应该立即停止并消耗所有gas来惩罚调用者(其实是为了让开发者重视)。
我个人习惯这样用:
// ✅ 正确用法
contract RequireVsAssert {
address public owner;
uint256 public totalSupply;
constructor() {
owner = msg.sender;
}
function withdraw(uint256 _amount) external {
// require:检查外部条件
require(msg.sender == owner, "Not owner");
require(_amount <= address(this).balance, "Insufficient balance");
payable(msg.sender).transfer(_amount);
}
function updateSupply(uint256 _newSupply) internal {
uint256 oldSupply = totalSupply;
totalSupply = _newSupply;
// assert:检查内部逻辑一致性
// 新供应量不应该比旧的小(除非有特殊逻辑)
assert(totalSupply >= oldSupply);
}
}
// ❌ 错误用法
contract BadUsage {
function transfer(address to, uint256 amount) external {
// 这里应该用require,因为输入可能不合法
assert(amount > 0); // ❌ 应该用require
assert(balances[msg.sender] >= amount); // ❌ 应该用require
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
assert。结果用户输入了一个非法参数,直接消耗了所有gas,用户气得不行。记住:require是给用户看的,assert是给开发者看的。
最后总结一下这三个安全点的关系:
- 可见性决定了谁能碰你的数据——别让不该看的人看到。
- 溢出检查决定了你的数字会不会突然变成0——别让数学背叛你。
- require/assert决定了你的合约会不会乱吞gas——别让用户骂你。
这三个问题,每一个我都见过真实案例。说实话,写合约就像盖房子——地基没打好,装修再漂亮也没用。咱们先把这些基础安全规范刻在脑子里,再谈什么DeFi、NFT这些花活。
下一章我们聊聊重入攻击和访问控制,那才是真正让人头疼的东西。先消化今天的内容,有问题随时问。