一、什么是Solidity合约安全审计和OpenZeppelin库

很多刚接触区块链的开发者,写Solidity合约时总觉得“能跑就行”,直到出现代币被转走、合约被篡改的问题才后悔。其实Solidity是专门给以太坊等区块链写智能合约的语言,合约一旦部署就没法随便改,所以代码里的小漏洞都可能变成大损失。

安全审计就是给合约做一次全面的“体检”,找出代码里的漏洞、逻辑错误,避免被黑客攻击。而OpenZeppelin是很多开发者都会用的“合约工具包”,里面封装了很多写好的、经过测试的代码,比如代币标准、权限控制这些常用功能,不用自己从零写,能省不少事,但要是用得不对,也会出问题。

二、Solidity合约源码安全审计的核心内容

审计不是随便扫一眼代码,得按固定的流程来,覆盖几个关键的检查点,下面用具体的例子来说明。

2.1 权限控制漏洞检查

权限控制是合约最容易出问题的地方,比如不该有操作权限的人能随便改合约参数,或者管理员权限没限制,被别人抢去。 举个反例,很多新手会这么写合约: 技术栈:Solidity 0.8.17

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract BadPermission {
    // 管理员地址,只有这个地址能改代币总量
    address public owner;
    // 代币总量
    uint256 public totalSupply;

    constructor() {
        // 部署合约的人就是初始管理员
        owner = msg.sender;
        totalSupply = 1000;
    }

    // 改代币总量的函数,新手可能会漏加权限判断
    function setTotalSupply(uint256 _newTotal) public {
        totalSupply = _newTotal;
    }
}

这个合约的问题很明显:setTotalSupply函数没加任何权限判断,任何人都能调用这个函数改代币总量,黑客要是调用这个函数把总量改成1000000000,原来的代币就直接贬值了。

正确的写法应该加权限判断,或者用OpenZeppelin的Ownable库(后面会讲)。比如自己加判断的话:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract GoodPermission {
    address public owner;
    uint256 public totalSupply;

    constructor() {
        owner = msg.sender;
        totalSupply = 1000;
    }

    // 只有管理员能调用的函数,加权限判断
    function setTotalSupply(uint256 _newTotal) public {
        require(msg.sender == owner, "Only owner can call this function");
        totalSupply = _newTotal;
    }
}

2.2 溢出/下溢漏洞检查

在Solidity 0.8版本之前,整数溢出是个大问题,比如uint256是无符号整数,最大值是2^256-1,要是加1超过这个值,就会变成0;减1比0小,就会变成最大值。0.8版本之后虽然默认加了溢出检查,但还是要注意一些特殊情况。

举个0.7版本的反例(现在很多老合约还在用): 技术栈:Solidity 0.7.6

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;

contract BadOverflow {
    uint256 public number = 1;

    function decrease() public {
        // 减1,0.7版本不会自动检查溢出
        number = number - 1;
    }
}

调用decrease函数,number变成0,再调用一次,就会溢出变成2^256-1,这个漏洞可能被黑客用来恶意操作数值。

0.8版本虽然不会自动溢出,但要是用了unchecked关键字(为了省gas),还是会有问题: 技术栈:Solidity 0.8.17

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract BadUnchecked {
    uint256 public number = 1;

    function decrease() public {
        // 用了unchecked,不会检查溢出
        unchecked {
            number = number - 1;
        }
    }
}

所以用unchecked的时候一定要确保数值不会溢出,或者自己加判断。

2.3 重入攻击漏洞检查

重入攻击是黑客常用的手段,简单说就是黑客调用合约的某个函数,在这个函数还没执行完的时候,再调用同一个函数,反复多次,把合约里的钱转走。

举个经典的反例: 技术栈:Solidity 0.8.17

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract BadReentrancy {
    // 记录每个用户存的以太币数量
    mapping(address => uint256) public balances;

    // 存以太币的函数
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // 取以太币的函数,先转钱再更新余额,容易被重入攻击
    function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
        // 先转钱给用户,转钱的时候会调用用户地址的fallback函数
        payable(msg.sender).transfer(amount);
        // 再更新余额,这里就有问题
        balances[msg.sender] = 0;
    }
}

