Skip to content

Commit 8396186

Browse files
🛡️ Sentinel: [CRITICAL] Fix arbitrary command execution via profile configuration
Co-authored-by: bitcoiner-dev <75873427+bitcoiner-dev@users.noreply.github.com>
1 parent 9fc14fb commit 8396186

25 files changed

Lines changed: 747 additions & 254 deletions

‎.jules/sentinel.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@
22
**Vulnerability:** The CLI application creates sensitive configuration files and directories (like wallets and snapshot data) using standard `fs::create_dir_all` and `fs::write` in Rust. These standard functions create files/directories using the system's default umask, which typically allows other users on the same Unix-like system to read the sensitive files.
33
**Learning:** This could lead to a local privilege escalation or exposure of sensitive user data if the user runs the CLI on a shared machine. Relying on default system configurations for sensitive files is unsafe.
44
**Prevention:** Always use `std::os::unix::fs::DirBuilderExt` and `std::os::unix::fs::OpenOptionsExt` to explicitly set file permissions (e.g., `0o700` for directories and `0o600` for files) when creating sensitive data on disk.
5+
6+
## 2024-05-23 - Arbitrary Command Execution via Configuration Profiles
7+
**Vulnerability:** The application executes `bitcoin-cli` using a user-provided configuration path (`profile.bitcoin_cli`) directly in `std::process::Command::new`, without verifying the actual executable name.
8+
**Learning:** This allows an attacker who gains write access to the `.zinc-cli/profiles/*.json` configuration files to set `bitcoin_cli` to any arbitrary malicious script or binary on the system (e.g., `rm -rf /` or `/bin/sh`). When the app subsequently attempts to use `bitcoin-cli`, it executes the malicious payload instead.
9+
**Prevention:** Always validate and restrict binary names executed via external commands, even when the path is loaded from a "trusted" local configuration file. Centralize command execution logic and enforce a strict whitelist of allowed binary filenames (e.g., exactly `bitcoin-cli` or `bitcoin-cli.exe`).

‎src/cli.rs‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ pub enum PolicyMode {
99
Strict,
1010
}
1111

12-
13-
1412
#[derive(Parser, Debug, Clone)]
1513
#[command(
1614
name = "zinc-cli",
@@ -21,8 +19,11 @@ pub struct Cli {
2119
#[command(subcommand)]
2220
pub command: Command,
2321

24-
25-
#[arg(long, global = true, help = "Agent mode (machine-readable JSON output)")]
22+
#[arg(
23+
long,
24+
global = true,
25+
help = "Agent mode (machine-readable JSON output)"
26+
)]
2627
pub agent: bool,
2728

2829
#[arg(long, global = true, help = "Suppress all non-error output")]
@@ -118,11 +119,7 @@ pub struct Cli {
118119
)]
119120
pub network_retries: u32,
120121

121-
#[arg(
122-
long,
123-
global = true,
124-
help = "Disable inscription thumbnails"
125-
)]
122+
#[arg(long, global = true, help = "Disable inscription thumbnails")]
126123
pub no_thumb: bool,
127124

