Skip to content

Commit 3d72715

Browse files
feat: full virtual memory subsystem
1 parent 7a66aba commit 3d72715

File tree

30 files changed

+1284
-240
lines changed

30 files changed

+1284
-240
lines changed

Cargo.lock

Lines changed: 47 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ kabort = { path = "libs/abort" }
2929
kpanic-unwind = { path = "libs/panic-unwind" }
3030
kutil = { path = "libs/util" }
3131
kasync = { path = "libs/async" }
32+
kmem = { path = "libs/mem" }
3233
kmem-core = { path = "libs/mem-core" }
34+
kmem-aslr = { path = "libs/mem-aslr" }
3335
karrayvec = { path = "libs/arrayvec" }
3436
range-tree = { path = "libs/range-tree" }
3537

libs/mem-aslr/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "kmem-aslr"
3+
version.workspace = true
4+
edition.workspace = true
5+
authors.workspace = true
6+
license.workspace = true
7+
8+
[dependencies]
9+
kmem-core.workspace = true
10+
11+
# 3rd-party dependencies
12+
rand.workspace = true
13+
rand_chacha.workspace = true
14+
15+
[lints]
16+
workspace = true

libs/mem-aslr/src/lib.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright 2025. Jonas Kruckenberg
2+
//
3+
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
4+
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5+
// http://opensource.org/licenses/MIT>, at your option. This file may not be
6+
// copied, modified, or distributed except according to those terms.
7+
8+
#![no_std]
9+
10+
//! The algorithm we use here - loosely based on Zircon's (Fuchsia's) implementation - is
11+
//! guaranteed to find a spot (if any even exist) with max 2 attempts. Additionally, it works
12+
//! elegantly *with* AND *without* ASLR, picking a random spot or the lowest free spot respectively.
13+
//! Here is how it works:
14+
//! 1. We set up two counters:
15+
//! - `candidate_spot_count` which we initialize to zero
16+
//! - `target_index` which we either set to a random value between 0..<the maximum number of
17+
//! possible addresses in the address space if ASLR is requested OR to zero otherwise.
18+
//! 2. We then iterate over all the gaps between virtual address regions from lowest to highest looking.
19+
//! We count the number of addresses in each gap that satisfy the requested `Layout`s size and
20+
//! alignment and add that to the `candidate_spot_count`. IF the number of spots in the gap is
21+
//! greater than our chosen target index, we pick the spot at the target index and finish.
22+
//! ELSE we *decrement* the target index by the number of spots and continue to the next gap.
23+
//! 3. After we have processed all the gaps, we have EITHER found a suitable spot OR our original
24+
//! guess for `target_index` was too big, in which case we need to retry.
25+
//! 4. When retrying we iterate over all gaps between virtual address regions *again*, but this time
26+
//! we know the *actual* number of possible spots in the address space since we just counted them
27+
//! during the first attempt. We initialize `target_index` to `0..candidate_spot_count`
28+
//! which is guaranteed to return us a spot.
29+
//! IF `candidate_spot_count` is ZERO after the first attempt, there is no point in
30+
//! retrying since we cannot fulfill the requested layout.
31+
//!
32+
//! Note that in practice, we use a binary tree to keep track of regions, and we use binary search
33+
//! to optimize the search for a suitable gap instead of linear iteration.
34+
35+
use core::alloc::Layout;
36+
use core::marker::PhantomData;
37+
use core::ops::Range;
38+
39+
use kmem_core::{AddressRangeExt, Arch, VirtualAddress};
40+
use rand::Rng;
41+
use rand::distr::Uniform;
42+
use rand_chacha::ChaCha20Rng;
43+
44+
pub struct Randomizer<A: Arch> {
45+
rng: Option<ChaCha20Rng>,
46+
_arch: PhantomData<A>,
47+
}
48+
49+
impl<A: Arch> Randomizer<A> {
50+
pub const fn new(rng: Option<ChaCha20Rng>) -> Self {
51+
Self {
52+
rng,
53+
_arch: PhantomData,
54+
}
55+
}
56+
57+
/// Find a spot in the given `gaps` that satisfies the given `layout` requirements.
58+
///
59+
/// If a spot suitable for holding data described by `layout` is found, the base address of the
60+
/// address range is returned in `Some`. The returned address is already correct aligned to
61+
/// `layout.align()`.
62+
///
63+
/// Returns `None` if no suitable spot was found. This *does not* mean there are no more gaps in
64+
/// the address space just that the *combination* of `layout.size()` and `layout.align()` cannot
65+
/// be satisfied *at the moment*. Calls to this method will a different size, alignment, or at a
66+
/// different time might still succeed.
67+
#[expect(clippy::missing_panics_doc, reason = "internal assert")]
68+
pub fn find_spot_in(
69+
&mut self,
70+
layout: Layout,
71+
gaps: impl Iterator<Item = Range<VirtualAddress>> + Clone,
72+
) -> Option<VirtualAddress> {
73+
let layout = layout.align_to(A::GRANULE_SIZE).unwrap();
74+
75+
// First attempt: guess a random target index from all possible virtual addresses
76+
let max_candidate_spots = const {
77+
// The bits of entropy that we can pick a virtual address from. The theoretical maximum
78+
// is just the number of usable bits in a virtual address. E.g. if a given architecture
79+
// supports 48 bits virtual addresses, then our theoretical maximum entropy is 47 bits.
80+
// But, because the virtual memory subsystem doesn't deal in byte-granularity allocations
81+
// but in pages we need to subtract the number of bits that are just used to encode
82+
// the offset within a page (which is the log2 of the translation granule size).
83+
let entropy = A::VIRTUAL_ADDRESS_BITS as u32 - A::GRANULE_SIZE.ilog2();
84+
85+
(1 << entropy) - 1
86+
};
87+
88+
let distr = Uniform::new(0, max_candidate_spots)
89+
.expect("no candidate spots in max range, this is a bug!");
90+
let target_index: usize = self
91+
.rng
92+
.as_mut()
93+
.map(|rng| rng.sample(distr))
94+
.unwrap_or_default();
95+
96+
// First attempt: visit the binary search tree to find a gap
97+
choose_spot(layout, target_index, gaps.clone())
98+
// Second attempt: pick a new target_index that's actually fulfillable
99+
// based on the candidate spots we counted during the previous attempt
100+
.map_err(|candidate_spots| {
101+
// if we counted no suitable candidate spots during the first attempt, we cannot fulfill
102+
// the request.
103+
if candidate_spots == 0 {
104+
return None;
105+
}
106+
107+
let distr = Uniform::new(0, candidate_spots).unwrap();
108+
109+
let target_index: usize = self
110+
.rng
111+
.as_mut()
112+
.map(|rng| rng.sample(distr))
113+
.unwrap_or_default();
114+
115+
let chosen_spot = choose_spot(layout, target_index, gaps)
116+
.expect("There must be a chosen spot after the first attempt. This is a bug!");
117+
118+
Some(chosen_spot)
119+
})
120+
.ok()
121+
}
122+
}
123+
124+
fn choose_spot(
125+
layout: Layout,
126+
mut target_index: usize,
127+
gaps: impl Iterator<Item = Range<VirtualAddress>>,
128+
) -> Result<VirtualAddress, usize> {
129+
let mut candidate_spots = 0;
130+
131+
for gap in gaps {
132+
let aligned_gap = gap.align_in(layout.align());
133+
134+
let spot_count = spots_in_range(layout, &aligned_gap);
135+
136+
candidate_spots += spot_count;
137+
138+
if target_index < spot_count {
139+
return Ok(aligned_gap
140+
.start
141+
.add(target_index << layout.align().ilog2()));
142+
} else {
143+
target_index -= spot_count;
144+
}
145+
}
146+
Err(candidate_spots)
147+
}
148+
149+
/// Returns the number of spots in the given range that satisfy the layout we require
150+
fn spots_in_range(layout: Layout, range: &Range<VirtualAddress>) -> usize {
151+
debug_assert!(
152+
range.start.is_aligned_to(layout.align()) && range.end.is_aligned_to(layout.align())
153+
);
154+
155+
// ranges passed in here can become empty for a number of reasons (aligning might produce ranges
156+
// where end > start, or the range might be empty to begin with) in either case an empty
157+
// range means no spots are available
158+
if range.is_empty() {
159+
return 0;
160+
}
161+
162+
let range_size = range.len();
163+
if range_size >= layout.size() {
164+
((range_size - layout.size()) >> layout.align().ilog2()) + 1
165+
} else {
166+
0
167+
}
168+
}

