ECDSA Signature Vulns in Solidity
在实际项目中,往往是密码算法的误用、算法特性的恶意利用,引发了各样的漏洞。本文从以太坊签名机制谈起,聚焦solidity合约开发中、ECDSA签名相关代码容易出现的问题,并举例实际场景说明。本文不会深入密码学算法,不会探讨算法本身的安全性。
TL;DR
在实际项目中,往往是密码算法的误用、算法特性的恶意利用,引发了各样的漏洞。本文从以太坊签名机制谈起,聚焦solidity合约开发中、ECDSA签名相关代码容易出现的问题,并举例实际场景说明。本文不会深入密码学算法,不会探讨算法本身的安全性。
以太坊签名机制
一条区块链是由无数地址构成的,每个地址都可以是一个用户账户。在以太坊上,用户想要创建一个新的账户,首先需要生成一个合适的公私钥对,私钥会用来对每一笔交易签名,公钥的前20个字节作为账户地址。签名机制是以太坊乃至整个区块链的基石
以太坊使用ECDSA算法,ECDSA是ECC(椭圆曲线密码学)的实际应用。签名需要用户私钥private_key和消息哈希message_hash两个参数,得到签名结果v、r、s三个参数,按照r(32bytes)+ s(32bytes)+ v(1bytes)拼接成为signature(65bytes)。各种语言的web3库都能封装了ECDSA签名算法,方便进行签名操作,包括web3.py、web3.js等。这里笔者使用forge script进行演示:
uint256 private_key = vm.envUint("PRIV"); // 从环境变量中拿到private-key
bytes32 message_hash = keccak256("hello from DX3906"); // 消息哈希
(uint8 v, bytes32 r, bytes32 s) = vm.sign(private_key, message_hash); // 签名API
bytes memory signature = abi.encodePacked(r, s, v); // 组装signature
// 输出看看
console.log(v);
console.logBytes32(r);
console.logBytes32(s);
console.logBytes(signature);输出:
== Logs ==
27
0xbf3ba8795f688d50c884bd6c8e03ee7da174227430d664d8cad6873fe1dcec94
0x1084a284f8b4464fa4f34036407ad3eb3999176c4fe4a2a0a720f92761336d29
0xbf3ba8795f688d50c884bd6c8e03ee7da174227430d664d8cad6873fe1dcec941084a284f8b4464fa4f34036407ad3eb3999176c4fe4a2a0a720f92761336d291b以上演示了客户端签名所做的工作,但这里的代码仅演示了基本原理,并不完全符合以太坊签名标准(生成消息哈希需要额外携带标识字符串),这里不再赘述。这样的签名操作不会出现在以太坊合约中,否则会发生私钥泄漏,签名也就没有了意义
合约中要完成signature的校验工作,即首先通过signature恢复签名者signer,然后验证signer身份有效性。对于恢复signer,广泛使用的API有两种,下面直接使用代码说明:
ecrecover:EVM预编译函数
uint256 private_key = vm.envUint("PRIV");
address user = vm.addr(private_key); // 使用私钥直接计算地址,待后续验证
console.log("user address:", user);
// 签名
bytes32 message_hash = keccak256("hello from DX3906");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(private_key, message_hash);
// 验签
address signer = ecrecover(message_hash, v, r, s); // 恢复signer地址
console.log("singer address:", signer);输出:
== Logs ==
user address: 0xaF29f0C57B61e0584c53D61a16090C3C2Ea7b54c
singer address: 0xaF29f0C57B61e0584c53D61a16090C3C2Ea7b54cECDSA.recover:openzeppelin开发的标准库,对ecrecover的封装
uint256 private_key = vm.envUint("PRIV");
address user = vm.addr(private_key); // 使用私钥直接计算地址,待后续验证
console.log("user address:", user);
// 签名
bytes32 message_hash = keccak256("hello from DX3906");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(private_key, message_hash);
// 验签
address signer = ECDSA.recover(message_hash, signature); // 恢复signer地址
console.log("singer address:", signer);输出:
== Logs ==
user address: 0xaF29f0C57B61e0584c53D61a16090C3C2Ea7b54c
singer address: 0xaF29f0C57B61e0584c53D61a16090C3C2Ea7b54copenzeppelin的库函数,相对于EVM预编译的函数,做了更多的预先检查,能避免一些攻击的发生(如ECDSA签名延展性攻击),也提供了更加丰富的支持,但若开发者不注意安全防范,也会带来新的风险(紧凑型签名攻击),下面会介绍漏洞场景和相关攻击手法
漏洞场景
首先明确,本文探讨的场景为项目合约中可能出现的漏洞。普通交易的签名记录验证的操作由EVM负责,不在本文讨论范围之内。例如,合约中随处可使用的msg.sender,默认为正确调用者地址,在合约中不可能对它进行验证
需要开发者自行验证签名的场景,基本可归为两类:
- 授权特权操作。通过验证单个用户签名,允许调用者进行特权操作
- 用户份额累加。通过验证多用户签名,累加用户份额,类似于投票的过程
在这样的场景中,会面临什么样的安全威胁呢?
我们默认签名算法是密码学安全的,即签名不可伪造。剩下的问题就是签名重放问题,任何传递到合约的签名,任何人都可以轻易拿到,所以任何一个签名都不该被使用第二次。同时,由于ECDSA算法本身的一些特性,根据一个现有的签名,可以生成等价的不同签名,这也是合约开发者需要防范的
漏洞实例
这一节通过几道CTF题目,实际来一些看漏洞实例,仅截取签名验证相关代码以供说明
0x00 签名重复性检验不完善
Remedy CTF 2025 rich-mans-bet
// Function to validate a signed message for changing bridge settings
function changeBridgeSettings(
bytes calldata message,
bytes[] calldata signatures
) external onlyValidator {
uint256 accumulatedWeight = 0;
address lastSigner = address(0);
address newChallengeContract;
address newAdminNftContract;
uint256 newThreshold;
for (uint256 i = 0; i < signatures.length; i++) {
address signer = message.toEthSignedMessageHash().recover(
signatures[i]
);
require(signer != lastSigner, "Repeated signer");
if (validatorWeights[signer] > 0) {
accumulatedWeight += validatorWeights[signer];
}
lastSigner = signer;
}
require(
accumulatedWeight >= totalWeight / 2,
"Insufficient weight to change settings"
);
// Decode new parameters from the message
(newChallengeContract, newAdminNftContract, newThreshold) = abi.decode(
abi.encodePacked(message),
(address, address, uint256)
);
require(newThreshold > 1, "New threshold must be above 1");
// Call internal function to update bridge settings
_updateBridge(newChallengeContract, newAdminNftContract, newThreshold);
}changeBridgeSettings函数通过验证签名,实现了一个用户份额累加功能。所有愿意使用message内容执行函数的用户需要用自己的私钥进行签名,合约累加用户份额,超过半数则进行执行
为了防止签名重放,合约使用lastSigner变量存储上一条signature,保证了相邻两个signature不同。然而,任意两个用户,它们应有的份额可能远远不到一半,但只要两个不同签名交替重复,就能轻松绕过这个检查,通过无数次重复,就能累加无数多的份额
0x01 ECDSA签名延展性攻击
SUCTF 2025 Onchain Magician
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.28;
contract MagicBox {
struct Signature {
uint8 v;
bytes32 r;
bytes32 s;
}
address magician;
bytes32 alreadyUsedSignatureHash;
bool isOpened;
constructor() {}
function isSolved() public view returns (bool) {
return isOpened;
}
function getMessageHash(address _magician) public view returns (bytes32) {
return keccak256(abi.encodePacked("I want to open the magic box", _magician, address(this), block.chainid));
}
function _getSignerAndSignatureHash(Signature memory _signature) internal view returns (address, bytes32) {
address signer = ecrecover(getMessageHash(msg.sender), _signature.v, _signature.r, _signature.s);
bytes32 signatureHash = keccak256(abi.encodePacked(_signature.v, _signature.r, _signature.s));
return (signer, signatureHash);
}
function signIn(Signature memory signature) external {
require(magician == address(0), "Magician already signed in");
(address signer, bytes32 signatureHash) = _getSignerAndSignatureHash(signature);
require(signer == msg.sender, "Invalid signature");
magician = signer;
alreadyUsedSignatureHash = signatureHash;
}
function openBox(Signature memory signature) external {
require(magician == msg.sender, "Only magician can open the box");
(address signer, bytes32 signatureHash) = _getSignerAndSignatureHash(signature);
require(signer == msg.sender, "Invalid signature");
require(signatureHash != alreadyUsedSignatureHash, "Signature already used");
isOpened = true;
}
}简单理解后可以发现,题目要求对同一个hash,生成两套不同的v, r, s,同时两套签名恢复出来的signer需要一致。正常来说,使用各种库进行签名时,得到的v, r, s都是唯一的,为什么会有两套不一样的呢?
我们得回到ECDSA算法本身,实际上,对于同样的r,存在两个不同的s满足要求,且根据一个s易求另外一个,这就是ECDSA签名延展性。具体求法如下:
def create_malleable_signature(original_v, original_r, original_s):
# secp256k1 曲线阶数 N
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
# 构造新签名
new_s = hex(N - original_s)[2:].zfill(64)
new_v = 28 if original_v == 27 else 27
new_r = original_r
return new_v, new_r, new_s更进一步,如果这里合约使用ECDSA.recover,即openzeppelin的标准库函数,就能避免这个漏洞。打开改函数的实现可以看到开头这样的注释:
* The `ecrecover` EVM opcode allows for malleable (non-unique) signatures:
* this function rejects them by requiring the `s` value to be in the lower
* half order, and the `v` value to be either 27 or 28.0x02 紧凑型签名(compact signature)
Remedy CTF 2025 casino-avengers
function _verifySignature(bytes memory signature, bytes memory digest) internal {
if (nullifiers[signature]) revert SignatureAlreadyUsed();
address signatureSigner = ECDSA.recover(keccak256(digest), signature);
if (signatureSigner != signer) revert InvalidSignature();
nullifiers[signature] = true;
}合约使用mapping来存储用过的signatures,避免了签名重放,且使用ECDSA.recover避免了延展性攻击,那么我们在已知用过的签名的情况下,还能构造新的签名吗?答案是可以
ERC-2098 是2019年提出的以太坊改进提案,引入了compact signature,即紧凑格式的签名,使得整个签名长度可以由65字节缩短到64字节,初衷是为了优化使用EVM空间,提高效率。在原文中也给出了紧凑型签名和一般签名的转换方法:
# Assume yParity is 0 or 1, normalized from the canonical 27 or 28
def to_compact(r, s, yParity):
return {
"r": r,
"yParityAndS": (yParity << 255) | s
}
def to_canonical(r, yParityAndS):
return {
"r": r,
"s": yParityAndS & ((1 << 255) - 1),
"yParity": (yParityAndS >> 255)
}是这个库本身不安全吗?其实不是。事实上,上面例子中犯了个大错,对于重复性检测,应该去重digest,而不是signature。只要将nullifiers变量改为存储使用过的digest,漏洞就被修复了
0x03 不遵守规范无限签名
这一小节作为补充说明,就不举具体例子了
以太坊签名机制是建立在RFC-6979标准上的,即使用确定性生成k的方法进行签名。故对于唯一的message,应只有唯一的签名(排除延展性情况)。之所以这么做,是因为使用不规范的k进行签名,存在私钥泄漏的风险。就在前两天,被广泛使用的js库elliptic刚爆出了存在漏洞,详见慢雾分析文章
然而,使用ecrecover进行公钥还原时,并不会关心k是否符合标准,都可以得到正确的签名者。故只要不遵守规范,就可以生成无数个可用的签名。下面给出一个任意k进行签名的python脚本(借鉴自https://cnwangjihe.notion.site/ECDSA-ec7a150a720d4cf29c6d060bbf56275b):
import hashlib
import hmac
from eth_utils import big_endian_to_int
from eth_account.account import Account
from hexbytes import HexBytes
from eth_keys.constants import (
SECPK1_N as N,
SECPK1_G as G
)
from eth_keys.backends.native.jacobian import (
inv,
fast_multiply
)
def deterministic_generate_k(msg_hash: bytes,
private_key_bytes: bytes) -> int:
v_0 = b'\x01' * 32
k_0 = b'\x00' * 32
k_1 = hmac.new(k_0, v_0 + b'\x00' + private_key_bytes + msg_hash, hashlib.sha256).digest()
v_1 = hmac.new(k_1, v_0, hashlib.sha256).digest()
k_2 = hmac.new(k_1, v_1 + b'\x01' + private_key_bytes + msg_hash, hashlib.sha256).digest()
v_2 = hmac.new(k_2, v_1, hashlib.sha256).digest()
kb = hmac.new(k_2, v_2, hashlib.sha256).digest()
k = big_endian_to_int(kb)
return k
def ecdsa_sign(msg_hash: bytes,
private_key: str | bytes,
deterministic: bool = True) -> tuple[int, int, int]:
assert len(msg_hash) == 32, "length of msg_hash must == 32"
if not isinstance(private_key, bytes):
private_key = HexBytes(private_key)
z = big_endian_to_int(msg_hash)
if deterministic:
k = deterministic_generate_k(msg_hash, private_key)
# print(f"k: {k}, G: {G}, N: {N}")
else:
import secrets
k = 1 + secrets.randbelow(N-1)
r, y = fast_multiply(G, k)
s_raw = inv(k, N) * (z + r * big_endian_to_int(private_key)) % N
v = 27 + ((y % 2) ^ (0 if s_raw * 2 < N else 1))
s = s_raw if s_raw * 2 < N else N - s_raw
return v, r, s
def ecdsa_recover(msg_hash: bytes,
vrs: tuple[int, int, int]):
return Account._recover_hash(msg_hash, vrs = (vrs[0] - 27, vrs[1], vrs[2]))
def test_case():
private_key = bytes.fromhex("be0a5d9f38057fa406c987fd1926f7bfc49f094dc4e138fc740665d179e6a56a")
sign_data = bytes.fromhex("631666568d3f1118d38685d8bb83b2422ac2ecd3585ecf0b08d16edeba002ee8")
(v, r, s) = ecdsa_sign(sign_data, private_key, deterministic=False)
pubkey = ecdsa_recover(sign_data, (v, r, s))
assert pubkey == Account.from_key(private_key).address
print(v, r, s)
if __name__ == "__main__":
test_case()想要避免这样的签名攻击,其实还是只要去重message hash即可,但千万不能去重signature