Landlock: Best Effort mode

How to make your use of Landlock backwards compatible with older kernels

One of Landlock’s strengths is that you can deploy the same program on multiple kernel versions, and make it use the best available sandboxing on each.

This “best effort” approach is already implemented for you in the Go-Landlock library and in the Rust Landlock library. But what if you need to implement it yourself?

This article assumes previous knowledge of Landlock - you can read more about the abstract model in the Landlock File System Access Model document which I’ve written previously, or in the official Landlock documentation.

ABI versions and matching access rights

Landlock exposes its feature set in the form of a numeric Landlock ABI version.

In order to implement the fallback correctly, you need to know which file system access rights are supported in which ABI version.

The (simplified) compatibility chart as of Linux 6.2 is:

ABI Linux File system access rights
1 5.13 almost all of the basic file operations (compare kernel docs)
2 5.19 +LANDLOCK_ACCESS_FS_REFER (reparenting files)
3 6.2 +LANDLOCK_ACCESS_FS_TRUNCATE (truncating files)

Further below, we will define this support matrix in C.

The problem: Refer is different

The backwards compatibility works differently for “refer” than for other access rights.

Skip this section if you are only interested in the C code.

Each Landlock ABI version is characterized by three disjoint sets of file system operations: The Always Forbidden operations, the Configurable operations and the Always Permitted operations.

The Configurable operations are the ones which already have names in the landlock.h header. With increasing ABI versions, all relevant operations should become “Configurable”, in particular the “Always permitted” operations.

In ABI v1, an set of useful file system operations is configurable in Landlock, but there is also a longer list of known operations which is always permitted (Landlock can not restrict them). Finally, some more complicated interactions are always forbidden because they would interfere with Landlock’s guarantees:

When introducing ABI v2, the “refer” operation, which was previously forbidden, became configurable:

With ABI v2, it is now possible to reparent (link(2) or rename(2)) files between different directories, as long as the involved files and directories meet certain constraints about their access rights.

The behavior of “refer” is different to “truncate” and other future access rights, which fall back to Always Permitted in earlier ABI versions, and so “refer” needs a different fallback logic.

Rule: If a program needs to do a “refer” (reparent files between directories), using ABI v1 is not an option.

The canonical documentation for this interaction is the kernel documentation.

Implementation of best-effort fallback in C

To implement the fallback logic, we will (a) define the compatibility table, and then, (b) depending on whether your program needs to do file reparenting, implement a slightly different fallback logic based on that compatibility table.

This question decides which approach to use.

The question to ask is:

Does your program need to do any file reparenting (“refer”)?

File reparenting means: Creating hard links or moving files or directories, between different(!) directories (compare the kernel documentation).

Common part: ABI version compatibility table

We define a small C array to hold the file system access rights which are supported by the different Landlock ABI versions. We store one __u64 bitmask for each ABI version.

__u64 landlock_fs_access_rights[] = {
  (1ULL << 13) - 1,  /* ABI v1                 */
  (1ULL << 14) - 1,  /* ABI v2: add "refer"    */
  (1ULL << 15) - 1,  /* ABI v3: add "truncate" */
};

I’m keeping it short here for brevity, but you can also use the constants from linux/landlock.h.

Case 1: the sandboxed program does not need to reparent files

Use this approach, if you don't use link(2) and rename(2) across directory boundaries.

If the sandboxed program does not need to reparent files, the best-effort logic is to simply remove the unsupported rights from ruleset_attr.handled_access_fs.

This code needs to be inserted after you have filled a struct landlock_ruleset_attr variable, but before you create the ruleset from it:

int abi = landlock_create_ruleset(NULL, 0,
                                  LANDLOCK_CREATE_RULESET_VERSION);
if (abi <= 0) {
  /*
   * This kernel does not let us use Landlock.
   * Best effort: Give up, and do not restrict the process.
   */
  return 0;
}
if (abi > ARRAY_SIZE(landlock_fs_access_rights)) {
  /* Kernel from the future: Treat as highest known ABI version. */
  abi = ARRAY_SIZE(landlock_fs_access_rights);
}
ruleset_attr.handled_access_fs &= landlock_fs_access_rights[abi-1];
Don't forget this!

Additionally, before you add a “path beneath” Landlock rule to the ruleset, make sure that the requested access rights fit into the previously restricted handled_access_fs rights:

path_beneath.allowed_access &= ruleset_attr.handled_access_fs;

Case 2: the sandboxed program needs to reparent files

If the sandboxed program does need to reparent files, the best-effort logic works the same as above, but it has a special case for ABI version 1. In this ABI version, file reparenting does not work under Landlock at all, and so we have to give up on it.

This code needs to be inserted after you have filled a struct landlock_ruleset_attr variable, but before you create the ruleset from it:

int abi = landlock_create_ruleset(NULL, 0,
                                  LANDLOCK_CREATE_RULESET_VERSION);
switch (abi) {
  case -1:
  case 0:  /* should not happen */
    /*
     * This kernel does not let us use Landlock.
     * Best effort: Give up, and do not restrict the process.
     */
    return 0;
  case 1:
    /*
     * This kernel only supports Landlock ABI v1.
     * We need the "refer" right, but it's not supported in ABI v1.
     * Best effort: Give up, and do not restrict the process.
     */
    return 0;
  default:
    if (abi > ARRAY_SIZE(landlock_fs_access_rights)) {
      /* 
       * A kernel with Landlock suppport from the future!
       * Treat it as the highest known ABI version.
       */
      abi = ARRAY_SIZE(landlock_fs_access_rights);
    }
    ruleset_attr.handled_access_fs &= landlock_fs_access_rights[abi-1];
}

Additionally, before you add a “path beneath” Landlock rule to the ruleset, make sure that the requested access rights fit into the previously restricted handled_access_fs rights:

path_beneath.allowed_access &= ruleset_attr.handled_access_fs;

Case 3: Sometimes this, sometimes that

This is the complicated case which we also implemented in the Go and Rust Landlock libraries. These libraries figure out at runtime which of the two cases we are in, and then use the adequate approach as above.

Conclusion

In the long run, when Landlock has stabilized more, and as older kernels become less relevant, this complication will hopefully go away. Until then, I’m hopeful that most users can simply use the simple approach in Case 1 above, because their programs do not need the “refer” right.

References

Thank you Mickaƫl, for pointing out some issues in this article!

Comments