Skip to main content

ghostscope_process/
module_probe.rs

1use crate::proc_maps::{is_filtered_module_prefix, normalize_mapped_module_path};
2use anyhow::Result;
3use memmap2::{Mmap, MmapOptions};
4use object::Object;
5use std::fs::{self, OpenOptions};
6use std::hash::{Hash, Hasher};
7use std::os::unix::fs::MetadataExt;
8use std::os::unix::fs::OpenOptionsExt;
9use std::path::PathBuf;
10
11pub struct ModuleProbe {
12    metadata_cookie: u64,
13    mmap: Mmap,
14}
15
16struct ValidatedModulePath {
17    resolved_path: PathBuf,
18    metadata: fs::Metadata,
19}
20
21impl ModuleProbe {
22    pub fn open(module_path: &str) -> Result<Self> {
23        let normalized_path = normalize_cookie_path(module_path);
24        let validated = validate_module_path(&normalized_path)?;
25
26        // Open the resolved regular file so common launcher symlinks like
27        // `/bin/sh` and `/usr/bin/python3` continue to probe correctly.
28        // `O_NOFOLLOW` still protects the final open against a symlink swap
29        // after resolution.
30        let file = OpenOptions::new()
31            .read(true)
32            .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
33            .open(&validated.resolved_path)?;
34        let meta = file.metadata()?;
35        if !meta.file_type().is_file() {
36            anyhow::bail!(
37                "refusing to read non-regular file {}",
38                validated.resolved_path.display()
39            );
40        }
41        if meta.dev() != validated.metadata.dev() || meta.ino() != validated.metadata.ino() {
42            anyhow::bail!(
43                "module path changed while opening {}",
44                validated.resolved_path.display()
45            );
46        }
47
48        let dev = meta.dev();
49        let ino = meta.ino();
50        let metadata_cookie = ((dev & 0xffff_ffff) << 32) | (ino & 0xffff_ffff);
51
52        let mmap = unsafe { MmapOptions::new().map(&file)? };
53
54        Ok(Self {
55            metadata_cookie,
56            mmap,
57        })
58    }
59
60    pub fn object(&self) -> Result<object::File<'_>> {
61        Ok(object::File::parse(&self.mmap[..])?)
62    }
63
64    pub fn cookie_for_object(&self, obj: &object::File<'_>) -> u64 {
65        if let Ok(Some(build_id)) = obj.build_id() {
66            return stable_hash(&build_id);
67        }
68
69        self.fallback_cookie()
70    }
71
72    pub fn cookie(&self) -> u64 {
73        self.object()
74            .map(|obj| self.cookie_for_object(&obj))
75            .unwrap_or_else(|_| self.fallback_cookie())
76    }
77
78    fn fallback_cookie(&self) -> u64 {
79        self.metadata_cookie
80    }
81}
82
83pub fn cookie_for_path(module_path: &str) -> u64 {
84    ModuleProbe::open(module_path)
85        .map(|probe| probe.cookie())
86        .unwrap_or_else(|_| stable_hash(&normalize_cookie_path(module_path)))
87}
88
89fn validate_module_path(path: &str) -> Result<ValidatedModulePath> {
90    // `/proc/<pid>/maps` is not a trustworthy module list. Reject procfs/sysfs
91    // paths up front, then resolve symlinks to the final target and insist that
92    // the resolved object is a regular file before it reaches the ELF path.
93    if is_filtered_module_prefix(path) {
94        anyhow::bail!("refusing to read pseudo-filesystem path {}", path);
95    }
96
97    let resolved_path = fs::canonicalize(path)?;
98    let resolved_str = resolved_path.to_string_lossy();
99    if is_filtered_module_prefix(&resolved_str) {
100        anyhow::bail!("refusing to read pseudo-filesystem path {}", resolved_str);
101    }
102    let meta = fs::metadata(&resolved_path)?;
103    if !meta.file_type().is_file() {
104        anyhow::bail!("refusing to read non-regular file {}", resolved_str);
105    }
106
107    Ok(ValidatedModulePath {
108        resolved_path,
109        metadata: meta,
110    })
111}
112
113fn normalize_cookie_path(module_path: &str) -> String {
114    normalize_mapped_module_path(module_path).replace("/./", "/")
115}
116
117fn stable_hash<T: Hash + ?Sized>(value: &T) -> u64 {
118    let mut hasher = std::collections::hash_map::DefaultHasher::new();
119    value.hash(&mut hasher);
120    hasher.finish()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::os::unix::fs::symlink;
127
128    #[test]
129    fn cookie_path_fallback_normalizes_equivalent_paths() {
130        let a = cookie_for_path("/tmp/./ghostscope-missing-lib.so");
131        let b = cookie_for_path("/tmp/ghostscope-missing-lib.so");
132        assert_eq!(a, b);
133    }
134
135    #[test]
136    fn rejects_pseudo_filesystem_paths() {
137        let err = match ModuleProbe::open("/dev/null") {
138            Ok(_) => panic!("expected /dev/null probe to be rejected"),
139            Err(err) => err,
140        };
141        assert!(err
142            .to_string()
143            .contains("refusing to read non-regular file /dev/null"));
144    }
145
146    #[test]
147    fn allows_symlinked_regular_files() {
148        let base = std::env::temp_dir().join(format!(
149            "ghostscope-module-probe-{}-{}",
150            std::process::id(),
151            std::time::SystemTime::now()
152                .duration_since(std::time::UNIX_EPOCH)
153                .unwrap()
154                .as_nanos()
155        ));
156        let target = base.with_extension("so");
157        let link = base.with_extension("link");
158        std::fs::write(&target, b"not an elf but still a regular file").unwrap();
159        symlink(&target, &link).unwrap();
160
161        let target_cookie = cookie_for_path(target.to_str().unwrap());
162        let link_cookie = cookie_for_path(link.to_str().unwrap());
163        assert_eq!(link_cookie, target_cookie);
164        ModuleProbe::open(link.to_str().unwrap()).unwrap();
165
166        let _ = std::fs::remove_file(&link);
167        let _ = std::fs::remove_file(&target);
168    }
169}