Skip to main content

tracexec_core/
elevate.rs

1//! Privilege elevation support for tracexec.
2//!
3//! When `--elevate` is used, tracexec captures the current user's credentials,
4//! creates a private abstract Unix domain socket, and spawns `sudo tracexec`.
5//! The elevated child requests the complete original environment over that
6//! socket. The unelevated parent verifies the child's Unix socket credentials
7//! before sending anything, then waits for the elevated child to exit. The
8//! elevated tracexec process may only consult an allowlisted subset for its own
9//! behavior, while the tracee is spawned with the complete original environment.
10
11use std::{
12  collections::HashSet,
13  ffi::{
14    OsStr,
15    OsString,
16  },
17  io::{
18    ErrorKind,
19    Read,
20    Write,
21  },
22  os::{
23    linux::net::SocketAddrExt,
24    unix::{
25      ffi::{
26        OsStrExt,
27        OsStringExt,
28      },
29      net::{
30        SocketAddr,
31        UnixListener,
32        UnixStream,
33      },
34      process::ExitStatusExt,
35    },
36  },
37  path::Path,
38  process::{
39    Child,
40    Command,
41    ExitStatus,
42  },
43  sync::LazyLock,
44  time::{
45    Duration,
46    Instant,
47  },
48};
49
50use color_eyre::eyre::bail;
51use nix::{
52  sys::socket::{
53    getsockopt,
54    sockopt,
55  },
56  unistd::Uid,
57};
58use rand::distr::{
59  Alphanumeric,
60  SampleString,
61};
62
63pub type EnvVars = Vec<(OsString, OsString)>;
64
65const ENV_REQUEST_MAGIC: &[u8] = b"tracexec-env-v1";
66const ENV_SOCKET_ACCEPT_TIMEOUT: Duration = Duration::from_secs(180);
67
68/// Environment variables elevated tracexec may consult after `--elevate`.
69///
70/// Keep this list limited to variables that tracexec itself reads. The complete
71/// original environment is transferred as data, but only these keys are exposed
72/// to elevated tracexec behavior. The tracee still receives the complete
73/// original environment at `execve(2)`.
74///
75/// `TRACEXEC_DATA` is passed via cmdline and thus not allowed here.
76/// Variables only for development, like `TRACEXEC_BPFCOV_OUTDIR`,
77/// are also not allowed.
78pub static RESTORED_ENV_ALLOWLIST: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
79  HashSet::from([
80    "NO_COLOR",
81    "RUST_LOG",
82    "TRACEXEC_LOG_LEVEL",
83    "TRACEXEC_NO_SLEEP",
84    "TRACEXEC_USE_FENTRY",
85    "TRACEXEC_USE_KPROBE",
86  ])
87});
88
89/// Saved credentials from before privilege elevation.
90#[derive(Debug, Clone)]
91pub struct PreElevationCreds {
92  pub username: String,
93  pub uid: u32,
94  pub gid: u32,
95}
96
97impl PreElevationCreds {
98  /// Capture the current process's real credentials.
99  pub fn capture() -> color_eyre::Result<Self> {
100    let uid = nix::unistd::getuid();
101    let gid = nix::unistd::getgid();
102    let user = crate::account::user_from_uid(uid)?
103      .ok_or_else(|| color_eyre::eyre::eyre!("Failed to look up current user (uid={uid})"))?;
104    Ok(Self {
105      username: user.name,
106      uid: uid.as_raw(),
107      gid: gid.as_raw(),
108    })
109  }
110}
111
112pub fn env_value<'a>(env: &'a [(OsString, OsString)], key: &str) -> Option<&'a OsStr> {
113  let key = OsStr::new(key);
114  env
115    .iter()
116    .rev()
117    .find_map(|(candidate, value)| (candidate == key).then_some(value.as_os_str()))
118}
119
120pub fn env_var_os(env: Option<&[(OsString, OsString)]>, key: &str) -> Option<OsString> {
121  match env {
122    Some(env) => env_value(env, key).map(OsStr::to_owned),
123    None => std::env::var_os(key),
124  }
125}
126
127pub fn env_var_string(env: Option<&[(OsString, OsString)]>, key: &str) -> Option<String> {
128  match env {
129    Some(env) => env_value(env, key).map(|value| value.to_string_lossy().into_owned()),
130    None => std::env::var(key).ok(),
131  }
132}
133
134pub fn filter_allowlisted_env_from(
135  vars: impl IntoIterator<Item = (OsString, OsString)>,
136) -> EnvVars {
137  vars
138    .into_iter()
139    .filter(|(key, _)| {
140      key
141        .to_str()
142        .is_some_and(|key| RESTORED_ENV_ALLOWLIST.contains(key))
143    })
144    .collect()
145}
146
147pub fn filter_allowlisted_env(env: &[(OsString, OsString)]) -> EnvVars {
148  filter_allowlisted_env_from(env.iter().cloned())
149}
150
151fn collect_original_env() -> EnvVars {
152  std::env::vars_os().collect()
153}
154
155/// Serialize environment variables into a byte buffer.
156///
157/// Uses the null-byte-separated `KEY=VALUE\0` format.
158fn serialize_env(env: &[(OsString, OsString)]) -> Vec<u8> {
159  let mut buf = Vec::new();
160  for (key, value) in env {
161    buf.extend_from_slice(key.as_bytes());
162    buf.push(b'=');
163    buf.extend_from_slice(value.as_bytes());
164    buf.push(0);
165  }
166  buf
167}
168
169/// Deserialize environment variables from null-byte-separated `KEY=VALUE\0` format.
170fn deserialize_env(data: &[u8]) -> EnvVars {
171  let mut result = Vec::new();
172  for entry in data.split(|&b| b == 0) {
173    if entry.is_empty() {
174      continue;
175    }
176    if let Some(eq_pos) = entry.iter().position(|&b| b == b'=') {
177      let key = OsString::from_vec(entry[..eq_pos].to_vec());
178      let value = OsString::from_vec(entry[eq_pos + 1..].to_vec());
179      result.push((key, value));
180    }
181  }
182  result
183}
184
185fn abstract_socket_addr(socket_name: &str) -> color_eyre::Result<SocketAddr> {
186  Ok(SocketAddr::from_abstract_name(socket_name.as_bytes())?)
187}
188
189fn random_socket_name() -> String {
190  let suffix = Alphanumeric.sample_string(&mut rand::rng(), 32);
191  format!("tracexec-env-{}-{suffix}", std::process::id())
192}
193
194fn bind_env_socket(socket_name: &str) -> color_eyre::Result<UnixListener> {
195  Ok(UnixListener::bind_addr(&abstract_socket_addr(
196    socket_name,
197  )?)?)
198}
199
200fn exit_code_from_status(status: ExitStatus) -> i32 {
201  status
202    .code()
203    .or_else(|| status.signal().map(|signal| 128 + signal))
204    .unwrap_or(1)
205}
206
207fn handle_env_request(
208  mut stream: UnixStream,
209  env: &[(OsString, OsString)],
210  required_uid: u32,
211) -> color_eyre::Result<()> {
212  let creds = getsockopt(&stream, sockopt::PeerCredentials)?;
213  if creds.uid() != required_uid {
214    bail!(
215      "Refusing to send environment variables to uid {} (expected uid {required_uid})",
216      creds.uid()
217    );
218  }
219
220  let mut request = vec![0; ENV_REQUEST_MAGIC.len()];
221  stream.read_exact(&mut request)?;
222  if request != ENV_REQUEST_MAGIC {
223    bail!("Invalid request");
224  }
225
226  let payload = serialize_env(env);
227  stream.write_all(&(payload.len() as u32).to_be_bytes())?;
228  stream.write_all(&payload)?;
229  stream.flush()?;
230  Ok(())
231}
232
233fn serve_env_to_child(
234  listener: &UnixListener,
235  child: &mut Child,
236  env: &[(OsString, OsString)],
237  timeout: Duration,
238) -> color_eyre::Result<Option<ExitStatus>> {
239  listener.set_nonblocking(true)?;
240  let deadline = Instant::now() + timeout;
241
242  loop {
243    if let Some(status) = child.try_wait()? {
244      return Ok(Some(status));
245    }
246
247    match listener.accept() {
248      Ok((stream, _)) => {
249        handle_env_request(stream, env, 0)?;
250        return Ok(None);
251      }
252      Err(e) if e.kind() == ErrorKind::WouldBlock => {
253        if Instant::now() >= deadline {
254          bail!("Timed out waiting for the elevated subprocess to request environment variables");
255        }
256        std::thread::sleep(Duration::from_millis(50));
257      }
258      Err(e) => return Err(e.into()),
259    }
260  }
261}
262
263/// Request the complete original environment from the unelevated parent.
264pub fn request_env_from_parent(socket_name: &str) -> color_eyre::Result<EnvVars> {
265  let mut stream = UnixStream::connect_addr(&abstract_socket_addr(socket_name)?)?;
266  stream.write_all(ENV_REQUEST_MAGIC)?;
267  stream.flush()?;
268
269  let mut len = [0; 4];
270  stream.read_exact(&mut len)?;
271  let len = u32::from_be_bytes(len) as usize;
272  let mut payload = vec![0; len];
273  stream.read_exact(&mut payload)?;
274  Ok(deserialize_env(&payload))
275}
276
277/// Construct the command line for re-execution with elevation.
278///
279/// Transforms `tracexec --elevate [opts] <subcommand> [args] -- <cmd...>`
280/// into
281///
282/// ```bash
283/// sudo tracexec --user <username> --restore-env-socket <socket-name> \
284///   --elevated-config-dir <path> --elevated-data-dir <path> \
285///   --elevated-data-local-dir <path> [opts] <subcommand> [args] \
286///   -- <cmd...>
287/// ```
288fn build_elevated_args(
289  creds: &PreElevationCreds,
290  socket_name: &str,
291  config_dir: Option<&Path>,
292  data_dir: Option<&Path>,
293  data_local_dir: Option<&Path>,
294) -> Vec<OsString> {
295  build_elevated_args_from(
296    std::env::args_os(),
297    creds,
298    socket_name,
299    config_dir,
300    data_dir,
301    data_local_dir,
302  )
303}
304
305/// Testable inner implementation of [`build_elevated_args`].
306///
307/// `args` should include argv\[0\] (the program name), which is skipped.
308fn build_elevated_args_from(
309  args: impl IntoIterator<Item = impl Into<OsString>>,
310  creds: &PreElevationCreds,
311  socket_name: &str,
312  config_dir: Option<&Path>,
313  data_dir: Option<&Path>,
314  data_local_dir: Option<&Path>,
315) -> Vec<OsString> {
316  let mut result = Vec::new();
317  let mut replaced = false;
318  let mut past_delimiter = false;
319
320  for arg in args.into_iter().skip(1) {
321    let arg: OsString = arg.into();
322    if past_delimiter {
323      // After "--", pass everything through verbatim.
324      result.push(arg);
325      continue;
326    }
327    if arg == "--" {
328      past_delimiter = true;
329      result.push(arg);
330      continue;
331    }
332    if !replaced && arg == "--elevate" {
333      // Replace the first --elevate with --user/--restore-env-socket and optional dir overrides.
334      replaced = true;
335      result.push(OsString::from("--user"));
336      result.push(OsString::from(&creds.username));
337      result.push(OsString::from("--restore-env-socket"));
338      result.push(OsString::from(socket_name));
339      if let Some(dir) = config_dir {
340        result.push(OsString::from("--elevated-config-dir"));
341        result.push(dir.as_os_str().to_owned());
342      }
343      if let Some(dir) = data_dir {
344        result.push(OsString::from("--elevated-data-dir"));
345        result.push(dir.as_os_str().to_owned());
346      }
347      if let Some(dir) = data_local_dir {
348        result.push(OsString::from("--elevated-data-local-dir"));
349        result.push(dir.as_os_str().to_owned());
350      }
351    } else {
352      result.push(arg);
353    }
354  }
355
356  result
357}
358
359/// Re-execute tracexec with elevated privileges via sudo.
360///
361/// This function does not return on success: the unelevated parent exits with
362/// the same status as the elevated child.
363pub fn elevate_and_reexec() -> color_eyre::Result<std::convert::Infallible> {
364  if Uid::effective().is_root() {
365    color_eyre::eyre::bail!("--elevate is not needed when already running as root");
366  }
367
368  let creds = PreElevationCreds::capture()?;
369  let socket_name = random_socket_name();
370  let listener = bind_env_socket(&socket_name)?;
371  let env = collect_original_env();
372  let exe = std::env::current_exe()?;
373
374  // Capture the current user's project directories so the elevated process
375  // can use them instead of root's directories.
376  let proj_dirs = crate::cli::config::project_directory();
377  let config_dir = proj_dirs.as_ref().map(|d| d.config_dir().to_path_buf());
378  let data_dir = proj_dirs.as_ref().map(|d| d.data_dir().to_path_buf());
379  let data_local_dir = proj_dirs.as_ref().map(|d| d.data_local_dir().to_path_buf());
380  let elevated_args = build_elevated_args(
381    &creds,
382    &socket_name,
383    config_dir.as_deref(),
384    data_dir.as_deref(),
385    data_local_dir.as_deref(),
386  );
387
388  tracing::debug!(
389    "Elevating: sudo {} {}",
390    exe.display(),
391    elevated_args
392      .iter()
393      .map(|a| a.to_string_lossy().to_string())
394      .collect::<Vec<_>>()
395      .join(" ")
396  );
397
398  let mut child = Command::new("sudo")
399    .arg(&exe)
400    .args(&elevated_args)
401    .spawn()?;
402  match serve_env_to_child(&listener, &mut child, &env, ENV_SOCKET_ACCEPT_TIMEOUT) {
403    Ok(Some(status)) => std::process::exit(exit_code_from_status(status)),
404    Ok(None) => {
405      drop(listener);
406      let status = child.wait()?;
407      std::process::exit(exit_code_from_status(status));
408    }
409    Err(e) => {
410      let _ = child.kill();
411      let _ = child.wait();
412      Err(e)
413    }
414  }
415}
416
417#[cfg(test)]
418mod tests {
419  use std::{
420    os::unix::ffi::OsStringExt,
421    thread,
422  };
423
424  use super::*;
425
426  fn test_creds() -> PreElevationCreds {
427    PreElevationCreds {
428      username: "testuser".to_string(),
429      uid: 1000,
430      gid: 1000,
431    }
432  }
433
434  #[test]
435  fn test_capture_creds() {
436    let creds = PreElevationCreds::capture().unwrap();
437    assert_eq!(creds.uid, nix::unistd::getuid().as_raw());
438    assert_eq!(creds.gid, nix::unistd::getgid().as_raw());
439    assert!(!creds.username.is_empty());
440  }
441
442  #[test]
443  fn test_allowlist_filters_unneeded_env_vars() {
444    let filtered = filter_allowlisted_env_from([
445      (OsString::from("TRACEXEC_NO_SLEEP"), OsString::from("1")),
446      (
447        OsString::from("TRACEXEC_TEST_MARKER"),
448        OsString::from("secret"),
449      ),
450      (OsString::from("PATH"), OsString::from("/usr/bin")),
451    ]);
452
453    assert_eq!(
454      filtered,
455      vec![(OsString::from("TRACEXEC_NO_SLEEP"), OsString::from("1"),),]
456    );
457  }
458
459  #[test]
460  fn test_serialize_deserialize_roundtrip() {
461    let original = vec![
462      (OsString::from("TRACEXEC_NO_SLEEP"), OsString::from("1")),
463      (OsString::from("PATH"), OsString::from("/usr/bin:/bin")),
464      (OsString::from("EMPTY"), OsString::from("")),
465      (OsString::from("MULTI_EQ"), OsString::from("a=b=c")),
466    ];
467
468    let data = serialize_env(&original);
469    let deserialized = deserialize_env(&data);
470    assert_eq!(deserialized, original);
471  }
472
473  #[test]
474  fn test_deserialize_env_ignores_malformed() {
475    // Entries without '=' are silently skipped
476    let data = b"GOOD=value\0BAD_NO_EQUAL\0ALSO_GOOD=\0";
477    let result = deserialize_env(data);
478    assert_eq!(
479      result,
480      vec![
481        (OsString::from("GOOD"), OsString::from("value")),
482        (OsString::from("ALSO_GOOD"), OsString::from("")),
483      ]
484    );
485  }
486
487  #[test]
488  fn test_deserialize_env_handles_non_utf8() {
489    // Environment variables can contain non-UTF-8 bytes on Unix
490    let mut data = Vec::new();
491    data.extend_from_slice(b"KEY=");
492    data.extend_from_slice(&[0xff, 0xfe]); // non-UTF-8
493    data.push(0);
494
495    let result = deserialize_env(&data);
496    assert_eq!(result.len(), 1);
497    assert_eq!(result[0].0, OsString::from("KEY"));
498    assert_eq!(result[0].1, OsString::from_vec(vec![0xff, 0xfe]));
499  }
500
501  #[test]
502  fn test_env_socket_roundtrip() {
503    let socket_name = random_socket_name();
504    let listener = bind_env_socket(&socket_name).unwrap();
505    let env = vec![
506      (OsString::from("TRACEXEC_NO_SLEEP"), OsString::from("1")),
507      (OsString::from("PATH"), OsString::from("/usr/bin")),
508    ];
509    let expected = env.clone();
510    let uid = nix::unistd::getuid().as_raw();
511
512    let server = thread::spawn(move || {
513      let (stream, _) = listener.accept().unwrap();
514      handle_env_request(stream, &env, uid).unwrap();
515    });
516
517    let received = request_env_from_parent(&socket_name).unwrap();
518    server.join().unwrap();
519    assert_eq!(received, expected);
520  }
521
522  #[test]
523  fn test_build_elevated_args_replaces_elevate() {
524    let creds = test_creds();
525    let input_args = vec![
526      OsString::from("tracexec"),
527      OsString::from("--elevate"),
528      OsString::from("tui"),
529      OsString::from("-t"),
530      OsString::from("--"),
531      OsString::from("sudo"),
532      OsString::from("ls"),
533    ];
534
535    let result = build_elevated_args_from(input_args, &creds, "sock-name", None, None, None);
536
537    assert_eq!(
538      result,
539      vec![
540        OsString::from("--user"),
541        OsString::from("testuser"),
542        OsString::from("--restore-env-socket"),
543        OsString::from("sock-name"),
544        OsString::from("tui"),
545        OsString::from("-t"),
546        OsString::from("--"),
547        OsString::from("sudo"),
548        OsString::from("ls"),
549      ]
550    );
551  }
552
553  #[test]
554  fn test_build_elevated_args_preserves_other_flags() {
555    let creds = PreElevationCreds {
556      username: "bob".to_string(),
557      uid: 1002,
558      gid: 1002,
559    };
560    let input_args = vec![
561      OsString::from("tracexec"),
562      OsString::from("--color=always"),
563      OsString::from("--elevate"),
564      OsString::from("-C"),
565      OsString::from("/tmp"),
566      OsString::from("log"),
567      OsString::from("--"),
568      OsString::from("ls"),
569    ];
570
571    let result = build_elevated_args_from(input_args, &creds, "sock-999", None, None, None);
572
573    assert_eq!(
574      result,
575      vec![
576        OsString::from("--color=always"),
577        OsString::from("--user"),
578        OsString::from("bob"),
579        OsString::from("--restore-env-socket"),
580        OsString::from("sock-999"),
581        OsString::from("-C"),
582        OsString::from("/tmp"),
583        OsString::from("log"),
584        OsString::from("--"),
585        OsString::from("ls"),
586      ]
587    );
588  }
589
590  #[test]
591  fn test_build_elevated_args_passes_project_dirs() {
592    let creds = test_creds();
593    let config_dir = Path::new("/home/testuser/.config/tracexec");
594    let data_dir = Path::new("/home/testuser/.local/share/tracexec");
595    let data_local_dir = Path::new("/home/testuser/.local/share/tracexec");
596
597    let input_args = vec![
598      OsString::from("tracexec"),
599      OsString::from("--elevate"),
600      OsString::from("log"),
601      OsString::from("--"),
602      OsString::from("ls"),
603    ];
604
605    let result = build_elevated_args_from(
606      input_args,
607      &creds,
608      "sock-abc",
609      Some(config_dir),
610      Some(data_dir),
611      Some(data_local_dir),
612    );
613
614    assert_eq!(
615      result,
616      vec![
617        OsString::from("--user"),
618        OsString::from("testuser"),
619        OsString::from("--restore-env-socket"),
620        OsString::from("sock-abc"),
621        OsString::from("--elevated-config-dir"),
622        OsString::from("/home/testuser/.config/tracexec"),
623        OsString::from("--elevated-data-dir"),
624        OsString::from("/home/testuser/.local/share/tracexec"),
625        OsString::from("--elevated-data-local-dir"),
626        OsString::from("/home/testuser/.local/share/tracexec"),
627        OsString::from("log"),
628        OsString::from("--"),
629        OsString::from("ls"),
630      ]
631    );
632  }
633
634  #[test]
635  fn test_build_elevated_args_ignores_elevate_after_delimiter() {
636    let creds = test_creds();
637    let input_args = vec![
638      OsString::from("tracexec"),
639      OsString::from("--elevate"),
640      OsString::from("log"),
641      OsString::from("--"),
642      OsString::from("cmd"),
643      OsString::from("--elevate"),
644    ];
645
646    let result = build_elevated_args_from(input_args, &creds, "sock-abc", None, None, None);
647
648    assert_eq!(
649      result,
650      vec![
651        OsString::from("--user"),
652        OsString::from("testuser"),
653        OsString::from("--restore-env-socket"),
654        OsString::from("sock-abc"),
655        OsString::from("log"),
656        OsString::from("--"),
657        OsString::from("cmd"),
658        OsString::from("--elevate"),
659      ]
660    );
661  }
662}