ParaShells: Parallels Desktop Turns Appliance Install Into a Root Shell
Your Mac runs a vulnerable version of Parallels Desktop. A malicious package, compromised CI job, or other unprivileged process is already running on it. No admin access. No Parallels-signed client. One appliance-install request later, attacker-controlled code runs as root.
While testing Desktop 26.4.0 (build 57513) on Apple silicon, we found that an unprivileged local user could execute code as root through prl_disp_service. The exploit combines its world-writable Unix socket with weak local-client authentication and argument injection in the appliance extraction path.
The chain is short: A world-writable Unix socket, a login that trusts peer credentials rather than a Team ID, and an appliance unpack path that builds tar arguments using Qt string splitting. A quote in the parent path injects --use-compress-program=, and macOS tar runs the attacker’s script as uid 0.
Who is affected by ParaShells?
Attack prerequisites
ParaShells needs three things that a normal Desktop install already provides by default:
- Parallels Desktop for Mac installed (lab proof: 26.4.0, build 57513, macOS arm64).
- Root helper
prl_disp_servicerunning, with/var/run/prl_disp_service.socketpresent. On a stock install, the launch daemon starts it at load. A running VM is not required. - A low-privileged local user on the target macOS. The proven path works when
PrlUsrCfg_IsLocalAdministratorreports 0. No Parallels code signature is required on the client.
We did not regression-test every older build for this writeup. Treat any Desktop install that still exposes the same InstallAppliance extract template and world-writable dispatcher socket as in scope.
App Store Edition may differ in how services start. The core risk is the same class of root helper IPC.
Detection
Check whether you are on a lab-proven build and whether the privileged socket is exposed:
# Product version (GUI About box, or):
defaults read "/Applications/Parallels Desktop.app/Contents/Info" CFBundleShortVersionString
defaults read "/Applications/Parallels Desktop.app/Contents/Info" CFBundleVersion
# Dispatcher socket world-writable?
ls -l /var/run/prl_disp_service.socket
# Expect something like: srwxrwxrwx ... prl_disp_service.socket
# Service state (if prlsrvctl is on PATH):
prlsrvctl info | head
If the socket mode is 0777 (srwxrwxrwx) and Desktop is at or near 26.4.0 (57513), assume exposure until you can confirm a patched build.
| Signal | Exposed (lab) | Notes |
|---|---|---|
| Product | Parallels Desktop 26.4.0 (57513) | arm64 confirmed |
| Socket | /var/run/prl_disp_service.socket mode 0777 |
Any local process can connect |
| Auth for InstallAppliance path | Unsigned PrlSrv_LoginLocal returns 0 |
Peercred, not Team ID |
| Admin required? | No (IsLocalAdministrator=0) |
Proven with non-admin account |
Diving into the ParaShells vulnerability
What is prl_disp_service? It is the privileged host daemon behind Parallels Desktop. The GUI, CLI tools, and the Virtualization SDK talk to it over a Unix domain socket. It starts networking helpers, registers VMs, and installs appliance packages. Because those jobs touch host networking and archives as root, the daemon itself runs as root. Whoever can ask it to do privileged work inherits that power.
Parallels Desktop is on a huge number of developer and enterprise Macs. The interesting attack surface is not only guest-to-host Toolgate bugs from past years, but also how an already-local user talks to the root dispatcher. ParaShells operates entirely on the host side.
Appliance install is meant for trusted catalog downloads. The SDK exposes PrlSrv_InstallAppliance(hServer, hAppCfg, sVmParentPath, nFlags). The optional sVmParentPath chooses where the new VM bundle is provisioned. That single string is where the quote break starts.
ParaShells: argument injection in InstallAppliance extract
Background
What is argument injection? Classic command injection feeds a shell metacharacter (;, &&, backticks) into a string that /bin/sh -c later parses. Argument injection is quieter. The program builds a command line as a string, then a library splits that string into argv tokens. If attacker text can close a quote early, leftover text becomes extra flags, not a second shell command. There is no shell. Tokens like ; id do nothing useful. Feature flags of the child binary (here, macOS tar) become the payload.
Local clients connect to /var/run/prl_disp_service.socket. The mode is srwxrwxrwx, where any process can connect.
PrlSrv_LoginLocal then succeeds for an unsigned Python client. Auth is peer credentials from the kernel (typically uid, gid, and sometimes pid). There is no Parallels Team ID validation for this call. The same login works for a non-admin user.
After login, the client builds an appliance config. PackageURL must look like a URL because the download helper always passes -ui. A bare filesystem path fails. file:///tmp/...tar works. That URL is only the fetch source. Our PackageURL value, inside the appliance config XML for this demo, would be:
<PackageURL>file:///tmp/prl_appl_rce_payload_505.tar</PackageURL>
<PackedSize>...</PackedSize>
<UnpackedSize>...</PackedSize>
<PackageMd5><md5 of that tar></PackageMd5>
The dispatcher copies bytes under sVmParentPath, then extracts the local copy.
The root cause
Extract is formatted as a single-quoted command string:
tar -xf "%1" -C "%2"
%1is the local post-download archive path under the parent.%2issVmParentPath (tar -C), meaning change directory before unpacking.- The finished string goes through Qt
QProcess::splitCommand, then starts as root as a real argv list.
Here is the malicious parent we used in the lab:
/tmp/sprl_p_505" --use-compress-program=/tmp/u505 "
Three pieces, glued into one string:
| Piece | Example | Role |
|---|---|---|
| Path before the quote | /tmp/sprl_p_505 | Survives as a normal path token after the split |
| Embedded “ | “ | Closes Qt’s opening quote early |
| Text after the quote | –use-compress-program=/tmp/u505 | Becomes extra argv |
The attacker also creates a real directory whose name is that full string, because the dispatcher treats sVmParentPath as the download/extract parent and checks that it exists.
A clean parent produces a standard command:
tar -xf "/clean/parent/payload.tar" -C "/clean/parent"
The malicious parent puts %1 under the quote-breaking directory. The formatted string expands into something shaped like:
tar -xf "/tmp/sprl_p_505" --use-compress-program=/tmp/u505 "/prl_appl_rce_payload_505.tar" -C "/tmp/sprl_p_505"
--use-compress-program=/tmp/u505 "
splitCommand walks left to right and treats " as quoting syntax, not as a filename character. The argv that actually runs looks like:
tar
-xf
/tmp/sprl_p_505
--use-compress-program=/tmp/u505
/prl_appl_rce_payload_505.tar
-C
/tmp/sprl_p_505
--use-compress-program=/tmp/u505
| argv token | Source |
|---|---|
/tmp/sprl_p_505 (after -xf) |
Path-before-quote from %1 |
--use-compress-program=/tmp/u505 |
Attacker text after the embedded “ |
leftover /prl_appl_rce_payload_505.tar |
Orphaned basename after the quote split; tar treats this as a file to extract from the archive, which fails the extraction – which we don’t care about |
-C / /tmp/sprl_p_505 |
Same quote break on %2 (not the exploit) |
This is argument injection into tar, not shell command injection. That distinction matters for both detection and defense.
From argv rewrite to root code execution
What does tar --use-compress-program do? On macOS, tar can hand the archive byte stream to an external program before it interprets the archive format. The flag --use-compress-program=COMMAND runs COMMAND as the same user as tar. When prl_disp_service starts tar as root, that external program is root too. The PoC script runs the payload, then cats stdin through so the archive stream can still pass.
After the split, tar -xf opens the path-before-quote file (/tmp/sprl_p_505), not the original file:// URL. The attacker places a second copy of the same archive bytes there so -xf has a real file to open while the compression program runs.
One local login plus one InstallAppliance call is enough. The install job often returns an appliance extract error afterward (lab: -41508). The root script has already run.
/tmp/u505 is the --use-compress-program script. When root tar opens the archive, it runs that script. In our demo, it does this:
- Proves root: Writes a marker file with
ACTOR=<uid>, UCP_UID=$(id -u), UCP_USER=$(id -un). - Plants a root shell path – Writes
/etc/sudoers.d/prl-rce-<uid>: <username> ALL=(ALL) NOPASSWD:ALL
When the Python PoC script sees UCP_UID=0 in the marker and runs sudo -i for an interactive root shell.

