本文主要介绍了重放攻击的概念、原理以及防范措施。重放攻击是指攻击者恶意重复使用有效的交易或签名,从而导致未经授权的状态变更。文章通过Solodit Checklist中的两个条目,详细讲解了如何利用nonce、链ID和域名分隔符等技术手段,防止重放攻击,从而保障智能合约的安全。
了解重放攻击如何利用智能合约中有效的签名,以及如何使用 nonce、链 ID 和域分隔符来防止它们。
欢迎回到 “Solodit 清单解释” 系列。
今天,我们将深入探讨 重放攻击,这是一种威胁,其中有效的交易或签名被恶意复制并重新执行。我们将检查两个 Solodit 清单项目( SOL-AM-ReplayAttack-1
和 SOL-AM-ReplayAttack-2
),以了解这些攻击如何破坏你协议的完整性以及如何有效地防御它们。
这是 “Solodit 清单解释” 系列的一部分。你可以在这里找到之前的文章:
为了获得最佳体验,请打开一个包含 Solodit 清单 的选项卡,以便在阅读时参考它。我的 GitHub 上提供了示例 这里。
当有效的数据传输(例如签名消息或交易)被恶意重复时,就会发生重放攻击。在智能合约中,攻击者拦截合法的、已签名的用户操作。然后,他们稍后或在不同的网络上 “重放” 它,以触发意外的 状态 更改,例如耗尽资金或在未经授权的情况下执行已授权的 函数。
为了防御这些攻击,开发人员使用几种关键工具:
Nonce(“仅使用一次的数字”):在 区块链 中,nonce 是与用户的 地址 相关的唯一、顺序计数器。通过要求每个签名操作都包含用户的当前 nonce,合约可以确保每个操作仅执行一次。处理后,nonce 会递增,因此签名无法重复使用。
链特定参数:随着多个以太坊虚拟机(EVM)兼容区块链(例如,以太坊、Polygon、Arbitrum 等)的兴起,用户的 私钥 和地址通常在不同网络上是相同的。如果签名不包含链特定数据,则为以太坊上的合约创建的签名可能对 Polygon 上相同的合约有效。在签名数据中包含 block.chainid
将签名与单个特定区块链绑定,从而防止跨链重放攻击。
域分隔符:这是一种密码学机制,在 EIP-712 中标准化,它将签名绑定到特定的应用程序上下文。它是一个唯一的 哈希,包含合约的名称、版本、地址和 chainid
等信息。这确保了用于一个去中心化应用程序(DApp)的签名不能在另一个应用程序中重放,从而为防止跨链和跨 DApp 重放攻击提供了强大的保护。
现在,让我们探讨如何将这些机制应用于特定的漏洞。
描述:如果未正确保护,失败的交易可能会受到重放攻击。
补救措施:实施基于 nonce 的或其他机制,以确保每个交易只能执行一次。这可以防止重放攻击,即使交易最初失败。
此检查的重点是常见的实现缺陷:仅在交易成功后才递增用户的 nonce。如果交易由于临时条件而失败(例如,合约缺乏足够的资金),则 nonce 保持不变。签名消息仍然有效,并且一旦条件解决,任何人都可以重放它。
考虑一下这个 RewardSystem
合约,它允许用户声明他们已获得的奖励。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "openzeppelin-contracts/contracts/access/Ownable.sol";
contract RewardSystem is Ownable {
mapping(address => uint256) public rewards;
mapping(address => uint256) public nonces;
constructor() Ownable(msg.sender) {}
// 为了 PoC 的简单性,我们不使用真实的签名
function claimReward(address user, uint256 amount, uint256 nonce, bytes memory signature) external {
require(rewards[user] >= amount, "Insufficient reward balance");
require(nonces[user] == nonce, "Invalid nonce");
// 漏洞:一旦合约有资金,签名就可以重放
bytes32 messageHash = keccak256(abi.encode(
user,
amount,
nonce
));
// 为了 PoC,使用简单的签名检查
bytes32 signedHash = abi.decode(signature, (bytes32));
require(signedHash == messageHash, "Invalid signature");
// 尝试转移奖励
rewards[user] -= amount;
(bool success,) = msg.sender.call{value: amount}("");
// 漏洞:只有在转移成功时,Nonce 才会递增
if (success) {
nonces[user]++;
} else {
revert("Transfer failed");
}
}
// 帮助函数添加奖励 - 只有所有者可以调用
function addReward(address user, uint256 amount) external onlyOwner {
rewards[user] += amount;
}
// 帮助函数接收 ETH
receive() external payable {}
}
claimReward
函数仅在 ETH 转移成功时才递增用户的 nonce。如果合约没有 ETH,则 msg.sender.call
将失败,交易将 revert,并且 nonces[user]
不会递增。用户的签名对于该 nonce 仍然有效。
攻击者可以监视这些失败的声明 。一旦 RewardSystem
合约获得资金,攻击者就可以重放用户原始的交易,将奖励导向他们自己的地址。
这个 Forge 测试说明了该攻击:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
// 假设 RewardSystem 合约在上面定义或导入
contract ReplayAttackTest is Test {
RewardSystem public rewardSystem;
address public user = address(1);
address public attacker = address(2);
uint256 constant REWARD_AMOUNT = 1 ether;
function setUp() public {
vm.prank(address(this));
rewardSystem = new RewardSystem();
// 为用户设置初始奖励
rewardSystem.addReward(user, REWARD_AMOUNT);
}
function testReplayAttack() public {
uint256 nonce = rewardSystem.nonces(user);
// 1. 用户创建一个签名来声明他们的奖励。
// 对于这个 PoC,"签名" 只是消息哈希。
bytes32 messageHash = keccak256(abi.encode(user, REWARD_AMOUNT, nonce));
bytes memory signature = abi.encode(messageHash);
// 2. 用户尝试声明,但交易失败,因为
// 合约没有要发送的 ETH。用户的 nonce 不会递增。
vm.prank(user);
vm.expectRevert("Transfer failed");
rewardSystem.claimReward(user, REWARD_AMOUNT, nonce, signature);
assertEq(rewardSystem.nonces(user), nonce, "Nonce 在失败时应该不会递增");
// 3. 合约由所有者或其他来源提供资金。
vm.deal(address(rewardSystem), REWARD_AMOUNT);
// 4. 一个正在监视的攻击者重放用户有效的签名。
// 攻击者是 msg.sender,因此奖励被发送给他们。
vm.prank(attacker);
rewardSystem.claimReward(user, REWARD_AMOUNT, nonce, signature);
// 5. 攻击者成功窃取用户的奖励。
assertEq(address(attacker).balance, REWARD_AMOUNT);
assertEq(rewardSystem.rewards(user), 0);
// Nonce 现在递增
assertEq(rewardSystem.nonces(user), nonce + 1);
}
}
攻击按以下方式展开:
签名创建:合法的 user
准备一条签名消息来声明他们的奖励。
声明失败:user
尝试声明他们的奖励,但由于 RewardSystem
合约没有 ETH 来发送给他们,因此交易恢复。因为交易在执行 nonces[user]++
行之前恢复,用户的 nonce 保持不变。该签名对于该 nonce 仍然有效。
条件变更:之后,合约获得资金,满足导致初始失败的条件。
重放:观察到初始失败交易的 attacker
现在重放它。签名仍然有效,因为 nonce 匹配。claimReward
函数从 user
的余额中扣除奖励,但将 ETH 发送到此交易的 msg.sender
,也就是攻击者。
成功窃取:攻击成功。测试确认 attacker
收到了资金,用户的奖励余额现在为零,并且 nonce 最终已递增。
确保在每个执行路径中都消耗(标记为已使用)nonce,无论它是成功还是失败。最佳实践是在验证 nonce 后立即消耗它,并确保交易在那之后不会恢复。这样,即使交易失败,nonce 仍然会递增,从而防止重放攻击。
// 更正后的 claimReward 函数(在 RewardSystem 中)
function claimReward(address user, uint256 amount, uint256 nonce, bytes memory signature) external {
require(rewards[user] >= amount, "Insufficient reward balance");
require(nonces[user] == nonce, "Invalid nonce");
// 为了 PoC,使用简单的签名检查
bytes32 messageHash = keccak256(abi.encode(user, amount, nonce));
bytes32 signedHash = abi.decode(signature, (bytes32));
require(signedHash == messageHash, "Invalid signature");
// 补救措施 1:验证 nonce 后立即消耗它
nonces[user]++;
rewards[user] -= amount;
(bool success,) = msg.sender.call{value: amount}("");
if (!success) {
// 如果转移失败,则恢复奖励扣除
rewards[user] += amount;
}
// 补救措施 2:没有恢复以确保 nonce 被消耗
}
通过此修复,即使转移失败,nonce 也会失效。攻击者无法重放签名,因为合约现在将期望 nonce + 1
。
描述:在一个区块链上有效的签名可能会在另一个区块链上重放,从而导致潜在的安全漏洞。
补救措施:使用链特定参数,例如 block.chainid
,或使用 EIP-712 中定义的域分隔符,以确保签名仅在预期的链上有效。
此检查项目解决了关键的多链漏洞。由于用户的私钥控制着他们在所有 EVM 链上的地址,因此为以太坊上的合约创建的签名可以在 Polygon、Arbitrum 或任何其他 EVM 链上该合约的相同部署上重放。如果签名不包含链 ID,它将成为攻击者可以在网络上使用的通用密钥。
让我们检查一个 VulnerableVault
合约,该合约旨在让其所有者通过签名消息更改接收者。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
contract VulnerableVault {
address public owner;
address public recipient;
mapping(bytes32 => bool) public isConsumed;
constructor(address _owner, address _recipient) {
owner = _owner;
recipient = _recipient;
}
function changeRecipient(address _newRecipient, uint256 _expiry, bytes memory _signature) external {
require(block.timestamp <= _expiry, "Signature expired");
// 漏洞:签名数据中缺少链 ID
bytes32 messageHash = keccak256(abi.encode(
msg.sender, // 在 PoC 中,这是测试合约的地址
_newRecipient,
_expiry
));
// 为了 PoC,使用简单的签名检查
bytes32 signedHash = abi.decode(_signature, (bytes32));
require(signedHash == messageHash, "Invalid signature");
require(!isConsumed[messageHash], "Signature already used");
isConsumed[messageHash] = true;
recipient = _newRecipient;
}
}
messageHash 包含新的接收者和一个过期时间戳,但它缺少 block.chainid。这意味着为此操作生成的签名是可移植的,即攻击者可以从一个链获取有效的签名并将其用于另一个链。
攻击场景假设 VulnerableVault 部署在以太坊(链 A)和 Polygon(链 B)上。
这个 Forge 测试模拟了该攻击:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
// 假设 VulnerableVault 合约在上面定义或导入
contract ReplayAttackTest is Test {
VulnerableVault public vault;
address owner = address(1);
address recipient = address(2);
address newRecipient = address(3);
uint256 expiry;
function setUp() public {
expiry = block.timestamp + 1 hours;
vault = new VulnerableVault(owner, recipient);
}
function testCrossChainReplayAttack() public {
// 1. 所有者创建一个签名以更改 "链 A" 上的接收者。
bytes32 messageHash = keccak256(abi.encode(
address(this),
newRecipient,
expiry
));
bytes memory signature = abi.encode(messageHash);
// 2. 交易在 "链 A" 上执行。
vm.prank(address(this));
vault.changeRecipient(newRecipient, expiry, signature);
assertEq(vault.recipient(), newRecipient, "在链 A 上失败");
assertTrue(vault.isConsumed(messageHash), "签名应该在链 A 上被消耗");
// 3. 通过手动重置状态来模拟移动到 "链 B"。
vm.store(
address(vault),
keccak256(abi.encode(messageHash, uint256(2))), // isConsumed[messageHash] 的存储槽
bytes32(0)
);
assertFalse(vault.isConsumed(messageHash), "链 B 的模拟重置失败");
// 4. 攻击者在 "链 B" 上重放相同的签名。
vm.prank(address(this));
vault.changeRecipient(newRecipient, expiry, signature);
// 5. 攻击在 "链 B" 上成功。
assertEq(vault.recipient(), newRecipient, "在链 B 上的重放应该成功");
}
}
messageHash
缺少任何链特定的标识符。
"链 A" 的签名:所有者(由测试合约 address(this)
模拟)创建一个有效的签名来更改接收者。此操作旨在用于初始链,我们将其称为 “链 A”。
在 "链 A" 上执行:交易在 "链 A" 上成功执行。测试验证 recipient
已更新,并且重要的是,isConsumed
映射已设置为该签名哈希的 true
,从而防止在此特定链上重放。
模拟 "链 B":这是模拟的关键部分。真正的第二个链(“链 B”)将具有合约自己的独立状态,其中 isConsumed
自然为 false
。为了在单个测试环境中模拟这一点,我们使用 Foundry 的 vm.store
作弊码直接写入合约的 存储 并将 isConsumed[messageHash]
重置为 false
。这有效地将我们的测试环境置于 “链 B” 的状态,在这种状态下,签名以前从未见过。
在 "链 B" 上重放:攻击者将原始 signature
提交给模拟的 "链 B" 状态中的合约。由于签名不是链特定的,并且未在此链上标记为已消耗,因此该交易被接受为有效。
攻击成功:测试确认接收者再次更改,但这次没有所有者对此链的意图。攻击成功,因为签名的有效性未限制在其预期的网络上。
直接的修复方法是在哈希和签名的数据中包含 block.chainid
。这会将签名永久绑定到单个链。对于更强大和标准化的解决方案,请采用EIP-712,该方案通过域分隔符将此保护直接构建到其结构中。
以下是将简单修复应用于新的 SecureVault
合约。
// 更正后的 changeRecipient 函数(在新的 SecureVault 合约中)
contract SecureVault {
// ... 与 VulnerableVault 相同的状态变量和构造函数 ...
address public owner;
address public recipient;
mapping(bytes32 => bool) public isConsumed;
constructor(address _owner, address _recipient) {
owner = _owner;
recipient = _recipient;
}
function changeRecipient(address _newRecipient, uint256 _expiry, bytes memory _signature) external {
require(block.timestamp <= _expiry, "Signature expired");
// 补救措施:包含链 ID 以使签名特定于链。
bytes32 messageHash = keccak256(abi.encode(
msg.sender,
_newRecipient,
_expiry,
block.chainid // 添加了链 ID
));
// 为了 PoC,使用简单的签名检查
bytes32 signedHash = abi.decode(_signature, (bytes32));
require(signedHash == messageHash, "Invalid signature");
require(!isConsumed[messageHash], "Signature already used");
isConsumed[messageHash] = true;
recipient = _newRecipient;
}
}
重放攻击在无效的上下文中利用了有效的用户意图。通过了解攻击者如何跨时间或跨链复制操作,我们可以将更精确和安全的验证逻辑构建到我们的合约中。
开发安全的合约需要对抗性的视角:
始终询问:在使用后,我是否完全使此签名无效?无论交易成功还是失败,都应消耗 nonce。
询问签名的上下文:此签名在何处有效?如果你的协议在多个链上运行或可能在多个链上运行,则每个链下签名都必须通过 block.chainid 或 EIP-712 域分隔符绑定到特定链。
验证每个假设:我的代码是否假设交易会成功?它是否假设签名仅适用于一个网络?主动识别和消除这些隐含的假设是防止重放攻击的关键。
通过严格应用这些原则并利用 Solodit 清单 等工具,你可以构建能够抵御这些微妙但危险的漏洞的系统。
请继续关注下一期 “Solodit 清单解释!”
- 原文链接: cyfrin.io/blog/solodit-c...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!