第1章:Solidity安全编码——四大核心漏洞实战

大家好,我是你们的老朋友。今天咱们聊聊Solidity安全编码里最要命的四个坑:整数溢出、重入攻击、访问控制、随机数安全。这几个问题,说白了就是区块链上最常见的“翻车现场”。我这些年审计过的合约,十有八九都能找到这些漏洞的影子。

核心观点:安全编码不是锦上添花,而是生死攸关。一个整数溢出,可能让用户资产归零;一次重入攻击,能直接掏空合约资金。

Solidity安全编码四大核心漏洞 整数溢出 Overflow/Underflow 重入攻击 Reentrancy 访问控制 Access Control 随机数安全 Randomness 资产归零 / 逻辑混乱 资金被掏空 权限被滥用 结果可预测 防御方案 SafeMath / Solidity 0.8+ 自动检查溢出 检查-生效-交互 重入锁 Ownable / RBAC 权限校验 Chainlink VRF 链下随机数 安全编码 = 防御思维 + 最佳实践 + 持续审计

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%,咱们后面章节慢慢聊。

最后说一句:安全编码不是一蹴而就的事。我做了这么多年审计,每次看到新合约还是会发现新问题。保持警惕,持续学习,这才是安全工程师的生存之道。

专注资料整理