Multiple issues with QNAP macOS tooling were identified when setting up an environment for Pwn2Own Ireland research. The vulnerabilities can be used for local privilege escalation via an insecure Privileged Helper Tool.
Despite some similar reports receiving reasonable bounties under QNAP's program, these bugs were assigned a total of $20, later followed by $310. While these bugs are incredibly easy to exploit and took very little time to find, such a low reward was puzzling - especially when we are aware of much higher rewards given for colliding reports.
This blog post provides a working LPE for every version of qsoftwareupdater from 1.0.0 to 1.0.4. Much of the thinking here can be applied to the new version, 1.0.5, if you want to spend time hitting race windows for the price of a Big Mac.
QNAP provides various macOS applications for their range of appliances, but this blog post will talk about qsoftwareupdater, the Privileged Helper Tool (PHT) used for QVPN, QFinder Pro and QSync.
Privileged Helper Tools are daemons launched by launchd to perform privileged activities on behalf of standard user applications. They are usually contacted using macOS APIs, where the PHT is meant to verify the source of the request and, if valid, perform an action.
This logic is in an implementation of -[NSXPCListenerDelegate listener:shouldAcceptNewConnection:] and usually involves requesting code-signing data to verify that the client is signed by the correct developer and, ultimately, the Apple Root CA.
QNAP decided to use an altogether different approach: an unauthenticated HTTP server! Any process can connect to qsoftwareupdater and is able to send it one of four requests:
/QSoftwareUpdater/Open - call open on a file/QSoftwareUpdater/UpdatePKG - Install a update PKG file/QSoftwareUpdater/Test - Check the PHT is running/QSoftwareUpdater/Version - Get the PHT versionThe main attack surface here is the UpdatePKG route, which accepts two parameters, a DMG and the name of a package that is meant to be in it. The request is passed in the form of a query string such as:
?path=%2ftmp%2fQclient.dmg&pkgName=Qclient HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n
Sample Request to UpdatePKG Endpoint
In the aforementioned example request, the path parameter (/tmp/Qclient.dmg) is a valid DMG file containing a package Qclient.pkg.
The UpdatePKG handler is +[SoftwareUpdateHelper installDMG:targetVolume:withReply:]
The first version performed the update in the following stages:
pkgutil --check-signaturecpinstallerAfter mounting, the package is checked with pkgutil:
// Check signature of package
v83[0] = (__int64)CFSTR("--check-signature");
v66 = objc_retainAutoreleasedReturnValue(v25);
v83[1] = (__int64)v66;
v26 = +[NSArray arrayWithObjects:count:](&OBJC_CLASS___NSArray, "arrayWithObjects:count:", v83, 2LL);
v27 = objc_retainAutoreleasedReturnValue(v26);
objc_release(v22);
v28 = objc_alloc_init(&OBJC_CLASS___NSTask);
v29 = +[NSPipe pipe](&OBJC_CLASS___NSPipe, "pipe");
v30 = objc_retainAutoreleasedReturnValue(v29);
-[NSTask setStandardOutput:](v28, "setStandardOutput:", v30);
v31 = +[NSURL fileURLWithPath:](&OBJC_CLASS___NSURL, "fileURLWithPath:", CFSTR("/usr/sbin/pkgutil"));
v32 = objc_retainAutoreleasedReturnValue(v31);
objc_release(v75);
v76 = v32;
-[NSTask setExecutableURL:](v28, "setExecutableURL:", v32);
v78 = v27;
-[NSTask setArguments:](v28, "setArguments:", v27);
-[NSTask launch](v28, "launch");
...
v38 = -[NSString initWithData:encoding:](v37, "initWithData:encoding:", v36, 4LL);
NSLog((NSString *)CFSTR("output: %@"), v38);
if ( -[NSString containsString:](
v38,
"containsString:",
CFSTR("Developer ID Installer: QNAP Systems, Inc. (JY5JM7GXQJ)")) )
...
}
If this check passes, cp is called to copy the package out of the mounted DMG to the directory containing the DMG:
// Copying the package out of the DMG
v42 = +[NSURL fileURLWithPath:](&OBJC_CLASS___NSURL, "fileURLWithPath:", CFSTR("/bin/cp"));
v61 = v34;
v71 = objc_retainAutoreleasedReturnValue(v42);
objc_release(v76);
-[NSTask setExecutableURL:](v69, "setExecutableURL:", v71);
v43 = v41;
-[NSTask setArguments:](v69, "setArguments:", v41);
-[NSTask launch](v69, "launch");
...
v48 = objc_msgSend(v68, "stringByAppendingFormat:", CFSTR("/%@"), v74);
v72 = objc_retainAutoreleasedReturnValue(v48);
Finally, installer is called to install the package:
// Build args for installer
v80[0] = CFSTR("-pkg");
v80[1] = v72;
v80[2] = CFSTR("-target");
v80[3] = v65;
v80[4] = CFSTR("-dumplog");
v49 = +[NSArray arrayWithObjects:count:](&OBJC_CLASS___NSArray, "arrayWithObjects:count:", v80, 5LL);
v50 = objc_retainAutoreleasedReturnValue(v49);
objc_release(v46);
v51 = objc_alloc_init(&OBJC_CLASS___NSTask);
v52 = +[NSURL fileURLWithPath:](&OBJC_CLASS___NSURL, "fileURLWithPath:", CFSTR("/usr/sbin/installer"));
v53 = objc_retainAutoreleasedReturnValue(v52);
...
-[NSTask setExecutableURL:](v51, "setExecutableURL:", v53);
-[NSTask setArguments:](v51, "setArguments:", v50);
-[NSTask launch](v51, "launch");
As the exit code of cp is discarded, the copy of the package can fail, whereupon the PHT attempts to install any package that matches the expected path.
This means that the following DMG structure can completely circumvent the pkgutil check
Outer.dmg (RO-mounted)
----> Payload.pkg (unsigned)
----> Inner.dmg
-----> Payload.pkg (QNAP-signed package)
With Outer.dmg mounted read-only and a request sent to install Payload.pkg in Inner.dmg, the Payload.pkg in Outer.dmg is installed instead.
But carrying an entire legit package is a pain, so it's time to break code-signing entirely
The 1.0.2 release fixes this issue by removing the cp call, however more issues were soon identified.
When verifying the package, the result of pkgutil is checked to contain strings indicating that the package is signed by QNAP, however the output of the command contains user-controllable data.
The initial check just checked for the presence of QNAP's developer ID:
v31 = +[NSURL fileURLWithPath:](&OBJC_CLASS___NSURL, "fileURLWithPath:", CFSTR("/usr/sbin/pkgutil"));
v32 = objc_retainAutoreleasedReturnValue(v31);
objc_release(v75);
v76 = v32;
-[NSTask setExecutableURL:](v28, "setExecutableURL:", v32);
v78 = v27;
-[NSTask setArguments:](v28, "setArguments:", v27);
-[NSTask launch](v28, "launch");
v59 = v30;
v33 = -[NSPipe fileHandleForReading](v30, "fileHandleForReading");
v34 = objc_retainAutoreleasedReturnValue(v33);
v35 = -[NSFileHandle readDataToEndOfFile](v34, "readDataToEndOfFile");
v36 = objc_retainAutoreleasedReturnValue(v35);
v37 = objc_alloc(&OBJC_CLASS___NSString);
v60 = v36;
v38 = -[NSString initWithData:encoding:](v37, "initWithData:encoding:", v36, 4LL);
NSLog((NSString *)CFSTR("output: %@"), v38);
// Check the signature
if ( (unsigned __int8)objc_msgSend(
v38,
"containsString:",
CFSTR("Developer ID Installer: QNAP Systems, Inc. (JY5JM7GXQJ)")) )
{
NSLog((NSString *)CFSTR("checkSignatureTask pass"));
// installation continues
...
}
By generating a certificate with Developer ID Installer: QNAP Systems, Inc. (JY5JM7GXQJ) in the certificate name and then signing a package with this certificate, it is possible to pass this check. It should be noted that macOS will not install a self-signed package by default, preventing this from being an issue in isolation.
Luckily, there's also a sufficiently large window between pkgutil and installer such that an attacker can redirect a symlink to an unsigned package, something that macOS will install without issue.
Therefore, to escalate using v1.0.2 we:
It's important to note that the package verification issue hasn't actually been fixed, but that QNAP's response has made the race window even larger. The new check searches more strings in the same user-controllable output, making the gap between pkgutil and installer larger than it was before:
if ( -[NSString containsString:](
v52,
"containsString:",
CFSTR("Status: signed by a developer certificate issued by Apple for distribution"))
&& -[NSString containsString:](v52, "containsString:", CFSTR("Notarization: trusted by the Apple notary service"))
&& -[NSString containsString:](
v52,
"containsString:",
CFSTR("Developer ID Installer: QNAP Systems, Inc. (JY5JM7GXQJ)"))
&& -[NSString containsString:](v52, "containsString:", CFSTR("Developer ID Certification Authority"))
&& -[NSString containsString:](v52, "containsString:", CFSTR("Apple Root CA")) )
{
NSLog((NSString *)CFSTR("checkSignatureTask pass"));
...
}
Bypassing the verification with a fake-signed package makes exploitation simpler and reduces the size of exploit resources. But what if we can remove the need for a DMG entirely?
The PHT will assume that the name of a DMG is also the Volume Name, and perform a precautionary forced unmount prior to attaching the requested DMG:
v7 = objc_retain(installDMG);
v8 = objc_msgSend(v7, "lastPathComponent");
v9 = objc_retainAutoreleasedReturnValue(v8);
v10 = objc_msgSend(v9, "stringByDeletingPathExtension");
v11 = objc_retainAutoreleasedReturnValue(v10);
objc_release(v9);
v57 = v11;
v12 = +[NSString stringWithFormat:](&OBJC_CLASS___NSString, "stringWithFormat:", CFSTR("/Volumes/%@"), v11);s
v70 = objc_retainAutoreleasedReturnValue(v12);
v85[0] = (__int64)CFSTR("unmount");
v85[1] = (__int64)v70;
v13 = +[NSArray arrayWithObjects:count:](&OBJC_CLASS___NSArray, "arrayWithObjects:count:", v85, 2LL);
v14 = objc_retainAutoreleasedReturnValue(v13);
v15 = objc_alloc_init(&OBJC_CLASS___NSTask);
v16 = +[NSURL fileURLWithPath:](&OBJC_CLASS___NSURL, "fileURLWithPath:", CFSTR("/usr/bin/hdiutil"));
v78 = objc_retainAutoreleasedReturnValue(v16);
-[NSTask setExecutableURL:](v15, "setExecutableURL:", v78);
-[NSTask setArguments:](v15, "setArguments:", v14);
-[NSTask launch](v15, "launch");
In a pattern reminiscent of the missing cp check, the unmount and subsequent attach calls never have their return code checked. This is somewhat understandable in the unmount case, but not so much with attach. The consequence of this is that, provided the path built by the PHT is valid, disk operations can fail entirely but the function can still successfully complete.
NSLog((NSString *)CFSTR("[installPKG] enter"), a2);
// Get filename from path and strip extension
v8 = objc_msgSend(v7, "lastPathComponent");
v9 = objc_retainAutoreleasedReturnValue(v8);
v10 = objc_msgSend(v9, "stringByDeletingPathExtension");
v11 = objc_retainAutoreleasedReturnValue(v10);
objc_release(v9);
v57 = v11;
// Build the expected DMG mount point
v12 = +[NSString stringWithFormat:](&OBJC_CLASS___NSString, "stringWithFormat:", CFSTR("/Volumes/%@"), v11);
v70 = objc_retainAutoreleasedReturnValue(v12);
...
// Get contents of mount point
v26 = -[NSFileManager contentsOfDirectoryAtPath:error:](v59, "contentsOfDirectoryAtPath:error:", v70, 0LL);
v27 = objc_retainAutoreleasedReturnValue(v26);
v28 = ((NSArray *(*)(id, SEL, id, ...))objc_msgSend)(&OBJC_CLASS___NSArray, "arrayWithObjects:", CFSTR("pkg"), 0LL);
v61 = objc_retainAutoreleasedReturnValue(v28);
// Query the results with an NSPredicate
v29 = ((NSPredicate *(*)(id, SEL, NSString *, ...))objc_msgSend)(
&OBJC_CLASS___NSPredicate,
"predicateWithFormat:",
&CFSTR("(pathExtension IN %@)").isa,
v61);
v30 = objc_retainAutoreleasedReturnValue(v29);
v60 = v27;
// Filter out other files
v31 = -[NSArray filteredArrayUsingPredicate:](v27, "filteredArrayUsingPredicate:", v30);
v32 = objc_retainAutoreleasedReturnValue(v31);
objc_release(v30);
v62 = v32;
if ( !v32 )
goto LABEL_5;
v25 = 0LL;
// Get the first filtered result and confirm the name names the value passed in pkgName
v33 = -[NSArray objectAtIndex:](v32, "objectAtIndex:", 0LL);
v34 = objc_retainAutoreleasedReturnValue(v33);
if ( !v34 )
goto LABEL_5;
v35 = v34;
if ( (unsigned __int8)objc_msgSend(v34, "isEqualToString:", &stru_100005488) ) // "pkgName"
// continue
}
A cursory glance at this code paired with the disk operation quirks reveals that you can direct the PHT outside a (supposedly) mounted DMG. This can be achieved by requesting a path that would exist if the DMG mount failed - such as /tmp/Payload.pkg inside a DMG named Macintosh HD.dmg.
Exploiting v1.0.4 is therefore possible by sending a request to install a package in this DMG, which causes the PHT to perform the following sequence of events:
/tmp/Macintosh HD.dmg to Macintosh HD/Volumes/Macintosh HD (this fails)/tmp/Macintosh HD.dmg/Volumes, Macintosh HD, and the supplied package name to call pkgutil -—check-signature on /Volumes/Macintosh HD/tmp/Qclient.pkg (a writeable location where we've dropped a legitimate or fake-signed package)Finally, reuse the old TOCTTOU bug from the v1.0.2 technique to redirect the link from a fake-signed package to your unsigned payload.
Because we never mount the DMG we don't need it to be a real image, so can use a renamed bash script instead. We've successfully removed all additional resources from the privesc.
Yeah I'm not doing this again for $330, but feel free, dear reader.
Working exploits for the second and third exploits can be found here. Fake certificates have not been provided.