learn: prototype sandlock learn subcommand#113
Conversation
|
Hi @ghazariann Thanks for your PR! It looks nice from my quick glance. Regarding your questions:
|
|
Hi @ghazariann Thanks for working on it. One design question: why not leveraging the existing |
…icy_fn, expose path/flags in SyscallEvent
Hi @congwang-mk, that makes more sense. I have extended My only concern is Also added resource peak sampling ([limits].memory, [limits].processes, [limits].open_files are now populated from observed peaks). |
|
@ghazariann Thanks for the update! Your concern is valid. The TOCTOU is very hard to resolve without help from kernel. This is why I submitted a linux kernel patchset to fix this: https://lore.kernel.org/all/20260704231831.354543-1-xiyou.wangcong@gmail.com/ On the bright side, this PR does not make it worse. |
congwang-mk
left a comment
There was a problem hiding this comment.
Thanks for the PR. Some comments below:
| } | ||
| "connect" | "sendto" => { | ||
| if let (Some(ip), Some(port)) = (event.host, event.port) { | ||
| self.connects.lock().unwrap().insert(format!("tcp://{ip}:{port}")); |
There was a problem hiding this comment.
I think we need to distinguish UDP and TCP here?
| pub denied: bool, | ||
| /// Resolved absolute path for file syscalls (openat, execve/execveat). | ||
| /// Read from the kernel's fd table via `/proc/<pid>/fd/<fd>` after the | ||
| /// supervisor's on-behalf open — not from child user memory — so it is |
There was a problem hiding this comment.
I think we read from child's memory via read_path_for_event(). So this comment needs to be updated?
| // openat(dirfd, pathname, flags, mode): resolved path + flags. | ||
| // Path is read via /proc/<pid>/fd after the on-behalf open — TOCTOU-safe. | ||
| // openat2 uses struct open_how* for flags so flags is left None there. | ||
| if nr == libc::SYS_openat || Some(nr) == arch::sys_open() { |
There was a problem hiding this comment.
Is openat2() missing here?
|
|
||
| sampler.abort(); | ||
|
|
||
| eprintln!("sandlock learn: done (exit={:?})", result.code()); |
There was a problem hiding this comment.
If the target program exits abnormally, should we still continue writing out its profile?
|
Two more comments:
|
Some thoughts on
|
|
Quick follow-up on the path-collapsing side, since it ties straight into the discussion in #72 — and we think it's one of the more important application aspects here, the thing that separates a genuinely minimal profile from " In #72 the collapser safety came up and the design landed on three guards: no collapse by default, a hard sensitive-prefix deny-list that's never aggregated regardless of how many files were touched ( Why we're flagging it here: the current prototype already does one directory collapse today, on the write side, without those guards. For a new file that COW intercepted,
None of those siblings were observed — exactly the "minimum-so-it-runs ≠ minimum-permission" divergence from #72. The write side is actually the trickier one, because unlike reads you can't just keep the leaf: Landlock has no "create only this one file" right — a new file inherently needs a write grant on an existing directory, and in the non-chroot path a non-existent rule path aborts Not trying to expand scope — just that this is the one spot where "don't collapse blindly" is already live in the code, and it's the aspect #72 spent the most time on, so it felt worth connecting the two. |
Prototype implementation of
sandlock learn(#72).Summary
Adds
sandlock learn -o profile.toml -- <cmd>which runs a workload under observationand writes a sandlock profile TOML directly usable by
sandlock run -p profile.toml.on_file_accessaudit hook in supervisor onopenat/open/execve/execveatO_WRONLY/O_RDWR/O_CREAT)on_net_connectaudit hook in supervisor onconnect/sendto/sendmsgImplementation
Runs the workload under fully-permissive Landlock and intercepts syscalls via two
audit hooks added to the sandlock-core supervisor, called before dispatch on every
notification:
on_file_access(path, flags)—openat/open/execve/execveaton_net_connect(ip, port)—connect/sendto/sendmsgResults are collected and serialized to a
ProfileInputTOML.Discussion points
1. Capturing the executed binary
For
sandlock learn -- cat /etc/hostname,/usr/bin/catmust appear in the profile sosandlock runcan allow it via Landlock. The binary is loaded throughexecve, notopenat, so theon_file_accesshook alone is not enough.Adding
execveto the hook condition inhandle_notificationwas not sufficient on its own. The seccomp BPF filter decides which syscalls generate aSECCOMP_RET_USER_NOTIF, and for a basic sandbox (fs_read,fs_write)execvewas not in that list. It only enterednotif_syscallsfor heavier features (COW, chroot). The notification never reached the supervisor.The fix: a new
audit_file_accessfeature flag inSandboxFeaturesthat is true whenon_file_accessis set. Innotif_syscalls_resolvedthis addsexecve/execveatto the BPF notif list.resolve_path_for_notifalready handled execve, so no other supervisor logic changed.Is this the right place to wire this? Should
execvehave its own separate hook (on_execve) instead of being folded intoon_file_access?2. Resolving the dynamic linker
After
execvethe kernel maps the dynamic linker (e.g./lib64/ld-linux-x86-64.so.2) in kernel space before transferring control to userspace, no syscall fires, so it never appears in theon_file_accesstrace. Without it in the profile,sandlock runfails: Landlock blocks the read of the linker and the process cannot start at all.The current workaround parses the ELF
PT_INTERPsegment of the binary (ELF64 only) to recover the interpreter path. This is ad-hoc and not portable (assumes ELF64, specific header offsets, and manual endianness handling).One idea: the linker appears in
/proc/<pid>/mapsafter execve completes. The supervisor already reads/proc/<pid>/mapsfor vDSO patching (maybe_patch_vdso), so the pattern exists. But im not sure with the timing cause the execve notification fires before the kernel completes exec.I'm not that experienced in kernel development and would love guidance on the right approach here.
3. Achieving permissive Landlock during observation
The spec calls for permissive Landlock + seccomp-notify during observation. For reads this is straightforward:
.fs_read("/"). For writes,.fs_write("/")is not an option: the observation run must be non-destructive, leaving no trace on the real filesystem.The current approach pairs
.fs_read("/")with.workdir(tempdir)(COW overlay): writes are granted everywhere but redirected to a temporary overlay, so the real filesystem is never touched. Theon_file_accesshook fires before the COW redirect, so it always sees the original requested path, which is what ends up in the profile.Is COW the right way to achieve a fully permissive observation environment? Is there a lighter approach?
What still needs to be done
http.allow)--learn-syscalls)/procsamplingTests
test_learn_captures_fs_read— runscat /etc/hostname, checks/etc/hostnameappears underreadtest_learn_then_run— full round-trip: learn generates profile fromcat /etc/hostname,sandlock runuses ittest_learn_captures_fs_write— writes to a pre-existingNamedTempFile(file exists before learn runs), checks path appears underwritetest_learn_new_file_collapses_to_parent— writes to a file that does not exist; checks the profile records the parent directory, and the real file is never created (COW isolation)test_learn_then_run_write— round-trip for writes: learn captures a write, run actually creates the filetest_learn_captures_net_connect— binds a realTcpListener, runs a Python connect, checks the address appears under[network] allowtest_learn_then_run_network— round-trip for network: single listener accepts two connections, one from learn and one from runTest plan
cargo test -p sandlock-cli test_learnsandlock learn -o /tmp/profile.toml -- <cmd>sandlock run -p /tmp/profile.toml -- <cmd>