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        // SAFETY: The descriptor is a validated read-only regular file. This code
53        // only exposes the mapping immutably for object parsing; callers must not
54        // concurrently modify the mapped file while the mapping is alive.
55        let mmap = unsafe { MmapOptions::new().map(&file)? };
56
57        Ok(Self {
58            metadata_cookie,
59            mmap,
60        })
61    }
62
63    pub fn object(&self) -> Result<object::File<'_>> {
64        Ok(object::File::parse(&self.mmap[..])?)
65    }
66
67    pub fn cookie_for_object(&self, obj: &object::File<'_>) -> u64 {
68        if let Ok(Some(build_id)) = obj.build_id() {
69            return stable_hash(&build_id);
70        }
71
72        self.fallback_cookie()
73    }
74
75    pub fn cookie(&self) -> u64 {
76        self.object()
77            .map(|obj| self.cookie_for_object(&obj))
78            .unwrap_or_else(|_| self.fallback_cookie())
79    }
80
81    fn fallback_cookie(&self) -> u64 {
82        self.metadata_cookie
83    }
84}
85
86pub fn cookie_for_path(module_path: &str) -> u64 {
87    ModuleProbe::open(module_path)
88        .map(|probe| probe.cookie())
89        .unwrap_or_else(|_| stable_hash(&normalize_cookie_path(module_path)))
90}
91
92fn validate_module_path(path: &str) -> Result<ValidatedModulePath> {
93    // `/proc/<pid>/maps` is not a trustworthy module list. Reject procfs/sysfs
94    // paths up front, then resolve symlinks to the final target and insist that
95    // the resolved object is a regular file before it reaches the ELF path.
96    let input_is_proc_root = is_safe_proc_root_path(path);
97    if is_filtered_module_prefix(path) && !input_is_proc_root {
98        anyhow::bail!("refusing to read pseudo-filesystem path {}", path);
99    }
100
101    let resolved_path = fs::canonicalize(path)?;
102    let resolved_str = resolved_path.to_string_lossy();
103    let resolved_is_safe_proc_root = input_is_proc_root && is_safe_proc_root_path(&resolved_str);
104    if is_filtered_module_prefix(&resolved_str) && !resolved_is_safe_proc_root {
105        anyhow::bail!("refusing to read pseudo-filesystem path {}", resolved_str);
106    }
107    let meta = fs::metadata(&resolved_path)?;
108    if !meta.file_type().is_file() {
109        anyhow::bail!("refusing to read non-regular file {}", resolved_str);
110    }
111
112    Ok(ValidatedModulePath {
113        resolved_path,
114        metadata: meta,
115    })
116}
117
118fn is_safe_proc_root_path(path: &str) -> bool {
119    let Some(rest) = path.strip_prefix("/proc/") else {
120        return false;
121    };
122    let Some((pid, path)) = rest.split_once('/') else {
123        return false;
124    };
125    let Some(inner_path) = path.strip_prefix("root/") else {
126        return false;
127    };
128    !pid.is_empty()
129        && pid.bytes().all(|byte| byte.is_ascii_digit())
130        && !matches!(inner_path, "proc" | "sys")
131        && !inner_path.starts_with("proc/")
132        && !inner_path.starts_with("sys/")
133}
134
135fn normalize_cookie_path(module_path: &str) -> String {
136    normalize_mapped_module_path(module_path).replace("/./", "/")
137}
138
139fn stable_hash<T: Hash + ?Sized>(value: &T) -> u64 {
140    let mut hasher = std::collections::hash_map::DefaultHasher::new();
141    value.hash(&mut hasher);
142    hasher.finish()
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::os::unix::fs::symlink;
149
150    #[test]
151    fn cookie_path_fallback_normalizes_equivalent_paths() {
152        let a = cookie_for_path("/tmp/./ghostscope-missing-lib.so");
153        let b = cookie_for_path("/tmp/ghostscope-missing-lib.so");
154        assert_eq!(a, b);
155    }
156
157    #[test]
158    fn rejects_pseudo_filesystem_paths() {
159        let err = match ModuleProbe::open("/dev/null") {
160            Ok(_) => panic!("expected /dev/null probe to be rejected"),
161            Err(err) => err,
162        };
163        assert!(err
164            .to_string()
165            .contains("refusing to read non-regular file /dev/null"));
166
167        let err = match ModuleProbe::open("/proc/self/maps") {
168            Ok(_) => panic!("expected /proc/self/maps probe to be rejected"),
169            Err(err) => err,
170        };
171        assert!(err
172            .to_string()
173            .contains("refusing to read pseudo-filesystem path /proc/self/maps"));
174
175        let err = match ModuleProbe::open("/proc/self/root/proc/self/maps") {
176            Ok(_) => panic!("expected /proc/self/root/proc/self/maps probe to be rejected"),
177            Err(err) => err,
178        };
179        assert!(err.to_string().contains("pseudo-filesystem path"));
180    }
181
182    #[test]
183    fn allows_proc_root_regular_files() {
184        let base = std::env::temp_dir().join(format!(
185            "ghostscope-module-probe-proc-root-{}-{}",
186            std::process::id(),
187            std::time::SystemTime::now()
188                .duration_since(std::time::UNIX_EPOCH)
189                .unwrap()
190                .as_nanos()
191        ));
192        std::fs::write(&base, b"not an elf but still a regular file").unwrap();
193        let pid = std::process::id();
194        let proc_root_path = format!("/proc/{pid}/root{}", base.display());
195
196        ModuleProbe::open(&proc_root_path).unwrap();
197
198        let _ = std::fs::remove_file(&base);
199    }
200
201    #[test]
202    fn allows_symlinked_regular_files() {
203        let base = std::env::temp_dir().join(format!(
204            "ghostscope-module-probe-{}-{}",
205            std::process::id(),
206            std::time::SystemTime::now()
207                .duration_since(std::time::UNIX_EPOCH)
208                .unwrap()
209                .as_nanos()
210        ));
211        let target = base.with_extension("so");
212        let link = base.with_extension("link");
213        std::fs::write(&target, b"not an elf but still a regular file").unwrap();
214        symlink(&target, &link).unwrap();
215
216        let target_cookie = cookie_for_path(target.to_str().unwrap());
217        let link_cookie = cookie_for_path(link.to_str().unwrap());
218        assert_eq!(link_cookie, target_cookie);
219        ModuleProbe::open(link.to_str().unwrap()).unwrap();
220
221        let _ = std::fs::remove_file(&link);
222        let _ = std::fs::remove_file(&target);
223    }
224}