引言
2022年的一系列安全事件让整个行业付出了惨痛代价:Ronin桥被盗6.25亿美元、Wormhole跨链桥损失3.2亿美元、Nomadbridge损失1.9亿美元。这些案例有一个共同点——它们本可以通过更严格的安全审计避免。
对于开发者来说,安全审计不应该是上线前的最后一道检查,而应该贯穿整个开发周期。本文整理了一份实用的安全审计清单,涵盖了开发过程中最常见的安全问题,分为基础检查、高级检查和架构检查三个层次。每个检查项都配有真实漏洞案例和可操作的修复方案。

一、基础检查:必须通过的安全门槛
1.1 访问控制检查
访问控制是最基本也是最容易被忽视的安全问题。合约中的特权操作(铸造代币、暂停合约、修改参数等)必须有严格的权限验证。
常见错误模式:
solidity
// ❌ 错误:缺少权限检查
contract BadBank {
mapping(address => uint) public balances;
function withdraw(uint amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
这个合约看起来简单,但问题在于任何人都可以修改其他人的余额——没有检查msg.sender之外的权限。攻击者可以直接给自己转账。
正确实现:
solidity
// ✅ 正确:添加访问控制修饰符
import "@openzeppelin/contracts/access/AccessControl.sol";
contract GoodBank is AccessControl {
bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");
mapping(address => uint) public balances;
function withdraw(uint amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// 只有管理员可以操作
function adjustBalance(address user, uint newBalance)
external
onlyRole(MANAGER_ROLE)
{
balances[user] = newBalance;
}
}
审计检查点:
- 所有特权函数都有
onlyOwner/onlyRole等修饰符 - 角色权限分配有清晰的层级结构
- 关键操作有多签要求(如管理员变更)
- 权限枚举值使用常量而非硬编码数值
1.2 整数溢出与下溢检查
虽然Solidity 0.8+内置了溢出检查,但在某些场景下仍需注意。
Solidity 0.7及以下的经典漏洞:
solidity
// ❌ Solidity < 0.8 版本:未检查溢出
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount; // 可能下溢
balances[to] += amount; // 可能溢出
}
当用户余额为0时,0 - 1会导致下溢,结果变成一个巨大的数字。
Solidity 0.8+的安全改进:
solidity
// ✅ Solidity 0.8+:自动溢出检查
function safeTransfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
require检查在unchecked块之外,因此即使在0.8+版本,也建议显式检查。
特殊情况:unchecked数学运算
有时候需要手动禁用溢出检查以节省Gas:
solidity
// ⚠️ 仅在确认不会溢出的场景使用
function calculateReward(uint256 principal, uint256 rate, uint256 time)
external
pure
returns (uint256)
{
// 使用SafeMath或确保值域可控
return (principal * rate * time) / (365 days * 100);
}
审计检查点:
- 使用SafeMath库(Solidity < 0.8)或显式检查(0.8+)
- 关键计算前验证输入值域
- 标记
unchecked块的边界和原因
1.3 重入攻击防护
重入攻击是智能合约历史上最经典的安全漏洞,2016年The DAO事件就是因此发生。
攻击原理:
solidity
// ❌ 脆弱合约:先转账后更新状态
contract VulnerableBank {
mapping(address => uint) public balances;
function withdraw() external {
uint balance = balances[msg.sender];
require(balance > 0);
// 先转账 - 此时合约状态未更新
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
// 后更新状态 - 攻击者可以在此期间再次调用
balances[msg.sender] = 0;
}
}
攻击者可以部署一个恶意合约,在call转账时再次调用withdraw(),因为状态还未更新,合约会重复放行资金。
防御方案一:检查-生效-交互模式
solidity
// ✅ 方案1:先更新状态,后转账
contract SecureBankV1 {
mapping(address => uint) public balances;
bool internal locked;
modifier noReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
function withdraw() external noReentrant {
uint balance = balances[msg.sender];
require(balance > 0, "No balance");
// 先清零
balances[msg.sender] = 0;
// 后转账
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
}
防御方案二:使用Pull Payment模式
solidity
// ✅ 方案2:将提款改为"领取"模式
contract PullPaymentBank {
mapping(address => uint) public pendingWithdrawals;
function withdraw() external {
uint payment = pendingWithdrawals[msg.sender];
require(payment > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
payable(msg.sender).transfer(payment);
}
// 存款时记录,而非直接转账
function deposit() external payable {
// 业务逻辑...
}
}
防御方案三:OpenZeppelin的ReentrancyGuard
solidity
// ✅ 方案3:使用官方防护库
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureBankV2 is ReentrancyGuard {
mapping(address => uint) public balances;
function withdraw() external nonReentrant {
uint balance = balances[msg.sender];
require(balance > 0);
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
}
审计检查点:
- 所有外部调用都在状态更新之后
- 使用
nonReentrant修饰符保护关键函数 - 考虑使用Pull Payment替代Push Payment
- 注意
transfer和send的2300 Gas限制
1.4 前端合约放大攻击
“拉地毯”(Rug Pull)事件中,骗子经常利用前端与合约的不一致来欺骗用户。
典型场景:
javascript
// ⚠️ 前端显示的代币数量与实际不一致
async function invest() {
const amount = document.getElementById("investment-amount").value;
// 前端显示预期收益
const expectedReturn = calculateReturn(amount);
document.getElementById("expected-return").innerText = expectedReturn;
// 但实际质押的代币可能不同
await stakingContract.deposit(tokenAddress, actualAmount);
}
防御措施:
javascript
// ✅ 使用合约返回值进行UI更新
async function invest() {
const amount = ethers.utils.parseEther(
document.getElementById("investment-amount").value
);
// 直接使用合约返回值(如果合约返回了实际数量)
const tx = await stakingContract.deposit(tokenAddress, amount);
const receipt = await tx.wait();
// 从事件日志中解析实际数量
const event = receipt.events?.find(e => e.event === 'Deposited');
if (event) {
const actualAmount = event.args.amount;
updateUI(actualAmount);
}
// 不信任前端计算结果
}
审计检查点:
- UI数据来源必须是合约事件/返回值,而非前端计算
- 交易确认后验证实际状态变化
- 提供链上数据验证入口(如Etherscan链接)
二、高级检查:深入安全细节
2.1 闪电贷攻击防护
闪电贷允许在单笔交易内借用并归还大量资产,这种机制被攻击者频繁利用。
典型攻击模式:
- 借出大量某代币
- 在DEX上进行大额交易操纵价格
- 利用虚假价格进行套利或清算
- 归还借款并保留利润
防御方案:
solidity
// ✅ 使用时间加权平均价格(TWAP)
import "@uniswap/v2-core/contracts/UniswapV2Pair.sol";
contract PriceAwareVault {
address public priceOracle;
function getAssetValue(address asset, uint256 amount)
public
view
returns (uint256)
{
// 使用时间加权价格,而非单点价格
(, int256 price, , , ) = priceOracle.latestRoundData();
return amount * uint256(price);
}
function borrow(address asset, uint256 amount) external {
uint256 collateralValue = calculateCollateralValue(msg.sender);
uint256 borrowValue = getAssetValue(asset, amount);
// 使用保守的抵押率(通常 > 1.5)
require(
collateralValue >= borrowValue * 150 / 100,
"Insufficient collateral"
);
// 业务逻辑...
}
}
更稳健的方案: Chainlink Automation + 延迟执行
solidity
// ✅ 关键操作延迟执行,给第三方验证留出时间
contract TimeLockVault {
uint256 public constant DELAY = 2 days;
mapping(bytes32 => uint256) public pendingOperations;
function scheduleWithdrawal(uint256 amount) external {
bytes32 operationId = keccak256(
abi.encode(msg.sender, amount, block.timestamp)
);
pendingOperations[operationId] = block.timestamp + DELAY;
emit OperationScheduled(operationId, msg.sender, DELAY);
}
function executeWithdrawal(uint256 amount, bytes32 operationId) external {
require(
pendingOperations[operationId] != 0 &&
block.timestamp >= pendingOperations[operationId],
"Operation not ready"
);
// 执行提款...
delete pendingOperations[operationId];
}
}
审计检查点:
- 关键操作是否依赖可被操纵的单点价格
- 是否有延迟执行机制
- 是否验证了借贷双方的余额快照
2.2 精度丢失问题
Solidity不支持浮点数,任何除法运算都可能导致精度丢失。
典型问题:
solidity
// ❌ 直接除法导致精度丢失
function calculateReward(uint256 stake, uint256 apy)
external
pure
returns (uint256)
{
// 假设apy = 5%(用5000表示)
// 质押1000代币
// 预期奖励:1000 * 5000 / 10000 = 500
// 但如果质押金额很小,可能得到0
return stake * apy / 10000;
}
正确做法:使用倍数放大
solidity
// ✅ 使用高精度计算
contract HighPrecisionStaking {
uint256 public constant PRECISION = 10**18;
uint256 public constant APR = 5000; // 5% * 1000
function calculateReward(uint256 stake, uint256 days)
public
pure
returns (uint256)
{
// 先乘后除,避免提前截断
return stake * APR * days / (365 * 100 * PRECISION);
}
}
审计检查点:
- 所有涉及代币数量的除法都考虑精度问题
- 使用SafeMath或检查被除数不为零
- 金额计算的顺序是否最优(先乘后除)
2.3 验证签名重放攻击
链上签名验证如果设计不当,可能遭受签名重放攻击——同一签名被多次使用。
攻击场景:
solidity
// ❌ 没有nonce的签名验证
function withdraw(uint256 amount, bytes memory signature) external {
bytes32 message = keccak256(abi.encodePacked(amount));
require(recoverSigner(message, signature) == owner, "Invalid signature");
payable(msg.sender).transfer(amount);
}
攻击者可以截获签名,在另一个合约中重放。
正确实现:
solidity
// ✅ 加入nonce防止重放
contract SecureSignedVault {
mapping(address => uint256) public nonces;
function withdraw(
uint256 amount,
uint256 nonce,
bytes memory signature
) external {
// 验证nonce
require(nonce == nonces[msg.sender], "Invalid nonce");
// 包含nonce构建消息
bytes32 message = keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:",
abi.encodePacked(amount, nonce, address(this))
)
);
require(recoverSigner(message, signature) == msg.sender, "Invalid sig");
// 更新nonce
nonces[msg.sender]++;
payable(msg.sender).transfer(amount);
}
}
审计检查点:
- 签名消息包含合约地址(防止跨合约重放)
- 签名消息包含nonce(防止重放)
- 签名消息包含过期时间(可选)
2.4 随机数安全
区块链上的随机数是个难题,因为所有数据都是公开的。
❌ 不安全的方法:使用区块变量
solidity
// ❌ blockhash可以由矿工操控
function random() internal view returns (uint256) {
return uint256(keccak256(abi.encodePacked(
blockhash(block.number - 1),
msg.sender,
block.timestamp
)));
}
矿工可以选择打包哪个区块,理论上可以影响结果。
✅ 推荐方案:Chainlink VRF
solidity
// ✅ 使用Chainlink的可验证随机函数
import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
contract RandomNFT is VRFConsumerBase {
bytes32 internal keyHash;
uint256 internal fee;
mapping(bytes32 => address) public requestIdToSender;
mapping(address => uint256[]) public tokenIds;
constructor() VRFConsumerBase(
0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655b, // VRF Coordinator
0x01BE23585060835E02B77ef475b0Cc51aA1e0709 // LINK Token
) {
keyHash = 0x2ed0feb3e7fd453212dc8b6a9721b98a11e3e9f5e4aa5c7d3a7e7c9a3d5b7c3;
fee = 0.1 * 10**18; // 0.1 LINK
}
function requestRandomNFT() external returns (bytes32) {
require(LINK.transferFrom(msg.sender, address(this), fee));
bytes32 requestId = requestRandomness(keyHash, fee);
requestIdToSender[requestId] = msg.sender;
return requestId;
}
function fulfillRandomness(
bytes32 requestId,
uint256 randomness
) internal override {
address sender = requestIdToSender[requestId];
uint256 tokenId = randomness % TOTAL_SUPPLY;
tokenIds[sender].push(tokenId);
emit RandomNFTMinted(sender, tokenId, randomness);
}
}
审计检查点:
- 不使用block变量作为随机数来源
- 对随机数质量要求高的场景使用VRF
- 随机数生成有延迟机制(给验证留出时间)
三、架构检查:系统性安全设计
3.1 合约升级机制
智能合约一旦部署就无法修改,因此需要预先设计升级机制。
代理模式概览:
plaintext
┌─────────────────────────────────────────────┐
│ Proxy Contract │
│ ┌─────────────────────────────────────┐ │
│ │ Storage Slot (EIP-1967) │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ Implementation Address │ │ │
│ │ └───────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────┐
│ Implementation Contract │
│ (可升级/可替换) │
└─────────────────────────┘
UUPS代理模式示例:
solidity
// 升级逻辑在实现合约中
abstract contract UUPSUpgradeable is Initializable {
function upgradeTo(address newImplementation)
internal
virtual
onlyProxy
{}
}
// 实现合约
contract MyTokenV1 is Initializable, UUPSUpgradeable {
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
function initialize() public initializer {
__Ownable_init();
}
// 升级函数(只有管理员可以调用)
function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {}
}
审计检查点:
- 升级权限有适当的门槛(时间锁、多签)
- 存储布局兼容性(新增变量放在最后)
- 实现合约有版本控制
3.2 紧急暂停机制
当发现漏洞时,需要能快速暂停合约。
solidity
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract SecureVault is Pausable, Ownable {
mapping(address => uint256) public balances;
function deposit() external payable whenNotPaused {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external whenNotPaused {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// 紧急暂停(只有owner可以操作)
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
}
审计检查点:
- 暂停权限集中度(单点故障风险)
- 暂停后用户资产的保护机制
- 是否需要时间锁保护暂停/解暂停操作
四、审计后的安全运营
代码通过审计不代表安全,部署后的持续监控同样重要。
4.1 监控告警
javascript
// 使用Forta Network设置异常告警
const { ethers } = require("ethers");
const { FORTA_AGENT_ABI } = require("./constants");
async function setupAlerts() {
// 监控大额转账
const amountThreshold = ethers.utils.parseEther("100");
// 监控频率异常
const frequencyThreshold = 10; // 每分钟超过10次
// 监控新地址活动
const newAddressMonitor = true;
console.log("Alerts configured:");
console.log("- Large transfer threshold:", amountThreshold);
console.log("- Frequency threshold:", frequencyThreshold);
}
4.2 保险机制
考虑为高价值合约购买保险或设置风险准备金:
solidity
contract InsuredVault {
uint256 public constant PREMIUM_RATE = 100; // 1%
uint256 public riskReserve;
function deposit() external payable {
// 1%作为保险费
uint256 premium = msg.value * PREMIUM_RATE / 10000;
riskReserve += premium;
// 实际质押金额
uint256 actualDeposit = msg.value - premium;
balances[msg.sender] += actualDeposit;
}
// 用于补偿安全事故损失
function compensate(address user, uint256 amount)
external
onlyGovernance
{
require(riskReserve >= amount);
riskReserve -= amount;
balances[user] += amount;
}
}
结语
智能合约安全没有银弹。每一行代码都应该经过仔细审查,每一个设计决策都应该考虑最坏情况。本文的检查清单是起点而非终点——随着技术演进和攻击手段翻新,新的安全威胁会不断出现。保持学习心态,关注安全社区的通报和报告,才能在这个快速发展的领域站稳脚跟。
安全审计的成本远低于安全事件后的损失。在代码上链之前投入时间,是对自己和用户最负责任的做法。
相关阅读

发表回复