From LoginLocal to root shell: escalating the primitive
End-to-end, the privilege chain is:
- Connect to
/var/run/prl_disp_service.socket (0777). PrlSrv_LoginLocalfrom an unsigned process → success via peercred.- Build a tiny fake appliance tar (
file://URL + matching MD5/size fields). - Pass a quote-breaking
sVmParentPaththat injects--use-compress-program=/path/to/script. - Seed the post-split
-xfpath with the same archive bytes. - Root compress-program script runs → marker
UCP_UID=0. - PoC drops a NOPASSWD sudoers fragment and opens
sudo -i.
Four paths matter in any faithful reproduction of the bug class:
| Path | Role |
|---|---|
/tmp/prl_appl_rce_payload_<uid>.tar |
PackageURL fetch source. Downloader + MD5 only. |
| Local copy under the quote-breaking parent | Real %1 after download. Quote split turns it into path-before-quote + inject flag + leftover basename. |
/tmp/sprl_p_<uid> |
Same archive bytes, placed where post-split tar -xf actually opens. |
Full sVmParentPath string |
Directory name that embeds the quote break and the injected tar flag. |
The compress-program script is separate. It is not inside the tar. Tar runs it because of the injected argv; the script then forwards stdin so extract continues even after the payload runs.
We glued this flow into a one-liner POC script and got a root shell:
Real-world impact: every local account on a Parallels Mac
ParaShells is a host LPE, so the “machine at risk” is the Mac itself.
Developer laptops. Parallels is common on engineering machines. A malicious Homebrew formula, a poisoned npm preinstall, or a browser download that lands as a local user suddenly has a clean path to root if Desktop is installed.
Shared lab and training machines. Universities and corporate training rooms often leave many local accounts on a single Mac. One weak student account becomes a host-wide incident.
The presence of Parallels Desktop is the only prerequisite. No separate Parallels admin role is required for the proven path. From root, the attacker can replace system software, read other users’ data, and persist via launchd.
Disclosure Timeline
| Date | Event |
|---|---|
| 2026-07-13 | Confirmed non-admin → root chain on Parallels Desktop 26.4.0 (57513) arm64 |
| 2026-07-14 | Disclosed to Parallels via security@parallels.com (KB 125214 process) |
| 2026-09-01 | Fix released in Parallels Desktop 27.0.0 |
| 2026-09-14 | CVE ID published (CVE-2026-90894) |
| 2026-09-15 | This blog post has been published |
Staying Safe
Do these three things first: Inventory every Mac with Parallels Desktop, lock down who can log in locally, and upgrade to a fixed version.
| Item | Value |
|---|---|
| Vulnerable (verified) | Parallels Desktop 26.4.0 (build 57513), macOS arm64 |
| Fixed version | 27.0.0 |
| CVE | CVE-2026-90894 |
| Interim control | Lock down local accounts on Parallels Desktop hosts |
Stay on top of the latest vulnerabilities by bookmarking https://research.jfrog.com


