Back to Blog
critical SEVERITY8 min read

How Command Injection happens in Python PopClip Extensions and how to fix it

A critical command injection vulnerability was discovered in `contrib/Klipz.popclipext/Klipz.py`, where user-controlled clipboard content was concatenated directly into shell commands executed via `osascript`. The fix replaces unsafe string concatenation with `subprocess` and proper argument lists, and replaces the unsafe `pickle` serialization with `json` to eliminate a secondary deserialization risk. Together, these changes close two distinct attack surfaces in a single file.

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

Answer Summary

The Klipz PopClip extension (`Klipz.py`) contained a command injection vulnerability (CWE-78) in Python, where clipboard text was concatenated unsanitized into shell command strings passed to `osascript`. An attacker could place shell metacharacters like `'; rm -rf ~; #` in the clipboard to execute arbitrary commands. The fix replaces string concatenation with `subprocess.run()` using a list of arguments (eliminating shell interpretation), replaces `pickle.load()` with `json.load()` to remove deserialization risk, and adds a `_safe_clip_path()` helper to sanitize board names used in file paths.

Vulnerability at a Glance

cweCWE-78
fixReplace shell string concatenation with subprocess argument lists; sanitize file paths; replace pickle with json
riskArbitrary command execution with the privileges of the PopClip process
languagePython
root causeClipboard text concatenated directly into shell command strings without escaping or validation
vulnerabilityOS Command Injection

Introduction

The contrib/Klipz.popclipext/Klipz.py file implements a PopClip extension that manages a clipboard history board — users copy text, and Klipz stores, retrieves, and replays those clips via macOS osascript commands. It's a small utility, but it sits at a uniquely dangerous intersection: it reads directly from the system clipboard (attacker-controlled input) and feeds that data into shell commands.

A code review flagged line 180 and the surrounding getOne() function, where clipboard content was being stitched into an osascript command string using Python string concatenation. No escaping. No quoting normalization. No subprocess argument list. Just raw user data dropped into a shell command — a textbook OS command injection.

What made this finding particularly interesting is that the same file also used pickle for serialization, introducing a secondary deserialization vulnerability on top of the injection flaw. The fix addressed both issues in a single, focused pull request.


The Vulnerability Explained

Shell Command Injection via Clipboard Data

The vulnerable pattern in getOne() (around line 180) constructed osascript commands by concatenating clipboard strings directly:

# BEFORE — vulnerable pattern (simplified from the original)
cmd = 'osascript -e \'set the clipboard to "' + clipboardText + '"\''
os.system(cmd)

The shell sees this as a single quoted string — until it doesn't. If clipboardText contains a double-quote followed by shell metacharacters, the attacker escapes the quoted context and injects arbitrary commands. For example, if the clipboard contains:

"; rm -rf ~/Documents; echo "

The constructed command becomes:

osascript -e 'set the clipboard to ""; rm -rf ~/Documents; echo ""'

The shell now sees three separate commands. The osascript call completes harmlessly, then rm -rf ~/Documents executes with full user privileges, then echo cleans up the appearance. The user sees nothing unusual.

The PR notes that line 217 follows the same pattern and was flagged for review alongside line 215.

Why Clipboard Data Is Especially Dangerous Here

Most injection vulnerabilities require an attacker to control a network request or form field. Here, the attack surface is the system clipboard — an input channel that users interact with constantly and rarely scrutinize for malicious content. A malicious string could arrive via:

  • A phishing page that uses document.execCommand('copy') to silently overwrite clipboard contents
  • A shared document or chat message that a user highlights and copies
  • A paste from a compromised upstream tool

Because PopClip extensions trigger automatically on text selection, the time between "user copies text" and "Klipz processes it" can be milliseconds.

Secondary Risk: pickle Deserialization

The original code also used Python's pickle module to persist clipboard history:

# BEFORE — unsafe deserialization
fp = open(path, "rb")
dict = pickle.load(fp)

pickle.load() on attacker-influenced files can execute arbitrary Python bytecode during deserialization. If an attacker could write to the .pic file (e.g., via a path traversal using a crafted board name), they could achieve code execution through the load path as well.

Additionally, the board name was used directly in a file path:

# BEFORE — unsanitized path construction
path += "/" + clibBoard.strip() + ".pic"

A board name like ../../.bashrc would write outside the intended Klipz directory.


The Fix

The pull request made three coordinated changes to address these issues.

1. Replace Shell String Concatenation with subprocess

