Skip to content

feat(jailer): add Landlock LSM sandboxing via --landlock flag#5771

Open
pavitrabhalla wants to merge 7 commits into
firecracker-microvm:mainfrom
superserve-ai:pavitrabhalla/issue-5513-impl
Open

feat(jailer): add Landlock LSM sandboxing via --landlock flag#5771
pavitrabhalla wants to merge 7 commits into
firecracker-microvm:mainfrom
superserve-ai:pavitrabhalla/issue-5513-impl

Conversation

@pavitrabhalla

@pavitrabhalla pavitrabhalla commented Mar 18, 2026

Copy link
Copy Markdown

Summary

This PR implements the feature requested in #5513 — adding opt-in Landlock LSM support to the Firecracker jailer as a defense-in-depth mechanism.

What it does

Adds a --landlock flag to the jailer. When passed:

  1. Before pivot_root: a Landlock ruleset is created and a PathFd is opened on the jail directory, capturing its inode
  2. Right before exec: restrict_self() is called, enforcing the ruleset on the jailed Firecracker process

The ruleset grants all filesystem access rights within the jail directory and denies everything outside. This means if Firecracker escapes the pivot_root chroot via a kernel exploit, Landlock independently prevents access to files outside the jail — enforced by a separate LSM path in the kernel.

The flag is opt-in — existing deployments are completely unaffected.

Why Landlock on top of chroot?

pivot_root + chroot lives in the kernel's VFS layer and can be bypassed by a guest-triggered kernel exploit. Landlock is enforced by a separate LSM subsystem and independently blocks filesystem access, so it provides defense-in-depth even if pivot_root is defeated. The inode-based rules (not path-based) also survive the pivot_root boundary.

Verification

Tested on kernel 6.8.0-1048-gcp (Landlock V1/V2/V3 supported). strace confirms the correct syscalls are made:

Without --landlock — no Landlock syscalls:

+++ exited with 0 +++

With --landlock — all 4 Landlock syscalls succeed:

landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) = 4
landlock_create_ruleset({handled_access_fs=LANDLOCK_ACCESS_FS_EXECUTE|...|LANDLOCK_ACCESS_FS_MAKE_SYM, ...}, 24, 0) = 4
landlock_add_rule(4, LANDLOCK_RULE_PATH_BENEATH, {allowed_access=..., parent_fd=3}, 0) = 0
landlock_restrict_self(4, 0) = 0
+++ exited with 0 +++

parent_fd=3 is the PathFd opened before pivot_root — confirming the inode reference survives the chroot boundary.

Changes

  • src/jailer/src/landlock.rs (new): prepare_ruleset() and enforce()
  • src/jailer/src/env.rs: wires Landlock into run() — before chroot and before exec
  • src/jailer/src/main.rs: --landlock CLI flag + JailerError::Landlock variant
  • src/jailer/Cargo.toml: landlock = "0.4" dependency
  • tests/framework/jailer.py: landlock parameter in JailerContext
  • CHANGELOG.md: entry under [Unreleased] → Added

License Acceptance

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

PR Checklist

  • I have read and understand CONTRIBUTING.md.
  • I have run tools/devtool checkbuild --all to verify that the PR passes build checks on all supported architectures.
  • I have run tools/devtool checkstyle to verify that the PR passes the automated style checks.
  • I have described what is done in these changes, why they are needed, and how they are solving the problem in a clear and encompassing way.
  • I have updated any relevant documentation (both in code and in the docs) in the PR.
  • I have mentioned all user-facing changes in CHANGELOG.md.
  • If a specific issue led to this PR, this PR closes the issue.
  • When making API changes, I have followed the Runbook for Firecracker API changes.
  • I have tested all new and changed functionalities in unit tests and/or integration tests.
  • I have linked an issue to every new TODO.

  • This functionality cannot be added in rust-vmm.