128125
#[arg(
@@ -195,7 +192,7 @@ pub struct SetupArgs {
195192
pub default_ord_url: Option<String>,
196193
#[arg(long)]
197194
pub quiet_default: Option<bool>,
198-
195+
199196
#[arg(long)]
200197
pub restore_mnemonic: Option<String>,
201198
#[arg(long)]

‎src/commands/account.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use crate::cli::{AccountAction, AccountArgs, Cli};
22
use crate::error::AppError;
3+
use crate::output::CommandOutput;
34
use crate::wallet_service::{now_unix, AccountState};
45
use crate::{load_wallet_session, profile_path, read_profile, write_profile};
5-
use crate::output::CommandOutput;
66
use zinc_core::Account;
77

88
pub async fn run(cli: &Cli, args: &AccountArgs) -> Result<CommandOutput, AppError> {

‎src/commands/address.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use crate::cli::{AddressArgs, AddressKind, Cli};
22
use crate::error::AppError;
3+
use crate::output::CommandOutput;
34
use crate::wallet_service::map_wallet_error;
45
use crate::{load_wallet_session, persist_wallet_session};
5-
use crate::output::CommandOutput;
66

77
pub async fn run(cli: &Cli, args: &AddressArgs) -> Result<CommandOutput, AppError> {
88
let mut session = load_wallet_session(cli)?;

‎src/commands/config.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ pub async fn run(cli: &Cli, args: &ConfigArgs) -> Result<CommandOutput, AppError
3535
save_persisted_config(&config)?;
3636
Ok(CommandOutput::ConfigSet {
3737
key: field.as_str().to_string(),
38-
value: applied.as_str().unwrap_or("").to_string().replace("\"", "").to_string(),
38+
value: applied
39+
.as_str()
40+
.unwrap_or("")
41+
.to_string()
42+
.replace("\"", "")
43+
.to_string(),
3944
saved: true,
4045
})
4146
}

‎src/commands/doctor.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::cli::Cli;
22
use crate::error::AppError;
3-
use crate::{profile_path, read_profile};
43
use crate::output::CommandOutput;
4+
use crate::{profile_path, read_profile};
55
use zinc_core::{OrdClient, ZincWallet};
66

77
pub async fn run(cli: &Cli) -> Result<CommandOutput, AppError> {

‎src/commands/inscription.rs‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,7 @@ pub async fn run(cli: &Cli, _args: &InscriptionArgs) -> Result<CommandOutput, Ap
1212
let display_items = if !cli.thumb_enabled() {
1313
None
1414
} else {
15-
Some(get_inscription_display_items(
16-
&session.profile.ord_url,
17-
&sorted_inscriptions,
18-
)
19-
.await)
15+
Some(get_inscription_display_items(&session.profile.ord_url, &sorted_inscriptions).await)
2016
};
2117

2218
Ok(CommandOutput::InscriptionList {

‎src/commands/lock.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use crate::cli::{Cli, LockAction, LockArgs};
22
use crate::error::AppError;
3+
use crate::output::CommandOutput;
34
use crate::wallet_service::now_unix;
45
use crate::{confirm, profile_lock_path, read_lock_metadata};
5-
use crate::output::CommandOutput;
66
use std::fs;
77

88
pub async fn run(cli: &Cli, args: &LockArgs) -> Result<CommandOutput, AppError> {

‎src/commands/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ pub mod setup;
1212
pub mod snapshot;
1313
pub mod sync;
1414
pub mod tx;
15+
pub mod version;
1516
pub mod wait;
1617
pub mod wallet;
17-
pub mod version;

‎src/commands/offer.rs‎

Lines changed: 135 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ use crate::commands::psbt::{analyze_psbt_with_policy, enforce_policy_mode};
44
use crate::config::NetworkArg;
55
use crate::error::AppError;
66
use crate::network_retry::with_network_retry;
7-
use crate::presenter::thumbnail::{render_non_image_badge, print_thumbnail};
7+
use crate::output::CommandOutput;
8+
use crate::presenter::thumbnail::{print_thumbnail, render_non_image_badge};
89
use crate::utils::{maybe_write_text, resolve_psbt_source};
910
use crate::wallet_service::map_wallet_error;
1011
use crate::{load_wallet_session, persist_wallet_session};
11-
use crate::output::CommandOutput;
1212
use serde_json::{json, Value};
1313
use std::io::Read;
1414
use std::time::{SystemTime, UNIX_EPOCH};
@@ -345,66 +345,132 @@ async fn finalize_offer_output(
345345
let hide_inscription_ids = cli.thumb_enabled();
346346

347347
match action {
348-
OfferAction::Create { inscription, .. } => {
349-
Ok(CommandOutput::OfferCreate {
350-
inscription: inscription.clone(),
351-
ask_sats: response.get("ask_sats").and_then(Value::as_u64).unwrap_or(0),
352-
fee_rate_sat_vb: response.get("fee_rate_sat_vb").and_then(Value::as_u64).unwrap_or(0),
353-
seller_address: response.get("seller_address").and_then(Value::as_str).unwrap_or("").to_string(),
354-
seller_outpoint: response.get("seller_outpoint").and_then(Value::as_str).unwrap_or("").to_string(),
355-
seller_pubkey_hex: response.get("offer").and_then(|o| o.get("seller_pubkey_hex")).and_then(Value::as_str).unwrap_or("").to_string(),
356-
expires_at_unix: response.get("offer").and_then(|o| o.get("expires_at_unix")).and_then(Value::as_i64).unwrap_or(0),
357-
thumbnail_lines,
358-
hide_inscription_ids,
359-
raw_response: response,
360-
})
361-
}
362-
OfferAction::Publish { .. } => {
363-
Ok(CommandOutput::OfferPublish {
364-
event_id: response.get("event").and_then(|v| v.get("id")).and_then(Value::as_str).unwrap_or("").to_string(),
365-
accepted_relays: response.get("accepted_relays").and_then(Value::as_u64).unwrap_or(0),
366-
total_relays: response.get("total_relays").and_then(Value::as_u64).unwrap_or(0),
367-
publish_results: response.get("publish_results").and_then(Value::as_array).unwrap_or(&vec![]).clone(),
368-
raw_response: response,
369-
})
370-
}
371-
OfferAction::Discover { .. } => {
372-
Ok(CommandOutput::OfferDiscover {
373-
event_count: response.get("event_count").and_then(Value::as_u64).unwrap_or(0),
374-
offer_count: response.get("offer_count").and_then(Value::as_u64).unwrap_or(0),
375-
offers: response.get("offers").and_then(Value::as_array).unwrap_or(&vec![]).clone(),
376-
thumbnail_lines,
377-
hide_inscription_ids,
378-
raw_response: response,
379-
})
380-
}
381-
OfferAction::SubmitOrd { .. } => {
382-
Ok(CommandOutput::OfferSubmitOrd {
383-
ord_url: response.get("ord_url").and_then(Value::as_str).unwrap_or("").to_string(),
384-
submitted: true,
385-
raw_response: response,
386-
})
387-
}
388-
OfferAction::ListOrd => {
389-
Ok(CommandOutput::OfferListOrd {
390-
ord_url: response.get("ord_url").and_then(Value::as_str).unwrap_or("").to_string(),
391-
count: response.get("count").and_then(Value::as_u64).unwrap_or(0),
392-
offers: response.get("offers").and_then(Value::as_array).unwrap_or(&vec![]).clone(),
393-
raw_response: response,
394-
})
395-
}
396-
OfferAction::Accept { .. } => {
397-
Ok(CommandOutput::OfferAccept {
398-
inscription: response.get("inscription_id").and_then(Value::as_str).unwrap_or("").to_string(),
399-
ask_sats: response.get("ask_sats").and_then(Value::as_u64).unwrap_or(0),
400-
txid: response.get("txid").and_then(Value::as_str).unwrap_or("-").to_string(),
401-
dry_run: response.get("dry_run").and_then(Value::as_bool).unwrap_or(false),
402-
inscription_risk: response.get("inscription_risk").and_then(Value::as_str).unwrap_or("").to_string(),
403-
thumbnail_lines,
404-
hide_inscription_ids,
405-
raw_response: response,
406-
})
407-
}
348+
OfferAction::Create { inscription, .. } => Ok(CommandOutput::OfferCreate {
349+
inscription: inscription.clone(),
350+
ask_sats: response
351+
.get("ask_sats")
352+
.and_then(Value::as_u64)
353+
.unwrap_or(0),
354+
fee_rate_sat_vb: response
355+
.get("fee_rate_sat_vb")
356+
.and_then(Value::as_u64)
357+
.unwrap_or(0),
358+
seller_address: response
359+
.get("seller_address")
360+
.and_then(Value::as_str)
361+
.unwrap_or("")
362+
.to_string(),
363+
seller_outpoint: response
364+
.get("seller_outpoint")
365+
.and_then(Value::as_str)
366+
.unwrap_or("")
367+
.to_string(),
368+
seller_pubkey_hex: response
369+
.get("offer")
370+
.and_then(|o| o.get("seller_pubkey_hex"))
371+
.and_then(Value::as_str)
372+
.unwrap_or("")
373+
.to_string(),
374+
expires_at_unix: response
375+
.get("offer")
376+
.and_then(|o| o.get("expires_at_unix"))
377+
.and_then(Value::as_i64)
378+
.unwrap_or(0),
379+
thumbnail_lines,
380+
hide_inscription_ids,
381+
raw_response: response,
382+
}),
383+
OfferAction::Publish { .. } => Ok(CommandOutput::OfferPublish {
384+
event_id: response
385+
.get("event")
386+
.and_then(|v| v.get("id"))
387+
.and_then(Value::as_str)
388+
.unwrap_or("")
389+
.to_string(),
390+
accepted_relays: response
391+
.get("accepted_relays")
392+
.and_then(Value::as_u64)
393+
.unwrap_or(0),
394+
total_relays: response
395+
.get("total_relays")
396+
.and_then(Value::as_u64)
397+
.unwrap_or(0),
398+
publish_results: response
399+
.get("publish_results")
400+
.and_then(Value::as_array)
401+
.unwrap_or(&vec![])
402+
.clone(),
403+
raw_response: response,
404+
}),
405+
OfferAction::Discover { .. } => Ok(CommandOutput::OfferDiscover {
406+
event_count: response
407+
.get("event_count")
408+
.and_then(Value::as_u64)
409+
.unwrap_or(0),
410+
offer_count: response
411+
.get("offer_count")
412+
.and_then(Value::as_u64)
413+
.unwrap_or(0),
414+
offers: response
415+
.get("offers")
416+
.and_then(Value::as_array)
417+
.unwrap_or(&vec![])
418+
.clone(),
419+
thumbnail_lines,
420+
hide_inscription_ids,
421+
raw_response: response,
422+
}),
423+
OfferAction::SubmitOrd { .. } => Ok(CommandOutput::OfferSubmitOrd {
424+
ord_url: response
425+
.get("ord_url")
426+
.and_then(Value::as_str)
427+
.unwrap_or("")
428+
.to_string(),
429+
submitted: true,
430+
raw_response: response,
431+
}),
432+
OfferAction::ListOrd => Ok(CommandOutput::OfferListOrd {
433+
ord_url: response
434+
.get("ord_url")
435+
.and_then(Value::as_str)
436+
.unwrap_or("")
437+
.to_string(),
438+
count: response.get("count").and_then(Value::as_u64).unwrap_or(0),
439+
offers: response
440+
.get("offers")
441+
.and_then(Value::as_array)
442+
.unwrap_or(&vec![])
443+
.clone(),
444+
raw_response: response,
445+
}),
446+
OfferAction::Accept { .. } => Ok(CommandOutput::OfferAccept {
447+
inscription: response
448+
.get("inscription_id")
449+
.and_then(Value::as_str)
450+
.unwrap_or("")
451+
.to_string(),
452+
ask_sats: response
453+
.get("ask_sats")
454+
.and_then(Value::as_u64)
455+
.unwrap_or(0),
456+
txid: response
457+
.get("txid")
458+
.and_then(Value::as_str)
459+
.unwrap_or("-")
460+
.to_string(),
461+
dry_run: response
462+
.get("dry_run")
463+
.and_then(Value::as_bool)
464+
.unwrap_or(false),
465+
inscription_risk: response
466+
.get("inscription_risk")
467+
.and_then(Value::as_str)
468+
.unwrap_or("")
469+
.to_string(),
470+
thumbnail_lines,
471+
hide_inscription_ids,
472+
raw_response: response,
473+
}),
408474
}
409475
}
410476

@@ -418,9 +484,12 @@ async fn maybe_offer_thumbnail_lines(
418484
}
419485

420486
let inscription_id = offer_thumbnail_inscription_id(action, response)?;
421-
let ord_url = resolve_ord_url(cli)
422-
.ok()
423-
.or_else(|| response.get("ord_url").and_then(Value::as_str).map(ToString::to_string))?;
487+
let ord_url = resolve_ord_url(cli).ok().or_else(|| {
488+
response
489+
.get("ord_url")
490+
.and_then(Value::as_str)
491+
.map(ToString::to_string)
492+
})?;
424493

425494
let client = OrdClient::new(ord_url);
426495
let details = client.get_inscription_details(&inscription_id).await.ok()?;
@@ -470,8 +539,6 @@ fn offer_thumbnail_inscription_id(action: &OfferAction, response: &Value) -> Opt
470539
}
471540
}
472541

473-
474-
475542
pub fn abbreviate(value: &str, prefix: usize, suffix: usize) -> String {
476543
if value.chars().count() <= prefix + suffix + 3 {
477544
return value.to_string();
@@ -614,9 +681,7 @@ fn map_offer_error<E: ToString>(err: E) -> AppError {
614681

615682
#[cfg(test)]
616683
mod tests {
617-
use super::{
618-
abbreviate, assert_offer_expectations, map_offer_error, resolve_offer_source,
619-
};
684+
use super::{abbreviate, assert_offer_expectations, map_offer_error, resolve_offer_source};
620685
use crate::error::AppError;
621686
use zinc_core::OfferEnvelopeV1;
622687

@@ -691,6 +756,4 @@ mod tests {
691756
let short = abbreviate(value, 6, 4);
692757
assert_eq!(short, "123456...cdef");
693758
}
694-
695-
696759
}

0 commit comments

Comments
 (0)