WTF Solidity 合约安全S06 签名重放Signature Replay攻击的原理、复现与三种防护方案【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity导读本文基于 WTF-Solidity 合约安全系列 S06 讲义对应仓库 Languages/pt-br/S06_SignatureReplay/readme.md系统讲解签名重放攻击Signature Replay从攻击原理与两类典型场景出发逐行拆解带漏洞的 ERC20 合约SigReplay并给出在 Remix 中完整复现攻击的步骤最后深入分析三种防御方案记录已用签名、引入 nonce 与 chainid、校验签名长度。读完本文你将能够识别链下签名类合约中的重放风险并写出可抵御普通重放与跨链重放的签名验证逻辑。一、签名重放什么是它为什么危险上学时老师常要求家长签字父母忙碌时有人会贴心地照着旧签名抄一遍——从某种意义上说这就是一次签名重放。在区块链世界中这个概念同样成立但后果严重得多。在区块链中数字签名用于识别数据签名者并验证数据完整性发送交易时用户用私钥对交易签名他人即可验证该交易确实由对应账户发出。智能合约也能利用ECDSA算法验证用户链下创建的签名再执行铸造、转账等逻辑数字签名的基础知识可参阅同仓库的 WTF Solidity 第 37 讲数字签名。数字签名常见的重放攻击有两种普通重放将本应只使用一次的签名多次使用。例如 NBA 官方发布的《The Association》系列 NFT 曾因这种攻击被免费铸造了上万枚跨链重放将本应在一条链上使用的签名在另一条链上重复使用。著名做市商 Wintermute 正是因跨链重放攻击被盗 2000 万枚 $OP。根本原因在于验证逻辑只确认签名者是谁却没有确认这条签名是否已经被消费过、是否适用于当前链。只要攻击者拿到了有效签名就可以无限次地触发同样的逻辑。二、漏洞合约SigReplay 逐行拆解仓库中的示例合约SigReplay源码见 Languages/pt-br/S06_SignatureReplay/SingatureReplay.sol是一个ERC20代币合约其铸造函数存在签名重放漏洞。它使用链下签名让白名单地址to铸造相应数量amount的代币合约中保存signer地址来验证签名有效性// SPDX-License-Identifier: MIT pragma solidity ^0.8.34; import openzeppelin/contracts/token/ERC20/ERC20.sol; import openzeppelin/contracts/access/Ownable.sol; import openzeppelin/contracts/utils/cryptography/ECDSA.sol; // 权限管理错误示例 contract SigReplay is ERC20 { address public signer; // 构造函数初始化代币名称和代号 constructor() ERC20(SigReplay, Replay) { signer msg.sender; } /** * 有签名重放漏洞的铸造函数 * to: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 * amount: 1000 * 签名 0x5a4f1ad4d8bd6b5582e658087633230d9810a0b7b8afa791e3f94cc38947f6cb1069519caf5bba7b975df29cbfdb4ada355027589a989435bf88e825841452f61b */ function badMint(address to, uint amount, bytes memory signature) public { bytes32 _msgHash toEthSignedMessageHash(getMessageHash(to, amount)); require(verify(_msgHash, signature), Invalid Signer!); _mint(to, amount); } /** * 将 to 地址address 类型和 amountuint256 类型拼成消息 msgHash * to: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 * amount: 1000 * 对应的消息 msgHash: 0xb4a4ba10fbd6886a312ec31c54137f5714ddc0e93274da8746a36d2fa96768be */ function getMessageHash(address to, uint256 amount) public pure returns(bytes32){ return keccak256(abi.encodePacked(to, amount)); } /** * dev 获得以太坊签名消息 * hash消息哈希 * 遵从以太坊签名标准 eth_sign 与 EIP-191 * 添加 \x19Ethereum Signed Message:\n32 字段防止签名的是可执行交易。 */ function toEthSignedMessageHash(bytes32 hash) public pure returns (bytes32) { // 32 is the length in bytes of hash, // enforced by the type signature above return keccak256(abi.encodePacked(\x19Ethereum Signed Message:\n32, hash)); } // ECDSA 验证 function verify(bytes32 _msgHash, bytes memory _signature) public view returns (bool){ return ECDSA.recover(_msgHash, _signature) signer; }关键点逐项分析signer与构造函数合约把部署者地址写死为签名者后续所有链下签名都必须由该地址私钥生成getMessageHash将toaddress与amountuint256用abi.encodePacked拼接后做keccak256得到消息哈希。注意这条消息里没有任何防重放字段toEthSignedMessageHash遵循 EIP-191在哈希前追加\x19Ethereum Signed Message:\n32前缀避免签名被误用于可执行交易verify调用 OpenZeppelin 的ECDSA.recover恢复出签名者地址并与signer比对漏洞所在badMint()没有对signature查重也没有在消息中引入 nonce 或 chainid导致同一签名可以反复通过验证、无限次铸造代币function badMint(address to, uint amount, bytes memory signature) public { bytes32 _msgHash toEthSignedMessageHash(keccak256(abi.encodePacked(to, amount))); require(verify(_msgHash, signature), Invalid Signer!); _mint(to, amount); }攻击流程可以概括为用户签一次名 → 攻击者或用户自己把同一份(to, amount, signature)提交无数次 → 每次都通过verify→ 代币被无限铸造总量失控。三、源码级原理ECDSA.recover 在底层做了什么要理解为什么签名能重复使用需要看清verify背后的实现。仓库的依赖库 lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol 中recover的核心逻辑L124-L128为function recover(bytes32 hash, bytes memory signature) internal pure returns (address) { (address recovered, RecoverError error, bytes32 errorArg) tryRecover(hash, signature); _throwError(error, errorArg); return recovered; }从源码可以看到recover最终走的是 EVM 预编译合约ecrecoverL190address signer ecrecover(hash, v, r, s); if (signer address(0)) { return (address(0), RecoverError.InvalidSignature, bytes32(0)); }可以推断出的关键事实ecrecover是纯函数给定相同的hash、v、r、s永远恢复出同一个签名者地址它不关心这条签名用过没有——这正是重放得以成立的根本原因签名唯一性校验OpenZeppelin 在 L176-L196 中拒绝了 malleable可篡改签名要求s位于 secp256k1 曲线阶的下半区间、v只能是 27 或 28否则返回InvalidSignatureS或InvalidSignature长度约束该版本recover只接受标准 65 字节签名r 32 字节 s 32 字节 v 1 字节其他长度会触发InvalidSignatureLength见 L102 与 L114-L116 的注释官方建议该库在注释中明确提醒——开发者不应把签名当作唯一标识符应使用哈希作废或 nonce 做重放保护L115-L116。也就是说ECDSA.recover只回答这个哈希是谁签的而这条签名能否再次消费必须由业务层自己回答——SigReplay恰恰漏掉了这层业务逻辑。四、在 Remix 中复现攻击本节按讲义步骤在 Remix IDE 中完整复现一次签名重放攻击。步骤 1部署合约。编译并部署SigReplay构造函数会把signer初始化为部署钱包地址讲义示例地址为0x5B38Da6a701c568545dCfcB03FcB875f56beddC4。步骤 2获取消息哈希。调用公开函数getMessageHash输入to 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4、amount 1000得到消息哈希0xb4a4ba10fbd6886a312ec31c54137f5714ddc0e93274da8746a36d2fa96768be该输入输出对直接来自 SingatureReplay.sol 源码注释可复现验证。步骤 3用私钥签名。点击 Remix 部署面板中的签名按钮将上一步得到的消息哈希填入 Sign a message 弹窗选择签名者账户的私钥完成签名得到 65 字节签名0x5a4f1ad4d8bd6b5582e658087633230d9810a0b7b8afa791e3f94cc38947f6cb1069519caf5bba7b975df29cbfdb4ada355027589a989435bf88e825841452f61b。步骤 4反复调用badMint实施重放。把(to, amount, signature)作为参数反复提交给badMint。由于合约不记录签名是否已使用每次调用都会通过verify并铸造 1000 枚代币——无限调用即可无限铸造这正是攻击的落地演示。五、三种防护方案方案一记录已使用过的签名将已消费的签名记录下来例如用一个mapping记录已铸造过代币的地址防止同一签名被再次消费mapping(address bool) public mintedAddress; // 记录已经 mint 的地址 function goodMint(address to, uint amount, bytes memory signature) public { bytes32 _msgHash toEthSignedMessageHash(getMessageHash(to, amount)); require(verify(_msgHash, signature), Invalid Signer!); // 检查该地址是否 mint 过 require(!mintedAddress[to], Already minted); // 记录 mint 过的地址 mintedAddress[to] true; _mint(to, amount); }要点先检查、后记录、再铸造且require(!mintedAddress[to])与mintedAddress[to] true位于同一个交易内天然具备原子性不存在检查-使用之间的竞态窗口。更通用的做法是直接对signature本身或msgHash建映射去重以覆盖同一地址多次铸造之外的场景。方案二在签名消息中引入 nonce 与 chainid将递增 nonce与链 ID纳入被签名的消息可同时抵御普通重放与跨链重放uint nonce; function nonceMint(address to, uint amount, bytes memory signature) public { bytes32 _msgHash toEthSignedMessageHash(keccak256(abi.encodePacked(to, amount, nonce, block.chainid))); require(verify(_msgHash, signature), Invalid Signer!); _mint(to, amount); nonce; }原理拆解nonce防普通重放每成功铸造一次nonce自增nonce。由于消息哈希中包含当前 nonce旧签名对应的哈希与新的 nonce 不再匹配ECDSA.recover恢复出的地址将不再是signerverify直接失败——已用签名自动失效block.chainid防跨链重放签名消息绑定生成它的那条链block.chainid为 Solidity 0.8.0 全局变量返回当前链 ID。把链 A 上的签名拿到链 B 上使用时链 B 的block.chainid与签名消息不符验证必然失败。需要特别说明的是链下签名端必须与链上保持同步——签名者生成签名时同样要把当前 nonce 和 chainid 拼进消息再签名否则会出现链上验证不过的问题。实际工程中更推荐使用 EIP-712 结构化签名来承载这些字段可参考仓库中的 Languages/pt-br/52_EIP712 与 Languages/pt-br/53_ERC20Permit 讲义的实现思路。方案三校验签名长度为 65 字节对于由用户直接传入signature的场景应显式校验签名长度必须为标准的 65 字节r 32 字节 s 32 字节 v 1 字节否则同样可能诱发签名重放问题。该方案是对上述两个方案的必要补充详见中文版讲义 S06_SignatureReplay/readme.mdfunction mint(address to, uint amount, bytes memory signature) public { require(signature.length 65, Invalid signature length); ... }之所以需要这道防线从 ECDSA.sol 的长度分支可以看到不同长度的签名会被解析出不同的v/r/s组合若业务层或所用库对非标准长度签名处理不当就可能出现同一份短签名被反复解析并放行的情况。显式限定 65 字节既能让错误在第一时间以清晰的自定义错误暴露也避免了对解析库行为的隐式依赖。六、总结签名重放漏洞的本质是链下签名缺少一次性与所属链的语义ecrecover/ECDSA.recover只能证明这个哈希是谁签的无法证明这条签名还能不能用。结合本文与仓库源码防护要点可归纳为记录已使用过的签名如mintedAddress映射在消费前查重、消费后登记将nonce与chainid纳入签名消息链上与链下同步生成同时抵御普通重放与跨链重放校验signature长度为 65 字节杜绝非标准签名带来的解析与重放风险。更进一步的参考本主题的源码与中文讲义位于 S06_SignatureReplay英文版位于 Languages/en/S06_SignatureReplay_en葡萄牙语版即本文依据 Languages/pt-br/S06_SignatureReplay/readme.md签名基础可回溯 Languages/pt-br/37_Signature/readme.md依赖库实现细节可查阅 lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol。在编写任何接受链下签名的合约时请务必把上述三条防线作为默认配置。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考