The import subprocess line was added at the top of the file, and shell command construction was refactored to pass arguments as a list:

# AFTER — safe subprocess invocation (import added at top)
import subprocess

# Arguments passed as list — shell never interprets the clipboard content
subprocess.run(["osascript", "-e", f'set the clipboard to "{clip_text}"'], check=True)

When subprocess.run() receives a list, Python's os.execvp passes each element as a distinct argument to the process. The shell is never invoked, so metacharacters in clip_text are treated as literal data by osascript, not as shell syntax.

2. Replace pickle with json

# BEFORE
import pickle
fp = open(path, "wb")
pickle.dump(dict, fp, pickle.HIGHEST_PROTOCOL)

# AFTER
import json
fp = open(path, "w")
json.dump({k: list(v) for k, v in dict.items()}, fp)

And on the load side:

# BEFORE
fp = open(path, "rb")
dict = pickle.load(fp)

# AFTER
fp = open(path, "r")
dict = json.load(fp)

json.load() parses a strict data format. It cannot execute code, import modules, or instantiate arbitrary Python objects. The {k: list(v) for k, v in dict.items()} conversion handles the fact that the in-memory structure uses deque objects (from collections), which JSON serializes as plain lists.

3. Sanitize Board Names Used in File Paths

A new helper function _safe_clip_path() was introduced:

def _safe_clip_path(base_dir, board_name):
    safe_name = re.sub(r'[^A-Za-z0-9_-]', '_', board_name.strip())
    return os.path.join(base_dir, safe_name + ".pic")

This strips any character that isn't alphanumeric, underscore, or hyphen — preventing path traversal via board names like ../../etc/passwd. The base directory is also resolved with os.path.realpath() before use:

# BEFORE
path = os.getcwd() + "/../../Klipz"

# AFTER
base = os.path.realpath(os.getcwd() + "/../../Klipz")

os.path.realpath() resolves symlinks and .. components, ensuring the resolved path actually points to the intended directory.


Prevention & Best Practices

Never Concatenate User Input into Shell Commands

The rule is simple and absolute: if data originates from user input — including clipboard content, filenames, environment variables, or network responses — it must never be interpolated into a shell command string. Use subprocess with argument lists:

# WRONG — always
os.system("cmd " + user_input)
subprocess.run("cmd " + user_input, shell=True)

# RIGHT — always
subprocess.run(["cmd", user_input])

Avoid pickle for Untrusted or Persistent Data

Python's own documentation warns: "The pickle module is not secure. Only unpickle data you trust." For configuration and clipboard history storage, json, toml, or sqlite3 are safer alternatives. If you need to serialize complex Python objects, consider msgpack with a schema or dataclasses + JSON.

Sanitize All Inputs Used in File Paths

Use os.path.realpath() to resolve the full path after construction, then verify it starts with the expected base directory:

resolved = os.path.realpath(os.path.join(base_dir, user_supplied_name))
assert resolved.startswith(os.path.realpath(base_dir) + os.sep)

Run Static Analysis on Extension Code

PopClip extensions and similar small utilities often escape security review because they feel like "just scripts." Tools like Bandit (bandit -r Klipz.py) will flag os.system() with string concatenation and pickle.load() on first run. Add them to CI even for contrib/extensions directories.

Relevant Standards

  • OWASP Command Injection: https://owasp.org/www-community/attacks/Command_Injection
  • OWASP Input Validation Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
  • CWE-78: OS Command Injection — https://cwe.mitre.org/data/definitions/78.html
  • CWE-502: Deserialization of Untrusted Data — https://cwe.mitre.org/data/definitions/502.html

Key Takeaways

  • Clipboard content is attacker-controlled input. In Klipz.py, clipboard text flowed directly into osascript shell commands — treat it with the same suspicion as an HTTP query parameter.
  • pickle.load() on persistent files is a code execution primitive. The .pic files written by saveClip() could have been poisoned; replacing pickle with json eliminates this attack path entirely.
  • _safe_clip_path() shows the right pattern for user-influenced filenames. Allowlist-based sanitization ([^A-Za-z0-9_-]) combined with os.path.realpath() prevents path traversal at the filesystem level.
  • subprocess with a list, not a string. The single most impactful change in this PR is switching from shell string construction to subprocess.run(["osascript", ...]). This makes shell metacharacters in clipboard data completely irrelevant.
  • Small utility scripts deserve the same security review as production services. The contrib/ directory prefix did not reduce the real-world risk — PopClip extensions run with full user privileges on macOS.

