第1章:Solidity安全编码——四大核心漏洞实战
大家好,我是你们的老朋友。今天咱们聊聊Solidity安全编码里最要命的四个坑:整数溢出、重入攻击、访问控制、随机数安全。这几个问题,说白了就是区块链上最常见的“翻车现场”。我这些年审计过的合约,十有八九都能找到这些漏洞的影子。
核心观点:安全编码不是锦上添花,而是生死攸关。一个整数溢出,可能让用户资产归零;一次重入攻击,能直接掏空合约资金。
1.1 整数溢出:最古老的漏洞,依然致命
整数溢出,说白了就是数字太大或太小,超出了变量能存的范围。比如uint8最大是255,你加1就变成0了。这在Solidity 0.8之前简直是家常便饭。
我个人习惯,写合约第一件事就是检查版本号。如果是0.8以下,必须用SafeMath库。0.8以上虽然内置了检查,但也不是万能的——unchecked块里照样会溢出。
我的经验:有一次审计一个DeFi项目,他们用了Solidity 0.6.12,但没引入SafeMath。结果一个简单的加法操作,让用户存入1000个代币后,余额反而变成了0。嗯,这就是典型的underflow。
// 不安全的写法(Solidity < 0.8)
function unsafeAdd(uint8 a, uint8 b) public pure returns (uint8) {
return a + b; // 如果 a=255, b=1, 结果变成0
}
// 安全的写法
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
function safeAdd(uint256 a, uint256 b) public pure returns (uint256) {
return SafeMath.add(a, b); // 溢出会revert
}
// Solidity 0.8+ 内置检查
function builtInAdd(uint256 a, uint256 b) public pure returns (uint256) {
return a + b; // 自动检查溢出
}
注意:unchecked块里的操作不会检查溢出。只有当你确定不会溢出时,才用unchecked来省gas。否则,这就是个定时炸弹。
1.2 重入攻击:The DAO事件的教训
重入攻击,说白了就是合约在转账给外部地址时,被攻击者反复调用。2016年的The DAO事件,就是因为这个漏洞丢了360万以太坊。你想想看,一个漏洞直接导致以太坊硬分叉,这得多严重。
我审计过的合约里,至少有三成存在重入风险。最常见的场景是:先转账,后更新余额。这顺序一错,资金就危险了。
// 不安全的写法
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] -= amount; // 先转账后更新,危险!
}
// 安全的写法(检查-生效-交互模式)
function safeWithdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // 先更新余额
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
// 使用重入锁
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureContract is ReentrancyGuard {
function withdraw(uint256 amount) public nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
核心原则:检查-生效-交互(Checks-Effects-Interactions)。先检查条件,再更新状态,最后才做外部调用。这个顺序,我建议你刻在脑子里。
1.3 访问控制:谁动了我的合约?
访问控制,说白了就是权限管理。谁可以调用哪个函数?谁可以升级合约?谁可以提取资金?这些问题搞不清楚,合约就是敞开的保险柜。
我曾经审计过一个NFT项目,他们的mint函数居然没有做任何权限校验。结果有人写了个脚本,一口气mint了5000个NFT,项目方直接傻眼。嗯,这就是典型的访问控制缺失。
// 不安全的写法
function mint(address to, uint256 tokenId) public {
_mint(to, tokenId); // 谁都能调用
}
// 使用Ownable
import "@openzeppelin/contracts/access/Ownable.sol";
contract SecureNFT is Ownable {
function mint(address to, uint256 tokenId) public onlyOwner {
_mint(to, tokenId);
}
}
// 使用RBAC(基于角色的访问控制)
import "@openzeppelin/contracts/access/AccessControl.sol";
contract RoleBasedContract is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
}
function mint(address to, uint256 tokenId) public onlyRole(MINTER_ROLE) {
_mint(to, tokenId);
}
}
我的建议:不要只依赖Ownable。对于复杂项目,用AccessControl做细粒度权限控制。比如mint权限给运营团队,upgrade权限给技术团队,withdraw权限给财务团队。各司其职,出了问题也好追责。
1.4 随机数安全:链上随机数是个伪命题
随机数安全,说白了就是区块链上没法生成真正的随机数。所有链上数据都是公开的、确定的。你用block.timestamp、blockhash、block.difficulty这些来做随机数,攻击者完全可以预测。
我记得有个抽奖合约,用block.timestamp % 100来决定中奖者。结果攻击者每次都在时间戳刚好满足条件时调用,次次中奖。你想想看,这公平吗?
// 不安全的随机数生成
function unsafeRandom() public view returns (uint256) {
return uint256(keccak256(abi.encodePacked(
block.timestamp,
block.difficulty,
msg.sender
)));
}
// 使用Chainlink VRF(推荐方案)
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
contract RandomNumberConsumer is VRFConsumerBaseV2 {
VRFCoordinatorV2Interface COORDINATOR;
uint64 s_subscriptionId;
bytes32 s_keyHash;
uint256 public s_randomWords;
constructor(uint64 subscriptionId) VRFConsumerBaseV2(0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D) {
COORDINATOR = VRFCoordinatorV2Interface(0x2Ca8E0C643bDe4C2E08ab1fA0da3401AdAD7734D);
s_subscriptionId = subscriptionId;
s_keyHash = 0x79d3d8832d904592c0bf9818b621522c988bb8b0c05cdc3b15aea1b6e8db0c15;
}
function requestRandomWords() external {
COORDINATOR.requestRandomWords(
s_keyHash,
s_subscriptionId,
3, // 确认数
100000, // gas限制
1 // 请求数量
);
}
function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
s_randomWords = randomWords[0];
}
}
避坑指南:我曾经见过一个项目,他们用blockhash(block.number - 1)做随机数。但矿工可以操纵这个值!只要矿工挖到新区块,就能决定blockhash的值。所以,永远不要用链上数据做随机数。
1.5 实战总结:安全编码的黄金法则
| 漏洞类型 | 风险等级 | 防御方案 | 我的建议 |
|---|---|---|---|
| 整数溢出 | 高 | SafeMath / Solidity 0.8+ | 升级到0.8+,省心省力 |
| 重入攻击 | 极高 | 检查-生效-交互 + 重入锁 | 两个都用,双重保险 |
| 访问控制 | 高 | Ownable / AccessControl | 复杂项目用RBAC |
| 随机数安全 | 中高 | Chainlink VRF | 别自己造轮子 |
这四个漏洞,说白了就是Solidity安全编码的四大天王。你把这四个搞定了,至少能防住80%的攻击。剩下的20%,咱们后面章节慢慢聊。
最后说一句:安全编码不是一蹴而就的事。我做了这么多年审计,每次看到新合约还是会发现新问题。保持警惕,持续学习,这才是安全工程师的生存之道。