Command Injection in Firejail's netfilter.c: How Environment Variables Can Lead to Root Compromise
Introduction
There's a certain irony when a security tool becomes the source of a security vulnerability. Firejail — the widely-used Linux sandboxing utility designed to restrict what programs can do — was found to contain a command injection flaw in its network filtering code. This vulnerability, tracked as V-004, existed in src/firejail/netfilter.c and could allow a local attacker to execute arbitrary commands with Firejail's elevated (potentially root) privileges.
This post breaks down what went wrong, how it could be exploited, and what developers working on privileged C applications can learn from it.
The Vulnerability Explained
What Is Command Injection?
Command injection occurs when an application constructs a shell command using untrusted input without proper sanitization. If an attacker can influence the content of that command string, they can append or inject additional shell instructions — potentially running any command they choose.
This is distinct from SQL injection or XSS, but the root cause is the same: mixing untrusted data with trusted instructions.
What Happened in netfilter.c?
In src/firejail/netfilter.c around line 70, the code constructed a shell command string using asprintf(). The command was assembled from three components:
terminal— a value sourced from theTERMINALenvironment variable (attacker-controlled)LIBDIR— a compile-time constant (safe)flog— derived fromgetpid()(safe, but predictable)
The resulting string looked something like this:
// Simplified representation of the vulnerable pattern
char *cmd;
char *terminal = getenv("TERMINAL"); // ← Attacker-controlled!
asprintf(&cmd, "%s %s/firejail/some-helper %s", terminal, LIBDIR, flog);
// Then passed to exec() or a shell function...
Because terminal came directly from the environment without any validation or sanitization, an attacker could set:
export TERMINAL="xterm; malicious-command #"
When Firejail constructed and executed the command, it would become:
xterm; malicious-command # /usr/lib/firejail/some-helper /tmp/firejail-1234.log
The semicolon terminates the first command, malicious-command runs independently, and the # comments out the rest. Shell metacharacters like ;, |, &&, ||, `, $(), and newlines all enable this kind of manipulation.
Why Is This Particularly Dangerous?
Firejail frequently runs with elevated privileges — often as a setuid-root binary or invoked with sudo. This means the injected command doesn't just run as the current user; it runs as root.
A local attacker (or a compromised process) that can set environment variables before Firejail executes could:
- Read or exfiltrate sensitive files (
/etc/shadow, private keys) - Create new privileged user accounts
- Install persistent backdoors
- Escape the very sandbox Firejail is meant to enforce
Real-World Attack Scenario
Consider this scenario:
- A developer uses Firejail to sandbox an untrusted application.
- That application, before being sandboxed, sets
TERMINALto include a malicious payload. - Firejail's netfilter code executes during sandbox setup with root privileges.
- The injected command runs as root, fully compromising the host system.
Alternatively, a local attacker on a shared system (e.g., a university server or cloud VM with multiple users) could set the environment variable in their shell session and trigger Firejail execution to escalate privileges.
The Fix
What Changed?
The fix removes the unsafe exec() call that relied on shell interpretation of the constructed command string. The core principle of the fix is:
Never pass attacker-controlled data to a shell. If you must execute subprocesses, use
execv()/execve()with argument arrays, not shell strings.
The key improvements are:
- Elimination of the
TERMINALenvironment variable dependency — The code no longer readsgetenv("TERMINAL")for constructing privileged commands. - Removal of shell-interpreted execution — Instead of constructing a string and passing it to a shell, the fix uses direct execution paths that don't invoke
/bin/sh. - Input validation — Any remaining external input is validated before use.
Before and After (Conceptual)
Before (vulnerable pattern):
// DANGEROUS: Environment variable used directly in shell command
char *terminal = getenv("TERMINAL");
char *cmd = NULL;
char *flog = /* derived from getpid() */;
asprintf(&cmd, "%s %s/firejail/helper %s", terminal, LIBDIR, flog);
// Shell interprets the entire string — metacharacters execute!
system(cmd); // or popen(cmd, ...) or similar
free(cmd);
After (safe pattern):
// SAFE: Use execv() with explicit argument array — no shell involved
char helper_path[PATH_MAX];
snprintf(helper_path, sizeof(helper_path), "%s/firejail/helper", LIBDIR);
// flog is derived from getpid(), validated to be numeric
char *args[] = {
helper_path,
flog,
NULL
};
// execv does NOT invoke a shell; metacharacters have no special meaning
execv(helper_path, args);
With execv(), there is no shell to interpret metacharacters. The arguments are passed directly to the program as discrete strings. Even if an attacker somehow influenced flog, a ; or | would be treated as a literal character, not a shell operator.
Why This Solves the Problem
The fundamental issue was shell interpolation — the act of passing a constructed string to a shell (/bin/sh -c "..." or equivalent) that then parses it for special characters. By removing the shell from the equation entirely:
- Metacharacters lose their power
- Each argument is isolated and cannot "escape" into another command
- The attack surface is dramatically reduced
Prevention & Best Practices
1. Never Trust Environment Variables in Privileged Code
Environment variables are user-controlled. In any setuid binary, privileged daemon, or code that runs with elevated permissions, treat all environment variables as hostile input.
// BAD: Using getenv() output directly in a privileged operation
char *path = getenv("PATH");
execl("/bin/sh", "sh", "-c", path, NULL);
// BETTER: Use hardcoded paths or validate strictly
// In setuid programs, consider clearing the environment entirely
In fact, the Linux dynamic linker (ld.so) automatically ignores certain environment variables (like LD_PRELOAD) for setuid binaries. Your application-level code should apply the same skepticism.
2. Prefer execv()/execve() Over system() or popen()
| Function | Shell Involved? | Safe for Untrusted Input? |
|---|---|---|
system(cmd) |
✅ Yes | ❌ No |
popen(cmd, mode) |
✅ Yes | ❌ No |
execl(path, ...) |
❌ No | ✅ With validation |
execv(path, argv[]) |
❌ No | ✅ With validation |
execve(path, argv[], envp[]) |
❌ No | ✅ Best option |
execve() is the gold standard — it lets you explicitly control both the argument array and the environment passed to the child process.
3. Validate and Sanitize All External Input
If you absolutely must use external input in a command, validate it strictly:
#include <ctype.h>
#include <string.h>
// Only allow alphanumeric characters and specific safe chars
int is_safe_terminal_name(const char *name) {
if (!name || strlen(name) == 0 || strlen(name) > 64)
return 0;
for (const char *p = name; *p; p++) {
if (!isalnum(*p) && *p != '-' && *p != '_' && *p != '/')
return 0; // Reject anything suspicious
}
return 1;
}
Better yet, use an allowlist of known-good values rather than trying to blocklist bad ones.
4. Drop Privileges as Early as Possible
If your application needs elevated privileges for only part of its operation, drop them immediately after:
// Perform privileged operation
setup_network_filter();
// Drop root privileges immediately
if (setuid(getuid()) != 0) {
perror("Failed to drop privileges");
exit(EXIT_FAILURE);
}
// Rest of the code runs as normal user
This limits the blast radius of any exploitation — even if a vulnerability exists, the attacker gains fewer privileges.
5. Clear the Environment in Setuid Binaries
For setuid programs, consider clearing the environment and rebuilding only what's needed:
#include <unistd.h>
// Clear all environment variables
clearenv();
// Set only what you need, with known-safe values
setenv("PATH", "/usr/local/bin:/usr/bin:/bin", 1);
6. Use Static Analysis Tools
Several tools can catch this class of vulnerability automatically:
- Semgrep — Has rules for detecting
getenv()usage in dangerous contexts - CodeQL — Can trace taint flow from environment variables to exec functions
- Coverity — Commercial tool with strong command injection detection
- Flawfinder — Lightweight C/C++ security scanner
- Compiler warnings —
-Wformat-securityand-D_FORTIFY_SOURCE=2catch some unsafe patterns
7. Relevant Security Standards
This vulnerability maps to well-known security weaknesses:
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- CWE-426: Untrusted Search Path
- OWASP: Command Injection: Comprehensive guidance on prevention
- SEI CERT C Coding Standard ENV33-C: Do not call
system()
A Note on the Severity Discrepancy
You may notice that the vulnerability description labels this as medium severity in one place and critical in another. This discrepancy likely reflects different scoring methodologies:
- A CVSS base score calculation considering local-only access and specific preconditions might yield a medium score
- A contextual/environmental score accounting for Firejail's setuid-root nature and the realistic exploitation scenario pushes this firmly into critical territory
When a security tool that runs as root is vulnerable to privilege escalation via trivially-set environment variables, treat it as critical regardless of the base score. Context matters.
Conclusion
The command injection vulnerability in Firejail's netfilter.c is a textbook example of a dangerous but preventable mistake: trusting environment variables in a privileged context and using shell-interpreted execution. The fix — removing the unsafe exec() call and eliminating the TERMINAL environment variable dependency — is the right approach and serves as a model for how to handle subprocess execution securely.
Key takeaways for developers:
- 🔴 Environment variables are attacker-controlled — never use them directly in privileged command construction
- 🔴
system()andpopen()invoke a shell — preferexecv()/execve()for subprocess execution - 🟡 Drop privileges as early as possible — minimize the window of elevated execution
- 🟢 Use static analysis — tools like CodeQL and Semgrep can catch taint-flow issues automatically
- 🟢 Apply defense in depth — validate inputs, use allowlists, and clear the environment in setuid binaries
Security tools deserve extra scrutiny precisely because they run with elevated privileges and are trusted by users to protect them. When writing or reviewing security-sensitive C code, approach every getenv() call, every asprintf(), and every exec*() with healthy paranoia.
Stay secure, and keep your shells clean. 🔒
This vulnerability was identified and fixed by OrbisAI Security. Responsible disclosure and timely patching make the open-source ecosystem safer for everyone.