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
14 changes: 10 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ on:

jobs:
rust:
name: Rust tests
runs-on: ubuntu-latest
name: Rust tests (${{ matrix.runner }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- uses: actions/checkout@v4

Expand All @@ -29,11 +33,13 @@ jobs:
run: cargo test --release --test integration -- --test-threads=1

python:
name: Python tests (py${{ matrix.python-version }})
runs-on: ubuntu-latest
name: Python tests (${{ matrix.runner }}, py${{ matrix.python-version }})
runs-on: ${{ matrix.runner }}
needs: rust
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
Expand Down
176 changes: 152 additions & 24 deletions crates/sandlock-core/src/vdso.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write};

use crate::error::SandlockError;

/// Find the base address of the vDSO mapping for a given process.
pub(crate) fn find_vdso_base(pid: i32) -> io::Result<u64> {
/// Find the base address and size of the vDSO mapping for a given process.
pub(crate) fn find_vdso_range(pid: i32) -> io::Result<(u64, u64)> {
let path = format!("/proc/{}/maps", pid);
let file = File::open(&path)?;
let reader = BufReader::new(file);
Expand All @@ -15,12 +15,16 @@ pub(crate) fn find_vdso_base(pid: i32) -> io::Result<u64> {
let line = line?;
if line.ends_with("[vdso]") {
// Line format: "7ffd1234000-7ffd1235000 r-xp ... [vdso]"
if let Some(dash_pos) = line.find('-') {
let start_hex = &line[..dash_pos];
let addr = u64::from_str_radix(start_hex, 16).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("bad vDSO address: {}", e))
let space = line.find(' ').unwrap_or(line.len());
let range = &line[..space];
if let Some(dash_pos) = range.find('-') {
let start = u64::from_str_radix(&range[..dash_pos], 16).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("bad vDSO start: {}", e))
})?;
let end = u64::from_str_radix(&range[dash_pos + 1..], 16).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("bad vDSO end: {}", e))
})?;
return Ok(addr);
return Ok((start, end - start));
}
}
}
Expand All @@ -31,6 +35,11 @@ pub(crate) fn find_vdso_base(pid: i32) -> io::Result<u64> {
))
}

/// Find the base address of the vDSO mapping for a given process.
pub(crate) fn find_vdso_base(pid: i32) -> io::Result<u64> {
find_vdso_range(pid).map(|(base, _)| base)
}

