查询 32 次,耗时 1.4085 秒

从签名重放到重入:使用 Foundry 审计 Solidity 授权提款合约

Likccc

New member

Likccc Rep
0
0
0
Rep
0
Likccc Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
Likccc 获得
0
0
0
获得
0
Likccc 凭证
0
0
0
凭证
0
主题
5
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
一、背景

链下签名授权是智能合约中非常常见的设计:用户或管理员在链下签署提款、兑换、白名单铸造等指令,执行者再把签名提交到链上。它能够减少签名者的链上操作,但也引入了重放攻击、跨链复用、参数替换、签名过期机制缺失和重入等风险。

这类问题的危险之处在于,ECDSA 验签成功只说明某个私钥签署过一段数据,并不自动说明该授权只能使用一次、只属于当前合约、只在当前链有效,也不保证执行过程中的资产状态安全。本文以本地授权提款合约为例,给出一套可复现的审计流程。所有测试仅适用于自有代码、本地节点或明确授权的靶场,请勿对真实第三方合约进行未经许可的测试。

二、漏洞模型与原理

考虑下面这个简化设计:合约中保存 ETH,用户提交管理员签名后领取指定金额。

```solidity
function withdraw(uint256 amount, bytes calldata signature) external {
bytes32 digest = keccak256(abi.encodePacked(msg.sender, amount));
address signer = ECDSA.recover(ECDSA.toEthSignedMessageHash(digest), signature);
require(signer == authorizer, "invalid signature");

(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
```

这段代码至少存在四类问题:

1. 没有 nonce 或已使用摘要记录。同一份签名可以被重复提交,直至合约余额不足。
2. 签名内容不包含 chainId 和 verifyingContract。如果另一条链或另一个合约采用相同验证规则,签名可能被跨域复用。
3. 没有 deadline。授权一旦泄露,理论上可以永久使用。
4. 转账前没有消费授权或更新状态。即使后续补充了计数逻辑,若更新发生在外部调用之后,仍可能遭遇重入。

此外,直接使用 abi.encodePacked 拼接多个动态类型时可能产生编码碰撞。上例只有地址和 uint256 两个定长类型,不会触发典型的动态类型边界歧义,但正式协议仍应优先使用结构化编码,避免后续字段变更时埋下问题。

三、前置条件

实践环境建议如下:

- Foundry,用于编译和本地测试;
- Solidity 0.8.24 或项目实际锁定的版本;
- OpenZeppelin Contracts 5.x;
- 仅使用 Anvil 本地链、测试账户和测试资金。

初始化项目:

```bash
forge init signed-withdraw-audit
cd signed-withdraw-audit
forge install OpenZeppelin/openzeppelin-contracts --no-commit
```

在 foundry.toml 中配置依赖映射,或创建 remappings.txt:

```text
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
```

执行基础检查:

```bash
forge build
forge test -vvv
```

四、第一步:建立签名安全检查清单

审计签名功能时,不要只盯着 ecrecover。至少逐项确认:

- 签名的业务动作是否明确,例如 WITHDRAW、MINT 或 CLAIM;
- 是否绑定接收人、金额、代币地址等全部关键参数;
- 是否包含 nonce,并在执行时可靠消费;
- 是否包含 deadline,并检查 block.timestamp;
- 是否绑定当前 chainId;
- 是否绑定当前验证合约地址;
- 是否使用标准 EIP-712 域和结构体;
- 签名者是否可能被更换,更换后旧签名如何处理;
- 是否支持 ERC-1271 合约钱包;
- 外部调用前是否已完成状态更新。

建议在审计记录中同时写明字段缺失造成的具体后果,而不是只写一句“可能重放”。例如:缺少 nonce 导致同一合约内重放,缺少 verifyingContract 导致同链不同合约间复用,缺少 chainId 则可能在兼容部署或分叉环境中跨链复用。

五、第二步:编写本地重放测试

测试的目标不是攻击真实合约,而是证明安全属性是否成立。下面展示核心测试思路:管理员为用户签署一次提款授权,用户连续调用两次。如果两次都成功,就说明授权没有一次性约束。

```solidity
function test_signatureCanBeReplayed() public {
uint256 amount = 1 ether;
bytes32 digest = keccak256(abi.encodePacked(user, amount));
bytes32 ethDigest = MessageHashUtils.toEthSignedMessageHash(digest);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(authorizerKey, ethDigest);
bytes memory sig = abi.encodePacked(r, s, v);

vm.prank(user);
vault.withdraw(amount, sig);

vm.prank(user);
vault.withdraw(amount, sig);

assertEq(user.balance, 2 ether);
}
```

