本文详细探讨了区块链重放攻击及其五种常见类型,包括缺失应用程序随机数、硬分叉、跨链、签名可变性和密码学随机数重用。文章还提供了针对这些攻击的有效防护措施,并引用了示例代码来展示如何实现这些保护机制。
了解区块链重放攻击是什么,以及如何通过五个常见示例来理解其工作原理。
重放攻击是一种攻击手段,攻击者拦截并操作网络上的数据传输。在区块链领域,重放攻击对交易的安全构成了重大威胁。本文将探讨不同类型的重放攻击,解释其机制以及有效策略以保护自己免受攻击。
为了阅读本文,建议阅读以下作为前提的文章:
— 请注意,本文中的代码只是为了演示目的,未经过全面的安全审查,不要将其作为生产代码使用。
在区块链技术的背景下,攻击者通过操控并重用有效交易来利用这种漏洞以获取利益。这可以通过将交易复制到另一个链上或复制或修改签名,从而通过任何验证检查。
如果你想深入了解重放攻击,以及如何保护你的协议和代码库免受这些攻击,请查看我们在 Cyfrin Updraft 上的 智能合约安全和审计。
现在让我们逐一了解区块链重放攻击的以下类型以及如何防止它们:
签名为区块链技术提供了加密认证的手段,作为唯一的“指纹”,构成区块链交易的基础。 它们用于验证在链外进行的计算并授权以签名者的名义进行交易。如果处理不当,攻击者可以重播前一个或待处理交易的签名,这意味着攻击者通过验证检查并执行恶意交易。
Nonce 是一个唯一标识符或一个“仅使用一次的数字”。 其缺失可能使攻击者能够反复利用捕获的签名进行欺诈交易。为了避免混淆,我们将此特定 nonce 称为 “应用程序 nonce”。
一个应用程序 nonce 被实现于智能合约中,以确保签名仅能使用一次。它应作为消息的字段被签名者包含在内。
缺失应用程序 nonce 重播攻击的工作原理:
添加一个 nonce 将阻止这种行为,因为合约将知道该签名已过时。
要实现此目的,请包括一个映射以跟踪每个签名者最后使用的 nonce:
mapping (address => uint256) public nonces;
这可以作为签名消息的一部分使用。有关实现 nonce 及其他重播防止数据的完整示例,请参见本文的“防止重播攻击”部分。
在以下 KYCRegistry 合约中,签名被用来通过 addKYCAddressViaSignature()
函数为用户授予 KYC 状态。但是,如果在截止日期之前撤销了用户的 KYC 状态,则不会防止签名重播。如果攻击者再次使用原始签名调用 addKYCAddressViaSignature()
,KYC 状态将再次强行授予攻击者。
function addKYCAddressViaSignature( ... ) external {
require(v == 27 || v == 28, "KYCRegistry: 签名中的无效 v 值");
require(
!kycState[kycRequirementGroup][user],
"KYCRegistry: 用户已验证"
);
require(block.timestamp <= deadline, "KYCRegistry: 签名已过期");
bytes32 structHash = keccak256(
abi.encode(_APPROVAL_TYPEHASH, kycRequirementGroup, user, deadline)
); // @audit - 需要在这里编码 nonce 以防止重播
bytes32 expectedMessage = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(expectedMessage, v, r, s);
_checkRole(kycGroupRoles[kycRequirementGroup], signer);
kycState[kycRequirementGroup][user] = true;
// ...
}
实现每个签名者的 nonce 映射可以修复此漏洞,如前所述。更多信息可在 这里 找到。
来自 Dacian 的这篇文章,Cyfrin 审计员,提供了对抗缺失 nonce 重播攻击的全面解析。
硬分叉涉及对区块链协议的重大且不向后兼容的更改,从而导致两个独立账本的创建:原始账本和新分叉账本。
当两个账本共享相同的交易和地址格式时,就会出现重放攻击的潜力。
在硬分叉期间,原始账本和新账本可能具有相似或相同的交易和地址格式,使它们易受重放攻击。在硬分叉后的重放攻击中,恶意行为者可以拦截并复制合法交易,将其从一个账本转移到另一个账本。攻击者可以,例如,在遗留链上铸造代币,然后将交易复制到规范链上。
图像:硬分叉重放攻击
以太坊经典硬分叉:以太坊在2016年进行了硬分叉,专注于提高效率和可扩展性。旧链改名为以太坊经典(ETC),而新链保留原名以太坊(ETH)。由于链具有相似的规范,因此交易在两条链上都是有效的。人们能够利用这种重放漏洞,例如,从交易所提取 ETH 并同样获得 ETC。
以太坊合并:在2022年9月,以太坊进行了名为“合并”的硬分叉。
“合并是将以太坊的原始执行层(自创世以来存在的主网)与其新的权益证明共识层,信标链结合在一起。它消除了对能源密集型挖矿的需求,而是使网络能够使用抵押的 ETH 进行保护。”
由于以太坊 PoW(硬分叉)与以太坊(合并后的主网)具有相同的链 ID,因此可能发生重放攻击。如果 Bob 在 PoW 链上向 Alice 发送 100 ETH,Alice 可以在以太坊上重播此交易并盗取资金。这是一个说明为什么不与硬分叉进行交互以防止遭受重放攻击的重要性的例子。
同样值得注意的是,重放攻击可以超越单个区块链网络,正如硬分叉重放一样,延伸到跨链场景。在跨链重放攻击中,威胁出现在一个区块链网络的交易在另一个区块链网络上被复制,通常利用相似或互操作的协议,其中相同或相似的智能合约被部署到多个链上。
对于 EVM 兼容的 rollups,还值得注意的是,当交易从 L1 发送到 L2 时,在 L2 上的交易发送者地址会被设置为在 L1 上的交易发送者地址。然而,如果该交易是由 L1 上的智能合约触发的,则 L2 上的交易发送者地址将不同。 由于 CREATE
操作码的行为,可以在 L1 和 L2 上同时存在具有相同地址但不同字节码(不同实现)的智能合约。如果 L2 交易的发送者是一个在 L1 上与 L2 上合约具有相同地址的智能合约,则该交易可以在 L2 上重播,但使用 L2 合约实现。为缓解此问题,某些在 rollups 上的合约被别名,以避免被恶意调用。
为防止跨链重放攻击,使用链特定的签名方案,例如 EIP-155,在签名消息中包含链 ID。签名还应使用链 ID 进行验证。这将防止在一个链上签名的交易被重播到另一个链上。
在 ECDSA 中使用的 椭圆曲线(SECP256k1),与所有椭圆曲线一样,在 x 轴上是对称的。因此,对于每一个 (r,s,v)
,存在另一坐标返回相同的有效结果。这意味着在曲线的某一点上为一个签名者存在两个签名,允许攻击者在不需要签名者私钥的情况下计算第二个有效签名。
SECP256k1 是在 y² = x³ + 7 曲线上的点集,与 ECDSA 一起用于生成密钥。
这条曲线可以被可视化为:
来源 : SECP256k1 曲线
ECDSA 签名表现为 (r,s,v)
,其中 v
用于从 r
的值确定公钥。由于曲线在 x 轴上对称,对于每个 r
的值存在两个有效公钥。
由于 x 轴的对称性,如果 (r,s)
是一个有效签名,则 (r, -s mod n)
(其中 n
是在 SECP256k1 中定义的点数量)也是。
💡 关键要点:曲线上的 x 轴对称性意味着每个 r
的值都有两个有效签名。
为了解决这个问题,以太坊经历了一次硬分叉:EIP-2。EIP-2 限制了 s
值以防止签名可塑性 仅允许较低的 s
值。 通过将有效范围限制为一半,EIP-2 有效地从群体中移除了半个点,确保在每个 x- 坐标(r
的值)上最多只有一个有效点,因此只有一个有效签名。然而,这些更改未在实现 ecrecover
的预编译合约中反映出来;因此,直接使用 ecrecover
仍然存在问题。
以下来自 Larva Labs 的 code4rena 审计的 代码 显示 ecrecover
直接使用而没有检查 s
以限制其值仅限于较低水平:
function verify(address signer, bytes32 hash, bytes memory signature) internal pure returns (bool) {
require(signature.length == 65);
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
if (v < 27) {
v += 27;
}
require(v == 27 || v == 28);
return signer == ecrecover(hash, v, r, s);
}
由于 s
没有被限制,因此存在两个有效签名,从而使合约易受签名可塑性攻击。
在 Sherlock 的一份审计中,合约使用 OpenZeppelin 的版本低于 4.7.3,其中存在 签名可塑性漏洞。攻击者可以利用签名可塑性重新提交请求,只要旧请求尚未过期。问题出在验证函数 ecrecover
上,它允许 s
的上下两个值。
在 OpenZeppelin 版本 ≥ 4.7.3 中,tryRecover
检查 s
的限制:
function verify(address signer, bytes32 hash, bytes memory signature) internal pure returns (bool) {
require(signature.length == 65);
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
if (v < 27) {
v += 27;
}
require(v == 27 || v == 28);
return signer == ecrecover(hash, v, r, s);
}
在直接实现 ecrecover
时,应限制 s
值仅允许较低水平。
此问题通常发生在直接使用 ecrecover
时,因此应使用 OpenZeppelin 的 ECDSA 库(版本 4.7.3 或更高)。
有关签名可塑性及其防止措施的更多信息,请参考 这篇来自 ImmuneFi 的文章。
如前所述,签名的 s
值是使用随机数 k
计算的,k
代表 nonce。如果两个消息用相同 nonce 签名,则可以提取 ECDSA 私钥。这意味着攻击者可以妥协签名者的帐户,并使用私钥签署恶意消息。
有关如何以及为什么使用多个 nonce 时可以恢复私钥的完整推导,请参考 这一全面的流程。
确保 k
对每个签名都是唯一的,并且绝不要重用。同样重要的是,每个 k
值不能通过前一种 k
值进行计算,例如 k = k + 1
,否则如果 k
是已知的,依然可以提取出私钥。使用 OpenZeppelin 的 ECDSA 库(版本 4.7.3 或更高)也防止 nonce 重用重播,因此也建议使用。
为了防止签名重播攻击,智能合约必须:
s
值仅限于一半可选的,但推荐:
ecrecover
的返回结果以下代码是该实践的示例:
struct ReplayResistantMessage {
uint256 number;
uint256 deadline;
uint256 nonce;
}
bytes32 public constant REPLAY_RESISTANT_MESSAGE_TYPEHASH =
keccak256("Message(uint256 number,uint256 deadline,uint256 nonce)");
// 跟踪 nonce!
mapping(address => mapping(uint256 => bool)) public noncesUsed;
mapping(address => uint256) public latestNonce;
// 在签名中包括截止日期和 nonce
function getSignerReplayResistant(
uint256 message,
uint256 deadline,
uint256 nonce,
uint8 _v,
bytes32 _r,
bytes32 _s
)
public
view
returns (address)
{
// 计算哈希以验证的参数
// 1: byte(0x19) - 初始的 0x19 字节
// 2: byte(1) - 版本字节
// 3: 域分隔符的哈希结构(包括域结构的 typehash)
// 4: 消息的哈希结构(包括消息结构的 typehash)
bytes1 prefix = bytes1(0x19);
bytes1 eip712Version = bytes1(0x01); // EIP-712 是 EIP-191 的版本 1
bytes32 hashStructOfDomainSeparator = i_domain_separator;
bytes32 hashedMessage = keccak256(
abi.encode(
REPLAY_RESISTANT_MESSAGE_TYPEHASH,
ReplayResistantMessage({ number: message, deadline: deadline, nonce: nonce })
)
);
bytes32 digest = keccak256(abi.encodePacked(prefix, eip712Version, hashStructOfDomainSeparator, hashedMessage));
return ecrecover(digest, _v, _r, _s);
}
// 这不包含上述建议的所有数据字段,如合约声明者、签名长度等
function verifySignerReplayResistant(
ReplayResistantMessage memory message,
uint8 _v,
bytes32 _r,
bytes32 _s,
address signer
)
public
returns (bool)
{
// 1. 使用未使用的唯一 nonce
require(!noncesUsed[signer][message.nonce], "需要唯一 nonce");
noncesUsed[signer][message.nonce] = true;
latestNonce[signer] = message.nonce;
// 2. 过期日期
require(block.timestamp < message.deadline, "已过期");
// 检查 ecrecover 的返回结果
address actualSigner = getSignerReplayResistant(message.number, message.deadline, message.nonce, _v, _r, _s);
require(actualSigner != address(0));
require(signer == actualSigner);
// 3. 限制 s 值为一半
// 这防止了“签名可塑性”
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/b5a7f977d8a57b6854545522e36d91a0c11723cd/contracts/utils/cryptography/ECDSA.sol#L128
if (uint256(_s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
revert("无效的 s 值");
}
// 4. 使用链 ID
// 在域分隔符中的
// 5. 其他数据
// 无
return true;
}
智能合约在多个方面可能对区块链重放攻击存在漏洞。为了确保攻击者无法重播交易或重用签名,重要的是确保智能合约经过全面测试和审核。
在使用签名时要谨慎 请查阅,特别是作为功能的参数。
- 原文链接: cyfrin.io/blog/replay-at...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!