本文根据Owen21小时合约审计课程中第二部分合约升级指南部分中关于离线签名的拓展性问题展开讨论。原视频:https://www.youtube.com/watch?v=DRZogmD647U
下面是一个易受攻击的代码:
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity 0.8.17;
contract signatureMealleable is Ownable {
address token;
mapping(bytes32 => bool) executed;
function signedTransfer(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
bytes32 msgHash = keccak256(abi.encode(msg.sender, to, amount));
address signer = ecrecover(msgHash, v, r, s);
require(signer == owner);
bytes32 sigHash = keccak256(abi.encode(msgHash, v, r, s));
require(!executed[sigHash]);
executed[sigHash] = true;
IERC(token).safeTransfer(to, amount);
}
}
如何攻击?这里的签名具有可塑性
因为owner
提供签名的v
,r
,s
组件,但是攻击者可以观察到这些v, r, s
的值来制作另一个签名,该签名也恢复到相同的 owner
地址。实际上,这是第二次执行owner
的消息,使得这份合约双倍支出。
但是为什么实际上是可能的?攻击者如何观察到这些值,来创建第二个有效的签名的呢?
首先,我们需要分解消息签名在以太坊中的工作原理,首先,我们要从椭圆曲线开始
首先是一些关于椭圆曲线的前置知识。以太坊的签名是根据$y^{2}=x^{3}+7(mod\ p)$这个椭圆曲线方程(SECP256K1)来计算公、私钥的。这里的
P
是一个非常非常大的质数N
是这个群的阶(本质上是椭圆曲线上的点的数量)G
是该椭圆曲线的基点有了这些,我们就可以讨论椭圆曲线数字签名算法:ECDSA,以及v, r, s
值是如何参与的
首先,当我们有了私钥,在以太坊上签署一笔交易,e
(私钥)只有我们知道,然后我们拥有一个任何人都知道的,由私钥生成的公钥(账户地址)。当我们共享公钥时,我们可以肯定,只有我们知道私钥是安全的。
但是当我们创建这个v, r, s
签名时,我们基本上要在我们的信息中传递,我们将用我们的私钥来签名,所以实际上必须有一些混淆的随机值,这使得攻击者无法观察到我们的v, r, s
和签名来还原得到我们的私钥。
生成 ECDSA 签名v, r, s
的过程:
k
($1 \leq k \leq n-1$)k
乘以基点G
得到公钥K
(点)x
为作为离线签名中的r
s
e
)n
的逆元v
值(恢复标志)在以太坊中,v值有两个可能值27
,28
v
的值是 recid + 27
,其中 recid
是恢复参数,有两个可能的值:0
或 1
。v
需要使用借助 js 或者 py 来进行计算实际上,椭圆曲线上有两个有效点,这两个点具有相同的r
值。这是真正产生签名可塑性的原因。事实证明:攻击者实际上可以非常容易地计算出相应的 s
值,这将用于推出相反的临时公钥,并提供第二个有效签名,这个签名也会恢复到同一个签名者
下面的代码,我们将验证这种签名的可塑性
const EC = require('elliptic').ec;
const ec = new EC('secp256k1');
const ecparams = ec.curve
const BN = require('bn.js');
const n = new BN(ecparams.n);
const privateKey = BigInt("0x3d81b4e2161512c578f9961ccf6860651901816272148053824fa5d91a791155");
const publicKey = ec.keyFromPrivate(privateKey).getPublic();
const msg = 1n;
const sig = ec.keyFromPrivate(privateKey).sign(msg);
sig
ec.verify(msg, sig, publicKey); // 验证签名
const s = new BN(sig.s);
const sig2 = { r: sig.r, s: n.sub(s) };
ec.verify(msg, sig2, publicKey); // 验证伪造签名
经过验证,第二个签名对消息也有效。
在上面的示例合约中,在一开始我们就可以看到v, r, s
的组合,允许攻击者观察所有者的签名。并且,在合约中确实出现了双花问题。
攻击者传入自己篡改后的v, r, s
版本,他将会恢复到相同的所有者地址,使得 owner 发生了双花问题。
最广泛的解决方法就是限制s
值的右半段,这样大于n/2
的s
值会变成非法值。所以我们可以进行限制,只允许大于或小于n/2
的s
值
下面是 OpenZeppelin ECDSA
安全库中的一个函数,他限制了s
的值必须大于n/2
:
function tryRecover(
bytes32 hash,
uint8 v,
bytes32 r,
bytes32 s
) internal pure returns (address, RecoverError, bytes32) {
// EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature
// unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines
// the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most
// signatures from current libraries generate a unique signature with an s-value in the lower half order.
//
// If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value
// with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or
// vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept
// these malleable signatures as well.
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
return (address(0), RecoverError.InvalidSignatureS, s);
}
// If the signature is valid (and not malleable), return the signer address
address signer = ecrecover(hash, v, r, s);
if (signer == address(0)) {
return (address(0), RecoverError.InvalidSignature, bytes32(0));
}
return (signer, RecoverError.NoError, bytes32(0));
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!