How Orbis AppSec Detected This

  • Source: User-controlled clipboard text, read by the getOne() function and passed into shell command construction at Klipz.py:180 and Klipz.py:215–217.
  • Sink: os.system() (and similar shell-invoking calls) receiving a string built by concatenating clipboard data without escaping — Klipz.py:215.
  • Missing control: No shell escaping (e.g., shlex.quote()), no use of subprocess with argument lists, and no validation of clipboard content before inclusion in the command string.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Replaced shell string concatenation with subprocess.run() using an argument list, eliminating shell interpretation of clipboard content 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 Klipz command injection vulnerability is a reminder that the attack surface of a clipboard manager is the entire clipboard — every piece of text a user copies becomes potential input to the tool's internal logic. By using subprocess argument lists instead of shell strings, replacing pickle with json, and sanitizing board names with _safe_clip_path(), the fix closes three related attack vectors without changing any user-visible behavior.

For developers building macOS extensions, automation scripts, or any tool that processes user-supplied text and passes it to system commands: the pattern to internalize is that shell interpretation is opt-in. If you use subprocess with a list, the shell never runs. Metacharacters become inert. The fix is not complicated — but it requires knowing where the boundary between "data" and "command" must be enforced.


References

Frequently Asked Questions

What is command injection?

Command injection (CWE-78) occurs when user-controlled data is embedded in a shell command string without sanitization, allowing attackers to append or inject additional commands that the shell executes.

How do you prevent command injection in Python?

Use `subprocess.run()` or `subprocess.Popen()` with a list of arguments instead of a shell string, and never pass `shell=True` with user-controlled input. This bypasses shell interpretation entirely.

What CWE is command injection?

Command injection maps to CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input validation alone enough to prevent command injection in Python?

No. Allowlist validation can help, but the most robust fix is to avoid shell interpretation altogether by using subprocess with argument lists, making shell metacharacters irrelevant.

Can static analysis detect command injection?

Yes. Tools like Semgrep, Bandit, and multi-agent AI scanners can trace tainted data from clipboard APIs to shell execution sinks and flag unsafe concatenation patterns before they reach production.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1342

Related Articles

high

How Shell Injection via os.system() happens in Python and how to fix it

A shell injection vulnerability in TensorFlow's DELF dataset download script allowed attackers who controlled the `data_dir` parameter to execute arbitrary shell commands by injecting metacharacters into `os.system()` calls. The fix replaces all four `os.system()` invocations with `subprocess.run()` using argument lists, eliminating shell interpretation entirely. This change closes a high-severity code execution path in production ML infrastructure.

critical

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

CVE-2026-9277 is a critical command injection vulnerability in the `shell-quote` npm package (versions prior to 1.8.4) caused by unescaped line terminators that allow attackers to inject and execute arbitrary shell commands. The fix pins `shell-quote` to `>=1.8.4` via a `pnpm.overrides` entry, ensuring every transitive consumer in the dependency tree receives the patched version. Any Node.js project that processes user-influenced input through `shell-quote` and has not yet upgraded is at risk of

critical

How Remote Code Execution via Security Fix Bypass happens in Node.js and how to fix it

CVE-2026-28292 is a critical Remote Code Execution vulnerability in the `simple-git` Node.js library that allowed attackers to bypass previously applied security fixes. Applications using `simple-git` versions below 3.32.3 remained exposed even after earlier patches, and upgrading to 3.32.3 — which introduced hardened argument parsing via new `@simple-git/argv-parser` and `@simple-git/args-pathspec` sub-packages — closes the bypass. This fix is especially urgent because the vulnerability affects

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 in parsed shell arguments could allow attackers to inject and execute arbitrary commands. The fix upgrades shell-quote to version 1.8.4 and pins the resolution in both `package.json` and `yarn.lock` to ensure the patched version is used across the entire dependency tree. Because this package is used in a production web application that processes user-influenced input,

critical

How Command Injection happens in Rust-generated Python scripts and how to fix it

A critical command injection vulnerability (CWE-78) was discovered in the Linux automation module of the `goose-mcp` crate, where Rust code generated Python scripts that passed user-controlled commands directly to `subprocess.run()` with `shell=True`. An attacker who could influence the `commands` parameter in `execute_system_script()` could inject arbitrary shell commands using metacharacters like `;`, `|`, or backticks. The fix replaces `shell=True` with `shlex.split()` and `shell=False`, and

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr