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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1342

Related Articles

critical

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

high

THiNX Device Platform: Command Injection via source.owner in Git Fetch

The THiNX device management platform fixed a critical vulnerability where the `source.owner` parameter flowed directly into shell commands during git repository operations. An attacker with source configuration access could inject arbitrary shell commands by providing malicious owner values containing metacharacters like `; curl`.

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.

critical

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.