The linux kernel Landlock security feature (and its usage with go)

The Landlock linux kernel feature allows a userspace process to further restrict its capabilities. Landlock can only be used to drop existing permissions, it can never be used to expand permissions. For a simple picture: Landlock might deny e.g. access to a file, however linux rbac and additional security systems are still evaluated as usual and also might deny access.

Currently, Landlock only allows the restriction of filesystem access and TCP connections. But conceptually extensions to e.g. UDP connections are possible from the kernel API perspective.

Here we will briefly take a look at the Landlock go library and discuss some limitations that result from Landlock operating on inode basis for filesystem access restrictions.

Filesystem access

A simple copy program

To have an easy to reason example, let's consider the implementation of a file copy functionality in go:

// copy simply reads from inPath and (over-)writes to outPath
func copy(inPath, outPath string) error {
	data, err := os.ReadFile(inPath)
	if err != nil {
		return fmt.Errorf("error reading input: %w", err)
	}
	// tries to truncate if file already exists
	if err = os.WriteFile(outPath, data, 0644); err != nil {
		return fmt.Errorf("error writing output: %w", err)
	}
	return nil
}

We now want to restrict the access to the filesystem to allow only reading from the input file and writing to the output file. To do so we add the following Landlock call prior to the execution of the copy function:

	// BestEffort means to not error if Landlock is not supported (kernel configuration on linux or just another OS).
	// Furthermore, it means falling back to e.g. the V4-Landlock-feature set if that is the highest version supported by the given kernel.
	ll := landlock.V5.BestEffort()
	if err := ll.RestrictPaths(
		landlock.ROFiles(inPath),
		landlock.RWFiles(outPath),
	); err != nil {
		// handle error
	}
	if err := copy(inPath, outPath); err != nil{
	  // handle error
	}

This restriction of access rights will work if and only if the output file already exists prior to the execution of the Landlock call. In that case there exists an inode for the output file and Landlock will allow it to be truncated (overwritten). However, if the file does not exist yet the Landlock call would error as the output target inode does not exist. Also silencing this error by e.g. putting something like Landlock.RWFiles(outPath).IgnoreIfMissing() won't work as the Landlock call itself would not error, but the permissions would still be restricted to only reading from the input file (no writing at all).

To allow writing to not yet existing file the output structure of the command needs to be re-thought and Landlock limitations have to be applied to the folder (inode) containing the output file.A robust version of the Landlock invocation hence reads:

	outDir := filepath.Dir(outPath)
	if err := os.MkdirAll(outDir, 0755); err != nil {
	  // handle error
	}
	ll := landlock.V5.BestEffort()
	if err := ll.RestrictPaths(
		landlock.ROFiles(inPath),
		landlock.RWDirs(outDir),
	); err != nil {
		// handle error
	}

Linked files

Landlock filesystem access restriction work inode based. This has some noteworthy consequences when dealing with linked files:

  • For single file restrictions links just work. Hard links refer to the same inode and symbolic links are resolved for the Landlock restrictions.
  • For directory restrictions hard linked files in the directory also work as they are recorded as child of the referenced directory inode.
  • For directory restrictions symbolic linked files in the directory won't always work. Access to the linked referenced file must be also allowed via Landlock for filesystem operations to be permitted.

Network access

Landlock currently only support TCP connections, UDP is not affected by Landlock. Generally Landlock allows to restrict to which ports an app might connect (egress) and to which ports it can bind (handling ingress).

Interplay with filesystem restrictions

Consider the following example restricting network egress to only TCP port 443 and not allowing any filesystem access.

	if err := ll.RestrictPaths(); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	if err := ll.RestrictNet(landlock.ConnectTCP(443)); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	if _, err := http.Get("https://ngergs.de"); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

This will error in the last step as even though we allowed TCP port 443 for the HTTPS call we still need filesystem access. On a linux system we need:

  • /etc/resolv.conf Even though DNS will by default tried via UDP port 53 which is not affected by Landlock the app needs access to the resolv.conf to determine via which DNS server to resolve the hostname of the target.
  • /etc/ssl/certs/ca-certificates.crt As we are making an HTTPS call and Go uses the system certificate authorities for checking the validity of the certificates, the corresponding file holding these needs to be accessible.

So working filesystem restrictions for this case reads:

	if err := ll.RestrictPaths(landlock.ROFiles("/etc/resolv.conf", "/etc/ssl/certs/ca-certificates.crt")); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}