Containerization and Virtualization solutions are crucial components in any modern infrastructure, which is why they're regular guests in the Snyk Security Labs research queue. In the past, we have examined solutions such as Docker and Proxmox, which have revealed some interesting vulnerabilities. We've recently been seeing a lot of chatter around the Incus project, which offers "a next-generation system container, application container, and virtual machine manager", and decided it would be worth a look.
The vulnerabilities discussed in this blog post were fixed in Incus 6.21, released January 24th, 2026.
What is Incus?
Incus offers an extremely smooth abstraction layer over both LXC containers and Qemu virtual machines. Creating a new container is as simple as
Or a virtual machine
From a researcher's point of view, this is amazing for creating short-lived testing environments, making a mess, then tearing them down again, and in fact, since starting this research project, I've replaced my messy libvirt-based scripts with Incus.
However, swish abstractions require interactions across multiple systems, and where multiple systems interact, vulnerabilities can grow between the cracks like weeds. In this blog post, we will look at 3 vulnerabilities (well, two, we'll get to that…) we identified in Incus, which allowed a normal user of the system to escalate their privileges to root on the host, and discuss how they were mitigated to ensure they can't happen again.
Command injection (not) privilege escalation
Up front, I'll point out that the Incus team did not consider this bug a vulnerability. This is a reasonable position, since you need high privileges to exploit this “vulnerability”, and if you've already got those privileges, there are easier ways to escalate to host root. That being said, they did fix this anyway with a normal bugfix. I still thought the process of exploitation was worth sharing.
Incus containers use LXC for lifecycle management, which includes creating and starting the container runtime environment, as well as various “hooks” that can execute commands at predefined times during the container lifecycle (pre-start, post-start, post-stop, etc.). These are configured inside the container's lxc.conf and look a little something like this:
Breaking out our trusty friend strace, we can see that these hooks are being run indirectly via /bin/sh:
This is the key component to open a command to arbitrary command injection. Looking at the arguments provided, we can see two that may be controllable. The first is “endless-walrus”. This is the (randomly generated) name for this container. Looking in the source code, we can immediately see that command injection will not be possible:
Without the ability to use characters such as $() or ;, we can't subvert the execution of the commands in a meaningful way.
The second controllable argument is the “default” string. This corresponds to the Incus project name, and is also controllable (given sufficient permissions). Exploring this argument by creating projects with payloads in their name shows a similarly (but not completely) restrictive character set:
The project name must be less than 64 characters
The project name must start and end with a character in a-z
The project cannot contain some useful special characters, such as whitespace or
{}
Normally, when exploiting command execution, I will default to something like $(echo [my payload in base64]|base64 -d|sh). Base64 is a safe encoding because it can be configured to omit special characters, which helps a lot with character set filters. However, you will notice that at least two space characters are required: one for the echo command and one for the base64 decode argument. Given that our execution occurs under /bin/sh, not something more featureful like /bin/bash, we cannot use fancy features such as heredocs (base64 -d<<<[my payload in base64]) to get around these restrictions. In other cases, I have used classic options such as ${IFS} to insert whitespace into my command injections, but as mentioned, {} are also not permitted.
Without {}, the shell can't tell where the environmental variable ends and when my base64 payload begins (e.g, echo$IFSAAAAAA). With a bit of back and forth with Gemini, giving it all of my requirements and rejecting the options that clearly won't work, I came across the technique for using both named and positional environmental variables. In the shell, the arguments passed as positional arguments are assigned to numbered variables ($0 to $9). We know that in our command injection context, no arguments are passed to the script line, so $2 and higher should be empty. The trick that makes this useful is that the shell will not allow a variable to start with a number and contain other alphanumeric characters afterwards. This means the shell will not parse for such a variable, and we can therefore use something like $9 to be parsed as a complete variable string, allowing the parsing to move on to other characters.
With this, we can now use a payload like $(echo$IFS$9[my base64 payload]|base64$IFS$9-d|sh). $IFS provides the whitespace needed to separate arguments, and $9 stops parsing the IFS string and lets us move the parsing on to our base64 string.
We are still limited in the length of our payload, however, which makes creating a fully arbitrary second stage quite a bit more complicated. Since this isn't a particularly impactful vulnerability given the permissions required to create new projects, I opted for a simple but unrealistic second stage: writing a shell script in /tmp, making it executable, and executing that as the second stage.
Bringing it all together, we can create a project name called a$(echo$IFS$9L3RtcC94Cg==|base64$IFS$9-d|sh)b, launch a container inside the project, and observe that the shell script I placed at /tmp/x is executed as root. An awful lot of complexity when the same user can just run a privileged container as root and do whatever they like; hence, this is not considered a vulnerability.
As mentioned, the Incus project did not consider this bug a real vulnerability. Nevertheless, they mitigated it in PR #2827, which both restricts the character set further to remove '$', as well as single-quoting each parameter in the configuration file. Single-quoting the parameters effectively stops command injection because the shell will not parse shell escapes (such as $()) in this context. And with single quotes disallowed by the character set, the context cannot be escaped.
Newline injection (actual) privilege escalation
Continuing the theme of exploiting the dynamically generated lxc.conf configuration file, I next looked into other parts of the container configuration that ended up in this file. Investigating the optional flags to the incus launch command, we come across the following:
Environmental variables are very interesting because they can generally contain arbitrary values. Creating a test variable file and launching a new container with this option results in the following in the lxc.conf for our new container:
Unquoted, unencoded, exactly as I inserted it inside my environmental variable file. Having my content inserted into such a privileged configuration file evokes ideas of inserting more than just lxc.environment stanzas. What if it were possible to add in more interesting configuration options like the lxc.hook.pre-start lines we saw in the previous section?
There are two main things I look for in this context. The first is in parsing the configuration, and the second is in writing the configuration.
When parsing a configuration file, especially ones as simple and flat as lxc.conf, applications tend to just go line by line. There are many ways to read a line from a file, some more risky than others. Whilst fgets(3) may seem safe, it has a max length after all (so no buffer overflows), there is a more subtle issue. fgets will read a line from a file, up to your buffer size, and return that line to you. It does not, however, indicate whether the line returned is a whole line (up to a newline character) or whether it just hit its n argument and is returning a truncated line. What this means is a well-crafted configuration line (an environmental variable in this case), in the form of FOO=BARAAAAAA[padded up to the fgets n argument]lxc.hook.pre-start = ... may trick the application into parsing one long line as two separate lines, as there is no distinction between reaching the max buffer size and reaching a newline.
Whilst this fake-newline-injection technique is neat, it turns out not to work in LXC; the lines are parsed correctly. It remains something worth checking for, as it makes the exploitation very tidy.
The second vulnerability to look for is direct newline injection. Playing around with the environment file we used above did not bear any fruit; the lines are inserted one at a time without processing, and we cannot have a newline copied from our input file into the configuration file. One of the examples in the same --help output we found --environment-file in may give us some more flexibility:
It's possible to define all environmental variables in a YAML configuration file as an alternative entry point. This may raise alarm bells for us because YAML allows for well-formed key-value pairs, which can contain newlines. Quickly looking up the correct format for the environmental variables in Incus's YAML format, we can come up with a configuration file like the following:
You can see that we are creating a configuration key environment.FOO, but using the YAML specific multiline string format |-. This means that when the application parses the YAML and uses the key environment.FOO, our newline after abc will remain present.
This proof of concept includes a payload to create a file in the root of the filesystem using a pre-start hook, as we saw in the first section. Trying out our new payload, we see success!:
Looking in the lxc.conf configuration file for this container, we can see what happened:
Our newline injection was successful, and we were able to add an lxc.hook.pre-start line of our own.
Unlike the previous vulnerability, this one is actually meaningfully exploitable. Any user who has access to launch containers, which is a pretty common use case of the system (there's an incus user group locally on an Incus system for non-admin users to be able to launch containers), can exploit this vulnerability to achieve arbitrary command execution as root. This also works against the new IncusOS dedicated Incus Linux distribution. This vulnerability is also much easier to perform follow-on exploitation, unlike the above, because there are no character set or length restrictions, which would make our payload more difficult.
This bug was considered a real vulnerability by the Incus team and was assigned CVE-2026-23953/GHSA-x6jc-phwx-hp32. Snyk provided a proposed patch for this vulnerability that rejected any environment variable containing a newline character when creating a container's lxc.conf. The Incus team modified this patch to reject the invalid configuration at definition time rather than at container launch time. This is an effective patch and occurs at a more sensible place.
BYO Images - Arbitrary File Write
The final vulnerability for today is, I think, the most interesting.
When creating a new container, Incus will use an “image”, which is the base disk and configuration for a specific container type (for example, there are images for Ubuntu, Debian, NixOS). These images comprise a root filesystem, a metadata YAML file, and, optionally, some template files, bundled into a single tar file. The template files are processed with the Pongo2 library to configure files on the new root filesystem to reflect the container instance details. For example, a template might be used to set the contents of the /etc/hostname file to the name of the newly created container.
The metadata.yaml, which includes the template definitions, looks a little something like this:
We can see in the templates block a definition for /etc/hostname, which will take the template hostname.tpl and apply it during container start.
Incus utilizes the Pongo2 library's sandboxing functionality to ensure that the templates being processed cannot perform any malicious actions, such as including external files. Digging into the source code to see how these templates are actually handled, we can immediately identify a couple of issues.
If you don't spot the problems in the code above, then you definitely need to spend more time reading the Snyk Security Labs blog. There are two distinct issues here, which we can exploit in different ways to achieve two impacts in the same code.
The first, and slightly less interesting, is in the block at the bottom of the code snippet. d.TemplatesPath is a reference to the templates directory inside the custom image being used. The template files specified by the metadata live in their own templates directory inside the image tar file, and this is what is being referenced. What isn't present in this processing is validation that the template name path does not contain directory traversal characters or symbolic links. A simple payload like the following can therefore be used to escalate up the directory structure, outside of the d.TemplatesPath directory and all the way to the host root filesystem.
With a template configuration like this, any host file can be read, including password files, SSH keys, and CA certificates (used for Incus authentication). However, since the file must be processable as a Pongo2 template, the input file types are a little limited, and I found it not possible to read binary files. It would have been nice to read the Incus database, which contains other certificates and authentication tokens.
Somewhat unsurprisingly, the second half of this vulnerability is the exact opposite: There is symbolic link protection for the target of the template being written. So much like above, we can specify a template like:
Include a symbolic link in our new image filesystem pointing from /realroot to /, and when the container starts, the contents of the 'pwned' template are written to /pwned on the host. This is because at this point the code has not entered the container, so the path / is the host's root, not the container's.
Single-file write vulnerabilities require additional effort to exploit. Quite often you can just overwrite something like root's ssh authorized_keys file and SSH in, but I wanted a payload that doesn't rely on other software being present and accessible, and I also wanted to be able to exploit this on IncusOS, the special Linux distribution for Incus which includes features such as no remote shells (so no ssh), and a read only root filesystem.
There are still a few exploitation options that rely solely on the Linux kernel. The path I chose for exploiting this issue was the core_pattern technique. Linux allows you to specify which command will be executed when a binary crashes and creates a core dump (used for later debugging). The configuration for this command is stored in /proc/sys/kernel/core_pattern on most Linux systems.
Using our root file write, we can write a simple payload to this file to set us up for the second stage, using the following template configuration and template file:
core_pattern.tpl:
metadata.yaml:
The core_pattern handler will perform some value interpolation on the command before it's executed. In this case, we use %E, which will interpolate:
Pathname of executable, with slashes ('/') replaced by exclamation marks ('!') (since Linux 3.0).
The rest of the interpolation options can be found in the core(5) manpage.
What this means is that if a binary now crashes anywhere in the system, the Linux kernel will run the configured command, after interpolation, as root. For example, if mybinary crashes, the kernel will run /bin/sh -c "mybinary". Obviously, this doesn't really help, but once we have command execution inside our container (since we will immediately after the root file write completes), we can create a binary called whatever we like.
To achieve a reliable crash, without using signals (such as kill -SEGV), we can use some C to perform an illegal action like a null dereference:
When compiling this C code, we need to carefully pick a target filename, as the filename itself will be part of the command executed by the kernel. Taking inspiration from the first section, we can use the $(echo [base64 encoded value]|base64 -d|sh) format to specify a path-safe payload. This time, however, we have more characters to play with, so we can make our second stage exploitation a lot easier on ourselves. We know that the rootfs of the running container is located under the /var/lib/incus/containers/[unique container name]/rootfs directory. Using a shell wildcard to find the specific root filesystem without knowing the container name beforehand, we can execute a second-stage shell script stored in our rootfs. To achieve this, we can use /var/lib/incus/containers/*/rootfs/stage2 as the value we will base64 encode inside the stage 1 payload. We cannot do this without encoding, as the kernel will replace slash characters with exclamation marks, rendering this payload unusable.
Putting this all together, we will take our crashy C code from above, and compile it inside our container to a file called
By the time we execute code inside the container, the root file write has already occurred, so all we need to do to finish this exploit is run the newly compiled binary. This binary will immediately crash, sending a signal to the kernel. The kernel will take the crashed binary and insert it into the pattern we previously wrote to the core_pattern file, and execute the new command:
The shell will perform the commands inside the $() first, executing /var/lib/incus/containers/*/rootfs/stage2, which is a shell script we control. This execution happens outside of our container, and as full root, so this is a successful container breakout via a single file write and a little bit of command execution inside a container.
Dealing with the filesystem safely can be quite tricky, as there are many potential risks associated with static files, including directory traversal, symbolic links, and even race conditions. Fortunately, the Go standard library (Incus is written in Go) has a very neat set of functions which can significantly reduce the risk when handling user- or attacker-controlled paths. The os.Root API can be used to create a filesystem object from a known-safe root (in our case, the parent directory of the rootfs and templates directory), and all future file operations (such as file reads, writes, or ownership changes) are restricted to ensure that under no circumstances can they escape out of the known-safe root. It does this by splitting all paths into their component parts, and for each part (i.e., a directory or a file), it opens the new component relative to the last, ensuring that no symbolic links or parent directory references (..) cause an escape of the initial trusted path. More information about this API can be found on the Go blog post here.
Alongside this vulnerability report, Snyk also provided a patch for this issue, which replaced all filesystem operations during the templating phase with operations on an os.Root instance relative to the known-safe image parent directory. This effectively prevents all directory traversal and symbolic link attacks from escaping this directory, and it's no longer possible to perform arbitrary file reads or writes.
This vulnerability was assigned CVE-2026-23954/GHSA-7f67-crqm-jgh7. The Incus team chose to patch this differently from our suggestion, and instead performed proper path validation against the provided paths (rather than using os.Root). This method is better for alerting the user to suspicious behaviour, as os.Root, in some cases, will just succeed safely (e.g., directory traversal to create files inside a writable directory will succeed, even if it doesn't break out of the new root).
Final thoughts
These three issues, only two of which are actually being considered vulnerabilities, underline the care that must be taken when crossing the boundaries between different components. In the case of the first issue, one component may not necessarily consider the attack surface of arbitrary configuration files, since this component assumes it will be used in a controlled and privileged manner. When this attack surface becomes exposed by other components, such as Incus dynamically creating such configuration files, the threat model can change, and both components should be carefully considered to ensure that the understanding of risk is shared between the two.
This also applies to our final vulnerability, but in this case, the second component is the filesystem. Reading and writing files, especially sourced from a potentially attacker-controlled archive file, carries considerable risk, and care should be taken when performing any actions in this area to ensure that what is expected in the 'happy-path' on the filesystem is always and must always be the case. Fortunately, more and more languages and standard libraries are including functionality that safely enables this with very little friction, as can be seen in the case of os.Root in Go.
Exploitation of these vulnerabilities generally requires user-level access to an Incus system. However, there is a supply chain risk component for the third vulnerability. Taking untrusted images from third parties could include exploits such as the one outlined above, compromising Incus systems, even with the most trusted of users, where the third-party images have not been vetted. Fortunately, the stock images provided by the Incus system are built in the open and can be assessed for safety.
I would like to thank the Incus team for a very prompt response to our vulnerability report and a very swift and comprehensive plan for getting the vulnerabilities mitigated. All vulnerabilities are fixed in Incus 6.21, released January 24th, 2026.
From newline injection to arbitrary file writes, small missteps can lead to host compromise. You can’t secure what you can’t see—especially with AI in the mix. Discover every AI component hidden in your codebase with Evo by Snyk.



