ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WTF Solidity 合约安全实战:签名重放(Signature Replay)攻击原理、复现与三种防护方案

WTF Solidity 合约安全实战:签名重放(Signature Replay)攻击原理、复现与三种防护方案 WTF Solidity 合约安全实战签名重放Signature Replay攻击原理、复现与三种防护方案【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity智能合约的数字签名用于识别数据签名者并验证数据完整性但一旦签名可以被反复提交就会演变成签名重放Signature Replay漏洞——它曾间接导致知名做市商 Wintermute 被盗 2000 万枚 $OP也让 NBA 官方 NFT 系列《The Association》被免费铸造上万枚。本文以 WTF-Solidity 合约安全系列 S06 讲为基础深入剖析存在签名重放漏洞的SigReplay合约结合 OpenZeppelinECDSA库源码讲清漏洞成因并给出三种可直接落地的防护方案。读完本文你将掌握签名重放攻击的判定方法、Remix 中的完整复现流程以及在生产合约中根除该漏洞的标准姿势。什么是签名重放攻击在区块链世界中数字签名是证明你是谁、你同意了什么的核心机制。用户发送交易时用私钥签名其他人可以借此验证交易确实由对应账户发出智能合约同样可以利用ECDSA算法验证用户在链下创建的签名然后执行铸造、转账等敏感逻辑。数字签名的基础原理可参考本仓库的 WTF Solidity 第 37 讲数字签名。签名重放Signature Replay的本质是一个本应只被使用一次的签名被攻击者反复提交、重复生效。这就好比上学时家长太忙孩子照着以前的签字抄一遍——签名是真实的但使用场景被滥用了。按照重放范围签名重放通常分为两类普通重放Regular Replay在同一条链上将本该使用一次的签名多次使用。NBA 官方发布的《The Association》系列 NFT 就因这类攻击被免费铸造了上万枚。跨链重放Cross-chain Replay将本该在一条链上使用的签名拿到另一条链上重复使用。做市商 Wintermute 因跨链重放攻击被盗 2000 万枚 $OP——链上签名消息中没有绑定链 ID导致在一条链上合法的签名在另一条链上依然有效。下图直观对比了正常转账与签名重放攻击的执行差异正常场景下签名只触发一次扣款而攻击场景下同一签名被重复提交用户余额被反复扣减漏洞合约剖析SigReplaySingatureReplay.sol 中的SigReplay是一个存在签名重放漏洞的ERC20代币合约根目录 S06_SignatureReplay/SingatureReplay.sol 与 src/S06_SignatureReplay/SingatureReplay.sol 均包含该实现。它的业务场景是项目方在链下为白名单地址签发铸造许可链上合约验证签名有效后铸造代币——这是 NFT 白名单、空投、白名单预售中非常常见的模式。合约结构// SPDX-License-Identifier: MIT pragma solidity ^0.8.21; 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)); } function toEthSignedMessageHash(bytes32 hash) public pure returns (bytes32) { 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状态变量记录合法的签名者白名单签发方地址在构造函数中被初始化为部署者msg.sender。getMessageHash(address to, uint256 amount)把铸造目标地址to与数量amount通过keccak256(abi.encodePacked(to, amount))打包成 32 字节的消息哈希。给定文档中的示例参数to 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4、amount 1000得到的消息哈希为0xb4a4ba10fbd6886a312ec31c54137f5714ddc0e93274da8746a36d2fa96768be。toEthSignedMessageHash(bytes32 hash)遵循EIP191标准在原始消息哈希前拼接\x19Ethereum Signed Message:\n32前缀并再次哈希得到以太坊签名消息。这个前缀可以防止用户误签一笔可执行的交易。verify(bytes32 _msgHash, bytes memory _signature)调用 OpenZeppelin 的ECDSA.recover()从签名中恢复出签名者地址并与合约记录的signer比对相等则签名有效。漏洞点badMint 缺少查重铸造函数badMint()的校验链路是完整的——它计算消息哈希、应用 EIP-191 前缀、通过ECDSA.recover恢复签名者并校验是否为signer。但整个流程唯独没有对签名本身做一次性约束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); }由于getMessageHash(to, amount)只由to和amount两个公开参数决定同一对(to, amount)永远得到相同的消息哈希同一个签名永远能通过verify()。攻击者拿到一次合法签名后可以无限次调用badMint()铸造任意数量的代币——签名真实、校验正确但语义上这张白名单券被反复使用了。从 OpenZeppelin ECDSA 源码看签名校验的边界本仓库 lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol 中保留了 OpenZeppelin Contracts v5.6.0 的ECDSA库实现其中tryRecover(bytes32 hash, bytes memory signature)揭示了两个值得注意的细节只接受 65 字节的标准 rsv 签名tryRecover首先检查signature.length 65然后通过内联汇编按偏移量拆出rmload(add(signature, 0x20))、smload(add(signature, 0x40))和vbyte(0, mload(add(signature, 0x60)))。标准rsv签名由r32 字节、s32 字节、v1 字节组成长度恰为 65 字节非 65 字节的签名会直接返回RecoverError.InvalidSignatureLength并触发ECDSAInvalidSignatureLength错误。做了抗延展性malleability校验tryRecover会拒绝s值落在 secp256k1 曲线阶上半区s 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0的签名且ecrecover返回address(0)时也会被判定为无效。这保证了恢复出的签名者地址是唯一的签名本身不可被篡改。也就是说ECDSA.recover在签名结构合法性这一层是严谨的它无法解决的是同一合法签名被多次提交的业务层漏洞——这正是重放攻击的根源也是为什么防御必须落在业务语义上记录已用签名、引入 nonce 与链 ID。Remix 复现四步完成签名重放攻击在 Remix IDE 中复现该攻击只需四步可以完整走通链下签名 → 链上校验 → 重复利用的攻击链路。第一步部署SigReplay合约。部署后signer被自动初始化为部署钱包的地址这个地址将作为后续所有签名的合法签发方第二步调用getMessageHash获取待签名的消息。传入to 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4、amount 1000合约返回消息哈希0xb4a4ba10fbd6886a312ec31c54137f5714ddc0e93274da8746a36d2fa96768be第三步用部署账户的私钥对消息签名。在 Remix 部署面板点击签名按钮Sign a message将上一步得到的消息哈希填入输入框并确认。得到的签名即为badMint所需的signature参数文档示例签名为0x5a4f1ad4d8bd6b5582e658087633230d9810a0b7b8afa791e3f94cc38947f6cb1069519caf5bba7b975df29cbfdb4ada355027589a989435bf88e825841452f61b第四步反复调用badMint重放签名。使用完全相同的to、amount、signature参数重复发起交易每一次调用都能通过verify()校验并铸造 1000 枚代币。同一个签名被无限次重放余额持续增加防护方案一记录已使用的签名mintedAddress最直观的防御思路是让签名只能使用一次用一个映射记录已经铸造过的地址凡是被记录过的地址再次调用即拒绝。goodMint()是修复后的版本它与badMint()的唯一区别是在_mint前后增加了查重与登记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], Already minted)负责拦截二次使用mintedAddress[to] true必须在_mint之前执行避免重入窗口。这种方案实现成本极低、gas 开销小适合一个地址限领一次的白名单铸造场景——它正是第 37 讲中SignatureNFT合约采用的防御模式。防护方案二在签名消息中绑定 nonce 与 chainid方案一以地址为粒度防重放但当业务允许同一地址多次操作如多次领空投、多次兑换时就不适用了。此时更通用的做法是让每一次签名的内容都独一无二将nonce随每次交易递增的序号和chainid链 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不匹配verify()必然失败旧签名彻底作废。chainid防跨链重放block.chainid将签名锁在当前链上。签名在以太坊主网上生成时绑定了主网链 ID即使被拿到测试网或侧链上提交链上重新计算的消息哈希也包含不同的链 ID签名恢复出的地址与signer不匹配从而阻断跨链重放。链 ID 的引入是有真实教训的——Wintermute 被盗 2000 万枚 $OP 的跨链重放攻击正是由于签名消息未绑定链 ID使同一签名在另一条链上被重复使用。防护方案三严格校验签名长度65 bytes在用户直接传入signature的场景下还需要防范签名结构被篡改/伪造造成的另一类重放问题必须校验signature.length 65。标准rsv签名长度固定为 65 字节r32 字节 s32 字节 v1 字节任何非 65 字节的输入都意味着签名结构异常。function mint(address to, uint amount, bytes memory signature) public { require(signature.length 65, Invalid signature length); // ... 后续校验与铸造逻辑 }这一层防御与 OpenZeppelinECDSA库的行为相呼应如前面源码分析所述lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol 的tryRecover在signature.length ! 65时直接返回InvalidSignatureLength。在调用ECDSA.recover之前显式检查长度可以在早期拦截异常输入避免因签名解析歧义如 64 字节 ERC-2098 短签名被错误解释而引入的签名复用风险。三种防护方案对比与最佳实践防护方案防普通重放防跨链重放适用场景实现要点mintedAddress记录已用地址✅每地址一次✅跨链同样生效因地址已被登记一个地址限领一次的铸造/空投先检查!mintedAddress[to]再登记后_mint签名消息绑定noncechainid✅✅同一地址多次操作的通用场景铸造后nonceblock.chainid绑定当前链校验signature.length 65辅助辅助所有由用户传入signature的入口在调用ECDSA.recover前检查长度实际生产中的最佳实践是多层防御组合对所有接收用户签名的函数统一校验signature.length 65在签名消息的构造中总是纳入nonce或基于keccak256的签名哈希失效机制与chainid对于一地址一次的语义再叠加mintedAddress映射做双重保险。同时应参照 OpenZeppelin 官方建议——不要把签名本身当作唯一标识符而应使用哈希失效hash invalidation或 nonce 机制来做重放防护并确保被签名的哈希一定是哈希运算的输出_msgHash必须来自keccak256等哈希函数而非直接对原文哈希否则存在伪造任意恢复地址的签名攻击面。总结这一讲围绕签名重放攻击给出了完整的原理 → 复现 → 防御闭环攻击原理badMint()只验证签名有效性不约束签名使用次数同一签名可被无限重放跨链场景下若消息未绑定链 ID签名还可跨链复用。复现路径在 Remix 中通过getMessageHash生成消息 → 用私钥签名 → 反复调用badMint即可无限铸造。三种预防方法记录已使用签名mintedAddress、在签名消息中绑定nonce与chainid、严格校验签名长度为 65 字节。数字签名本身是安全的漏洞往往出在签名被如何消费的业务逻辑上。理解签名重放是编写安全的铸造、空投、白名单合约的必修课。完整的可运行示例见 S06_SignatureReplay/SingatureReplay.sol教程文档见 S06_SignatureReplay/readme.md。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表