签名重放攻击

  • Dacian
  • 发布于 2023-03-29 14:42
  • 阅读 5

本文深入探讨了以太坊交易中签名验证的潜在安全漏洞,包括重放攻击(缺失 Nonce、跨链重放)、缺少参数、签名过期、未检查 ecrecover() 返回值以及签名可延展性。文章通过具体的代码示例和漏洞案例,强调了智能合约开发者在签名实现中必须注意的关键安全问题和防范措施。

签名 可用于以太坊交易中,以验证链下执行的计算,从而有助于最大限度地减少链上 gas 费用。签名主要用于代表签名者授权交易,并证明签名者签署了特定消息。签名重放攻击允许攻击者通过复制先前交易的签名并传递验证检查来重放该交易。

缺失 Nonce 重放

考虑来自 Ondo 的 code4rena 竞赛的以下代码

function addKYCAddressViaSignature(
    uint256 kycRequirementGroup,
    address user,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s ) external {
    // ...
    bytes32 structHash = keccak256(
      abi.encode(_APPROVAL_TYPEHASH, kycRequirementGroup, user, deadline)
    );

    bytes32 expectedMessage = _hashTypedDataV4(structHash);

    address signer = ECDSA.recover(expectedMessage, v, r, s);
    // ...
}

addKYCAddressViaSignature() 使用签名来授予用户(签名者)KYC 状态。然而,如果 KYC 状态随后被撤销会发生什么?用户可以简单地重放原始签名,KYC 状态将再次被授予。

为了防止签名重放攻击,智能合约必须:

  • 跟踪 nonce

  • 使当前 nonce 可供签名者使用,

  • 使用当前 nonce 验证签名,

  • 一旦使用了 nonce,就将其保存到存储中,以防止再次使用相同的 nonce。

这要求签名者签署包含当前 nonce 的消息,因此已经使用过的签名无法被重放,因为旧的 nonce 将在存储中被标记为已使用,并且不再有效。一个例子可以在 OpenZeppelin 的 ERC20Permit 实现中看到:

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) public virtual override {
    // ...
    bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));
    // incorporates chain_id (ref next section Cross Chain Replay)
    // 包含 chain_id (参考下一节“跨链重放”)
    bytes32 hash = _hashTypedDataV4(structHash);
    // ...
}

function _useNonce(address owner) internal virtual returns (uint256 current) {
    Counters.Counter storage nonce = _nonces[owner];
    current = nonce.current();
    nonce.increment();
}

更多缺失 nonce 签名重放攻击的例子:[ 1, 2, 3, 4, 5]

跨链重放

许多智能合约在多个链上以相同的合约地址运行,用户也同样在多个链上操作相同的地址。Biconomy 的 code4rena 竞赛有以下代码:

function getHash(UserOperation calldata userOp)
public pure returns (bytes32) {
    //can't use userOp.hash(), since it contains also the paymasterAndData itself.
    // 无法使用 userOp.hash(),因为它本身也包含 paymasterAndData。
    return keccak256(abi.encode(
            userOp.getSender(),
            userOp.nonce,
            keccak256(userOp.initCode),
            keccak256(userOp.callData),
            userOp.callGasLimit,
            userOp.verificationGasLimit,
            userOp.preVerificationGas,
            userOp.maxFeePerGas,
            userOp.maxPriorityFeePerGas
        ));
}

由于 UserOperation 没有使用 chain_id 进行签名或验证,因此在一个链上使用的有效签名可能被攻击者复制并传播到另一个链上,在那里它对于相同的用户和合约地址也将是有效的!为了防止跨链签名重放攻击,智能合约必须使用 chain_id 验证签名,并且用户必须在要签名的消息中包含 chain_id。更多例子:[ 1, 2]

缺失参数

考虑一个签名,其中签名者允许合约花费他们的一些代币 - 要花费的代币数量必须是签名的一部分,以防止使用任意数量!考虑这个 gas 退款代码,同样来自 Biconomy 的 code4rena 竞赛:

function encodeTransactionData(
    Transaction memory _tx,
    FeeRefund memory refundInfo,
    uint256 _nonce
) public view returns (bytes memory) {
    bytes32 safeTxHash =
        keccak256(
            abi.encode(
                ACCOUNT_TX_TYPEHASH,
                _tx.to,
                _tx.value,
                keccak256(_tx.data),
                _tx.operation,
                _tx.targetTxGas,
                refundInfo.baseGas,
                refundInfo.gasPrice,
                refundInfo.gasToken,
                refundInfo.refundReceiver,
                _nonce
            )
        );
    return abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(), safeTxHash);
}

这允许用户签署一项交易,允许向交易提交者退还 gas。然而,在计算退款时,会使用一个额外的参数 tokenGasPriceFactor计算实际金额:

