From fda804e3f5d384da440a4464754d58242786f7ad Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 17:19:27 +0100 Subject: [PATCH] fix: wipe credentials in the connect path and keep release builds out of dumps and debuggers - Credential callbacks (SecretFn) now return Zeroizing, as do the session-only/pending caches they read and the RDP client's password, so Nexus's own copies are wiped; a plain String copy is made only where a library API needs one (SSH password auth, IRC, VNC, CredSSP). - Release builds disable core dumps (RLIMIT_CORE 0) and, on Linux, mark the process non-dumpable (PR_SET_DUMPABLE 0), which also blocks ptrace attach and /proc//mem reads by other processes of the same user. Debug builds are unchanged so they stay debuggable. Verified on the release binary: core limit 0, /proc//{environ,mem} root-owned and unreadable, local shells (including cwd capture) still work. Closes the remaining parts of SEC-20261012-007 that the app controls; egui's internal text buffers remain outside its reach. --- rust/Cargo.lock | 1 + rust/app/Cargo.toml | 3 +++ rust/app/src/main.rs | 18 ++++++++++++++++++ rust/core/src/clients/rdp.rs | 6 +++--- rust/core/src/clients/ssh.rs | 9 +++++---- rust/core/src/sessions/credentials.rs | 12 ++++++------ rust/core/src/sessions/mod.rs | 4 ++-- rust/core/src/sessions/protocols.rs | 7 +++++-- 8 files changed, 43 insertions(+), 17 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index af571ef..1a43d64 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -3452,6 +3452,7 @@ dependencies = [ "egui", "egui-phosphor", "image", + "libc", "nexus-core", "parking_lot", "regex", diff --git a/rust/app/Cargo.toml b/rust/app/Cargo.toml index cd99e14..5f9d918 100644 --- a/rust/app/Cargo.toml +++ b/rust/app/Cargo.toml @@ -28,3 +28,6 @@ arboard = "3.6.1" [build-dependencies] winresource = "0.1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs index 00cc0ce..4733e6a 100644 --- a/rust/app/src/main.rs +++ b/rust/app/src/main.rs @@ -33,6 +33,7 @@ use nexus_core::service::Service; use std::sync::Arc; fn main() -> eframe::Result { + harden_process(); // A panic on a worker thread (a session's reader, a transfer) ends only that thread; say so in // the UI rather than letting the session just stop. let default_hook = std::panic::take_hook(); @@ -161,3 +162,20 @@ fn init_logging(data_dir: &std::path::Path) { .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,nexus_terminal=info,nexus_core=info")); let _ = tracing_subscriber::registry().with(filter).with(file).with(stderr).try_init(); } + +/// Release builds keep secrets out of core dumps and away from same-user debuggers: no core +/// files, and on Linux the process is non-dumpable (also blocks ptrace attach by other processes +/// of the same user). Debug builds skip this so they can still be debugged. +fn harden_process() { + if cfg!(debug_assertions) { + return; + } + #[cfg(unix)] + // SAFETY: plain syscalls on this process with valid arguments; failure is harmless. + unsafe { + let none = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; + libc::setrlimit(libc::RLIMIT_CORE, &none); + #[cfg(target_os = "linux")] + libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0); + } +} diff --git a/rust/core/src/clients/rdp.rs b/rust/core/src/clients/rdp.rs index d56aaff..dcef335 100644 --- a/rust/core/src/clients/rdp.rs +++ b/rust/core/src/clients/rdp.rs @@ -37,7 +37,7 @@ pub struct Config { pub host: String, pub username: String, pub domain: String, - pub password: String, + pub password: zeroize::Zeroizing, pub width: u16, pub height: u16, /// Also allow plain TLS (graphical logon) for servers without NLA, such as xrdp. @@ -121,7 +121,7 @@ impl Client { fn connector_config(&self) -> connector::Config { let c = &self.cfg; connector::Config { - credentials: Credentials::UsernamePassword { username: c.username.clone(), password: c.password.clone() }, + credentials: Credentials::UsernamePassword { username: c.username.clone(), password: c.password.to_string() }, domain: (!c.domain.is_empty()).then(|| c.domain.clone()), enable_tls: c.allow_tls_only, enable_credssp: true, @@ -458,7 +458,7 @@ mod tests { host: host.clone(), username: v[2].into(), domain: String::new(), - password: v[3].into(), + password: zeroize::Zeroizing::new(v[3].into()), width: 1024, height: 768, allow_tls_only: true, diff --git a/rust/core/src/clients/ssh.rs b/rust/core/src/clients/ssh.rs index e24d105..31ed87a 100644 --- a/rust/core/src/clients/ssh.rs +++ b/rust/core/src/clients/ssh.rs @@ -34,7 +34,8 @@ pub struct HostKeyInfo { pub type HostKeyCheck = Arc std::result::Result<(), String> + Send + Sync>; /// Blocking credential source (may prompt the user). -pub type SecretFn = Arc Result + Send + Sync>; +/// The value is wiped from memory when dropped; libraries that need a plain `String` get a copy. +pub type SecretFn = Arc Result> + Send + Sync>; pub fn host_key_info(host: &str, port: u16, key: &PublicKey) -> HostKeyInfo { HostKeyInfo { @@ -291,7 +292,7 @@ async fn authenticate(h: &mut Handle, a: Auth<'_>) -> Result<()> { "password" => { if let Some(get) = a.password.clone() { let pw = blocking(move || get()).await?; - if h.authenticate_password(a.user, pw).await?.success() { + if h.authenticate_password(a.user, pw.as_str()).await?.success() { return Ok(()); } } @@ -310,7 +311,7 @@ async fn authenticate(h: &mut Handle, a: Auth<'_>) -> Result<()> { answers.push(totp_code(&a.totp_secret).map_err(|e| format!("failed to generate TOTP code: {e}"))?); } else { let get = get.clone(); - answers.push(blocking(move || get()).await?); + answers.push(blocking(move || get()).await?.to_string()); } } resp = h.authenticate_keyboard_interactive_respond(answers).await?; @@ -848,7 +849,7 @@ mod tests { // Encrypted: unreadable without the passphrase, readable with it. let p = path.to_string_lossy().into_owned(); assert!(load_private_key(&p, None).is_err()); - let pp: SecretFn = Arc::new(|| Ok("pass phrase".into())); + let pp: SecretFn = Arc::new(|| Ok(zeroize::Zeroizing::new("pass phrase".into()))); assert_eq!(load_private_key(&p, Some(&pp)).unwrap().algorithm().as_str(), "ssh-ed25519"); assert!(generate_key_pair(&dir, "id_test", "ed25519", 0, "").is_err(), "must not overwrite"); let plain = generate_key_pair(&dir, "id_plain", "ecdsa", 0, "").unwrap(); diff --git a/rust/core/src/sessions/credentials.rs b/rust/core/src/sessions/credentials.rs index a3cca15..748cd85 100644 --- a/rust/core/src/sessions/credentials.rs +++ b/rust/core/src/sessions/credentials.rs @@ -55,29 +55,29 @@ impl Manager { if mode == SecretMode::Saved { let mut st = state.lock(); if let Some(v) = st.pending.get(&key) { - return Ok(v.to_string()); + return Ok(v.clone()); } if !st.rejected.remove(&key) && let Some(v) = secrets.get(&key) { st.used.insert(key.clone()); store.add_audit("secret.access", &pid, "", &format!("Accessed {kind} credential")); - return Ok(v); + return Ok(Zeroizing::new(v)); } } if mode == SecretMode::SessionOnly - && let Some(v) = state.lock().session_only.get(&key).map(|v| v.to_string()) + && let Some(v) = state.lock().session_only.get(&key).cloned() { return Ok(v); } - let v = prompt(&format!("Enter {kind}:"))?; + let v = Zeroizing::new(prompt(&format!("Enter {kind}:"))?); let mut st = state.lock(); match mode { SecretMode::Saved => { - st.pending.insert(key.clone(), Zeroizing::new(v.clone())); + st.pending.insert(key.clone(), v.clone()); } SecretMode::SessionOnly => { - st.session_only.insert(key.clone(), Zeroizing::new(v.clone())); + st.session_only.insert(key.clone(), v.clone()); } SecretMode::AskEveryTime => {} } diff --git a/rust/core/src/sessions/mod.rs b/rust/core/src/sessions/mod.rs index 228558d..699e801 100644 --- a/rust/core/src/sessions/mod.rs +++ b/rust/core/src/sessions/mod.rs @@ -838,7 +838,7 @@ mod tests { // Two separate resolutions (as two connects of the same session make) prompt once. for _ in 0..2 { let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone())); - assert_eq!(f().unwrap(), "hunter2"); + assert_eq!(f().unwrap().as_str(), "hunter2"); } assert_eq!(asked.load(Ordering::SeqCst), 1); // Ask-every-time still prompts each time. @@ -848,7 +848,7 @@ mod tests { // An explicit reconnect carries the cache to the new session id. let new = m.reconnect("s1").unwrap(); let f = m.secret_fn(&new, &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone())); - assert_eq!(f().unwrap(), "hunter2"); + assert_eq!(f().unwrap().as_str(), "hunter2"); assert_eq!(asked.load(Ordering::SeqCst), 2); m.close_all(); let _ = std::fs::remove_dir_all(dir); diff --git a/rust/core/src/sessions/protocols.rs b/rust/core/src/sessions/protocols.rs index fc173a1..bd5dedb 100644 --- a/rust/core/src/sessions/protocols.rs +++ b/rust/core/src/sessions/protocols.rs @@ -125,11 +125,14 @@ impl Manager { let server_pass = if ic.server_password_required { self.secret_fn(id, &p.id, SecretKind::IrcServerPassword, ic.server_password_mode, prompt.clone())() .map_err(|e| format!("IRC server password: {e}"))? + .to_string() } else { String::new() }; let ns_pass = if ic.nick_serv_identify { - self.secret_fn(id, &p.id, SecretKind::IrcNickServPassword, ic.nick_serv_mode, prompt.clone())().map_err(|e| format!("NickServ password: {e}"))? + self.secret_fn(id, &p.id, SecretKind::IrcNickServPassword, ic.nick_serv_mode, prompt.clone())() + .map_err(|e| format!("NickServ password: {e}"))? + .to_string() } else { String::new() }; @@ -403,7 +406,7 @@ pub(super) fn connect_vnc_client( Ok(()) => Ok(c), // Ask for the password only when the server wants one. Err(e) if e.to_string().to_lowercase().contains("password") => { - cfg.password = Some(password()?); + cfg.password = Some(password()?.to_string()); let c = clients::vnc::Client::new(cfg); c.connect()?; Ok(c)