Skip to main content

hermit/
instruction_map.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Offline discovery of nondeterministic x86 instructions in ELF binaries.
10
11use std::fs;
12use std::fs::File;
13use std::io::Read;
14use std::io::Write;
15use std::os::unix::ffi::OsStrExt;
16use std::os::unix::fs::MetadataExt;
17use std::path::Path;
18use std::path::PathBuf;
19
20use goblin::elf::Elf;
21use goblin::elf::header;
22use goblin::elf::program_header;
23use goblin::elf::section_header;
24use iced_x86::Decoder;
25use iced_x86::DecoderOptions;
26use iced_x86::Instruction;
27use iced_x86::Mnemonic;
28use serde::Deserialize;
29use serde::Serialize;
30use uuid::Uuid;
31
32use crate::Context;
33use crate::Error;
34
35const CACHE_SCHEMA_VERSION: u32 = 2;
36
37/// One instruction that can expose host nondeterminism or enter the kernel.
38#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
39pub struct InstructionSite {
40    /// Byte offset from the start of the ELF file.
41    pub offset: u64,
42    /// Lowercase x86 mnemonic.
43    pub instruction: String,
44    /// Encoded instruction length in bytes.
45    pub length: u8,
46}
47
48/// Nanosecond-resolution file modification time used to validate cache entries.
49#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
50pub struct ModificationTime {
51    pub seconds: i64,
52    pub nanoseconds: i64,
53}
54
55/// A self-describing map of nondeterministic instruction sites in one ELF file.
56#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
57pub struct InstructionMap {
58    pub schema_version: u32,
59    pub binary: PathBuf,
60    pub file_length: u64,
61    pub modified: ModificationTime,
62    pub sites: Vec<InstructionSite>,
63}
64
65/// Whether a map was loaded without reading and decoding the binary.
66#[derive(Debug, Clone, Copy, Eq, PartialEq)]
67pub enum CacheStatus {
68    Hit,
69    Miss,
70}
71
72/// Result of a cached instruction-map lookup.
73#[derive(Debug, Clone, Eq, PartialEq)]
74pub struct InstructionMapResult {
75    pub map: InstructionMap,
76    pub cache_status: CacheStatus,
77    pub cache_path: PathBuf,
78}
79
80#[derive(Debug, Clone, Eq, PartialEq)]
81struct FileIdentity {
82    binary: PathBuf,
83    file_length: u64,
84    modified: ModificationTime,
85}
86
87#[derive(Debug, Clone, Copy)]
88struct ExecutableRange {
89    file_offset: u64,
90    address: u64,
91    size: u64,
92}
93
94/// Default directory for cached instruction maps.
95pub fn default_cache_dir() -> PathBuf {
96    dirs::cache_dir()
97        .map_or_else(|| PathBuf::from("/tmp/hermit"), |dir| dir.join("hermit"))
98        .join("instruction-maps")
99}
100
101/// Load an instruction map from the cache or generate and atomically cache it.
102pub fn load_or_generate(
103    binary: impl AsRef<Path>,
104    cache_dir: impl AsRef<Path>,
105) -> Result<InstructionMapResult, Error> {
106    let requested = binary.as_ref();
107    let canonical = fs::canonicalize(requested)
108        .with_context(|| format!("failed to resolve binary {}", requested.display()))?;
109    let mut file = File::open(&canonical)
110        .with_context(|| format!("failed to open binary {}", canonical.display()))?;
111    let before = file
112        .metadata()
113        .with_context(|| format!("failed to stat binary {}", canonical.display()))?;
114    if !before.is_file() {
115        return Err(Error::msg(format!(
116            "instruction map input is not a regular file: {}",
117            canonical.display()
118        )));
119    }
120
121    let identity = FileIdentity::new(canonical, &before);
122    let cache_dir = cache_dir.as_ref();
123    let cache_path = cache_dir.join(format!("{}.json", cache_key(&identity)));
124    if let Some(map) = read_valid_cache(&cache_path, &identity) {
125        return Ok(InstructionMapResult {
126            map,
127            cache_status: CacheStatus::Hit,
128            cache_path,
129        });
130    }
131
132    let mut bytes = Vec::new();
133    file.read_to_end(&mut bytes)
134        .with_context(|| format!("failed to read binary {}", identity.binary.display()))?;
135    let after = file
136        .metadata()
137        .with_context(|| format!("failed to restat binary {}", identity.binary.display()))?;
138    if before.len() != after.len()
139        || before.mtime() != after.mtime()
140        || before.mtime_nsec() != after.mtime_nsec()
141    {
142        return Err(Error::msg(format!(
143            "binary changed while generating instruction map: {}",
144            identity.binary.display()
145        )));
146    }
147
148    let sites = scan_elf(&bytes)
149        .with_context(|| format!("failed to scan binary {}", identity.binary.display()))?;
150    let map = InstructionMap {
151        schema_version: CACHE_SCHEMA_VERSION,
152        binary: identity.binary.clone(),
153        file_length: identity.file_length,
154        modified: identity.modified.clone(),
155        sites,
156    };
157    write_cache(&cache_path, &map)?;
158
159    Ok(InstructionMapResult {
160        map,
161        cache_status: CacheStatus::Miss,
162        cache_path,
163    })
164}
165
166impl FileIdentity {
167    fn new(binary: PathBuf, metadata: &fs::Metadata) -> Self {
168        Self {
169            binary,
170            file_length: metadata.len(),
171            modified: ModificationTime {
172                seconds: metadata.mtime(),
173                nanoseconds: metadata.mtime_nsec(),
174            },
175        }
176    }
177}
178
179fn cache_key(identity: &FileIdentity) -> String {
180    let mut input = Vec::new();
181    input.extend_from_slice(&CACHE_SCHEMA_VERSION.to_le_bytes());
182    input.extend_from_slice(identity.binary.as_os_str().as_bytes());
183    input.push(0);
184    input.extend_from_slice(&identity.file_length.to_le_bytes());
185    input.extend_from_slice(&identity.modified.seconds.to_le_bytes());
186    input.extend_from_slice(&identity.modified.nanoseconds.to_le_bytes());
187    Uuid::new_v5(&Uuid::NAMESPACE_URL, &input)
188        .simple()
189        .to_string()
190}
191
192fn read_valid_cache(cache_path: &Path, identity: &FileIdentity) -> Option<InstructionMap> {
193    let map: InstructionMap = serde_json::from_reader(File::open(cache_path).ok()?).ok()?;
194    (map.schema_version == CACHE_SCHEMA_VERSION
195        && map.binary == identity.binary
196        && map.file_length == identity.file_length
197        && map.modified == identity.modified)
198        .then_some(map)
199}
200
201fn write_cache(cache_path: &Path, map: &InstructionMap) -> Result<(), Error> {
202    let cache_dir = cache_path
203        .parent()
204        .ok_or_else(|| Error::msg("instruction map cache path has no parent"))?;
205    fs::create_dir_all(cache_dir).with_context(|| {
206        format!(
207            "failed to create instruction map cache directory {}",
208            cache_dir.display()
209        )
210    })?;
211
212    let mut temporary = tempfile::Builder::new()
213        .prefix(".instruction-map-")
214        .tempfile_in(cache_dir)
215        .with_context(|| {
216            format!(
217                "failed to create temporary instruction map in {}",
218                cache_dir.display()
219            )
220        })?;
221    serde_json::to_writer(&mut temporary, map)?;
222    temporary.write_all(b"\n")?;
223    temporary.as_file().sync_all()?;
224    temporary.persist(cache_path).with_context(|| {
225        format!(
226            "failed to persist instruction map cache {}",
227            cache_path.display()
228        )
229    })?;
230    Ok(())
231}
232
233fn scan_elf(bytes: &[u8]) -> Result<Vec<InstructionSite>, Error> {
234    let elf = Elf::parse(bytes).context("input is not a valid ELF file")?;
235    if !elf.little_endian {
236        return Err(Error::msg("big-endian x86 ELF files are not supported"));
237    }
238    let bitness = match elf.header.e_machine {
239        header::EM_386 => 32,
240        header::EM_X86_64 => 64,
241        machine => {
242            return Err(Error::msg(format!(
243                "unsupported ELF machine {machine}; expected x86 or x86-64"
244            )));
245        }
246    };
247
248    let mut sites = Vec::new();
249    for range in executable_ranges(&elf) {
250        let start = usize::try_from(range.file_offset)
251            .context("executable range offset does not fit in memory")?;
252        let size =
253            usize::try_from(range.size).context("executable range size does not fit in memory")?;
254        let end = start
255            .checked_add(size)
256            .ok_or_else(|| Error::msg("executable range overflows the file offset space"))?;
257        let code = bytes.get(start..end).ok_or_else(|| {
258            Error::msg(format!(
259                "executable range {:#x}..{:#x} lies outside the ELF file",
260                range.file_offset,
261                range.file_offset.saturating_add(range.size)
262            ))
263        })?;
264        scan_range(code, range, bitness, &mut sites)?;
265    }
266    sites.sort_unstable();
267    sites.dedup();
268    Ok(sites)
269}
270
271fn executable_ranges(elf: &Elf<'_>) -> Vec<ExecutableRange> {
272    let mut ranges = elf
273        .section_headers
274        .iter()
275        .filter(|section| {
276            section.sh_flags & u64::from(section_header::SHF_EXECINSTR) != 0
277                && section.sh_type != section_header::SHT_NOBITS
278                && section.sh_size != 0
279        })
280        .map(|section| ExecutableRange {
281            file_offset: section.sh_offset,
282            address: section.sh_addr,
283            size: section.sh_size,
284        })
285        .collect::<Vec<_>>();
286
287    if ranges.is_empty() {
288        ranges.extend(
289            elf.program_headers
290                .iter()
291                .filter(|segment| {
292                    segment.p_type == program_header::PT_LOAD
293                        && segment.p_flags & program_header::PF_X != 0
294                        && segment.p_filesz != 0
295                })
296                .map(|segment| ExecutableRange {
297                    file_offset: segment.p_offset,
298                    address: segment.p_vaddr,
299                    size: segment.p_filesz,
300                }),
301        );
302    }
303
304    ranges.sort_unstable_by_key(|range| (range.file_offset, range.address, range.size));
305    ranges
306}
307
308fn scan_range(
309    bytes: &[u8],
310    range: ExecutableRange,
311    bitness: u32,
312    sites: &mut Vec<InstructionSite>,
313) -> Result<(), Error> {
314    let mut decoder = Decoder::with_ip(bitness, bytes, range.address, DecoderOptions::NONE);
315    while decoder.can_decode() {
316        let instruction = decoder.decode();
317        let Some(name) = nondeterministic_instruction(&instruction) else {
318            continue;
319        };
320        let relative_offset = instruction
321            .ip()
322            .checked_sub(range.address)
323            .ok_or_else(|| Error::msg("decoded instruction precedes its executable range"))?;
324        let offset = range
325            .file_offset
326            .checked_add(relative_offset)
327            .ok_or_else(|| Error::msg("decoded instruction file offset overflowed"))?;
328        sites.push(InstructionSite {
329            offset,
330            instruction: name.to_string(),
331            length: u8::try_from(instruction.len())
332                .context("decoded x86 instruction length does not fit in u8")?,
333        });
334    }
335    Ok(())
336}
337
338// TODO-HUMAN-REVIEW(PR-594): Review the expanded public instruction-map coverage.
339fn nondeterministic_instruction(instruction: &Instruction) -> Option<&'static str> {
340    match instruction.mnemonic() {
341        Mnemonic::Syscall => Some("syscall"),
342        Mnemonic::Cpuid => Some("cpuid"),
343        Mnemonic::Rdrand => Some("rdrand"),
344        Mnemonic::Rdtsc => Some("rdtsc"),
345        Mnemonic::Rdtscp => Some("rdtscp"),
346        Mnemonic::Rdseed => Some("rdseed"),
347        Mnemonic::Sysenter => Some("sysenter"),
348        Mnemonic::Xbegin => Some("xbegin"),
349        Mnemonic::Xend => Some("xend"),
350        Mnemonic::Int if instruction.immediate8() == 0x80 => Some("int80"),
351        _ => None,
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    const CODE_OFFSET: usize = 64;
360    const CODE_ADDRESS: u64 = 0x0040_0000;
361    const SECTION_HEADER_SIZE: usize = 64;
362
363    fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
364        bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
365    }
366
367    fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
368        bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
369    }
370
371    fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
372        bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
373    }
374
375    fn elf_with_executable_section(code: &[u8]) -> Vec<u8> {
376        let section_table = (CODE_OFFSET + code.len() + 7) & !7;
377        let mut bytes = vec![0; section_table + 2 * SECTION_HEADER_SIZE];
378        bytes[..4].copy_from_slice(b"\x7fELF");
379        bytes[4] = 2;
380        bytes[5] = 1;
381        bytes[6] = 1;
382        write_u16(&mut bytes, 16, header::ET_EXEC);
383        write_u16(&mut bytes, 18, header::EM_X86_64);
384        write_u32(&mut bytes, 20, 1);
385        write_u64(&mut bytes, 24, CODE_ADDRESS);
386        write_u64(&mut bytes, 40, section_table as u64);
387        write_u16(&mut bytes, 52, 64);
388        write_u16(&mut bytes, 58, SECTION_HEADER_SIZE as u16);
389        write_u16(&mut bytes, 60, 2);
390        bytes[CODE_OFFSET..CODE_OFFSET + code.len()].copy_from_slice(code);
391
392        let text = section_table + SECTION_HEADER_SIZE;
393        write_u32(&mut bytes, text + 4, section_header::SHT_PROGBITS);
394        write_u64(
395            &mut bytes,
396            text + 8,
397            u64::from(section_header::SHF_ALLOC | section_header::SHF_EXECINSTR),
398        );
399        write_u64(&mut bytes, text + 16, CODE_ADDRESS);
400        write_u64(&mut bytes, text + 24, CODE_OFFSET as u64);
401        write_u64(&mut bytes, text + 32, code.len() as u64);
402        write_u64(&mut bytes, text + 48, 1);
403        bytes
404    }
405
406    fn all_target_instructions() -> Vec<u8> {
407        vec![
408            0x0f, 0x05, 0x0f, 0xa2, 0x48, 0x0f, 0xc7, 0xf0, 0x0f, 0x31, 0x48, 0x0f, 0xc7, 0xf8,
409            0x0f, 0x01, 0xf9, 0x0f, 0x34, 0xcd, 0x80, 0xc7, 0xf8, 0, 0, 0, 0, 0x0f, 0x01, 0xd5,
410        ]
411    }
412
413    #[test]
414    fn scans_every_requested_instruction() {
415        let sites = scan_elf(&elf_with_executable_section(&all_target_instructions())).unwrap();
416        assert_eq!(
417            sites,
418            vec![
419                InstructionSite {
420                    offset: 64,
421                    instruction: "syscall".into(),
422                    length: 2
423                },
424                InstructionSite {
425                    offset: 66,
426                    instruction: "cpuid".into(),
427                    length: 2
428                },
429                InstructionSite {
430                    offset: 68,
431                    instruction: "rdrand".into(),
432                    length: 4
433                },
434                InstructionSite {
435                    offset: 72,
436                    instruction: "rdtsc".into(),
437                    length: 2
438                },
439                InstructionSite {
440                    offset: 74,
441                    instruction: "rdseed".into(),
442                    length: 4
443                },
444                InstructionSite {
445                    offset: 78,
446                    instruction: "rdtscp".into(),
447                    length: 3
448                },
449                InstructionSite {
450                    offset: 81,
451                    instruction: "sysenter".into(),
452                    length: 2
453                },
454                InstructionSite {
455                    offset: 83,
456                    instruction: "int80".into(),
457                    length: 2
458                },
459                InstructionSite {
460                    offset: 85,
461                    instruction: "xbegin".into(),
462                    length: 6
463                },
464                InstructionSite {
465                    offset: 91,
466                    instruction: "xend".into(),
467                    length: 3
468                },
469            ]
470        );
471    }
472
473    #[test]
474    fn cache_hit_skips_regeneration_and_identity_changes_miss() {
475        let temp = tempfile::tempdir().unwrap();
476        let binary = temp.path().join("fixture");
477        let cache = temp.path().join("cache");
478        fs::write(
479            &binary,
480            elf_with_executable_section(&all_target_instructions()),
481        )
482        .unwrap();
483
484        let first = load_or_generate(&binary, &cache).unwrap();
485        assert_eq!(first.cache_status, CacheStatus::Miss);
486        assert!(first.cache_path.is_file());
487
488        let second = load_or_generate(&binary, &cache).unwrap();
489        assert_eq!(second.cache_status, CacheStatus::Hit);
490        assert_eq!(second.map, first.map);
491        assert_eq!(second.cache_path, first.cache_path);
492
493        let mut changed = fs::read(&binary).unwrap();
494        changed.push(0);
495        fs::write(&binary, changed).unwrap();
496        let third = load_or_generate(&binary, &cache).unwrap();
497        assert_eq!(third.cache_status, CacheStatus::Miss);
498        assert_ne!(third.cache_path, first.cache_path);
499    }
500
501    #[test]
502    fn stale_schema_cache_is_regenerated() {
503        let temp = tempfile::tempdir().unwrap();
504        let binary = temp.path().join("fixture");
505        let cache = temp.path().join("cache");
506        fs::write(
507            &binary,
508            elf_with_executable_section(&all_target_instructions()),
509        )
510        .unwrap();
511
512        let canonical = fs::canonicalize(&binary).unwrap();
513        let metadata = fs::metadata(&canonical).unwrap();
514        let identity = FileIdentity::new(canonical.clone(), &metadata);
515        let cache_path = cache.join(format!("{}.json", cache_key(&identity)));
516        let stale = InstructionMap {
517            schema_version: CACHE_SCHEMA_VERSION - 1,
518            binary: canonical,
519            file_length: identity.file_length,
520            modified: identity.modified,
521            sites: Vec::new(),
522        };
523        write_cache(&cache_path, &stale).unwrap();
524
525        let regenerated = load_or_generate(&binary, &cache).unwrap();
526        assert_eq!(regenerated.cache_status, CacheStatus::Miss);
527        assert_eq!(regenerated.map.schema_version, CACHE_SCHEMA_VERSION);
528        assert!(
529            regenerated
530                .map
531                .sites
532                .iter()
533                .any(|site| site.instruction == "rdtscp")
534        );
535    }
536
537    /// Two read-write pages that meet at a 4 GiB-aligned address.
538    struct FourGibBoundary {
539        base: *mut u8,
540    }
541
542    impl FourGibBoundary {
543        const PAGE: usize = 4096;
544
545        /// Maps the pages at the first free 4 GiB multiple. MAP_FIXED_NOREPLACE
546        /// never displaces an existing mapping; a kernel too old to know the
547        /// flag treats the address as a hint, so a placement elsewhere is
548        /// unmapped and the next multiple is tried.
549        fn map() -> Self {
550            for multiple in 1..=64usize {
551                let base = (multiple << 32) - Self::PAGE;
552                // SAFETY: an anonymous private mapping that cannot replace
553                // any existing mapping.
554                let mapped = unsafe {
555                    libc::mmap(
556                        base as *mut libc::c_void,
557                        2 * Self::PAGE,
558                        libc::PROT_READ | libc::PROT_WRITE,
559                        libc::MAP_PRIVATE | libc::MAP_ANONYMOUS | libc::MAP_FIXED_NOREPLACE,
560                        -1,
561                        0,
562                    )
563                };
564                if mapped == libc::MAP_FAILED {
565                    continue;
566                }
567                if mapped as usize == base {
568                    return Self {
569                        base: mapped.cast(),
570                    };
571                }
572                // SAFETY: unmaps only the mapping created just above.
573                unsafe { libc::munmap(mapped, 2 * Self::PAGE) };
574            }
575            panic!("no 4 GiB-aligned address below 256 GiB was free to map");
576        }
577
578        /// The bytes of both pages; index `PAGE` is the 4 GiB-aligned address.
579        fn bytes(&mut self) -> &mut [u8] {
580            // SAFETY: `map` created exactly these two read-write pages and
581            // `self` owns them until drop.
582            unsafe { std::slice::from_raw_parts_mut(self.base, 2 * Self::PAGE) }
583        }
584    }
585
586    impl Drop for FourGibBoundary {
587        fn drop(&mut self) {
588            // SAFETY: unmaps the two pages that `map` created.
589            unsafe { libc::munmap(self.base.cast(), 2 * Self::PAGE) };
590        }
591    }
592
593    /// iced-x86 1.21.0 computes an instruction's length as
594    /// `end as u32 - start as u32` on host pointers (decoder.rs:1421 and its
595    /// invalid-instruction twin at 1468). That difference is exact under
596    /// wrapping arithmetic but panics with "attempt to subtract with overflow"
597    /// when overflow checks are on and the instruction ends at or past a
598    /// 4 GiB-aligned host address. Where a buffer lands is an allocator
599    /// accident, so the panic was intermittent: the LiteInst census decodes
600    /// all of libc's text in the tracer, and it failed full validations as a
601    /// container-child panic
602    /// (https://github.com/rrnewton/hermit/issues/3462). The workspace
603    /// therefore builds iced-x86 without overflow checks; this places the
604    /// instructions at the boundary on purpose so that the setting is tested
605    /// on every run rather than once in a few thousand.
606    #[test]
607    fn decodes_instructions_that_end_at_or_cross_a_4gib_host_address() {
608        const NOP: u8 = 0x90;
609        const CPUID: [u8; 2] = [0x0f, 0xa2];
610        const RDTSC: [u8; 2] = [0x0f, 0x31];
611        // mov rax, [rip + 0x10]
612        const MOV_RIP_RELATIVE: [u8; 7] = [0x48, 0x8b, 0x05, 0x10, 0x00, 0x00, 0x00];
613        let boundary = FourGibBoundary::PAGE;
614        let mut mapping = FourGibBoundary::map();
615        let bytes = mapping.bytes();
616        assert_eq!(bytes[boundary..].as_ptr() as usize & 0xffff_ffff, 0);
617
618        // `cpuid` ends exactly at the boundary, `rdtsc` crosses it, and both
619        // are sites the instruction map records with their encoded lengths.
620        for (instruction, start, name) in [
621            (CPUID, boundary - 2, "cpuid"),
622            (RDTSC, boundary - 1, "rdtsc"),
623        ] {
624            bytes.fill(NOP);
625            bytes[start..start + instruction.len()].copy_from_slice(&instruction);
626            let window = boundary - 16..boundary + 16;
627            let range = ExecutableRange {
628                file_offset: 0x1000,
629                address: CODE_ADDRESS,
630                size: window.len() as u64,
631            };
632            let mut sites = Vec::new();
633            scan_range(&bytes[window.clone()], range, 64, &mut sites).unwrap();
634            assert_eq!(
635                sites,
636                vec![InstructionSite {
637                    offset: range.file_offset + (start - window.start) as u64,
638                    instruction: name.into(),
639                    length: 2,
640                }]
641            );
642        }
643
644        // A longer instruction crossing the boundary decodes to its true
645        // length and next instruction pointer.
646        bytes.fill(NOP);
647        let start = boundary - 3;
648        bytes[start..start + MOV_RIP_RELATIVE.len()].copy_from_slice(&MOV_RIP_RELATIVE);
649        let mut decoder = Decoder::with_ip(64, &bytes[start..], CODE_ADDRESS, DecoderOptions::NONE);
650        let instruction = decoder.decode();
651        assert_eq!(instruction.mnemonic(), Mnemonic::Mov);
652        assert_eq!(instruction.len(), MOV_RIP_RELATIVE.len());
653        assert_eq!(
654            instruction.next_ip(),
655            CODE_ADDRESS + MOV_RIP_RELATIVE.len() as u64
656        );
657        assert_eq!(decoder.position(), MOV_RIP_RELATIVE.len());
658    }
659}