fix: wipe credentials in the connect path and keep release builds out of dumps and debuggers
- Credential callbacks (SecretFn) now return Zeroizing<String>, 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/<pid>/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/<pid>/{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.
This commit is contained in:
Generated
+1
@@ -3452,6 +3452,7 @@ dependencies = [
|
|||||||
"egui",
|
"egui",
|
||||||
"egui-phosphor",
|
"egui-phosphor",
|
||||||
"image",
|
"image",
|
||||||
|
"libc",
|
||||||
"nexus-core",
|
"nexus-core",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"regex",
|
"regex",
|
||||||
|
|||||||
@@ -28,3 +28,6 @@ arboard = "3.6.1"
|
|||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
winresource = "0.1"
|
winresource = "0.1"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use nexus_core::service::Service;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn main() -> eframe::Result {
|
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
|
// 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.
|
// the UI rather than letting the session just stop.
|
||||||
let default_hook = std::panic::take_hook();
|
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"));
|
.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();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ pub struct Config {
|
|||||||
pub host: String,
|
pub host: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub domain: String,
|
pub domain: String,
|
||||||
pub password: String,
|
pub password: zeroize::Zeroizing<String>,
|
||||||
pub width: u16,
|
pub width: u16,
|
||||||
pub height: u16,
|
pub height: u16,
|
||||||
/// Also allow plain TLS (graphical logon) for servers without NLA, such as xrdp.
|
/// 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 {
|
fn connector_config(&self) -> connector::Config {
|
||||||
let c = &self.cfg;
|
let c = &self.cfg;
|
||||||
connector::Config {
|
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()),
|
domain: (!c.domain.is_empty()).then(|| c.domain.clone()),
|
||||||
enable_tls: c.allow_tls_only,
|
enable_tls: c.allow_tls_only,
|
||||||
enable_credssp: true,
|
enable_credssp: true,
|
||||||
@@ -458,7 +458,7 @@ mod tests {
|
|||||||
host: host.clone(),
|
host: host.clone(),
|
||||||
username: v[2].into(),
|
username: v[2].into(),
|
||||||
domain: String::new(),
|
domain: String::new(),
|
||||||
password: v[3].into(),
|
password: zeroize::Zeroizing::new(v[3].into()),
|
||||||
width: 1024,
|
width: 1024,
|
||||||
height: 768,
|
height: 768,
|
||||||
allow_tls_only: true,
|
allow_tls_only: true,
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ pub struct HostKeyInfo {
|
|||||||
|
|
||||||
pub type HostKeyCheck = Arc<dyn Fn(&HostKeyInfo) -> std::result::Result<(), String> + Send + Sync>;
|
pub type HostKeyCheck = Arc<dyn Fn(&HostKeyInfo) -> std::result::Result<(), String> + Send + Sync>;
|
||||||
/// Blocking credential source (may prompt the user).
|
/// Blocking credential source (may prompt the user).
|
||||||
pub type SecretFn = Arc<dyn Fn() -> Result<String> + Send + Sync>;
|
/// The value is wiped from memory when dropped; libraries that need a plain `String` get a copy.
|
||||||
|
pub type SecretFn = Arc<dyn Fn() -> Result<zeroize::Zeroizing<String>> + Send + Sync>;
|
||||||
|
|
||||||
pub fn host_key_info(host: &str, port: u16, key: &PublicKey) -> HostKeyInfo {
|
pub fn host_key_info(host: &str, port: u16, key: &PublicKey) -> HostKeyInfo {
|
||||||
HostKeyInfo {
|
HostKeyInfo {
|
||||||
@@ -291,7 +292,7 @@ async fn authenticate(h: &mut Handle<Handler>, a: Auth<'_>) -> Result<()> {
|
|||||||
"password" => {
|
"password" => {
|
||||||
if let Some(get) = a.password.clone() {
|
if let Some(get) = a.password.clone() {
|
||||||
let pw = blocking(move || get()).await?;
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,7 +311,7 @@ async fn authenticate(h: &mut Handle<Handler>, a: Auth<'_>) -> Result<()> {
|
|||||||
answers.push(totp_code(&a.totp_secret).map_err(|e| format!("failed to generate TOTP code: {e}"))?);
|
answers.push(totp_code(&a.totp_secret).map_err(|e| format!("failed to generate TOTP code: {e}"))?);
|
||||||
} else {
|
} else {
|
||||||
let get = get.clone();
|
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?;
|
resp = h.authenticate_keyboard_interactive_respond(answers).await?;
|
||||||
@@ -848,7 +849,7 @@ mod tests {
|
|||||||
// Encrypted: unreadable without the passphrase, readable with it.
|
// Encrypted: unreadable without the passphrase, readable with it.
|
||||||
let p = path.to_string_lossy().into_owned();
|
let p = path.to_string_lossy().into_owned();
|
||||||
assert!(load_private_key(&p, None).is_err());
|
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_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");
|
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();
|
let plain = generate_key_pair(&dir, "id_plain", "ecdsa", 0, "").unwrap();
|
||||||
|
|||||||
@@ -55,29 +55,29 @@ impl Manager {
|
|||||||
if mode == SecretMode::Saved {
|
if mode == SecretMode::Saved {
|
||||||
let mut st = state.lock();
|
let mut st = state.lock();
|
||||||
if let Some(v) = st.pending.get(&key) {
|
if let Some(v) = st.pending.get(&key) {
|
||||||
return Ok(v.to_string());
|
return Ok(v.clone());
|
||||||
}
|
}
|
||||||
if !st.rejected.remove(&key)
|
if !st.rejected.remove(&key)
|
||||||
&& let Some(v) = secrets.get(&key)
|
&& let Some(v) = secrets.get(&key)
|
||||||
{
|
{
|
||||||
st.used.insert(key.clone());
|
st.used.insert(key.clone());
|
||||||
store.add_audit("secret.access", &pid, "", &format!("Accessed {kind} credential"));
|
store.add_audit("secret.access", &pid, "", &format!("Accessed {kind} credential"));
|
||||||
return Ok(v);
|
return Ok(Zeroizing::new(v));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if mode == SecretMode::SessionOnly
|
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);
|
return Ok(v);
|
||||||
}
|
}
|
||||||
let v = prompt(&format!("Enter {kind}:"))?;
|
let v = Zeroizing::new(prompt(&format!("Enter {kind}:"))?);
|
||||||
let mut st = state.lock();
|
let mut st = state.lock();
|
||||||
match mode {
|
match mode {
|
||||||
SecretMode::Saved => {
|
SecretMode::Saved => {
|
||||||
st.pending.insert(key.clone(), Zeroizing::new(v.clone()));
|
st.pending.insert(key.clone(), v.clone());
|
||||||
}
|
}
|
||||||
SecretMode::SessionOnly => {
|
SecretMode::SessionOnly => {
|
||||||
st.session_only.insert(key.clone(), Zeroizing::new(v.clone()));
|
st.session_only.insert(key.clone(), v.clone());
|
||||||
}
|
}
|
||||||
SecretMode::AskEveryTime => {}
|
SecretMode::AskEveryTime => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -838,7 +838,7 @@ mod tests {
|
|||||||
// Two separate resolutions (as two connects of the same session make) prompt once.
|
// Two separate resolutions (as two connects of the same session make) prompt once.
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone()));
|
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);
|
assert_eq!(asked.load(Ordering::SeqCst), 1);
|
||||||
// Ask-every-time still prompts each time.
|
// Ask-every-time still prompts each time.
|
||||||
@@ -848,7 +848,7 @@ mod tests {
|
|||||||
// An explicit reconnect carries the cache to the new session id.
|
// An explicit reconnect carries the cache to the new session id.
|
||||||
let new = m.reconnect("s1").unwrap();
|
let new = m.reconnect("s1").unwrap();
|
||||||
let f = m.secret_fn(&new, &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone()));
|
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);
|
assert_eq!(asked.load(Ordering::SeqCst), 2);
|
||||||
m.close_all();
|
m.close_all();
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
|||||||
@@ -125,11 +125,14 @@ impl Manager {
|
|||||||
let server_pass = if ic.server_password_required {
|
let server_pass = if ic.server_password_required {
|
||||||
self.secret_fn(id, &p.id, SecretKind::IrcServerPassword, ic.server_password_mode, prompt.clone())()
|
self.secret_fn(id, &p.id, SecretKind::IrcServerPassword, ic.server_password_mode, prompt.clone())()
|
||||||
.map_err(|e| format!("IRC server password: {e}"))?
|
.map_err(|e| format!("IRC server password: {e}"))?
|
||||||
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let ns_pass = if ic.nick_serv_identify {
|
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 {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -403,7 +406,7 @@ pub(super) fn connect_vnc_client(
|
|||||||
Ok(()) => Ok(c),
|
Ok(()) => Ok(c),
|
||||||
// Ask for the password only when the server wants one.
|
// Ask for the password only when the server wants one.
|
||||||
Err(e) if e.to_string().to_lowercase().contains("password") => {
|
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);
|
let c = clients::vnc::Client::new(cfg);
|
||||||
c.connect()?;
|
c.connect()?;
|
||||||
Ok(c)
|
Ok(c)
|
||||||
|
|||||||
Reference in New Issue
Block a user