function handlePaymentRevert(
    uint256 gasUsed,
    uint256 baseGas,
    uint256 gasPrice,
    uint256 tokenGasPriceFactor,
    address gasToken,
    address payable refundReceiver
) external returns (uint256 payment) {
    // ...
    payment = (gasUsed + baseGas) * (gasPrice) / (tokenGasPriceFactor);
    // ...
}

由于 tokenGasPriceFactor 不是用户签名参数的一部分,因此获得 gas 退款的交易提交者可以将 tokenGasPriceFactor 设置为一个足够大的数字来耗尽用户的资金,同时仍然通过合约的签名验证检查,因为该参数未包含在签名中。智能合约审计员应仔细验证函数的所有必需参数也构成签名的一部分。

为了防止缺失参数签名攻击,用户必须始终签署包含特定消息参数的消息。更多例子:[ 1]

无过期时间

用户签署的签名应始终具有过期时间或时间戳截止日期,以便在该时间之后签名不再有效。如果没有签名过期时间,则用户通过签署消息实际上是授予了“终身许可”。考虑来自 NFTPort 的 sherlock 审计的这段代码,它缺少过期时间:

function call(
    address instance,
    bytes calldata data,
    bytes calldata signature
)
    external
    payable
    operatorOnly(instance)
    signedOnly(abi.encodePacked(msg.sender, instance, data), signature)
{
    _call(instance, data, msg.value);
}

以及包含过期时间的修复版本

function call(CallRequest calldata request, bytes calldata signature)
    external
    payable
    operatorOnly(request.instance)
    validRequestOnly(request.metadata)
    signedOnly(_hash(request), signature)
{
    _call(request.instance, request.callData, msg.value);
}

function _hash(CallRequest calldata request)
    internal
    pure
    returns (bytes32)
{
    return
        keccak256(
            abi.encode(
                _CALL_REQUEST_TYPEHASH,
                request.instance,
                keccak256(request.callData),
                _hash(request.metadata)
            )
        );
}

function _hash(RequestMetadata calldata metadata)
    internal
    pure
    returns (bytes32)
{
    return
        keccak256(
            abi.encode(
                _REQUEST_METADATA_TYPEHASH,
                metadata.caller,
                metadata.expiration // signature expiration
                // 签名过期时间
            )
        );
}

为了帮助防止重放攻击,签名实现应始终包含过期时间戳,并旨在符合 EIP-712。一些经过审计且经过充分测试的构建块,用于在你的智能合约中实现 EIP-712,可在 OpenZeppelin 的实用程序 EIP712.sol 中找到。

未检查的 ecrecover() 返回值

Solidity 的 ecrecover() 函数返回签名地址或 0(如果签名无效);必须检查 ecrecover() 的返回值以检测无效签名!考虑来自 Swivel 的 code4rena 审计中的这段代码 [ 1, 2]:

function validOrderHash(Hash.Order calldata o, Sig.Components calldata c) internal view returns (bytes32) {
    bytes32 hash = Hash.order(o);
    // ...
    require(o.maker == Sig.recover(Hash.message(domain, hash), c), 'invalid signature');
    // ...
}

// Sig.recover
// Sig.recover
function recover(bytes32 h, Components calldata c) internal pure returns (address) {
    // ...
    return ecrecover(h, c.v, c.r, c.s);
}

validOrderHash() 检查 o.maker == Sig.recover(),其中 Sig.recover() 返回 ecrecover(),因此该检查实际上是 o.maker == ecrecover()。这允许攻击者简单地为 o.maker 传递 0,并使此检查对于无效签名通过,因为 ecrecover() 对于无效签名返回 0!更多例子:[ 1]

签名可延展性

以太坊用于签名的椭圆曲线是对称的,因此对于每个 [v,r,s],都存在另一个 [v,r,s],它返回相同有效的的结果。因此,存在两个有效的签名,这允许攻击者在不知道签名者的私钥的情况下计算有效的签名。 ecrecover() 容易受到签名可延展性的影响 [ 1, 2],因此直接使用它可能很危险。考虑来自 Larva Lab 的 code4rena 审计中的这段代码

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);
}

攻击者可以计算另一个相应的 [v,r,s],由于椭圆曲线的对称性,这将使此检查通过。防止此问题的最简单方法是使用 OpenZeppelin 的 ECDSA.sol 库,阅读 ECDSA 的 tryRecover() 函数上方的注释提供了关于正确实现签名检查以防止签名可延展性漏洞的非常有用的信息。更多例子:[ 1, 2]

使用 OpenZeppelin 的 ECDSA 库时,必须特别注意使用 4.7.3 或更高版本,因为之前的版本包含签名可延展性错误。

另一个关于防止签名可延展性漏洞的很好的资源是 ImmuneFi 的这篇优秀文章

  • 原文链接: dacian.me/signature-repl...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Dacian
Dacian
in your storage