SC Day 58
签名重放(Signature Replay) + EIP-712 + 中心化风险
### 1. ECDSA 签名基础
2026-05-28
第三阶段:安全审计ecdsasignature-replayeip-712permitcentralization-risk
日期: 2026-05-28 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #ecdsa #signature-replay #eip-712 #permit #centralization-risk
今日目标
- 理解 ECDSA 签名在 Solidity 中的使用和验证
- 掌握签名重放攻击的原理和防护方法
- 深入学习 EIP-712 结构化数据签名标准
- 实现 ERC2612 Permit 模式
- 识别和评估中心化风险
核心概念
1. ECDSA 签名基础
以太坊使用 ECDSA(椭圆曲线数字签名算法)进行消息签名和验证:
签名过程:
1. 对消息进行 keccak256 哈希 → messageHash
2. 用私钥对 messageHash 签名 → (v, r, s)
3. v = recovery id (27 或 28)
4. r, s = 签名的两个组成部分
验证过程:
1. ecrecover(messageHash, v, r, s) → 恢复出签名者地址
2. 对比恢复出的地址与期望的签名者地址
// Solidity 中的签名验证
function verify(
address signer,
bytes32 messageHash,
uint8 v,
bytes32 r,
bytes32 s
) public pure returns (bool) {
// 添加以太坊前缀(防止签名被用于发送交易)
bytes32 ethSignedHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
);
address recovered = ecrecover(ethSignedHash, v, r, s);
return recovered == signer && recovered != address(0);
}
ecrecover 的安全注意事项
// 危险!ecrecover 在签名无效时返回 address(0),不会 revert
address recovered = ecrecover(hash, v, r, s);
// 如果 recovered == address(0) 且 signer 碰巧也是 address(0)...
// 永远要检查 recovered != address(0)
// 推荐:使用 OpenZeppelin ECDSA 库
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
function verify(bytes32 hash, bytes memory signature) public view returns (bool) {
// ECDSA.recover 在签名无效时直接 revert
address recovered = ECDSA.recover(hash, signature);
return recovered == expectedSigner;
}
2. 签名重放攻击(Signature Replay)
攻击类型
| 类型 | 说明 | 示例 |
|---|---|---|
| 同合约重放 | 同一签名在同一合约上重复使用 | 用一个签名多次提款 |
| 跨合约重放 | 签名在另一个合约上使用 | 合约A的签名在合约B上验证通过 |
| 跨链重放 | 签名在另一条链上使用 | 以太坊的签名在BSC上使用 |
同合约重放示例
// 漏洞合约:签名可以重复使用
contract VulnerableAirdrop {
mapping(address => bool) public claimed;
address public signer;
// 漏洞:没有 nonce,同一签名可以在不同场景重用
function claim(uint256 amount, bytes memory signature) external {
bytes32 messageHash = keccak256(abi.encodePacked(
msg.sender,
amount
// 缺少:nonce、合约地址、chainId
));
bytes32 ethSignedHash = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
messageHash
));
address recovered = ECDSA.recover(ethSignedHash, signature);
require(recovered == signer, "Invalid signature");
// 即使有 claimed 检查,如果 amount 不同的签名可以重用
require(!claimed[msg.sender], "Already claimed");
claimed[msg.sender] = true;
// 发送代币
}
}
跨链重放示例
以太坊上的合约 A (chainId: 1)
BSC 上的合约 A' (chainId: 56) — 相同代码部署在不同链
如果签名中不包含 chainId:
1. 用户在以太坊上签名消息
2. 攻击者拿这个签名到 BSC 上使用
3. 验证通过!(因为签名不包含链 ID 信息)
3. EIP-712 结构化数据签名
EIP-712 定义了一个标准的结构化数据签名方案,解决了签名重放问题:
EIP-712 签名结构:
hashStruct = keccak256(
abi.encode(
TYPE_HASH, // 结构体类型哈希
struct.field1, // 结构体字段
struct.field2,
...
)
)
domainSeparator = keccak256(
abi.encode(
DOMAIN_TYPE_HASH,
keccak256(bytes(name)), // 合约名
keccak256(bytes(version)), // 版本号
chainId, // 链 ID ← 防跨链重放
address(this) // 合约地址 ← 防跨合约重放
)
)
最终哈希 = keccak256(
abi.encodePacked(
"\x19\x01", // EIP-712 前缀
domainSeparator, // 域分隔符
hashStruct // 结构体哈希
)
)
Domain Separator 的作用
Domain Separator 包含:
├── name: "MyToken" → 区分不同合约
├── version: "1" → 区分不同版本
├── chainId: 1 → 防止跨链重放
└── verifyingContract: 0x... → 防止跨合约重放
只要任一字段不同,签名就无法在其他上下文使用
4. 中心化风险
中心化风险是 DeFi 审计中经常被忽视但极其重要的问题。
常见中心化风险
| 风险 | 说明 | 影响 |
|---|---|---|
| 单一 owner | 合约由一个 EOA 控制 | 私钥泄露 = 全部资金损失 |
| 无限铸造 | Owner 可以无限 mint | 通胀攻击 |
| 暂停功能 | Owner 可以暂停合约 | 用户资金冻结 |
| 黑名单 | Owner 可以冻结任意地址 | 审查风险 |
| 升级权限 | Owner 可以升级合约逻辑 | 可能改为恶意逻辑 |
| 手续费调整 | Owner 可以设置任意手续费 | 可能设为100% |
| 预言机操控 | Owner 可以修改预言机地址 | 价格操纵 |
中心化风险评估框架
风险等级评估:
[Critical] Owner 可以直接提走用户资金
[High] Owner 可以冻结用户资金 / 无限铸造
[Medium] Owner 可以暂停功能 / 修改关键参数
[Low] Owner 可以修改非关键参数(如手续费率在合理范围内)
[Info] 存在 owner 角色但权限有限
缓解措施:
├── Timelock: 管理操作有时间延迟(用户可以在执行前撤离)
├── Multisig: 多签控制(如 3/5 签名)
├── DAO: 去中心化治理
├── 参数边界: 手续费最大值、mint 速率限制
└── 逐步去中心化: 初期有 admin,后续移交给 DAO
代码实战
实现 ERC2612 Permit(EIP-712 签名授权)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/interfaces/IERC20Permit.sol";
/**
* @title MomoToken with ERC2612 Permit
* @notice ERC20 + permit: 用签名代替 approve,实现一步授权+转账
*/
contract MomoToken is ERC20, EIP712, IERC20Permit {
using ECDSA for bytes32;
// EIP-712 类型哈希
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
// 每个地址的 nonce,防止签名重放
mapping(address => uint256) private _nonces;
constructor() ERC20("Momo Token", "MOMO") EIP712("Momo Token", "1") {
_mint(msg.sender, 1000000 * 1e18);
}
/**
* @notice 使用签名进行授权(代替 approve)
* @param owner Token 持有者
* @param spender 被授权者
* @param value 授权数量
* @param deadline 签名过期时间
* @param v 签名参数
* @param r 签名参数
* @param s 签名参数
*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external override {
// 检查1: 签名是否过期
require(block.timestamp <= deadline, "Permit: expired deadline");
// 构造 EIP-712 结构化哈希
bytes32 structHash = keccak256(
abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
_useNonce(owner), // 使用并递增 nonce(防重放)
deadline
)
);
// 计算完整的 EIP-712 哈希
bytes32 hash = _hashTypedDataV4(structHash);
// _hashTypedDataV4 内部:
// keccak256("\x19\x01" || domainSeparator || structHash)
// domainSeparator 包含 name, version, chainId, address(this)
// 恢复签名者地址
address signer = ECDSA.recover(hash, v, r, s);
// 检查2: 签名者是否是 owner
require(signer == owner, "Permit: invalid signature");
// 执行授权
_approve(owner, spender, value);
}
/**
* @notice 获取地址的当前 nonce
*/
function nonces(address owner) external view override returns (uint256) {
return _nonces[owner];
}
/**
* @notice 获取 domain separator
*/
function DOMAIN_SEPARATOR() external view override returns (bytes32) {
return _domainSeparatorV4();
}
/**
* @dev 使用 nonce 并递增
*/
function _useNonce(address owner) internal returns (uint256 current) {
current = _nonces[owner];
_nonces[owner] = current + 1;
}
}
前端签名示例
// 前端使用 ethers.js v6 构造 Permit 签名
async function createPermitSignature(
signer, // ethers.js Signer
tokenAddress,
spender,
value,
deadline
) {
const token = new ethers.Contract(tokenAddress, TOKEN_ABI, signer);
const ownerAddress = await signer.getAddress();
const nonce = await token.nonces(ownerAddress);
// EIP-712 domain
const domain = {
name: "Momo Token",
version: "1",
chainId: (await signer.provider.getNetwork()).chainId,
verifyingContract: tokenAddress,
};
// EIP-712 types
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
// EIP-712 message
const message = {
owner: ownerAddress,
spender: spender,
value: value,
nonce: nonce,
deadline: deadline,
};
// 签名
const signature = await signer.signTypedData(domain, types, message);
const { v, r, s } = ethers.Signature.from(signature);
return { v, r, s };
}
// 使用示例:一步完成 approve + swap
async function permitAndSwap(tokenAddress, dexAddress, amount) {
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1小时后过期
// 第一步:链下签名(不消耗 Gas)
const { v, r, s } = await createPermitSignature(
signer, tokenAddress, dexAddress, amount, deadline
);
// 第二步:一笔交易完成 permit + swap
const dex = new ethers.Contract(dexAddress, DEX_ABI, signer);
const tx = await dex.permitAndSwap(
tokenAddress, amount, deadline, v, r, s
);
await tx.wait();
}
签名重放攻击漏洞和修复
// ===== 漏洞版本 =====
contract VulnerableMultisig {
address[] public owners;
uint256 public threshold;
// 漏洞:没有 nonce,同一签名可以反复使用
function execute(
address to,
uint256 value,
bytes memory data,
bytes[] memory signatures
) external {
bytes32 txHash = keccak256(abi.encodePacked(to, value, data));
// 缺少:nonce、address(this)、chainId
uint256 validSigs = 0;
address lastSigner = address(0);
for (uint256 i = 0; i < signatures.length; i++) {
address signer = ECDSA.recover(
ECDSA.toEthSignedMessageHash(txHash),
signatures[i]
);
require(signer > lastSigner, "Duplicate signer");
require(isOwner(signer), "Not owner");
lastSigner = signer;
validSigs++;
}
require(validSigs >= threshold, "Not enough signatures");
(bool success,) = to.call{value: value}(data);
require(success, "Execution failed");
// 同一组签名可以反复调用 execute!
}
}
// ===== 修复版本 =====
contract SecureMultisig is EIP712 {
address[] public owners;
uint256 public threshold;
uint256 public nonce; // 添加 nonce
bytes32 public constant EXECUTE_TYPEHASH = keccak256(
"Execute(address to,uint256 value,bytes data,uint256 nonce)"
);
constructor()
EIP712("SecureMultisig", "1")
{}
function execute(
address to,
uint256 value,
bytes memory data,
bytes[] memory signatures
) external {
// 使用 EIP-712 标准哈希(包含 chainId、合约地址)
bytes32 structHash = keccak256(abi.encode(
EXECUTE_TYPEHASH,
to,
value,
keccak256(data),
nonce // 包含 nonce
));
bytes32 digest = _hashTypedDataV4(structHash);
uint256 validSigs = 0;
address lastSigner = address(0);
for (uint256 i = 0; i < signatures.length; i++) {
address signer = ECDSA.recover(digest, signatures[i]);
require(signer > lastSigner, "Duplicate/unsorted signer");
require(isOwner(signer), "Not owner");
lastSigner = signer;
validSigs++;
}
require(validSigs >= threshold, "Not enough signatures");
nonce++; // 递增 nonce,防止重放
(bool success,) = to.call{value: value}(data);
require(success, "Execution failed");
}
function isOwner(address addr) public view returns (bool) {
for (uint256 i = 0; i < owners.length; i++) {
if (owners[i] == addr) return true;
}
return false;
}
}
中心化风险示例
// 高中心化风险的合约
contract CentralizedToken {
address public owner;
mapping(address => uint256) public balances;
bool public paused;
mapping(address => bool) public blacklisted;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// [CRITICAL] Owner 可以直接修改任何人的余额
function setBalance(address user, uint256 amount) external onlyOwner {
balances[user] = amount; // 可以清零任何人的余额!
}
// [HIGH] Owner 可以无限铸造
function mint(address to, uint256 amount) external onlyOwner {
balances[to] += amount; // 无上限!
}
// [HIGH] Owner 可以冻结任何地址
function blacklist(address user) external onlyOwner {
blacklisted[user] = true;
}
// [MEDIUM] Owner 可以暂停所有操作
function pause() external onlyOwner {
paused = true; // 所有用户资金被冻结
}
function transfer(address to, uint256 amount) external {
require(!paused, "Paused");
require(!blacklisted[msg.sender], "Blacklisted");
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
// 缓解中心化风险的改进版本
contract DecentralizedToken {
address public owner;
address public pendingOwner;
uint256 public constant MAX_SUPPLY = 1e9 * 1e18;
uint256 public totalSupply;
// 时间锁:关键操作需要延迟执行
uint256 public constant TIMELOCK_DELAY = 48 hours;
mapping(bytes32 => uint256) public timelocked;
// 铸造有上限
function mint(address to, uint256 amount) external onlyOwner {
require(totalSupply + amount <= MAX_SUPPLY, "Exceeds max supply");
totalSupply += amount;
// ...
}
// 关键参数修改需要时间锁
function queueAction(bytes32 actionHash) external onlyOwner {
timelocked[actionHash] = block.timestamp + TIMELOCK_DELAY;
}
function executeAction(bytes32 actionHash) external onlyOwner {
require(timelocked[actionHash] != 0, "Not queued");
require(block.timestamp >= timelocked[actionHash], "Timelock not expired");
delete timelocked[actionHash];
// 执行操作
}
// 两步 owner 转移(防止转到错误地址)
function transferOwnership(address newOwner) external onlyOwner {
pendingOwner = newOwner;
}
function acceptOwnership() external {
require(msg.sender == pendingOwner, "Not pending owner");
owner = pendingOwner;
pendingOwner = address(0);
}
}
关键要点总结
签名安全检查清单
| 检查项 | 重要性 | 说明 |
|---|---|---|
| nonce 管理 | 必须 | 每次签名使用唯一 nonce |
| deadline/expiry | 必须 | 签名应有过期时间 |
| chainId | 必须 | 包含在 domain separator 中 |
| 合约地址 | 必须 | 包含在 domain separator 中 |
| ecrecover != address(0) | 必须 | 验证签名有效性 |
| EIP-712 标准 | 推荐 | 使用结构化签名而非原始哈希 |
| 签名可展性保护 | 推荐 | 使用 OpenZeppelin ECDSA |
EIP-712 域分隔符组成
domainSeparator = hash(
typeHash, // "EIP712Domain(string name,...)"
nameHash, // 合约名称哈希
versionHash, // 版本哈希
chainId, // 链 ID → 防跨链重放
verifyingContract // 合约地址 → 防跨合约重放
)
中心化风险缓解矩阵
| 缓解措施 | 效果 | 成本 |
|---|---|---|
| Timelock | 用户有时间撤离 | 低(部署合约) |
| Multisig | 分散权力 | 低(Gnosis Safe) |
| 参数边界 | 限制极端操作 | 低(代码限制) |
| DAO 治理 | 完全去中心化 | 高(代币+治理系统) |
| 透明度 | 社区监督 | 低(开源+文档) |
常见误区
误区 1:"签名只用一次就安全了"
不一定。如果签名没有绑定 nonce 或合约地址,攻击者可以在其他合约或其他链上使用同一签名。EIP-712 的 domain separator 确保签名与特定上下文绑定。
误区 2:"EIP-712 签名可以被其他人发送"
正确!EIP-712 Permit 的一个特性是签名可以被任何人提交到链上——不需要是签名者本人。这允许"gasless"交易:用户签名,第三方(relayer)支付 Gas 提交。
误区 3:"有 Timelock 就去中心化了"
Timelock 只是缓解措施,不是去中心化。Owner 仍然可以执行恶意操作——只是给了用户反应时间。真正的去中心化需要移除 owner 角色或转为 DAO 治理。
误区 4:"ecrecover 是安全的"
原始 ecrecover 有签名可展性(signature malleability)问题——同一个签名可以有多种等价表示。始终使用 OpenZeppelin 的 ECDSA.recover,它强制 s 值在低半区间。
面试关联
Q: 什么是签名重放攻击?EIP-712 如何防护?
简短回答:签名重放是将有效签名在非预期的上下文中重复使用。EIP-712 通过 domain separator(包含 chainId、合约地址)和 nonce 防护。
详细回答:
- 同合约重放:nonce 递增后旧签名失效
- 跨合约重放:domain separator 包含
verifyingContract - 跨链重放:domain separator 包含
chainId - 签名过期:deadline 参数确保签名有时效性
- EIP-712 还提供了结构化数据展示,用户在钱包中可以看到签名的具体内容
Q: 如何评估一个 DeFi 协议的中心化风险?
回答(检查框架):
- Owner 权限范围:能做什么?能提走资金?能暂停?能升级?
- Owner 类型:EOA?多签?DAO?Timelock?
- 关键参数可修改性:手续费率?抵押率?预言机地址?
- 应急机制:有暂停功能?是否合理?
- 去中心化路径:是否有移交给 DAO 的计划?
Q: ERC2612 Permit 相比传统 approve 有什么优势?
回答:
- 节省 Gas:从两笔交易(approve + transferFrom)变为一笔
- Gasless 交易:签名在链下完成,提交可由 relayer 代付 Gas
- 用户体验:一次签名+一次交易,而非两次交易
- 安全性:有过期时间(deadline),避免无限授权长期暴露
参考资源
- EIP-712: Typed Structured Data — EIP-712 标准
- EIP-2612: Permit Extension — ERC2612 标准
- OpenZeppelin ECDSA — 签名库文档
- OpenZeppelin EIP712 — EIP712 实现
- SWC-117 Signature Malleability — 签名可展性
- SWC-121 Missing Protection against Signature Replay — 签名重放
- Centralization Risk by Trail of Bits — 中心化风险检查
- Rari Capital Hack — 中心化风险案例