Comment thread src/jailer/src/landlock.rs Outdated
/// Returns [`JailerError::Landlock`] if the kernel does not support Landlock (kernel < 5.13),
/// if `jail_dir` cannot be opened, or if any ruleset syscall fails.
pub fn prepare_ruleset(jail_dir: &Path) -> Result<RulesetCreated, JailerError> {
let abi = ABI::V1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't pin to ABI::V1 but to the latest that you actually tested on a machine. Restricting only to ABI::V1 will limit the features to only the initial ones, whereas if you take the greatest version you'll get all the ones supported by the running system (i.e. best-effort). See the documentation. BTW, this version should regularly be incremented once the new version is tested on a kernel supporting it.

Comment thread src/jailer/src/landlock.rs
Comment thread src/jailer/src/landlock.rs Outdated
let mut parts = release.split('.');
let major: i32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let minor: i32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
major > 5 || (major == 5 && minor >= 13)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't check kernel version. Landlock may be available/backported to older kernels. Landlock provides a dedicated interface to check which ABI version is supported by the running kernel. You should use LandlockStatus instead, but keep in mind that there are only a few valid use case to check this support (testing makes sense here) because the normal use of the Rust crate automatically follows a best-effort approach.

Comment thread src/jailer/src/main.rs Outdated
.takes_value(false)
.help("Print the binary version number."),
)
.arg(Argument::new("landlock").takes_value(false).help(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This option should probably reflect the goal, not (only) the mechanism. Landlock is gaining new feature over time and the current option doesn't reflect the related description.

@pavitrabhalla

Copy link
Copy Markdown
Author

@l0kod Thanks for the feedback. I updated everything as per your comments.

@JackThomson2

Copy link
Copy Markdown
Contributor

Hi @pavitrabhalla thanks for the PR and sorry for the delay got lost in my queue somehow. I am talking with the team to decide if this is something we want to support. However, for now, a couple of things to look at now:

  • Could we get some testing for this in our integration test framework?
  • We need to rebase against main, looks like there's a conflict in the Cargo.toml
  • Looks like the style tests are failing for gitlint and rust style (you can test these with ./tool/devtool checkstyle)
  • It looks like it's failing to compile with this error below:

error[E0599]: no method named `set_compatibility` found for struct `landlock::Ruleset` in the current scope
--
--> src/jailer/src/landlock.rs:87:14
\|
86 \| /         Ruleset::default()
87 \| \|             .set_compatibility(CompatLevel::HardRequirement)
\| \|             -^^^^^^^^^^^^^^^^^ method not found in `landlock::Ruleset`
\| \|_____________\|
\|
\|
::: /usr/local/rust/registry/src/index.crates.io-1949cf8c6b5b557f/landlock-0.4.4/src/compat.rs:571:8
\|
571 \|       fn set_compatibility(mut self, level: CompatLevel) -> Self {
\|          ----------------- the method is available for `landlock::Ruleset` here
\|
= help: items from traits can only be used if the trait is in scope
help: trait `Compatible` which provides `set_compatibility` is implemented but not in scope; perhaps you want to import it
\|
76 +     use landlock::Compatible;
\|
 
error[E0282]: type annotations needed
--> src/jailer/src/landlock.rs:89:24
\|
89 \|             .and_then(\|r\| r.create())
\|                        ^  - type must be known at this point
\|
help: consider giving this closure parameter an explicit type
\|
89 \|             .and_then(\|r: /* Type */\| r.create())
\|                         ++++++++++++
 
Some errors have detailed explanations: E0282, E0599.
For more information about an error, try `rustc --explain E0282`.
error: could not compile `jailer` (bin "jailer" test) due to 2 previous errors
warning: build failed, waiting for other jobs to finish...
 
Returned error code: 101

Thanks again,

Jack

@pavitrabhalla pavitrabhalla force-pushed the pavitrabhalla/issue-5513-impl branch from 53f29e1 to 46dd797 Compare May 13, 2026 18:56
@pavitrabhalla pavitrabhalla requested a review from micz010 as a code owner May 13, 2026 18:56
@pavitrabhalla

Copy link
Copy Markdown
Author

@JackThomson2 thanks for your comments and apologies for the delay here. I addressed as per your feedback. Let me know if you have further feedback and I'll address quickly.

Comment thread src/jailer/Cargo.toml Outdated
tracing = ["log-instrument", "utils/tracing"]

[dependencies]
landlock = "0.4"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For our CI it looks like we'll need to specify the patch version in the dependency, so if you could update that'd be great.

@JackThomson2

Copy link
Copy Markdown
Contributor

Hey @pavitrabhalla thanks taking a look,

Looks like some doc style failures:


Returned error code: 1
--
  | FAILED integration_tests/style/test_markdown.py::test_markdown_style - AssertionError: Some markdown files need formatting. Either run `./tools/devtool sh mdformat .` in the repository root, or apply the diffs manually.
  |  

@pavitrabhalla pavitrabhalla force-pushed the pavitrabhalla/issue-5513-impl branch 2 times, most recently from a19e57b to 17b618a Compare May 21, 2026 21:42
@codecov

codecov Bot commented May 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.70588% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.86%. Comparing base (7503f25) to head (ff33f03).

⚠️ Current head ff33f03 differs from pull request most recent head c92cb2d

Please upload reports for the commit c92cb2d to get more accurate results.

Files with missing lines Patch % Lines
src/jailer/src/env.rs 25.00% 6 Missing ⚠️
src/jailer/src/landlock.rs 72.72% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5771      +/-   ##
==========================================
- Coverage   83.08%   82.86%   -0.23%     
==========================================
  Files         277      278       +1     
  Lines       30201    30120      -81     
==========================================
- Hits        25094    24959     -135     
- Misses       5107     5161      +54     
Flag Coverage Δ
5.10-m5n.metal 83.14% <44.11%> (-0.29%) ⬇️
5.10-m6a.metal 82.47% <44.11%> (-0.30%) ⬇️
5.10-m6g.metal 79.76% <44.11%> (-0.30%) ⬇️
5.10-m6i.metal 83.12% <44.11%> (-0.30%) ⬇️
5.10-m7a.metal-48xl 82.46% <44.11%> (-0.30%) ⬇️
5.10-m7g.metal 79.76% <44.11%> (-0.30%) ⬇️
5.10-m7i.metal-24xl 83.10% <44.11%> (-0.29%) ⬇️
5.10-m7i.metal-48xl 83.11% <44.11%> (-0.29%) ⬇️
5.10-m8g.metal-24xl 79.76% <44.11%> (-0.30%) ⬇️
5.10-m8g.metal-48xl 79.76% <44.11%> (-0.30%) ⬇️
5.10-m8i.metal-48xl 83.11% <44.11%> (-0.29%) ⬇️
5.10-m8i.metal-96xl 83.11% <44.11%> (-0.28%) ⬇️
6.1-m5n.metal 83.19% <64.70%> (-0.26%) ⬇️
6.1-m6a.metal 82.53% <64.70%> (-0.27%) ⬇️
6.1-m6g.metal 79.79% <64.70%> (-0.28%) ⬇️
6.1-m6i.metal 83.18% <64.70%> (-0.26%) ⬇️
6.1-m7a.metal-48xl 82.52% <64.70%> (-0.26%) ⬇️
6.1-m7g.metal 79.79% <64.70%> (-0.28%) ⬇️
6.1-m7i.metal-24xl 83.20% <64.70%> (-0.25%) ⬇️
6.1-m7i.metal-48xl 83.19% <64.70%> (-0.27%) ⬇️
6.1-m8g.metal-24xl 79.78% <64.70%> (-0.28%) ⬇️
6.1-m8g.metal-48xl 79.79% <64.70%> (-0.28%) ⬇️
6.1-m8i.metal-48xl 83.19% <64.70%> (-0.27%) ⬇️
6.1-m8i.metal-96xl 83.20% <64.70%> (-0.26%) ⬇️
6.18-m5n.metal ?
6.18-m6a.metal ?
6.18-m6g.metal ?
6.18-m6i.metal ?
6.18-m7a.metal-48xl ?
6.18-m7g.metal ?
6.18-m7i.metal-24xl ?
6.18-m7i.metal-48xl ?
6.18-m8g.metal-24xl ?
6.18-m8g.metal-48xl ?
6.18-m8i.metal-48xl ?
6.18-m8i.metal-96xl ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@pavitrabhalla

Copy link
Copy Markdown
Author

@JackThomson2 please approve the workflows again. The code coverage and styling issues should be resolved now.

Comment thread src/jailer/src/landlock.rs Outdated
/// Returns [`JailerError::Landlock`] if the kernel does not support Landlock,
/// if `jail_dir` cannot be opened, or if any ruleset syscall fails.
pub fn prepare_ruleset(jail_dir: &Path) -> Result<RulesetCreated, JailerError> {
let abi = ABI::V4;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How come we've chosen V4 here, I was looking through the docs seems like V4 requires 6.7 kernel version, so to support 6.1 we'd need min V2?

Firecracker recently onboarded 6.18 as the next host kernel version, so I wonder if the best setup would be, hard requirement of v2 (for 6.1 hosts) then best effort v6 (for the 6.18 hosts) seems like v5 added some nice device IOCTL features.

What do you think?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Landlock crate is in best-effort by default (i.e. it uses the latest available ABI, detected at runtime), which means that the abi should be the highest tested version (currently ABI::V7).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated 👍

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The crate’s default SoftRequirement mode automatically downgrades at runtime to whatever the running kernel supports. So rather than a hard V2 + best-effort V6 split, I updated to ABI::V7 with Ruleset::default() (soft-requirement). On a 6.1 host it degrades gracefully; on 6.18 it uses the full V7 feature set.

ruleset.restrict_self().map_err(|err| {
JailerError::Landlock(format!("Failed to enforce Landlock ruleset: {err}"))
})?;
Ok(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to check the okay value it looks like it returns a status docs and we'd silently continue even if this wasn't enabled?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re right that the status is silently dropped. This is intentional — since we’re using soft-requirement mode, NotEnforced or PartiallyEnforced on older kernels is expected best-effort behavior rather than a failure condition. I’ve added a doc comment to enforce() in c92cb2d to make this explicit so it’s clear it’s a deliberate choice and not an oversight.

pavitrabhalla and others added 6 commits June 28, 2026 11:40
…xing

Add an opt-in --landlock-restrict-fs jailer flag that uses the Linux
Landlock LSM to restrict Firecracker's filesystem access to the jail
directory as a defense-in-depth mechanism.

When passed:
- Before pivot_root: open a PathFd on the jail directory (captures the
  inode; survives the pivot_root boundary).
- Right before exec: call restrict_self() to enforce the ruleset on the
  jailed Firecracker process.

Uses ABI::V4 in best-effort mode; on kernels without Landlock support
(< 5.13) the flag has no effect. Existing deployments are unaffected.

Closes firecracker-microvm#5513

Signed-off-by: Pavitra Bhalla <pavitra@superserve.ai>
Verify that Firecracker boots and runs correctly when the jailer is
started with --landlock-restrict-fs. On kernels without Landlock
support the flag has no effect so the test passes regardless.

Signed-off-by: Pavitra Bhalla <pavitra@superserve.ai>
- Pin `landlock` dependency to exact patch version "0.4.4" per CI
  requirement (was "0.4").
- Run `mdformat .` to fix markdown style failures in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Pavitra Bhalla <pavitra@superserve.ai>
Run mdformat to normalize blank lines between list items.

Signed-off-by: Pavitra Bhalla <pavitra@superserve.ai>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `test_enforce` to landlock.rs: forks a child process to call
  `enforce()` (which is irreversible in the calling process), covering
  the `restrict_self` path that was previously untouched by unit tests.
- Add `test_new_env_with_landlock` to env.rs: exercises `make_args`
  and `Env::new` with `--landlock-restrict-fs` set, covering the branch
  in `make_args` that builds the flag and verifying the field is wired
  through to `Env`.

The `prepare_ruleset`/`enforce` calls inside `Env::run()` remain covered
only by the integration test (`test_landlock_restrict_fs`) since `run()`
calls `chroot()` and is not amenable to unit testing.

Signed-off-by: Pavitra Bhalla <pavitra@superserve.ai>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per the Landlock crate maintainer's guidance, the ABI parameter should be
the highest version that has been tested, not a pinned older version.
Ruleset::default() uses SoftRequirement by default, so the crate
automatically downgrades to whatever the running kernel supports — no
behaviour change on older kernels.

- Bump landlock dependency from 0.4.4 to 0.4.5 (adds ABI::V7 support,
  Linux 6.15).
- Switch from ABI::V4 to ABI::V7.
- Update the prepare_ruleset doc comment to reflect best-effort
  semantics (no error on kernels with partial/no Landlock support).

Signed-off-by: Pavitra Bhalla <pavitra@superserve.ai>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pavitrabhalla pavitrabhalla force-pushed the pavitrabhalla/issue-5513-impl branch from ff33f03 to c0f6eaa Compare June 28, 2026 18:45
…ce()

Adds a doc comment explaining why the RestrictionStatus returned by
restrict_self() is not inspected: Ruleset::default() uses soft-requirement
(best-effort) mode, so NotEnforced/PartiallyEnforced on older kernels is
expected behaviour, not an error condition.

Addresses reviewer question in PR firecracker-microvm#5771.

Signed-off-by: Pavitra Bhalla <pavitra@superserve.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants