1.执行摘要(ExecutiveSummary)本报告详细阐述了对PuppyRaffle.sol智能合约进行全面安全审计的结果。作为区块链安全审计流程的一部分,本次审计旨在识别合约逻辑中的严重漏洞、经济模型中的攻击向量、代码规范性问题以及潜在的Gas优化空间。审计对象PuppyRa
本报告详细阐述了对 PuppyRaffle.sol 智能合约进行全面安全审计的结果。作为区块链安全审计流程的一部分,本次审计旨在识别合约逻辑中的严重漏洞、经济模型中的攻击向量、代码规范性问题以及潜在的 Gas 优化空间。审计对象 PuppyRaffle 是一个基于以太坊的抽奖协议,允许用户购买彩票(Ticket)并有机会通过随机抽取获得“小狗”NFT,协议同时包含了退款机制与费用管理功能。
本次审计采用了静态分析、手动代码审查以及基于已知攻击向量的逻辑推演相结合的方法。审计过程深入剖析了合约的资金流向、状态管理、访问控制以及对以太坊虚拟机(EVM)特性的依赖。
审计结果表明,PuppyRaffle.sol 存在**极高(Critical)**的安全风险。合约中包含多个致命漏洞,攻击者可以利用这些漏洞窃取协议中的所有资金(包括用户本金与累计费用),或者通过拒绝服务攻击(DoS)导致协议永久瘫痪。此外,合约的随机数生成机制存在根本性缺陷,无法保证抽奖的公平性。
尽管该合约被设计为一个具有教育意义的靶场项目(源自 Cyfrin Updraft 或 Patrick Collins 的安全课程),但其代码中反映出的安全误区极具代表性。如果该代码被部署到主网环境中,将面临即刻被黑客攻击并耗尽资金的必然结局。
本次审计共发现 10 个主要问题,风险分布如下表所示:
| ID | 标题 | 严重程度 | 类别 |
|---|---|---|---|
| [H-01] | refund 函数中的重入攻击漏洞 (Reentrancy) |
High | 资金损失 |
| [H-02] | selectWinner 中的整数溢出与不安全类型转换 |
High | 逻辑错误/资金计算 |
| [M-01] | enterRaffle 中的无限循环导致拒绝服务 (DoS) |
Medium | 可用性破坏 |
| [M-02] | selectWinner 使用弱随机源 (Weak Randomness) |
Medium | 公平性操纵 |
| [M-03] | withdrawFees 中的严格等式检查导致资金冻结 |
Medium | 治理攻击 |
| [M-04] | 向获胜者转账时的 Push 模式导致逻辑阻塞 | Medium | 协议瘫痪 |
| [L-01] | getActivePlayerIndex 返回歧义性结果 |
Low | 逻辑偏差 |
| [I-01] | 浮动编译器版本 (Floating Pragma) | Info | 最佳实践 |
| [I-02] | 状态变量可见性与修饰符缺失 | Gas | Gas 优化 |
| [I-03] | 魔法数字 (Magic Numbers) 与代码风格 | Info | 代码质量 |
在 PuppyRaffle.sol 的 refund 函数中,存在经典的重入攻击(Reentrancy Attack)漏洞。该漏洞源于合约在处理退款逻辑时,违反了“检查-生效-交互”(Checks-Effects-Interactions, CEI)的设计模式。具体而言,合约在向用户发送以太币(External Call)之前,未能先行更新合约内部的状态(即从 players 数组中移除玩家或标记其已退款)。
在以太坊虚拟机(EVM)中,当合约通过 call、send 或 transfer 向外部地址发送以太币时,会触发接收方地址的 fallback 或 receive 函数(如果接收方是一个合约)。恶意攻击者可以部署一个攻击合约,在 receive 函数中编写逻辑,当收到退款时,再次调用 PuppyRaffle 的 refund 函数。
漏洞代码位置分析:
Solidity
function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "PuppyRaffle: Only the player can refund");
require(playerAddress!= address(0), "PuppyRaffle: Player already refunded, or is not active");
// 外部调用发生在状态更新之前
payable(msg.sender).sendValue(entranceFee);
// 状态更新滞后
players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
}
在上述代码中,payable(msg.sender).sendValue(entranceFee) 被执行时,控制权移交给了攻击者合约。此时,players[playerIndex] 的值尚未被置为 address(0)。因此,当攻击者在重入调用中再次进入 refund 函数时,require(playerAddress!= address(0)) 检查依然通过,导致合约再次向攻击者发送资金。
此漏洞的影响是灾难性的。攻击者可以利用该漏洞在单次交易中多次提取退款,直到耗尽 PuppyRaffle 合约中的所有余额(包括其他用户的存款和协议尚未提取的费用)。这直接导致协议资不抵债,用户资金全部丢失
攻击场景推演:
AttackContract。AttackContract 存入 entranceFee(例如 1 ETH)进入抽奖。AttackContract 调用 refund 函数。PuppyRaffle 检查通过,发送 1 ETH 给 AttackContract。AttackContract 的 receive 函数被触发,其中包含再次调用 PuppyRaffle.refund 的代码。PuppyRaffle 尚未将玩家状态置零,第二次调用检查依然通过,再次发送 1 ETH。修复此漏洞的核心在于严格遵守 Checks-Effects-Interactions (CEI) 模式,即在进行任何外部调用之前,必须先完成所有状态变量的更新。或者,引入重入锁(Reentrancy Guard)作为防御层。
修复方案 1:应用 CEI 模式(推荐)
Solidity
function refund(uint256 playerIndex) public {
address playerAddress = players[playerIndex];
require(playerAddress == msg.sender, "PuppyRaffle: Only the player can refund");
require(playerAddress!= address(0), "PuppyRaffle: Player already refunded, or is not active");
// [FIX] 1. Effect: 先更新状态
players[playerIndex] = address(0);
emit RaffleRefunded(playerAddress);
// [FIX] 2. Interaction: 后进行外部交互
payable(msg.sender).sendValue(entranceFee);
}
修复方案 2:使用 OpenZeppelin 的 ReentrancyGuard
Solidity
// 引入 ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract PuppyRaffle is ERC721, Ownable, ReentrancyGuard {
//...
function refund(uint256 playerIndex) public nonReentrant {
//... 原有逻辑...
}
}
合约声明使用 Solidity ^0.7.6 版本。在 Solidity 0.8.0 版本之前,整数算术运算在超出类型最大值时会发生“静默溢出”(Silent Overflow/Underflow),而不会抛出异常。PuppyRaffle 在计算和累加费用时,既未引入 SafeMath 库,也未进行手动溢出检查,且存在极度危险的类型转换操作。
漏洞代码位置分析:
Solidity
uint256 totalAmountCollected = players.length * entranceFee;
uint256 prizePool = (totalAmountCollected * 80) / 100;
uint256 fee = (totalAmountCollected * 20) / 100;
// 不安全的类型转换: uint256 -> uint64
// 算术溢出: totalFees +... 没有溢出检查
totalFees = totalFees + uint64(fee);
问题详解:
fee 是 uint256 类型,但在累加时被强制转换为 uint64。如果单次抽奖的费用超过 type(uint64).max(约 18.4 ETH),转换操作将截断高位数据,导致实际记录的 totalFees 远小于应收费用。totalFees 定义为 uint64。随着协议运行,累计费用很容易超过 uint64 的上限。一旦溢出,totalFees 将回绕至接近 0 的数值。totalFees 变量无法正确反映合约中实际沉淀的费用余额。withdrawFees 依赖 totalFees 进行严格的余额检查。一旦 totalFees 因溢出或截断而变得不准确(即 address(this).balance!= totalFees),所有者将永远无法提取费用,导致资金永久锁定在合约中 。 ^0.8.0 或更高,利用编译器内置的溢出检查机制。SafeCast 库进行类型转换。totalFees 的类型从 uint64 更改为 uint256,避免不必要的精度压缩。修复代码示例:
Solidity
// 修改状态变量定义
uint256 public totalFees = 0;
// 在 selectWinner 中
totalFees = totalFees + fee; // 如果使用 Solidity 0.8+,溢出时会自动 Revert
enterRaffle 函数旨在允许新玩家加入抽奖,同时通过遍历数组检查是否存在重复地址。然而,该检查机制采用了嵌套循环(Nested Loop)的方式,其时间复杂度为 O(N2),其中 N 为当前玩家总数。
漏洞代码位置分析:
Solidity
// Check for duplicates
for (uint256 i = 0; i < players.length - 1; i++) {
for (uint256 j = i + 1; j < players.length; j++) {
require(players[i]!= players[j], "PuppyRaffle: Duplicate player");
}
}
随着 players 数组长度的增加,执行该检查所需的 Gas 量呈指数级增长。例如,当前已有 100 名玩家时,新加入一名玩家需要进行数千次比较操作。当数组长度达到一定阈值(例如几百名玩家),执行该交易所消耗的 Gas 将超过以太坊区块的 Gas 上限(Block Gas Limit)。
一旦达到 Gas 上限,任何新用户尝试调用 enterRaffle 都会因 "Out of Gas" 而失败。这将导致协议在一段时间后不可避免地陷入瘫痪状态,新的参与者无法进入,抽奖活动被迫终止。这是一种基于逻辑缺陷的拒绝服务(DoS) 。
废弃嵌套循环的检查方式,改用 mapping 来记录地址是否已参与。Mapping 的查找时间复杂度为 O(1),不会随玩家数量增加而增加 Gas 消耗。
修复代码示例:
Solidity
// 定义映射
mapping(address => bool) public isPlayerActive;
function enterRaffle(address memory newPlayers) public payable {
require(msg.value == entranceFee * newPlayers.length, "PuppyRaffle: Must send enough to enter raffle");
for (uint256 i = 0; i < newPlayers.length; i++) {
address player = newPlayers[i];
require(!isPlayerActive[player], "PuppyRaffle: Duplicate player");
isPlayerActive[player] = true; // 更新映射
players.push(player);
}
emit RaffleEnter(newPlayers);
}
注意:需要在 selectWinner 和 refund 函数中同步更新 isPlayerActive 映射(重置为 false)。
合约在 selectWinner 函数中生成随机数以选出获胜者和确定 NFT 稀有度。其使用的随机源是链上变量的哈希值:msg.sender、block.timestamp 和 block.difficulty。
漏洞代码位置分析:
Solidity
uint256 winnerIndex = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length;
这些变量对于矿工(在 PoS 机制下为验证者)是可预测或可操纵的:
block.timestamp: 验证者可以在一定范围内(通常为 15 秒)调整时间戳。block.difficulty: 在合并后的以太坊中变为 PREVRANDAO,虽然随机性有所增强,但在特定时隙内验证者依然可以知晓该值。msg.sender: 攻击者可以完全控制。验证者或与之勾结的攻击者可以预先计算出能让自己获胜的区块参数,并选择性地打包交易或构造区块。这破坏了彩票系统的公平性,使得恶意参与者能够以极高的概率获胜,掠夺诚实用户的资金 。
不应依赖链上数据生成随机数。建议集成 Chainlink VRF (Verifiable Random Function),这是一种可验证的链下随机数解决方案,能够提供防篡改的随机性证明。
withdrawFees 函数包含一个保护性检查,旨在防止在有活跃玩家时提取资金。该检查通过对比合约当前余额 address(this).balance 与 totalFees 是否完全相等来实现。
漏洞代码位置分析:
Solidity
function withdrawFees() external {
// 严格等式检查
require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");
//...
}
攻击者可以利用以太坊的 selfdestruct 机制,强制向 PuppyRaffle 合约发送少量以太币(例如 1 Wei)。由于 selfdestruct 不会触发合约的 fallback 或 receive 函数,PuppyRaffle 无法拒绝这笔资金。这将导致 address(this).balance 略微大于 totalFees(假设没有其他玩家),使得 == 检查永远失败。
一旦攻击者向合约强制转入哪怕 1 Wei,协议的所有者将永远无法调用 withdrawFees 提取累积的费用。这是一种针对协议治理层的拒绝服务攻击(Griefing Attack),导致协议收入永久锁定 。
避免使用严格等式检查。应更改逻辑为判断余额是否大于或等于应提费用,或者仅提取 totalFees 数额,而不关心剩余余额。
修复代码示例:
Solidity
require(address(this).balance >= uint256(totalFees), "PuppyRaffle: Insufficient balance");
在 selectWinner 函数中,合约直接向获胜者地址发送以太币(奖金)。
Solidity
(bool success,) = winner.call{value: prizePool}("");
require(success, "PuppyRaffle: Failed to send prize pool to winner");
如果获胜者是一个合约地址,且该合约没有实现 receive/fallback 函数,或者其 receive 函数被故意设计为 Revert(回滚),那么 winner.call 将返回 false。由于紧接着使用了 require(success,...),整个 selectWinner 交易将回滚。
如果恶意用户通过攻击合约参与抽奖并被选为获胜者,他们可以导致 selectWinner 永远无法执行成功。这意味着抽奖活动无法结束,下一轮无法开始,且所有资金被锁定在当前轮次中。
采用 Pull over Push(拉取优于推送)模式。不要在 selectWinner 中直接转账,而是更新获胜者的待领取余额(winnings[winner] += prizePool),并提供一个独立的 withdrawWinnings 函数供获胜者自行提取。
getActivePlayerIndex 函数用于查找玩家在数组中的索引。如果玩家不存在,该函数返回 0。然而,0 也是数组第一个元素的有效索引。
调用者无法区分“玩家是第一个参与者”和“玩家未参与”。这可能导致前端展示错误或合约集成时的逻辑误判 。
函数应返回 int256(未找到返回 -1),或者返回一个包含布尔值的元组 (bool exists, uint256 index)。
Description: 合约使用了 pragma solidity ^0.7.6;。这种“浮动”写法允许使用任何兼容的编译器版本(如 0.7.9)。虽然这在库合约中常见,但为了确保部署字节码的确定性,应用合约应锁定具体版本(例如 pragma solidity 0.7.6; 或升级到 0.8.20)。
raffleDuration 在构造函数中赋值后不再修改,应声明为 immutable 以减少存储读取(SLOAD)消耗。commonImageUri 等 URI 字符串字面量应声明为 constant。enterRaffle 的循环中,多次读取 players.length。应在循环前将其缓存到内存变量中(uint256 len = players.length),以节省 Gas 。 Description: 代码中使用了 80 和 20 这样的数字字面量来计算奖金分配。应将其定义为常量(如 uint256 constant PRIZE_PERCENTAGE = 80;),以提高代码可读性和可维护性。
本次对 PuppyRaffle.sol 的审计揭示了该合约在安全性设计上的重大缺陷。合约实际上是一个充满陷阱的“雷区”,包含了重入攻击、整数溢出、无限循环 DoS 以及弱随机性等教科书级别的漏洞。
总体评价:不合格 (Fail)。 该合约在当前状态下绝对不能部署到主网。它不仅无法保护用户资金,甚至无法保证基本的协议功能在少量负载下的正常运行。
建议开发团队立即采取以下行动:
SafeMath(或升级 Solidity),使用 Pull 支付模式,集成 Chainlink VRF。refund 的重入问题。PuppyRaffle.sol 并非一个旨在投入生产的商业代码,而是 Cyfrin Updraft 课程或 Patrick Collins 区块链安全教程中精心设计的教学靶场(CTF)。
该合约通常出现在“初级”到“中级”审计课程的过渡阶段(例如 "First Flight" 系列)。在学习了基本的 Solidity 语法和简单的 PasswordStore 审计后,学生被引入这个更复杂的场景。它的设计目的是让学生在相对受控的环境中,亲手挖掘出 DeFi 历史上最臭名昭著的几类漏洞。
block.timestamp 或 difficulty,学生可以学习到链上随机数的不可靠性以及 MEV(矿工可提取价值)的基本概念。selfdestruct 强制转账的案例,是教授 EVM 隐晦特性的经典教材。综上所述,PuppyRaffle.sol 是一个集成了多种经典攻击向量的“大杂烩”,其价值在于通过反面教材提升审计人员对常见漏洞模式的敏感度。对于立志成为智能合约审计师的学习者而言,能独立发现并利用这些漏洞,是通向专业领域的关键里程碑。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!