这里应确保测试合约预先给 Vault 注入足够余额,并给 user 设置明确的初始余额,避免断言受到测试框架默认余额影响。运行指定测试:

```bash
forge test --match-test test_signatureCanBeReplayed -vvvv
```

审计结论应包含可验证的影响范围:攻击者必须先获得一份合法签名;可重复领取的上限通常受合约余额、提款限额和暂停机制约束;如果签名绑定 msg.sender,其他地址不能直接复用,但签名持有人本人仍可重放。

六、第三步:使用 EIP-712 修复域隔离问题

推荐使用 OpenZeppelin 的 EIP712 和 ECDSA 实现,避免自行拼装域分隔符。一个较完整的授权结构可以定义为:

```solidity
bytes32 private constant WITHDRAW_TYPEHASH = keccak256(
"Withdraw(address receiver,uint256 amount,uint256 nonce,uint256 deadline)"
);

mapping(address => uint256) public nonces;
```

安全版核心逻辑如下:

```solidity
function withdraw(
address receiver,
uint256 amount,
uint256 nonce,
uint256 deadline,
bytes calldata signature
) external nonReentrant {
require(receiver != address(0), "zero receiver");
require(amount != 0, "zero amount");
require(block.timestamp <= deadline, "authorization expired");
require(nonce == nonces[receiver], "invalid nonce");

bytes32 structHash = keccak256(
abi.encode(WITHDRAW_TYPEHASH, receiver, amount, nonce, deadline)
);
bytes32 digest = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(digest, signature);
require(signer == authorizer, "invalid signer");

nonces[receiver] = nonce + 1;

(bool ok,) = receiver.call{value: amount}("");
require(ok, "transfer failed");
}
```

EIP-712 域通常由 name、version、chainId 和 verifyingContract 构成。继承 OpenZeppelin EIP712 后,_hashTypedDataV4 会把结构体摘要与当前域组合,从而限制签名的使用范围。

nonce 在外部转账前递增,符合检查、效果、交互模式。即使接收方 fallback 尝试再次调用,原 nonce 也已经失效;nonReentrant 则提供第二层保护。状态更新后如果转账失败,整个交易会回滚,因此 nonce 不会被无故消耗。

七、第四步:为修复结果添加负向测试

安全测试不能只验证正常提款成功,还应覆盖失败路径:

```solidity
function test_cannotReplayAuthorization() public {
// 生成 nonce 为 0 的合法 EIP-712 签名并执行第一次提款
// 再次提交相同参数和签名,应因 nonce 不匹配而回滚
vm.expectRevert("invalid nonce");
vm.prank(user);
vault.withdraw(user, amount, 0, deadline, sig);
}
```

建议补充以下用例:

1. 修改 amount 后使用原签名,应验签失败;
2. 修改 receiver 后使用原签名,应验签失败;
3. deadline 之前执行成功,deadline 之后回滚;
4. 相同结构体在另一份 Vault 部署实例上执行失败;
5. 在不同 chainId 的本地分叉配置中,原签名失效;
6. 使用错误 authorizer 私钥签名时回滚;
7. 接收方合约尝试重入时无法重复提款;
8. 转账失败时 nonce 和余额状态整体回滚;
9. 零金额、零地址和超出余额等边界输入被明确处理。

可用 Foundry 临时修改链 ID 进行本地验证:

```solidity
vm.chainId(31337);
// 生成签名
vm.chainId(31338);
// 提交旧签名,预期失败
```

测试时应注意:改变 chainId 后重新调用合约的域计算逻辑,才能真实验证域隔离。不要把预先缓存且未正确更新的测试摘要误当成合约实际行为。

八、第五步:检查权限变更和升级场景

实际项目经常遗漏 authorizer 轮换问题。若管理员密钥泄露,仅替换 authorizer 不一定足够,因为旧签名可能已在公开交易池、日志或业务服务器中传播。建议:

- authorizer 变更事件必须记录旧地址和新地址;
- 使用授权 epoch,将 epoch 纳入签名结构,轮换密钥时递增 epoch,使旧授权整体失效;
- 高价值提款增加单笔限额、周期限额和暂停开关;
- 管理权限使用多签或延时机制,不由普通外部账户单独控制;
- 不要把私钥、助记词或生产签名服务凭据写入测试文件和仓库。

