8000 Linux: handle stack guards protected via MADV_GUARD_INSTALL by schopin-mozilla · Pull Request #245 · rust-minidump/minidump-writer · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,23 @@ jobs:

test:
name: Test
runs-on: ${{ matrix.job.os }}
runs-on: ${{ matrix.os || format('ubuntu-{0}{1}', matrix.ubuntu, matrix.job.arm && '-arm' || '') }}
strategy:
matrix:
# Supported Ubuntu LTS releases
ubuntu: ['22.04', '24.04', '26.04']
job:
- { os: ubuntu-24.04, target: x86_64-unknown-linux-gnu, release: true }
- { os: ubuntu-24.04, target: x86_64-unknown-linux-musl }
- { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
- { os: windows-2025, target: x86_64-pc-windows-msvc }
- { os: macos-15-intel, target: x86_64-apple-darwin }
- { os: macos-15, target: aarch64-apple-darwin }
- { target: x86_64-unknown-linux-gnu, release: true }
- { target: aarch64-unknown-linux-gnu, arm: true }
# These don't match any of the Ubuntu combinations, so each is added
# as a single extra job.
include:
# musl comes with the Rust toolchain, not the OS, and the kernel
# versions are covered by the glibc jobs.
- { ubuntu: '24.04', job: { target: x86_64-unknown-linux-musl } }
- { os: windows-2025, job: { target: x86_64-pc-windows-msvc } }
- { os: macos-15-intel, job: { target: x86_64-apple-darwin } }
- { os: macos-15, job: { target: aarch64-apple-darwin } }
steps:
- uses: actions/checkout@v4
- name: Install Rust
Expand Down
54 changes: 54 additions & 0 deletions src/bin/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,58 @@ mod linux {
}
}

/// If MADV_GUARD_INSTALL fails, backup method to use to install
/// test guard pages.
enum BackupGuard {
Unmap,
MProtect,
}

/// Maps 4 pages filled with 0xab, with an unreadable guard page in the
/// third one, and prints the mapping address, mapping length, top of
/// stack, stack size, guard page address and page size.
fn spawn_guarded_mmap_wait(backup_guard: BackupGuard) -> Result<()> {
// Not in the libc crate yet.
const MADV_GUARD_INSTALL: libc::c_int = 102;

let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize;
let len = 4 * page_size;
let mapping = unsafe {
libc::mmap(
ptr::null_mut(),
len,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
-1,
0,
)
};
test!(mapping != libc::MAP_FAILED, "mmap failed");
unsafe { ptr::write_bytes(mapping.cast::<u8>(), 0xab, len) };

let guard = unsafe { mapping.add(2 * page_size) };
let top_of_stack = unsafe { mapping.add(3 * page_size) };
let stack_size = page_size;
let rc = unsafe { libc::madvise(guard, page_size, MADV_GUARD_INSTALL) };
if rc != 0 {
let rc = match backup_guard {
BackupGuard::Unmap => unsafe { libc::munmap(guard, page_size) },
BackupGuard::MProtect => unsafe {
libc::mprotect(guard, page_size, libc::PROT_NONE)
},
};
test!(rc == 0, "couldn't install the guard page");
}

println!(
"{} {} {} {} {} {page_size}",
mapping as usize, len, top_of_stack as usize, stack_size, guard as usize,
);
loop {
thread::park();
}
}

fn spawn_mmap_wait() -> Result<()> {
let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
assert!(page_size > 0);
Expand Down Expand Up @@ -397,6 +449,8 @@ mod linux {
"mappings_include_linux_gate" => test_mappings_include_linux_gate(),
"linux_gate_mapping_id" => test_linux_gate_mapping_id(),
"spawn_mmap_wait" => spawn_mmap_wait(),
"spawn_guarded_mmap_wait" => spawn_guarded_mmap_wait(BackupGuard::Unmap),
"spawn_guarded_stack_wait" => spawn_guarded_mmap_wait(BackupGuard::MProtect),
"spawn_alloc_wait" => spawn_alloc_wait(),
_ => Err("Len 1: Unknown test option".into()),
},
Expand Down
88 changes: 55 additions & 33 deletions src/linux/minidump_writer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,8 +532,9 @@ impl MinidumpWriter {
}
};

