Solodit Checklist详解:重放攻击

  • cyfrin
  • 发布于 5天前
  • 阅读 132

本文主要介绍了重放攻击的概念、原理以及防范措施。重放攻击是指攻击者恶意重复使用有效的交易或签名,从而导致未经授权的状态变更。文章通过Solodit Checklist中的两个条目,详细讲解了如何利用nonce、链ID和域名分隔符等技术手段,防止重放攻击,从而保障智能合约的安全。

Solodit 清单解释 (9): 重放攻击

了解重放攻击如何利用智能合约中有效的签名,以及如何使用 nonce、链 ID 和域分隔符来防止它们。

欢迎回到 “Solodit 清单解释” 系列。

今天,我们将深入探讨 重放攻击,这是一种威胁,其中有效的交易签名被恶意复制并重新执行。我们将检查两个 Solodit 清单项目( SOL-AM-ReplayAttack-1SOL-AM-ReplayAttack-2),以了解这些攻击如何破坏你协议的完整性以及如何有效地防御它们。

这是 “Solodit 清单解释” 系列的一部分。你可以在这里找到之前的文章:

为了获得最佳体验,请打开一个包含 Solodit 清单 的选项卡,以便在阅读时参考它。我的 GitHub 上提供了示例 这里

理解重放攻击

当有效的数据传输(例如签名消息或交易)被恶意重复时,就会发生重放攻击。在智能合约中,攻击者拦截合法的、已签名的用户操作。然后,他们稍后或在不同的网络上 “重放” 它,以触发意外的 状态 更改,例如耗尽资金或在未经授权的情况下执行已授权的 函数

为了防御这些攻击,开发人员使用几种关键工具:

  • Nonce(“仅使用一次的数字”):在 区块链 中,nonce 是与用户的 地址 相关的唯一、顺序计数器。通过要求每个签名操作都包含用户的当前 nonce,合约可以确保每个操作仅执行一次。处理后,nonce 会递增,因此签名无法重复使用。

  • 链特定参数:随着多个以太坊虚拟机(EVM)兼容区块链(例如,以太坊、Polygon、Arbitrum 等)的兴起,用户的 私钥 和地址通常在不同网络上是相同的。如果签名不包含链特定数据,则为以太坊上的合约创建的签名可能对 Polygon 上相同的合约有效。在签名数据中包含 block.chainid 将签名与单个特定区块链绑定,从而防止跨链重放攻击。

  • 域分隔符:这是一种密码学机制,在 EIP-712 中标准化,它将签名绑定到特定的应用程序上下文。它是一个唯一的 哈希,包含合约的名称、版本、地址和 chainid 等信息。这确保了用于一个去中心化应用程序(DApp)的签名不能在另一个应用程序中重放,从而为防止跨链和跨 DApp 重放攻击提供了强大的保护。

现在,让我们探讨如何将这些机制应用于特定的漏洞。

SOL-AM-ReplayAttack-1:是否存在针对失败交易的重放攻击的保护措施?

描述:如果未正确保护,失败的交易可能会受到重放攻击。

补救措施:实施基于 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);
    }
}

攻击如何运作

攻击按以下方式展开:

  1. 签名创建:合法的 user 准备一条签名消息来声明他们的奖励。

  2. 声明失败user 尝试声明他们的奖励,但由于 RewardSystem 合约没有 ETH 来发送给他们,因此交易恢复。因为交易在执行 nonces[user]++ 行之前恢复,用户的 nonce 保持不变。该签名对于该 nonce 仍然有效。

  3. 条件变更:之后,合约获得资金,满足导致初始失败的条件。

  4. 重放:观察到初始失败交易的 attacker 现在重放它。签名仍然有效,因为 nonce 匹配。claimReward 函数从 user 的余额中扣除奖励,但将 ETH 发送到交易的 msg.sender,也就是攻击者。

  5. 成功窃取:攻击成功。测试确认 attacker 收到了资金,用户的奖励余额现在为零,并且 nonce 最终已递增。

补救措施:在每个执行路径中消耗 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

SOL-AM-ReplayAttack-2:是否存在防止在不同链上重放签名的保护措施?

描述:在一个区块链上有效的签名可能会在另一个区块链上重放,从而导致潜在的安全漏洞。

补救措施:使用链特定参数,例如 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 缺少任何链特定的标识符。

  1. "链 A" 的签名:所有者(由测试合约 address(this) 模拟)创建一个有效的签名来更改接收者。此操作旨在用于初始链,我们将其称为 “链 A”。

  2. 在 "链 A" 上执行:交易在 "链 A" 上成功执行。测试验证 recipient 已更新,并且重要的是,isConsumed 映射已设置为该签名哈希的 true,从而防止在此特定链上重放。

  3. 模拟 "链 B":这是模拟的关键部分。真正的第二个链(“链 B”)将具有合约自己的独立状态,其中 isConsumed 自然为 false。为了在单个测试环境中模拟这一点,我们使用 Foundry 的 vm.store 作弊码直接写入合约的 存储 并将 isConsumed[messageHash] 重置为 false。这有效地将我们的测试环境置于 “链 B” 的状态,在这种状态下,签名以前从未见过。

  4. 在 "链 B" 上重放:攻击者将原始 signature 提交给模拟的 "链 B" 状态中的合约。由于签名不是链特定的,并且未在此链上标记为已消耗,因此该交易被接受为有效。

  5. 攻击成功:测试确认接收者再次更改,但这次没有所有者对此链的意图。攻击成功,因为签名的有效性未限制在其预期的网络上。

补救措施:在签名中嵌入链特定数据

直接的修复方法是在哈希和签名的数据中包含 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.