libs/mem-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ kspin = { workspace = true, optional = true }
1313
proptest = { workspace = true, optional = true }
1414
proptest-derive = { workspace = true, optional = true }
1515
parking_lot = { version = "0.12.5", optional = true }
16+
range-tree.workspace = true
1617

1718
# 3rd-party dependencies
1819
mycelium-bitfield.workspace = true

libs/mem-core/src/address.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
// http://opensource.org/licenses/MIT>, at your option. This file may not be
66
// copied, modified, or distributed except according to those terms.
77

8+
use std::num::NonZeroU64;
9+
10+
use range_tree::RangeTreeIndex;
11+
812
use crate::arch::Arch;
913

1014
macro_rules! impl_address_from {
@@ -310,6 +314,18 @@ impl VirtualAddress {
310314
}
311315
}
312316

317+
impl RangeTreeIndex for VirtualAddress {
318+
type Int = NonZeroU64;
319+
320+
fn to_int(self) -> Self::Int {
321+
NonZeroU64::new(u64::try_from(self.get()).unwrap()).unwrap()
322+
}
323+
324+
fn from_int(int: Self::Int) -> Self {
325+
Self::new(usize::try_from(int.get()).unwrap())
326+
}
327+
}
328+
313329
#[repr(transparent)]
314330
#[derive(Default, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
315331
#[cfg_attr(feature = "test_utils", derive(proptest_derive::Arbitrary))]

0 commit comments

Comments
 (0)