let stack_copy = match MinidumpWriter::copy_from_process(
let stack_copy = match MinidumpWriter::read_until_guard(
self.process_inspector.as_ref(),
self.page_size,
valid_stack_pointer,
stack_len,
) {
Expand Down Expand Up @@ -877,46 +878,40 @@ impl MinidumpWriter {
pub fn get_stack_info(&self, int_stack_pointer: usize) -> Result<(usize, usize), WriterError> {
// Round the stack pointer to the nearest page, this will cause us to
// capture data below the stack pointer which might still be relevant.
let mut stack_pointer = int_stack_pointer & !(self.page_size - 1);
let mut mapping = self.find_mapping(stack_pointer);
let (stack_pointer, mapping) = self
.skip_guard_pages(int_stack_pointer & !(self.page_size - 1))
.ok_or(WriterError::NoStackPointerMapping)?;
Ok((stack_pointer, mapping.end_address() - stack_pointer))
}

/// Returns the first page at or above `address` that may be part of a
/// stack, and the mapping containing it.
fn skip_guard_pages(&self, mut address: usize) -> Option<(usize, &MappingInfo)> {
// The guard page has been 1 MiB in size since kernel 4.12, older
// kernels used a 4 KiB one instead. Note the saturating add, as 32-bit
// processes can have a stack pointer within 1MiB of usize::MAX
let guard_page_max_addr = stack_pointer.saturating_add(1024 * 1024);

// If we found no mapping, or the mapping we found has no permissions
// then we might have hit a guard page, try looking for a mapping in
// addresses past the stack pointer. Stack grows towards lower addresses
// on the platforms we care about so the stack should appear after the
// guard page.
while !Self::may_be_stack(mapping) && (stack_pointer <= guard_page_max_addr) {
stack_pointer += self.page_size;
mapping = self.find_mapping(stack_pointer);
let guard_page_max_addr = address.saturating_add(1024 * 1024);
let mem = self.process_inspector.process_reader();

while address <= guard_page_max_addr {
if let Some(mapping) = self.find_mapping(address)
&& Self::may_be_stack(mapping)
// Actually read a byte. Permissions sometimes lie.
&& matches!(mem.read(address, &mut [0u8]), Ok(1))
{
return Some((address, mapping));
}
// Look upwards since the stack grows downwards on supported platforms
address = address.checked_add(self.page_size)?;
}

mapping
.map(|mapping| {
let valid_stack_pointer = if mapping.contains_address(stack_pointer) {
stack_pointer
} else {
mapping.start_address
};

let stack_len = mapping.size - (valid_stack_pointer - mapping.start_address);
(valid_stack_pointer, stack_len)
})
.ok_or(WriterError::NoStackPointerMapping)
None
}

fn may_be_stack(mapping: Option<&MappingInfo>) -> bool {
if let Some(mapping) = mapping {
return mapping
.permissions
.intersects(MMPermissions::READ | MMPermissions::WRITE);
}

false
fn may_be_stack(mapping: &MappingInfo) -> bool {
mapping
.permissions
.intersects(MMPermissions::READ | MMPermissions::WRITE)
}

pub fn sanitize_stack_copy(
Expand Down Expand Up @@ -1068,6 +1063,33 @@ impl MinidumpWriter {
let mem = process_inspector.process_reader();
mem.read_all_to_vec(src, length)
}

/// Copies up to `length` bytes from the target process, stopping at the
/// first page that can't be read, typically a guard page. Fails only if
/// nothing could be read at all.
pub fn read_until_guard(
process_inspector: &dyn ProcessInspector,
page_size: usize,
src: usize,
length: usize,
) -> Result<Vec<u8>, CopyFromProcessError> {
let mem = process_inspector.process_reader();
let mut output = vec![0u8; length];
let mut offset = 0;
while offset < length {
let chunk = (length - offset).min(page_size - (src + offset) % page_size);
if let Err(e) = mem.read_exact(src + offset, &mut output[offset..offset + chunk]) {
if offset == 0 {
return Err(e);
}
break;
}
offset += chunk;
}

output.truncate(offset);
Ok(output)
}
}

impl Drop for MinidumpWriter {
Expand Down
3 changes: 2 additions & 1 deletion src/linux/minidump_writer/thread_list_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,9 @@ impl MinidumpWriter {
let stack_copy = failspot!(if ThreadStackCopy {
Err(CopyFromProcessError::InvalidArgument)
} else {
MinidumpWriter::copy_from_process(
MinidumpWriter::read_until_guard(
self.process_inspector.as_ref(),
self.page_size,
valid_stack_ptr,
stack_len,
)
Expand Down
61 changes: 61 additions & 0 deletions tests/linux_minidump_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,67 @@ contextual_test! {
}
}

/// The stack pointer of a thread that overflowed its stack points into its
/// guard page. With glibc 2.42 and later, guard pages are installed with
/// `MADV_GUARD_INSTALL` and are part of the stack mapping, but can't be read.
/// On kernels without it, the child falls back to a separate `PROT_NONE`
/// mapping like older libcs.
///
/// The writer doesn't check that the stack pointer is within the thread's
/// actual stack, so we point the main thread's at a guarded mapping.
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
#[test]
fn stack_pointer_in_guard_page() {
let mut child = start_child_and_return(&["spawn_guarded_stack_wait"]);
let pid = child.id() as i32;

let mut line = String::new();
BufReader::new(child.stdout.as_mut().unwrap())
.read_line(&mut line)
.expect("Couldn't read mapping address from child");
let [.., top_of_stack, stack_size, guard, page_size]: [usize; 6] = line
.split_whitespace()
.map(|x| x.parse().unwrap())
.collect::<Vec<_>>()
.try_into()
.unwrap();

let mut crash_context = get_dummy_crash_context(pid);
let sp = guard + page_size / 2;
Comment thread
gabrielesvelto marked this conversation as resolved.
Resolved
#[cfg(target_arch = "x86_64")]
{
crash_context.inner.context.uc_mcontext.gregs[libc::REG_RSP as usize] = sp as _;
}
#[cfg(target_arch = "aarch64")]
{
crash_context.inner.context.uc_mcontext.sp = sp as _;
}

let mut tmpfile = tempfile::Builder::new()
.prefix("stack_pointer_in_guard_page")
.tempfile()
.unwrap();
let mut config = remote_mw_config(pid, pid);
config.set_crash_context(crash_context);
config
.write(&mut tmpfile)
.expect("Could not write minidump");
child.kill().expect("Failed to kill process");
child.wait().expect("Failed to wait for child");

let dump = Minidump::read_path(tmpfile.path()).expect("Failed to read minidump");
let thread_list: MinidumpThreadList = dump.get_stream().expect("No thread list");
let thread = thread_list
.get_thread(pid as u32)
.expect("Crashing thread not in thread list");
let stack = &thread.raw.stack;
assert_eq!(stack.start_of_memory_range, top_of_stack as u64);
assert!(stack.memory.data_size as usize >= stack_size);
let rva = stack.memory.rva as usize;
let bytes = std::fs::read(tmpfile.path()).unwrap();
assert!(bytes[rva..rva + stack_size].iter().all(|&b| b == 0xab));
}

#[test]
fn minidump_size_limit() {
let num_of_threads = 40;
Expand Down
66 changes: 66 additions & 0 deletions tests/ptrace_dumper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,69 @@ fn sanitizes_stack_copies() {
assert_eq!(waitres.code(), None);
assert_eq!(status, libc::SIGKILL);
}

// Ensures that reads stop right at a guard page, whichever way the memory is read
#[test]
fn reads_until_guard_page() {
use minidump_writer::{ProcessReaderKind, minidump_writer::MinidumpWriter};

let mut child = start_child_and_return(&["spawn_guarded_mmap_wait"]);
let pid = child.id() as i32;

let mut line = String::new();
BufReader::new(child.stdout.as_mut().expect("Can't open stdout"))
.read_line(&mut line)
.expect("Couldn't read address provided by child");
let [mapping, map_len, .., guard, page_size]: [usize; 6] = line
.split_whitespace()
.map(|x| x.parse().unwrap())
.collect::<Vec<_>>()
.try_into()
.unwrap();

let mut dumper = assert_no_soft_errors!(
soft_errors,
remote_mw_config(pid, pid).build_for_testing(&mut soft_errors)
)
.expect("Couldn't init dumper");

for kind in [
ProcessReaderKind::VirtualMem,
ProcessReaderKind::File,
ProcessReaderKind::Ptrace,
] {
let name = format!("{kind:?}");
dumper
.process_inspector
.force_process_reader_kind(kind)
.unwrap();

// Start in the middle of a page, to check that the reads stop at the
// guard page rather than at some multiple of the page size.
let src = mapping + 100;
let data = MinidumpWriter::read_until_guard(
dumper.process_inspector.as_ref(),
page_size,
src,
map_len - 100,
)
.unwrap_or_else(|e| panic!("{name}: read failed: {e}"));
assert_eq!(data.len(), guard - src, "{name}");
assert!(data.iter().all(|&b| b == 0xab), "{name}");

assert!(
MinidumpWriter::read_until_guard(
dumper.process_inspector.as_ref(),
page_size,
guard,
page_size
)
.is_err(),
"{name}: read starting at the guard page succeeded"
);
}

drop(dumper);
child.kill().expect("Failed to kill process");
child.wait().expect("Failed to wait for child");
}
Comment thread
schopin-mozilla marked this conversation as resolved.
Resolved
Loading
0