八字节盲点:Relay 协议旧版签名哈希漏洞烧掉比特币存款
在审查 Relay Protocol 的 Settlement Protocol 时,我们花了一些时间研究 Bitcoin sweep 路径,这是一小段机制,负责将已确认的 BTC 存款从每个订单的地址转入 Relay 的托管库。它看起来平淡无奇,正如好的结算代码应该有的样子:一个输入、一个托管库输出、一个 OP_RETURN、一个 MPC 签名。
危险之处不在于奇怪的曲线技巧或损坏的 MPC 协议,而在于一条更古老的 Bitcoin 签名规则。Relay 的 sweep 使用 legacy P2PKH,其 SIGHASH_ALL preimage 不包含所花费输入的数量。当签名者被假定知道自己签名的币时,这没问题。但当签名者是一个通用的 NEAR Chain Signatures 合约、只能看到一个 32 字节哈希时,问题就大多了。
sweep() 函数也是无需许可的,其 UTXO 值直接来自 calldata。攻击者可以拿一个真实的已确认存款,谎称其价值低得多,请求 MPC 对结果哈希签名,然后广播交易。Bitcoin 会用真实 UTXO 验证签名,接受微小的输出,并将差额记为矿工费。
所以这个 bug 不是 Relay 签错了交易——它签的正是被请求签名的交易。缺失的八字节金额字段意味着 Bitcoin 和 Relay 并没有就该交易实际花费了什么达成一致。
棋盘上的棋子
已经了解 Bitcoin 的脚本类型和 BIP-143?直接跳到根因。
Bitcoin UTXO
- UTXO - Bitcoin 没有账户,余额是一堆未花费的交易输出,每个输出都锁定到一个脚本。UTXO 集合是公开的,可以从任何全节点查询——包括本协议持有的存款 UTXO。
- P2PKH vs P2WPKH vs P2TR - 一个疑问驱动了整个 bug:签名是否承诺了输入的金额?
| 脚本 | 年份 | 签名 | 是否承诺输入金额? |
|---|---|---|---|
| P2PKH | ~2009 | ECDSA | 否 |
| P2WPKH | 2017 | ECDSA | 是,通过 BIP-143↗ |
| P2TR | 2021 | Schnorr | 是,通过 BIP-341 |
- Aurora + Chain Signatures - Aurora 是 NEAR 的 EVM 兼容执行层,原生 NEAR 合约 Chain Signatures↗ 合约运行于此——一个由 MPC 支持的签名预言机:给它一个 32 字节哈希和一个派生路径,它就会返回一个有效的 ECDSA 签名。
设计上的无需许可
Relay 通过由 NEAR MPC 派生的唯一存款地址来结算每个 Bitcoin 侧订单。用户将 BTC 发送到该地址。存款确认后,任何人都可以用 UTXO 和费用调用 sweep()。Relay 构建一笔交易,将存款减去费用后发送到规范托管库,并在 OP_RETURN 输出中记录订单 ID。NEAR MPC 对交易签名,调用者广播它。
BITCOIN UTXO 集合
────────────────
真实存款:1,000,000 sats
(txid, idx, scriptPubKey)
│ 读取
▼
AURORA · BitcoinDepositAddress
──────────────────────────────
sweep(orderId, UTXO{value: 1,000,000}, fee)
buildSweepPayload → out[997,310 | OP_RETURN]
hashToSign = SHA256d(legacy preimage)
│ 哈希
▼
NEAR MPC ─── sign(hash) ─── 返回 sig
│
▼
链下调用者
────────────────
组装 scriptSig(sig, derived pubkey)
广播原始 Bitcoin 交易
│
▼
BITCOIN 共识
─────────────────
sig 对真实 1,000,000-sat UTXO 有效
fee = 1,000,000 − 997,310 = 2,690 sats
托管库收到 997,310 sats
Aurora 无法看到 Bitcoin 的 UTXO 集合,因此 sweep 在链下运行。调用者读取发送到每个订单存款地址的 UTXO,并将其作为 calldata 传给合约。合约构建交易、对其哈希,并请求 NEAR MPC 对该哈希签名。调用者广播结果。
以下是构建 payload 的入口点:
function buildSweepPayload(
bytes32 orderId,
bytes calldata data
) external view returns (bytes memory payload, uint64 sweepAmount) {
(UTXO memory utxo, uint64 feeRate) = abi.decode(data, (UTXO, uint64));
if (feeRate > maxFeeRate) {
revert FeeRateTooHigh(feeRate, maxFeeRate);
}
uint256 fees = uint256(feeRate) * SWEEP_TX_SIZE;
if (utxo.value < fees) {
revert InsufficientUTXOValue(utxo.value, fees);
}
sweepAmount = utxo.value - uint64(fees);
if (sweepAmount < DUST_THRESHOLD) {
revert SweepAmountBelowDust(sweepAmount);
}
// ...
}
这里发生了三项检查:合约将 feeRate 限制在所有者设定的上限(maxFeeRate)之内,确认声明的值足以覆盖费用(InsufficientUTXOValue),并确保 sweep 金额超过 dust 阈值(SweepAmountBelowDust)。那么缺少了什么?
没有任何地方询问 utxo.value 是否就是位于 utxo.txid / utxo.index 的真实 UTXO 的值。Aurora 无法查看 Bitcoin 的 UTXO 集合,而 data 直接来自不可信的 calldata。
Bitcoin 实际检查什么
签名者会签署任何东西,而在 Bitcoin 上,对于每个输入,节点会根据自身的 UTXO 集合解析 outpoint (prev_txid, prev_index),得知必须满足的锁定脚本以及该输出的真实 sat 值。
对于 legacy P2PKH 花费,有两项检查很重要。首先,节点为该输入重建 legacy sighash preimage,并针对其双重 SHA-256 摘要验证 ECDSA 签名。其次,经济层面:被消耗 UTXO 的总价值——从节点自身的 UTXO 集合读取——必须至少等于新输出的总价值。剩下的就是费用……
这里有两个不同的数字都被称为“费用”。bug 就存在于它们之间的空隙:
- 合约的规模费用,
feeRate * SWEEP_TX_SIZE,是合约纯粹为了确定outputs[0]的大小而计算的数字。它受maxFeeRate限制。 - Bitcoin 的实际费用是
sum(inputs) - sum(outputs),由节点从自身 UTXO 集合计算。合约中没有任何东西限制它。
关键的分裂在于:Relay 根据调用者声明的值来确定输出大小,而 Bitcoin 则根据真实 UTXO 值来验证花费。
缺失的八字节
在审视了两侧之后,很明显 bug 不在任何一侧,而在于它们连接的地方。preimage 构建器忠实地按规范序列化了 legacy 格式:
function buildPreImageForInput(
BitcoinTransactionData memory txData,
uint256 whichInput
) internal pure returns (bytes memory) {
bytes memory versionLE = Utils.encodeUint32LE(1);
bytes memory inputCountLE = encodeVarInt(txData.inputs.length);
bytes memory allInputs;
for (uint256 i = 0; i < txData.inputs.length; i++) {
bytes memory prevTxidLe = txData.inputs[i].txid;
bytes memory prevIndexLe = txData.inputs[i].index;
// scriptSig 是被签名输入的 prevout scriptPubKey,否则为空
// ...
bytes memory sequenceLE = Utils.encodeUint32LE(0xFFFFFFFD);
allInputs = bytes.concat(
allInputs,
prevTxidLe, // 哪个 outpoint
prevIndexLe, // 哪个索引
scriptSigLen,
scriptSigBytes,
sequenceLE // 仅此而已:txData.inputs[i].value 从未被序列化
);
}
// ...outputs、locktime、hashType(0x01 = SIGHASH_ALL)...
}
每个 BitcoinTransactionDataInput 已经携带了一个 .value 字段。序列化器只是从不读回它——bytes.concat 贡献了 outpoint、scriptSig 和 sequence,然后停止。金额从未进入 preimage。
总体而言,这个 bug 有三层:
- 一个无需许可的入口点(
sweep→buildSweepPayload),接受调用者提供的utxo.value,而不对照真实 Bitcoin UTXO 进行验证。 - 一个 legacy
SIGHASH_ALLpreimage,按 legacy 规范的定义省略了输入金额。 - 一个通用目的的 ECDSA 预言机(
ChainSignatures),对你给它的任何双重 SHA-256 哈希签名,完全没有 Bitcoin 感知。
如果只有受信任的中继者能调用,这种模式是安全的。但 sweep() 是故意无需许可的——NatSpec 就是这么说的:
// contracts/BitcoinDepositAddress.sol (sweep, L133-155)
/// @dev 无需许可——任何人都可以调用。待处理签名冷却期过后可重新调用
/// 以防止通过低 gas 设置进行 griefing 攻击。
function sweep(
bytes32 orderId,
bytes calldata data,
GasSettings calldata gasSettings
) external {
// ...构建 payload、对其哈希,并请求 MPC 签名...
}
综合起来,任何人都可以用一个真实的已确认 outpoint 调用 sweep(),但传入一个虚假的低 UTXO 输入值。合约产生一个有效的 P2PKH 签名,却认为输入只有更少的 sat。节点使用真实 UTXO 的锁定脚本来检查该签名,而由于金额从未进入摘要,没有任何东西提出异议。交易创建了价值极少的输出,没有人将签名者预期的输入与真实输入进行比较,节点将剩余的大笔 sat 记为费用。
那么为什么要烧掉这些币?为什么不把它们指向攻击者的地址然后带着财富离开?因为调用者无法选择目的地。两个输出都由合约定义,而不是由 calldata 定义:
// 输出 0:托管库(P2PKH)- 脚本在构造时设定,并非来自 calldata
outputs[0] = BitcoinTransactionDataOutput({
value: Utils.encodeUint64LE(sweepAmount),
script: depositoryScriptBytes
});
// 输出 1:携带 orderId 的 OP_RETURN,值为 0 - 不是价值出口
outputs[1] = BitcoinTransactionDataOutput({
value: Utils.encodeUint64LE(0),
script: abi.encodePacked(hex"6a42", "0x", ChainSignatures.stringifyBytes(...))
});
输出 0 支付给 depositoryScriptBytes,在构造时固化。输出 1 是一个价值为零的 OP_RETURN。
所以攻击者可以谎报 utxo.value,但无法重定向任何一个输出。剩下的唯一杠杆是 Bitcoin 的隐式费用。拉动它,存款就以费用的形式离开。这看起来像是纯粹的破坏,但费用归挖出该区块的矿工所有——矿工攻击者,或任何与矿工有费用分成安排的人,都能收回它。对其他人来说这就是纵火。无论哪种方式,存款人的币都消失了。
一个 Outpoint,两种真相
这里的 PoC 不需要花费一枚币。有一种更尖锐的方式:展示 MPC 无论你告诉合约输入值 10,000 sat 还是 1,000,000 sat,都会签署相同的摘要。如果两个摘要相等,那么一个 ECDSA 签名对两者都有效——这意味着输入值从来就不是合约承诺的一部分。
两个 Foundry 测试固定了 bug 的两半。两者构建相同的交易,只改变输入的声明值:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Test} from "forge-std/Test.sol";
import {BitcoinDepositSweepBuilder} from "../contracts/BitcoinDepositSweepBuilder.sol";
import {Utils} from "../contracts/Utils.sol";
import {
UTXO,
BitcoinTransactionDataInput,
BitcoinTransactionDataOutput,
BitcoinTransactionData
} from "../contracts/PayloadBuilders/BitcoinPayloadBuilder.sol";
contract LegacySighashTest is Test {
BitcoinDepositSweepBuilder builder;
bytes32 constant TXID = bytes32(type(uint256).max / 0xFF * 0xAA); // 0xaaaa…aaaa
bytes32 constant ORDER_ID = bytes32(type(uint256).max / 0xFF * 0x11); // 0x1111…1111
bytes REAL_SCRIPT = hex"76a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac";
// 托管库 P2PKH 脚本(25 字节);其 base64 就是构造函数接收的内容。
bytes DEPOSITORY = hex"76a914cccccccccccccccccccccccccccccccccccccccc88ac";
// ORDER_ID 的 OP_RETURN 输出:0x6a42 ‖ "0x" ‖ hex(orderId),值为 0(68 字节)。
bytes OP_RETURN_OUT =
hex"6a42307831313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131";
function setUp() public {
// base64("76a914cc..cc88ac") == "dqkUzMzMzMzMzMzMzMzMzMzMzMzMzMyIrA==";maxFeeRate = 50。
builder = new BitcoinDepositSweepBuilder(address(this), "dqkUzMzMzMzMzMzMzMzMzMzMzMzMzMyIrA==", 50);
}
function _payload(uint64 inputValue) internal view returns (bytes memory) {
BitcoinTransactionDataInput[] memory ins = new BitcoinTransactionDataInput[](1);
ins[0] = BitcoinTransactionDataInput({
txid: abi.encodePacked(TXID),
index: Utils.encodeUint32LE(0),
script: REAL_SCRIPT,
value: Utils.encodeUint64LE(inputValue) // 我们唯一改变的东西
});
BitcoinTransactionDataOutput[] memory outs = new BitcoinTransactionDataOutput[](2);
outs[0] = BitcoinTransactionDataOutput({ // 托管库 P2PKH,sweepAmount = 7,310
value: Utils.encodeUint64LE(7_310), script: DEPOSITORY
});
outs[1] = BitcoinTransactionDataOutput({ // OP_RETURN,值为 0
value: Utils.encodeUint64LE(0), script: OP_RETURN_OUT
});
return abi.encode(BitcoinTransactionData({inputs: ins, outputs: outs}));
}
function test_legacySighashIgnoresInputValue() public view {
bytes32 hLie = builder.hashToSign(_payload(10_000)); // 攻击者的谎言
bytes32 hTruth = builder.hashToSign(_payload(1_000_000)); // 真实 UTXO
assertEq(hLie, hTruth); // 相同 -> 一个签名对两者都有效
}
function test_underdeclaredValueIsAccepted() public view {
UTXO memory utxo = UTXO({txid: TXID, index: 0, value: 10_000, scriptPubKey: REAL_SCRIPT});
(, uint64 sweepAmount) = builder.buildSweepPayload(ORDER_ID, abi.encode(utxo, uint64(10)));
assertEq(sweepAmount, 7_310);
// 在 Bitcoin 上:真实输入 1,000,000 - 输出 7,310 = 992,690 sats 作为矿工费被烧毁。
}
}
摘要忽略了价值,而 test_legacySighashIgnoresInputValue 这个测试用一个 assertEq 概括了整个 bug:谎言(10,000)和真相(1,000,000)哈希到相同的双重 SHA-256 摘要,因此一个 MPC 签名对两者都有效:
谎言(value = 10,000 sats):
0xa2700ab86539ee75bddd64ea4b0f3a00f82bc82ff31a625b5f069e00e0b0d4ba
真相(value = 1,000,000 sats):
0xa2700ab86539ee75bddd64ea4b0f3a00f82bc82ff31a625b5f069e00e0b0d4ba
合约吞下了谎言,而 test_underdeclaredValueIsAccepted 将低报的 10,000 直接送入 buildSweepPayload——它通过了所有检查,并将托管库输出定为 7,310 sat。对照真实的 1,000,000 sat UTXO,这留下 992,690 sat 无人对账,等待变成费用。
让 Bitcoin 烧掉它
当有人广播一笔击败诚实 sweep 的花费时,签名就变成了损失,而该花费的每一个要素都已经是公开的。存款 outpoint 及其 scriptPubKey 在链上,每个订单的公钥由 (address(this), orderId) 派生。因此攻击者从任何 Bitcoin 节点读取已确认的 UTXO,请求 NEAR MPC 对一个远低于真实值的 hashToSign(payload) 签名,组装 P2PKH scriptSig(<sig> <pubkey>),然后广播交易。
攻击者的交易并不是在竞争下一个区块的位置,它取代了诚实的、向托管库入账的 sweep——两者花费同一个 outpoint,所以只有一个能确认。每个输入都带有 nSequence = 0xFFFFFFFD,这使交易选择加入 Replace-By-Fee(BIP-125↗),而攻击者的版本将几乎整个存款作为费用提供给矿工。任何正常费用的诚实替换都无法在不花费超过存款价值的情况下超越它。
教会签名者数数
修复方案(commit 4beb1a4)直击签名层的根因,而不是在 sweep() 上硬加一个权限检查。我们提供了两个选项:一个最小方案(将 sweep() 限制为授权调用者)和一个更强方案(迁移到 SegWit,让 Bitcoin 强制执行金额)。Relay 选择了更强的路径,并保持了 sweep() 的无需许可性,这意味着 Bitcoin 的共识层现在强制执行了过去由脆弱的链下信任假设糟糕地执行的事情。
buildPreImageForInput 函数被重写为真正的 BIP-143 P2WPKH sighash,这一次输入金额在承诺之中:
function buildPreImageForInput(
BitcoinTransactionData memory txData,
uint256 whichInput
) internal pure returns (bytes memory) {
BitcoinTransactionDataInput memory input = txData.inputs[whichInput];
bytes memory outpoint = bytes.concat(input.txid, input.index);
bytes memory scriptCode = _buildScriptCode(input.script);
bytes memory firstHalf = bytes.concat(
Utils.encodeUint32LE(1),
_hashPrevouts(txData.inputs),
_hashSequence(txData.inputs),
outpoint,
scriptCode
);
return bytes.concat(
firstHalf,
input.value, // 被签名输入的值
Utils.encodeUint32LE(0xFFFFFFFD),
_hashOutputs(txData.outputs),
Utils.encodeUint32LE(0),
Utils.encodeUint32LE(0x01)
);
}
再次运行双交易实验,过去产生一个相同摘要的地方,BIP-143 现在产生两个。如果合约签署了基于声明的 10,000 构建的哈希,节点会对照从自身 UTXO 集合读取的真实 1,000,000 构建的哈希进行验证:
合约签名(value = 10,000):
0x2b1fbf13703033f154e96679e0a09549276053fea8412bfc5b5a5c123fc9c0d8
节点验证(value = 1,000,000):
0x67912bcf4c83c286e2757bc39b9297de47602b467754c13043c577e591e2be26
谎言不再能产生可花费的交易。
参与与披露
- 审计: Zellic 对 Relay Protocol 的 Settlement Protocol 合约的审查。
- 发现: 无需许可的 sweep 使用未经验证的 UTXO 值,将存款人资金作为矿工费烧毁 - 严重(可能性:高,影响:严重)。
| 日期 | 事件 |
|---|---|
| 2026年3月10日 | 启动并开始主要审查期 |
| 2026年3月10日–17日 | 发现该问题并向 Relay 报告 |
| 2026年3月17日 | 主要审查期结束 |
| 审查后 | 在 commit 4beb1a4 中修复(legacy P2PKH → P2WPKH + BIP-143) |
| 本文 | 协调公开披露 |
结论
无需许可的 sweep 和 legacy P2PKH sighash 各自都站得住脚,而破裂的信任边界不属于任何一方。它只在三个选择组合在一起时才出现:在开放调用者背后信任 calldata、对输入金额保持沉默的 sighash、以及一个对交给它的任何 32 字节哈希都签名的通用 ECDSA 预言机。
这个总体形态值得牢记。任何在一条链上构建哈希并交给另一条链上的签名者的东西,都有两个值得审计的接缝:签名承诺了什么,以及合约验证了什么。对于这个 bug,两者都很短:
LEGACY 签名
────────────────
承诺: outpoint (txid, index)
scriptCode(prevout 脚本)
outputs(金额 + 脚本)
locktime、sequence、hashType
省略: 输入金额
SWEEP 合约
──────────────────
验证: feeRate ≤ maxFeeRate
utxo.value ≥ fees
sweepAmount ≥ dust
忽略: utxo.value 与链的对照
调用者身份
任何一侧都没有承诺输入金额,也没有任何一侧对照链检查它。一切又回到缺失的八字节字段:Bitcoin 和 Relay 从未就该交易花费了什么达成一致,而差额留给了矿工作为费用。
关于我们
Zellic 专注于保护新兴技术。我们的安全研究人员在最有价值的目标中发现了漏洞,从财富 500 强到 DeFi 巨头。
开发者、创始人和投资者信任我们的安全评估,以快速、自信且没有严重漏洞地交付产品。凭借我们在真实世界进攻性安全研究方面的背景,我们能发现别人遗漏的东西。
联系我们↗ 以获得比同行更好的审计。真正的审计,不是橡皮图章。
- 原文链接: zellic.io/blog/signing-b...
- 鸿途知科网 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
版权声明
本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。
鸿途知科网
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。