Skip to content

Commit b082095

Browse files
committed
fix(memtrack): prevent stale rmap ownership attribution
Drop stale mm ownership when a PID changes address spaces. Validate foreign rmap ownership and preserve live CLONE_VM parent bindings. Add deterministic exec-churn and clone-vm regressions plus xz/bzip2 coverage. Fixes COD-3285
1 parent 1e9b524 commit b082095

10 files changed

Lines changed: 573 additions & 167 deletions

File tree

crates/memtrack/src/ebpf/c/rss.bpf.h

Lines changed: 59 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,11 @@
55
#include "utils/event_helpers.h"
66
#include "utils/process_tracking.h"
77

8-
/* (rss_stat mm_id << 32 | member) -> {owning tgid, that tgid's mm at seeding time,
9-
* last in-context size}. Keyed per counter so an external (curr==0) update is
10-
* attributed only once that mm/member was established in-context. An external event
11-
* may only lower a counter: any size above the last in-context value is dropped, so
12-
* neither a stale/racing reclaim read nor an mm_id hash collision with another task
13-
* can invent a peak. mm_id is only a hash, so the pointer is stored alongside and
14-
* revalidated against pid_mm on the external path: an entry whose mm the owner no
15-
* longer holds describes a freed mm_struct whose slab slot (and therefore hash) has
16-
* been recycled by an unrelated address space. */
8+
/* (rss_stat mm_id << 32 | member) -> {owning tgid, its mm when seeded, last
9+
* in-context size}. An external (curr==0) update may only lower the counter, and
10+
* only while pid_mm still binds the owner to the seeded mm: mm_id is a hash, so
11+
* without both guards a stale reclaim read, a hash collision, or a recycled
12+
* mm_struct slab slot could invent a peak. */
1713
struct rss_owner {
1814
__u32 pid;
1915
__u64 mm;
@@ -29,19 +25,11 @@ struct {
2925
/* Foreign-actor rmap attribution: rmap events run by a task other than the mm's
3026
* owner (kswapd reclaim, another process's process_madvise, khugepaged, KSM,
3127
* uffd) carry no owning-pid context, so mm_owner recovers it from the mm_struct
32-
* pointer. pid_mm is the inverse, letting the exec and exit hooks remove an entry
33-
* by value.
28+
* pointer. pid_mm is the inverse, letting exec and exit remove an entry by
29+
* value; attribution requires both to agree, so a stale mm fails closed.
3430
*
35-
* Lifecycle invariant: every mm_owner entry is removed when its process execs
36-
* (the old mm is freed mid-life) or when its thread group dies, whichever comes
37-
* first; LRU eviction is only a backstop. A stale entry surviving mm-pointer
38-
* reuse would misattribute another process's events, so ownership is only ever
39-
* registered from an in-context (task->mm == mm) event.
40-
*
41-
* pid_mm is a plain hash on purpose: an LRU inverse could be evicted while its
42-
* forward twin stays lookup-hot, leaving exec/exit unable to remove the live
43-
* mm_owner entry. Like tracked_pids, its entries are bound to the process
44-
* lifecycle and removed at group death. */
31+
* pid_mm must not use LRU eviction: losing the inverse binding would leave exec
32+
* and exit unable to remove the forward entry. */
4533
struct {
4634
__uint(type, BPF_MAP_TYPE_LRU_HASH);
4735
__uint(max_entries, 10240);
@@ -50,13 +38,42 @@ struct {
5038
} mm_owner SEC(".maps");
5139
BPF_HASH_MAP(pid_mm, __u32, __u64, 10240);
5240

53-
/* Guard-then-write keeps the common case a read-only lookup (pid_mm is a plain
54-
* hash, so a fresh entry never needs rewriting). The recorded mm is what the
55-
* external rss_stat path validates against to detect a recycled mm_struct. */
56-
static __always_inline void refresh_pid_mm(__u32 pid, __u64 mm) {
57-
__u64* cur_mm = bpf_map_lookup_elem(&pid_mm, &pid);
58-
if (!cur_mm || *cur_mm != mm) {
41+
/* Rebind pid's address space; mm == 0 unbinds at exit. The mm_owner entry is only
42+
* dropped while it still names pid: a live CLONE_VM sibling shares the mm and must
43+
* keep its registration. */
44+
static __always_inline void set_pid_mm(__u32 pid, __u64 mm) {
45+
__u64* cur = bpf_map_lookup_elem(&pid_mm, &pid);
46+
if (cur && *cur == mm) {
47+
return;
48+
}
49+
if (cur) {
50+
__u32* owner = bpf_map_lookup_elem(&mm_owner, cur);
51+
if (owner && *owner == pid) {
52+
bpf_map_delete_elem(&mm_owner, cur);
53+
}
54+
}
55+
if (mm) {
5956
bpf_map_update_elem(&pid_mm, &pid, &mm, BPF_ANY);
57+
} else {
58+
bpf_map_delete_elem(&pid_mm, &pid);
59+
}
60+
}
61+
62+
/* Claim mm for pid without stealing from a live owner: CLONE_VM siblings share the
63+
* mm, and overwriting would let the child's exec-time cleanup delete the entry out
64+
* from under the still-live parent. */
65+
static __always_inline void mm_owner_take(__u64 mm, __u32 pid) {
66+
__u32* reg = bpf_map_lookup_elem(&mm_owner, &mm);
67+
if (!reg) {
68+
bpf_map_update_elem(&mm_owner, &mm, &pid, BPF_ANY);
69+
return;
70+
}
71+
if (*reg == pid) {
72+
return;
73+
}
74+
__u64* reg_mm = bpf_map_lookup_elem(&pid_mm, reg);
75+
if (!reg_mm || *reg_mm != mm) {
76+
bpf_map_update_elem(&mm_owner, &mm, &pid, BPF_ANY);
6077
}
6178
}
6279

@@ -87,15 +104,13 @@ int tracepoint_rss_stat(struct trace_event_raw_rss_stat* ctx) {
87104
return 0;
88105
}
89106
owner = cur;
90-
/* curr means current->mm is the mm the counter belongs to. Recording it
91-
* (and keeping pid_mm current, which the rmap hooks may never do when
92-
* only rss_stat is attached) is what lets the external path below tell a
93-
* live mm from a recycled slab slot. */
107+
/* curr == 1 means current->mm is the counter's mm. pid_mm is maintained
108+
* here too because the rmap hooks may not be attached. */
94109
struct task_struct* task = bpf_get_current_task_btf();
95110
__u64 mm = (__u64)BPF_CORE_READ(task, mm);
96111
struct rss_owner state = {.pid = cur, .mm = mm, .size = size};
97112
bpf_map_update_elem(&rss_counter_owner, &key, &state, BPF_ANY);
98-
refresh_pid_mm(cur, mm);
113+
set_pid_mm(cur, mm);
99114
} else {
100115
struct rss_owner* found = bpf_map_lookup_elem(&rss_counter_owner, &key);
101116
if (!found) {
@@ -108,10 +123,6 @@ int tracepoint_rss_stat(struct trace_event_raw_rss_stat* ctx) {
108123
if (cur == owner) {
109124
return 0;
110125
}
111-
/* mm_id is a hash of a pointer the tracepoint never exposes, so the key
112-
* survives the mm it was seeded from. Once the owner no longer holds that
113-
* mm (it execed, or the entry predates a pid reuse), the hash now belongs
114-
* to a recycled mm_struct and its counters describe another address space. */
115126
__u64* owner_mm = bpf_map_lookup_elem(&pid_mm, &owner);
116127
if (!owner_mm || *owner_mm != found->mm) {
117128
return 0;
@@ -205,14 +216,8 @@ static __always_inline int submit_rmap(struct vm_area_struct* vma, __s32 member,
205216
return 0;
206217
}
207218

208-
/* Register ownership so foreign actors can later attribute to this pid.
209-
* The guarded updates keep the hot path read-only in the common case and
210-
* keep the LRU mm_owner entry fresh even when nothing else touches it. */
211-
__u32* reg = bpf_map_lookup_elem(&mm_owner, &mm);
212-
if (!reg || *reg != pid) {
213-
bpf_map_update_elem(&mm_owner, &mm, &pid, BPF_ANY);
214-
}
215-
refresh_pid_mm(pid, mm);
219+
mm_owner_take(mm, pid);
220+
set_pid_mm(pid, mm);
216221
owner = pid;
217222
} else {
218223
/* Foreign actor (task->mm != mm, including kthreads whose task->mm is NULL):
@@ -226,6 +231,12 @@ static __always_inline int submit_rmap(struct vm_area_struct* vma, __s32 member,
226231
if (!is_tracked(owner)) {
227232
return 0;
228233
}
234+
/* An mm_struct address may be reused while a stale owner entry remains.
235+
* Accept only the current inverse binding. */
236+
__u64* owner_mm = bpf_map_lookup_elem(&pid_mm, &owner);
237+
if (!owner_mm || *owner_mm != mm) {
238+
return 0;
239+
}
229240
}
230241

231242
/* header.tid is stamped from the current task; for a foreign actor it
@@ -331,39 +342,20 @@ int tracepoint_task_newtask(struct trace_event_raw_task_newtask* ctx) {
331342
SUBMIT_EVENT_AS(child_pid, EVENT_TYPE_FORK, { e->data.fork.parent_pid = parent_pid; });
332343
}
333344

334-
/* Remove pid's ownership registration. The mm_owner value is verified against
335-
* pid before deleting: a stale pid_mm entry (LRU eviction skew) could otherwise
336-
* point at an mm since re-registered by another process, and deleting that
337-
* would silence a live owner's foreign attribution. */
338-
static __always_inline void drop_mm_ownership(__u32 pid) {
339-
__u64* mm = bpf_map_lookup_elem(&pid_mm, &pid);
340-
if (mm) {
341-
__u32* owner = bpf_map_lookup_elem(&mm_owner, mm);
342-
if (owner && *owner == pid) {
343-
bpf_map_delete_elem(&mm_owner, mm);
344-
}
345-
}
346-
bpf_map_delete_elem(&pid_mm, &pid);
347-
}
348-
349345
SEC("tracepoint/sched/sched_process_exec")
350346
int tracepoint_sched_process_exec(void* ctx) {
351347
__u32 pid = bpf_get_current_pid_tgid() >> 32;
352348
if (!is_tracked(pid)) {
353349
return 0;
354350
}
355351

356-
/* Maintain ownership before submitting (SUBMIT_EVENT_AS returns from the
357-
* function). Exec frees the old mm long before group death, so the stale
358-
* pointer must be dropped here or a reused mm_struct would be misattributed. */
359-
drop_mm_ownership(pid);
360-
352+
/* SUBMIT_EVENT_AS returns, so the rebind must precede it. */
361353
struct task_struct* task = bpf_get_current_task_btf();
362354
__u64 new_mm = (__u64)BPF_CORE_READ(task, mm);
363355
if (new_mm) {
364-
bpf_map_update_elem(&mm_owner, &new_mm, &pid, BPF_ANY);
365-
bpf_map_update_elem(&pid_mm, &pid, &new_mm, BPF_ANY);
356+
mm_owner_take(new_mm, pid);
366357
}
358+
set_pid_mm(pid, new_mm);
367359

368360
SUBMIT_EVENT_AS(pid, EVENT_TYPE_EXEC, {});
369361
}
@@ -398,7 +390,7 @@ int tracepoint_sched_process_exit(void* ctx) {
398390

399391
/* Drop the ownership mapping so foreign actors stop attributing to a pid
400392
* the kernel may reuse. */
401-
drop_mm_ownership(pid);
393+
set_pid_mm(pid, 0);
402394

403395
SUBMIT_EVENT_AS(pid, EVENT_TYPE_EXIT, {});
404396
}

crates/memtrack/src/ebpf/memtrack/maps.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,50 @@ impl MemtrackBpf {
6767
"dropped_events",
6868
)
6969
}
70+
71+
pub fn ownership_maps(&self) -> Result<OwnershipMaps> {
72+
let mm_owner = entries(with_skel!(self, skel => &skel.maps.mm_owner))?;
73+
let pid_mm = entries(with_skel!(self, skel => &skel.maps.pid_mm))?;
74+
Ok(OwnershipMaps {
75+
mm_owner: mm_owner
76+
.into_iter()
77+
.map(|(mm, pid)| (mm, pid as u32))
78+
.collect(),
79+
pid_mm: pid_mm
80+
.into_iter()
81+
.map(|(pid, mm)| (pid as u32, mm))
82+
.collect(),
83+
})
84+
}
85+
}
86+
87+
/// Live ownership bindings: `mm_owner` (`mm_struct` pointer -> owning pid) and its
88+
/// inverse `pid_mm`, which foreign-actor rmap attribution validates against.
89+
pub struct OwnershipMaps {
90+
pub mm_owner: Vec<(u64, u32)>,
91+
pub pid_mm: Vec<(u32, u64)>,
92+
}
93+
94+
/// Iteration is `BPF_MAP_GET_NEXT_KEY` followed by a separate lookup, so it is not
95+
/// atomic: a key deleted in between is skipped rather than reported.
96+
fn entries(map: &impl MapCore) -> Result<Vec<(u64, u64)>> {
97+
let mut entries = Vec::new();
98+
for key in map.keys() {
99+
let value = map
100+
.lookup(&key, libbpf_rs::MapFlags::ANY)
101+
.context("Failed to read map entry")?;
102+
if let Some(value) = value {
103+
entries.push((le(&key), le(&value)));
104+
}
105+
}
106+
Ok(entries)
107+
}
108+
109+
fn le(bytes: &[u8]) -> u64 {
110+
bytes
111+
.iter()
112+
.rev()
113+
.fold(0, |acc, &b| acc << 8 | u64::from(b))
70114
}
71115

72116
/// Read slot 0 of a single-entry `__u64` array map.

crates/memtrack/src/ebpf/memtrack/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ mod allocator;
2222
mod maps;
2323
mod tracking;
2424

25+
pub use maps::OwnershipMaps;
26+
2527
use crate::bpf_token::has_delegated_bpf_token;
2628

2729
/// Which attach mechanism a loaded skeleton uses for its uprobes. See

crates/memtrack/src/ebpf/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ mod proc_fs;
66
mod spawn;
77
mod tracker;
88

9-
pub use memtrack::{BpfVariant, MemtrackBpf, ResolvedSymbols, resolve_symbol_offsets};
9+
pub use memtrack::{
10+
BpfVariant, MemtrackBpf, OwnershipMaps, ResolvedSymbols, resolve_symbol_offsets,
11+
};
1012
pub use tracker::{Tracker, TrackerOptions};

crates/memtrack/src/ebpf/tracker.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::ebpf::attach_worker::AttachWorker;
22
use crate::ebpf::spawn::{resume, spawn_stopped, wrap_stopped};
3-
use crate::ebpf::{BpfVariant, MemtrackBpf};
3+
use crate::ebpf::{BpfVariant, MemtrackBpf, OwnershipMaps};
44
use crate::prelude::*;
55
use crate::session::Session;
66
use parking_lot::Mutex;
@@ -135,6 +135,11 @@ impl Tracker {
135135
self.bpf.lock().dropped_events_count()
136136
}
137137

138+
/// Only meaningful while the BPF object is alive; teardown frees the maps.
139+
pub fn ownership_maps(&self) -> Result<OwnershipMaps> {
140+
self.bpf.lock().ownership_maps()
141+
}
142+
138143
/// Stop the attach worker, if any, and surface any fatal error it recorded,
139144
/// including missed exec mappings (incomplete allocator coverage). A tracker
140145
/// without an allocator watcher has no worker, so this is a no-op.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#ifndef FILE_REGION_H
2+
#define FILE_REGION_H
3+
4+
#include <fcntl.h>
5+
#include <stdio.h>
6+
#include <stdlib.h>
7+
#include <sys/mman.h>
8+
#include <sys/syscall.h>
9+
#include <sys/uio.h>
10+
#include <sys/wait.h>
11+
#include <unistd.h>
12+
13+
/* Maps len bytes of a private file mapping and faults every page in, so the
14+
* pages land in the caller's RSS (MM_FILEPAGES) in-context and seed ownership.
15+
* Returns the mapping, or NULL.
16+
*
17+
* The data file is derived from base rather than placed in /tmp, which is tmpfs
18+
* on Ubuntu >= 25.04 and would account mapped file pages as shmem, not file. */
19+
static void* map_and_fault_file(const char* base, size_t len) {
20+
char path[4096];
21+
snprintf(path, sizeof(path), "%s.data-XXXXXX", base);
22+
int fd = mkstemp(path);
23+
if (fd < 0) return NULL;
24+
unlink(path);
25+
if (ftruncate(fd, len) != 0) return NULL;
26+
27+
void* mem = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0);
28+
if (mem == MAP_FAILED) return NULL;
29+
30+
/* volatile so the reads are not elided. 4096 is the fault stride: a smaller
31+
* one would fault the same page twice, a larger one would skip pages. */
32+
volatile char sink = 0;
33+
for (size_t i = 0; i < len; i += 4096) sink ^= ((volatile char*)mem)[i];
34+
(void)sink;
35+
return mem;
36+
}
37+
38+
/* Pages out [mem, mem+len) of the calling process from a forked child, and
39+
* waits for it. The reclaim must run in another task's context to exercise
40+
* foreign-actor attribution. Returns 0 on success. */
41+
static int reclaim_from_child(void* mem, size_t len) {
42+
pid_t pid = fork();
43+
if (pid < 0) return 1;
44+
if (pid == 0) {
45+
int pidfd = syscall(SYS_pidfd_open, getppid(), 0);
46+
if (pidfd < 0) _exit(1);
47+
struct iovec iov = {.iov_base = mem, .iov_len = len};
48+
syscall(SYS_process_madvise, pidfd, &iov, 1UL, MADV_PAGEOUT, 0UL);
49+
_exit(0);
50+
}
51+
int status;
52+
return waitpid(pid, &status, 0) < 0;
53+
}
54+
55+
#endif

0 commit comments

Comments
 (0)