对于可升级代理,还要确认 EIP-712 的 verifyingContract 应为代理地址,而不是逻辑合约地址。升级时修改 name、version 或结构体定义会导致旧签名失效,这可能是预期的安全措施,也可能破坏尚未执行的合法订单,必须在升级方案中明确说明。

九、风险与注意事项

1. block.timestamp 可被区块生产者在有限范围内调整,因此 deadline 适合控制授权有效期,不适合要求秒级精确的业务规则。
2. 顺序 nonce 简单可靠,但会造成同一用户的授权必须按顺序执行。若业务允许并行订单,可以使用随机 salt 配合 usedDigests 映射,或使用位图 nonce。
3. 不建议直接调用原生 ecrecover。OpenZeppelin ECDSA 会处理无效签名和部分签名延展性问题,降低实现错误概率。
4. ECDSA.recover 只适用于普通外部账户。若签名者可能是 Safe 等合约钱包,应使用 SignatureChecker,并支持 ERC-1271。
5. nonReentrant 不是状态设计的替代品。即使使用重入锁,也应在外部调用前消费 nonce、扣减余额或标记授权已用。
6. 采用 pull payment 可减少主动向复杂接收方转账带来的风险,但仍需评估业务体验和资金滞留问题。
7. 前端展示的 EIP-712 内容必须与链上类型完全一致。字段顺序、类型、name、version 或 chainId 任一不一致,都会造成签名无法验证。

十、常见问题

问:签名已经包含 msg.sender,为什么还需要 nonce?

答:msg.sender 只能限制谁可以提交,并不能限制提交次数。合法用户仍可重复使用自己的签名,转发服务也可能重复广播同一交易。

问:用交易哈希判断是否重复可以吗?

答:不可以。每次交易的哈希都不同,即使 calldata 完全相同,账户 nonce、费用或区块环境变化也会产生新交易哈希。应记录业务授权的 nonce、salt 或结构体摘要。

问:只加入合约地址,不加入 chainId 是否足够?

答:不够稳妥。相同地址可能通过确定性部署等方式出现在不同链上,签名仍可能被复用。标准 EIP-712 域应同时绑定 chainId 和 verifyingContract。

问:nonce 应该按签名者还是接收者维护?

答:取决于授权模型。如果一个管理员为多个用户签名,按 receiver 维护可以让不同用户独立排序;若签名者拥有多个并行业务流,也可以采用签名者加业务域的复合 nonce。关键是链下签名服务与链上消费规则必须完全一致。

问:用了 EIP-712 就一定安全吗?

答:不是。EIP-712 解决的是结构化签名和域隔离,无法自动解决权限配置、业务参数遗漏、nonce 消费、过期控制、重入、余额记账和私钥泄露等问题。

十一、总结

授权提款审计的核心不是“验签是否通过”,而是验证一份授权在什么链、什么合约、由谁、针对什么参数、在什么时间范围内、最多执行几次。可靠实现通常需要 EIP-712 域隔离、完整参数绑定、nonce 或摘要消费、deadline、成熟密码库以及检查—效果—交互顺序,并辅以重入保护和权限轮换机制。

在交付审计结果前,建议同时提供最小复现测试、修复后的回归测试和升级兼容性说明。这样既能证明问题真实存在,也能避免修复只覆盖表面症状,或在代理升级、合约钱包和并发授权场景中引入新的故障。
 

jj1010223

New member

jj1010223 Rep
0
0
0
Rep
0
jj1010223 Vouches
Vouches
0
3 MONTHS
3 3 MONTHS OF SERVICE
jj1010223 获得
0
0
0
获得
0
jj1010223 凭证
0
0
0
凭证
0
主题
7
点赞
0
HakB
3 月
在社区成长 3 月
LEVEL 1 1 XP
 
建议在 Foundry 中补齐修复后的失败路径:用 vm.sign 签署 `_hashTypedDataV4(structHash)`,首次提款后断言接收人的 nonce 已递增,再用原签名调用并配合 vm.expectRevert;用 vm.warp(deadline + 1) 验证过期,再部署第二个 Vault、切换 vm.chainId 验证域隔离。实现时应先校验并消费 nonce,再执行转账,同时保留 nonReentrant。若业务允许授权并行签发和乱序执行,递增 nonce 会限制执行顺序,可改用随机 salt 或订单 ID,并在外部调用前写入 used[digest]。
 
顶部