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 intoosascriptshell commands — treat it with the same suspicion as an HTTP query parameter. pickle.load()on persistent files is a code execution primitive. The.picfiles written bysaveClip()could have been poisoned; replacingpicklewithjsoneliminates this attack path entirely._safe_clip_path()shows the right pattern for user-influenced filenames. Allowlist-based sanitization ([^A-Za-z0-9_-]) combined withos.path.realpath()prevents path traversal at the filesystem level.subprocesswith a list, not a string. The single most impactful change in this PR is switching from shell string construction tosubprocess.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 atKlipz.py:180andKlipz.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 ofsubprocesswith 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.