黑客可以写一个恶意合约,fallback函数会反复调用BadReentrancy的withdraw函数,因为每次调用的时候,余额还没更新,所以每次都能转走钱,直到合约里的以太币被转空。

正确的做法是先更新余额,再转钱,或者用OpenZeppelin的ReentrancyGuard库:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract GoodReentrancy {
    mapping(address => uint256) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // 先更新余额,再转钱
    function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
        // 先更新余额,就算转钱的时候被重入,余额已经是0了,没法再转
        balances[msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }
}

三、OpenZeppelin库集成的风险规避方法

OpenZeppelin的库确实好用,但很多开发者只会复制粘贴,不知道里面的坑,下面说几个常见的风险和规避方法。

3.1 不要随便修改OpenZeppelin的源码

很多开发者觉得OpenZeppelin的代码不够灵活,就随便改里面的逻辑,比如改ERC20代币的转账函数,这很容易出问题。因为OpenZeppelin的代码是经过全球很多开发者测试的,改了之后可能会引入漏洞。

举个反例,比如改ERC20的transfer函数: 技术栈:Solidity 0.8.17

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

// 复制OpenZeppelin的ERC20代码,然后随便改
contract BadModifiedERC20 {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    uint256 public totalSupply;
    string public name;
    string public symbol;
    uint8 public decimals;

    constructor(string memory _name, string memory _symbol, uint8 _decimals, uint256 _initialSupply) {
        name = _name;
        symbol = _symbol;
        decimals = _decimals;
        totalSupply = _initialSupply;
        balanceOf[msg.sender] = _initialSupply;
    }

    // 改了OpenZeppelin的transfer函数,漏了减发送方的余额
    function transfer(address to, uint256 amount) public returns (bool) {
        require(to != address(0), "ERC20: transfer to the zero address");
        require(balanceOf[msg.sender] >= amount, "ERC20: transfer amount exceeds balance");
        // 这里漏了:balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }

    event Transfer(address indexed from, address indexed to, uint256 value);
}

这个改后的transfer函数,发送方的余额不会减少,相当于可以无限转账,黑客只要调用这个函数,就能把所有代币转走。

正确的做法是继承OpenZeppelin的ERC20库,不要改源码,要是需要自定义逻辑,可以重写函数(但要确保逻辑正确),或者用钩子函数(OpenZeppelin很多函数都有_beforeTokenTransfer、_afterTokenTransfer这样的钩子)。比如要加转账前的检查:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

// 继承OpenZeppelin的ERC20,版本要指定
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract GoodCustomERC20 is ERC20 {
    // 自定义的黑名单地址
    mapping(address => bool) public blacklist;

    constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {}

    // 重写钩子函数,加黑名单检查
    function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override {
        super._beforeTokenTransfer(from, to, amount);
        require(!blacklist[from], "ERC20: from address is blacklisted");
        require(!blacklist[to], "ERC20: to address is blacklisted");
    }

    // 管理员加黑名单的函数,用Ownable的权限控制
    function addBlacklist(address _addr) public {
        require(msg.sender == owner(), "Only owner can call this function");
        blacklist[_addr] = true;
    }
}

3.2 正确使用OpenZeppelin的权限控制库

OpenZeppelin的Ownable、AccessControl这些权限控制库,用得不对也会出问题。比如Ownable库的owner函数,要是忘了转移所有权,部署合约的人一直是管理员,要是部署者的私钥丢了,合约就没法改了;要是部署者的私钥被偷了,黑客就能控制合约。

举个反例,部署后不转移所有权: 技术栈:Solidity 0.8.17

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";

contract BadOwnable is Ownable {
    // 管理员可以改代币总量
    function setTotalSupply(uint256 _newTotal) public onlyOwner {
        // 逻辑...
    }
}

部署这个合约的人是owner,要是他不把所有权转移给团队的多签地址,一直自己拿着,风险很大。

正确的做法是部署后尽快转移所有权,或者在构造函数里直接把所有权转移给指定地址:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";

contract GoodOwnable is Ownable {
    // 构造函数里把所有权转移给团队的多签地址(这里用一个示例地址)
    constructor(address _teamAddress) Ownable() {
        // 把所有权转移给团队地址,注意要确保这个地址是有效的
        transferOwnership(_teamAddress);
    }

    function setTotalSupply(uint256 _newTotal) public onlyOwner {
        // 逻辑...
    }
}

另外,AccessControl库比Ownable更灵活,可以设置多个角色,适合复杂的权限控制场景,比如有的角色可以改参数,有的角色可以暂停合约,要是项目比较大,建议用AccessControl。

3.3 注意OpenZeppelin库的版本兼容

OpenZeppelin的库有很多版本,不同版本的Solidity要求不一样,比如OpenZeppelin 4.x版本要求Solidity 0.8.x,OpenZeppelin 3.x版本要求Solidity 0.6.x,要是版本不兼容,编译的时候会报错,甚至会出现隐藏的漏洞。

比如用OpenZeppelin 4.x的库,却用Solidity 0.7.x编译,就会出问题: 技术栈:Solidity 0.7.6

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;

// 导入OpenZeppelin 4.x的ERC20,版本不兼容
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract BadVersion is ERC20 {
    constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {}
}

这个合约编译的时候会报错,因为OpenZeppelin 4.x的代码用了Solidity 0.8.x的语法,比如默认的溢出检查,0.7.x不支持。

正确的做法是用OpenZeppelin的安装命令指定版本,比如:

# 安装OpenZeppelin 4.9.3版本,对应Solidity 0.8.x
npm install @openzeppelin/contracts@4.9.3

然后在合约里导入对应版本的库,确保Solidity版本和库的版本兼容。

四、应用场景、技术优缺点和注意事项

4.1 应用场景

Solidity合约安全审计几乎适用于所有需要部署到区块链的合约,比如代币合约、DeFi借贷合约、NFT合约、DAO治理合约等,尤其是涉及到大量资金的合约,一定要做审计。

OpenZeppelin库的应用场景也很广,比如写ERC20/ERC721/ERC1155代币的时候,直接继承OpenZeppelin的标准合约,不用自己实现转账、授权这些逻辑;做权限控制的时候,用Ownable或AccessControl;做安全控制的时候,用ReentrancyGuard、Pausable这些库。

4.2 技术优缺点

  • Solidity合约安全审计的优缺点:优点是能提前发现漏洞,避免资金损失,提升项目可信度;缺点是专业的审计服务价格比较高(比如审计一个复杂的DeFi合约可能要几十万),自己审计的话需要有足够的经验,容易漏检。
  • OpenZeppelin库的优缺点:优点是代码经过测试,安全可靠,能节省开发时间;缺点是库的代码比较复杂,新手不容易理解,要是用得不对反而会引入风险,而且定制化难度比较大。

4.3 注意事项

  1. 审计的时候,除了检查代码,还要检查合约的逻辑是否符合预期,比如代币的释放规则、DAO的投票规则等。
  2. 用OpenZeppelin库的时候,一定要看官方文档,不要随便复制网上的代码,网上的代码可能是旧版本的,有漏洞。
  3. 合约部署后,要及时做测试,比如测试转账、权限控制、重入攻击等场景,确保合约正常运行。
  4. 定期关注OpenZeppelin的更新,要是发现库有漏洞,要及时升级版本。

五、文章总结

Solidity合约的安全是区块链项目的核心,因为合约一旦部署就没法修改,任何漏洞都可能造成不可挽回的损失。安全审计是保障合约安全的重要手段,要重点检查权限控制、溢出、重入攻击等常见漏洞。

OpenZeppelin库是提升开发效率和合约安全性的好工具,但要注意不要随便修改源码、正确使用权限控制、注意版本兼容,避免因为使用不当引入新的风险。

对于开发者来说,最好的做法是先掌握Solidity的基础语法和常见漏洞,再学习OpenZeppelin库的使用方法,写好的合约先自己做一次初步审计,再找专业的审计机构做最终审计,确保合约的安全。