/// Read `len` bytes from `/proc/{pid}/mem` at the given address.
fn read_proc_mem(pid: i32, addr: u64, len: usize) -> io::Result<Vec<u8>> {
let mut file = File::open(format!("/proc/{}/mem", pid))?;
Expand Down Expand Up @@ -64,6 +73,41 @@ fn push_insn(stub: &mut Vec<u8>, insn: u32) {
stub.extend_from_slice(&insn.to_le_bytes());
}

/// Encode an arm64 unconditional `B target` instruction located at `from`.
/// `imm26` is signed and scaled by 4, so the reachable range is ±128 MiB.
#[cfg(target_arch = "aarch64")]
fn arm64_b_insn(from: u64, to: u64) -> Result<u32, SandlockError> {
let delta = to as i64 - from as i64;
if delta % 4 != 0 {
return Err(SandlockError::MemoryProtect(format!(
"arm64 B target {:#x} not 4-byte aligned from {:#x}",
to, from
)));
}
let offset = delta / 4;
if !(-(1i64 << 25)..(1i64 << 25)).contains(&offset) {
return Err(SandlockError::MemoryProtect(format!(
"arm64 B {:#x}->{:#x} out of ±128 MiB range",
from, to
)));
}
Ok(0x14000000u32 | ((offset as u32) & 0x03FF_FFFF))
}

/// Compute the offset within the vDSO mapping where the trampoline area starts —
/// just past the last symbol, rounded up to a 16-byte boundary.
#[cfg(target_arch = "aarch64")]
fn vdso_tramp_start(vdso_bytes: &[u8]) -> Option<u64> {
let elf = goblin::elf::Elf::parse(vdso_bytes).ok()?;
let highest_end = elf
.dynsyms
.iter()
.filter(|s| s.st_value != 0)
.map(|s| s.st_value + s.st_size)
.max()?;
Some((highest_end + 15) & !15)
}

#[cfg(target_arch = "aarch64")]
fn movz_x(reg: u32, imm16: u16, shift: u32) -> u32 {
0xD280_0000 | (((shift / 16) & 0x3) << 21) | ((imm16 as u32) << 5) | reg
Expand Down Expand Up @@ -218,11 +262,12 @@ pub(crate) fn patch(
time_offset_secs: Option<i64>,
_patch_for_random: bool,
) -> Result<(), SandlockError> {
let base = find_vdso_base(pid).map_err(|e| {
SandlockError::MemoryProtect(format!("failed to find vDSO base: {}", e))
let (base, mapping_size) = find_vdso_range(pid).map_err(|e| {
SandlockError::MemoryProtect(format!("failed to find vDSO range: {}", e))
})?;

let vdso_bytes = read_proc_mem(pid, base, 0x2000).map_err(|e| {
let read_size = std::cmp::min(mapping_size as usize, 0x4000);
let vdso_bytes = read_proc_mem(pid, base, read_size).map_err(|e| {
SandlockError::MemoryProtect(format!("failed to read vDSO memory: {}", e))
})?;

Expand All @@ -235,26 +280,76 @@ pub(crate) fn patch(
SandlockError::MemoryProtect(format!("failed to open /proc/{}/mem: {}", pid, e))
})?;

// arm64: place full stubs in slack space at the tail of the vDSO mapping and
// patch each function entry with a single 4-byte B that jumps to its stub.
// x86_64: stubs are short and inter-symbol gaps are wide; patch inline.
#[cfg(target_arch = "aarch64")]
let mut tramp_offset = vdso_tramp_start(&vdso_bytes).unwrap_or(0);

for (name, alt_name, syscall_nr) in vdso_targets() {
if let Some(&offset) = symbols.get(name).or_else(|| symbols.get(alt_name)) {
let addr = base + offset;
let entry_addr = base + offset;
let stub = match (time_offset_secs, name) {
(Some(off), "clock_gettime") => offset_stub_clock_gettime(off),
(Some(off), "gettimeofday") => offset_stub_gettimeofday(off),
_ => simple_stub(syscall_nr),
};
mem.seek(SeekFrom::Start(addr)).map_err(|e| {
SandlockError::MemoryProtect(format!(
"failed to seek to {} at {:#x}: {}",
name, addr, e
))
})?;
mem.write_all(&stub).map_err(|e| {
SandlockError::MemoryProtect(format!(
"failed to write {} stub at {:#x}: {}",
name, addr, e
))
})?;

#[cfg(target_arch = "x86_64")]
{
mem.seek(SeekFrom::Start(entry_addr)).map_err(|e| {
SandlockError::MemoryProtect(format!(
"failed to seek to {} at {:#x}: {}",
name, entry_addr, e
))
})?;
mem.write_all(&stub).map_err(|e| {
SandlockError::MemoryProtect(format!(
"failed to write {} stub at {:#x}: {}",
name, entry_addr, e
))
})?;
}

#[cfg(target_arch = "aarch64")]
{
if tramp_offset + stub.len() as u64 > mapping_size {
return Err(SandlockError::MemoryProtect(format!(
"vDSO trampoline area exhausted: need {} bytes at offset {:#x}, mapping ends at {:#x}",
stub.len(), tramp_offset, mapping_size
)));
}
let tramp_addr = base + tramp_offset;

mem.seek(SeekFrom::Start(tramp_addr)).map_err(|e| {
SandlockError::MemoryProtect(format!(
"failed to seek to {} trampoline at {:#x}: {}",
name, tramp_addr, e
))
})?;
mem.write_all(&stub).map_err(|e| {
SandlockError::MemoryProtect(format!(
"failed to write {} trampoline at {:#x}: {}",
name, tramp_addr, e
))
})?;

let b_insn = arm64_b_insn(entry_addr, tramp_addr)?;
mem.seek(SeekFrom::Start(entry_addr)).map_err(|e| {
SandlockError::MemoryProtect(format!(
"failed to seek to {} entry at {:#x}: {}",
name, entry_addr, e
))
})?;
mem.write_all(&b_insn.to_le_bytes()).map_err(|e| {
SandlockError::MemoryProtect(format!(
"failed to write {} branch at {:#x}: {}",
name, entry_addr, e
))
})?;

tramp_offset = (tramp_offset + stub.len() as u64 + 3) & !3;
}
}
}

Expand Down Expand Up @@ -288,18 +383,51 @@ mod tests {
}

#[test]
#[cfg(target_arch = "x86_64")]
fn test_simple_stub_size() {
let stub = simple_stub(228);
assert_eq!(stub.len(), 8);
assert_eq!(stub[0], 0xB8); // mov eax
}

#[test]
#[cfg(target_arch = "aarch64")]
fn test_simple_stub_size() {
let stub = simple_stub(228);
// movz x8, #228 / svc #0 / ret — three 4-byte instructions.
assert_eq!(stub.len(), 12);
}

#[test]
#[cfg(target_arch = "x86_64")]
fn test_offset_stub_contains_offset() {
let offset: i64 = -86400; // one day back
let stub = offset_stub_clock_gettime(offset);
// Should contain the offset bytes somewhere
// x86_64 encodes the offset as a single movabs imm64, so the 8 bytes
// appear contiguously in the stub.
let offset_bytes = offset.to_le_bytes();
assert!(stub.windows(8).any(|w| w == offset_bytes));
}

#[test]
#[cfg(target_arch = "aarch64")]
fn test_offset_stub_contains_offset() {
let offset: i64 = -86400;
let stub = offset_stub_clock_gettime(offset);
// arm64 splits a 64-bit immediate across movz/movk instructions, so the
// bytes are not contiguous. Verify each 16-bit chunk is encoded as a
// movz/movk imm16 field (bits 5..21 of the 32-bit instruction).
let raw = offset as u64;
for shift in 0..4 {
let chunk = ((raw >> (shift * 16)) & 0xFFFF) as u32;
if chunk == 0 {
continue; // a zero imm16 collides with too many other instructions to assert on
}
let found = stub.chunks_exact(4).any(|insn| {
let word = u32::from_le_bytes(insn.try_into().unwrap());
((word >> 5) & 0xFFFF) == chunk
});
assert!(found, "chunk {:#06x} for shift {} not encoded in stub", chunk, shift);
}
}
}
Loading