Rootpipe Reborn (Part II): CVE-2019-8565 Feedback Assistant Race Condition

Relying on pid to validate IPC peer is unsafe.

There’s a general bug type on macOS. When a privileged (or loosely sandboxed) user space process accepts an IPC message from an unprivileged or sandboxed client, it decides whether the operation is valid by enforcing code signature (bundle id, authority or entitlements). If such security check is based on process id, it can be bypassed via pid reuse attack.

Background

An unprivileged client can send an IPC message, then spawns an entitled process to reuse current pid. The privileged service will then validate on the new process and accept the previous IPC request, leading to privilege escalation or even sandbox escape. The attacker can stably win the race by spawning multiple child processes to fill up the message queue.

Security checks based on pid, like sandbox_check and SecTaskCreateWithPID suffer from this attack.

The idea and the initial PoC was borrowed from Ian Beer:

Samuel Groß has also been aware of this senario:

Put another way, the IPC server should never use xpc_connection_get_pid or [NSXPCConnection processIdentifier] to check the validity of incoming clients. It should use the audit_token_t instead (note: there was an exception).

Unfortunately these functions are undocumented and private:

Since, as noted, these methods are private, third-party developers are trapped in this issue repeatedly:

Apple please consider opening these functions to developers!

Oh wait. Actually audit_token_t was not so trustworthy. @5aelo has just pointed out another bug before iOS 12.2 / macOS 10.14.4: Issue 1757: XNU: pidversion increment during execve is unsafe 🤦‍♂

The bug

The privileged XPC service com.apple.appleseed.fbahelperd has exported the following interface:

@protocol FBAPrivilegedDaemon <NSObject>
- (void)copyLogFiles:(NSDictionary *)arg1;
- (void)gatherInstallLogsWithDestination:(NSURL *)arg1;
- (void)gatherSyslogsWithDestination:(NSURL *)arg1;
- (void)sampleProcessWithPID:(unsigned long long)arg1 withDestination:(NSURL *)arg2;
- (void)runMDSDiagnoseWithDestination:(NSURL *)arg1;
- (void)runTMDiagnoseWithDestination:(NSURL *)arg1;
- (void)runBluetoothDiagnoseWithDestination:(NSURL *)arg1 shortUserName:(NSString *)arg2;
- (void)runWifiDiagnoseWithDestination:(NSURL *)arg1;
- (void)runSysdiagnoseWithDestination:(NSURL *)arg1 arguments:(NSArray *)arg2;
- (void)runSysdiagnoseWithDestination:(NSURL *)arg1;
- (void)runMobilityReportWithDestination:(NSURL *)arg1;
- (void)runSystemProfileWithDetailLevel:(NSString *)arg1 destination:(NSURL *)arg2;
- (void)stopDaemon;
- (void)showPrivileges;
- (void)performReadyCheck;
@end

Look at the implementation of -[FBAPrivilegedDaemon listener:shouldAcceptNewConnection:] method. It only allows XPC messages from one client: /System/Library/CoreServices/Applications/Feedback Assistant.app/Contents/MacOS/Feedback Assistant

launchctl plist "./System/Library/CoreServices/Applications/Feedback Assistant.app/Contents/Library/LaunchServices/fbahelperd"
{
	"CFBundleIdentifier" = "com.apple.appleseed.fbahelperd";
	"SMAuthorizedClients" = (
		"(identifier com.apple.appleseed.FeedbackAssistant) and anchor apple";
	);
};
id -[FBAPrivilegedDaemon authorizedClientReq](FBAPrivilegedDaemon *self, SEL selector)
{
  id bundle = NSBundle.mainBundle;
  id value = [bundle objectForInfoDictionaryKey:@"SMAuthorizedClients"];
  if (value && value.count == 1) {
    return value.lastObject;
  }
  return nil;
}

BOOL -[FBAPrivilegedDaemon listener:shouldAcceptNewConnection:](
        FBAPrivilegedDaemon *self,
        SEL a2,
        id a3,
        id a4)
{
  id pid = [NSNumber numberWithLong:connection.processIdentifier];
  id attr = [NSDictionary dictionaryWithObjects:&pid forKeys:&kSecGuestAttributePid count:1];
  if (SecCodeCopyGuestWithAttributes(0, attr, 0, &guest)) {
    syslog_DARWIN_EXTSN(3, "FBAPrivilegedDaemon SecCodeCreateWithPID returns error 0x%x\n", attr);
    return NO;
  }

  id reqstr = [self authorizedClientReq];
  if (!reqstr) {
    syslog_DARWIN_EXTSN(3, "FBAPrivilegedDaemon couldn't find an authorized client requirement\n");
    return NO;
  }

  SecRequirementRef requirement;
  if (SecRequirementCreateWithString(reqstr, 0, &requirement)) {
    syslog_DARWIN_EXTSN(3, "FBAPrivilegedDaemon SecRequirementCreateWithString returns error 0x%x\n", attr);
    return NO;
  }

  if (SecCodeCheckValidity(guest, 0, requirement)) {
    syslog_DARWIN_EXTSN(2, "FBAPrivilegedDaemon SecCodeCheckValidity returns error %d (0x%x)\n", attr, attr);
    return NO;
  }
  // ...
}

But since it performs the security check based on process id, we can bypass it. You can now refer to the proof of concept by Ian Beer entitlement_spoof.c or see my full exploit at the end.

The steps to trigger the race condition are as follows:

Create multiple client processes via posix_spawn or NSTask (note: you can’t do this on iOS).

Avoid using fork because Objective-C runtime may crash between fork and exec, which is required by this attack.

On 10.13 you can add an OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES environment variable before process creation or add a __DATA,__objc_fork_ok section to your executable as a workaround. But these workarounds are not compatible with previous macOS. For more information, please refer to Objective-C and fork() in macOS 10.13.

Send multiple XPC messages to the server to block the message queue.

Ian Beer uses execve to replace the binary to a trusted one and write to its its buffer to prevent the new process from terminating. Instead, I chose to pass these flags POSIX_SPAWN_SETEXEC | POSIX_SPAWN_START_SUSPENDED to posix_spawn to create a suspended child process and reuse the pid of the parent.

Since the child process has been replaced, there won’t be any callback. You have to use a “canary” to detect whether the race is successful based on the server’s behavior, e.g., the existence of a newly created file.

From the console output, the server accepts our request:

Now the check is passed

Give Me Root

Now continue code auditing on FBAPrivilegedDaemon.

The method copyLogFiles: accepts one NSDictionary argument, whose keys as the sources and the correspond NSString as destination to perform file copy. It supports multiple tasks at once, and the path can be both directory or file.

-[FBAPrivilegedDaemon copyLogFiles:]

if ([src hasPrefix:@"/LibraryLogs"] || [src hasPrefix:@"/var/log"]) {
  if (![self canModifyPath:dst]) {
    result[src] = [NSString stringWithFormat:@"Invalid destination: %@", dst];
  } else {
    result[src] = @"File must be copied from a log directory";
  }
}

-[FBAPrivilegedDaemon canModifyPath:]

if ([dst hasPrefix:@"/var/folders/"] || [dst hasPrefix:@"/private/var/"] || [dst hasPrefix:@"/tmp/"]) {
  return TRUE;
} else {
  return [dst rangeOfString:@"Library/Caches/com.apple.appleseed.FeedbackAssistant"] != 0;
}

The source must start with /Library/Logs or /var/log, and the destination must match one the following patterns:

It will not override an existing destination.

These constraints can be bypassed throuth path traversal. So now we can copy arbitrary file or folder to anywhere unless rootless protected.

NSMutableDictionary *traversal(NSDictionary *mapping) {
  NSMutableDictionary *transformed = [[NSMutableDictionary alloc] init];
  for (NSString *key in mapping) {
    NSString *val = mapping[key];
    NSString *newKey = [@"/var/log/../../.." stringByAppendingPathComponent:key];
    NSString *newVal = [@"/tmp/../.." stringByAppendingPathComponent:val];
    transformed[newKey] = newVal;
  }
  return transformed;
}

Additionally, after each copy, it will call -[FBAPrivilegedDaemon fixPermissionsOfURL:recursively:] to set the copied files’ owner to the XPC client process’s gid and uid. This is extremely ideal for macOS LPE CTF challenges. I used this zero day exploit during #35C3 CTF to simply copy the flag and read it, lol.

If you don’t mind reboot, getting root privilege is simple. Copy the executable to the places that will be automatically launched with privilege during startup. For example, the bundles in /Library/DirectoryServices/PlugIns will be loaded by the process /usr/libexec/dspluginhelperd, who has root privilege and is not sandboxed.

Can we have an instant trigger solution?

Since it will never override existing file, we can not:

And it will fix file permissions, none of these would work:

We need more primitives.

The daemon has other methods named run*diagnoseWithDestination. They are various external command wrappers just like those diagnose helpers mentioned from my previous post. What’s interesting is that runTMDiagnoseWithDestination: acts the same as timemachinehelper , thus we can trigger the CVE-2019-8513 command injection.

At first I was looking at runMDSDiagnoseWithDestination: , who launches /usr/bin/mddiagnose that will finally spawn /usr/local/bin/ddt after around 10 seconds, waiting for the /usr/bin/top command to end. Remember the previous post? This location does not exist by default and we can put custom executable with the arbitrary file copy bug.

Another exploit path is method runMobilityReportWithDestination:. It invokes this shell script: /System/Library/Frameworks/SystemConfiguration.framework/Versions/A/Resources/get-mobility-info

The script checks the existence of /usr/local/bin/netdiagnose. If so, execute it as root. The exploit will success within milliseconds.

PCAP_STARTED=0
if [ -x /usr/local/bin/netdiagnose -a ${NO_PCAP} -ne 1 ]; then
  trap stop_pcap SIGINT
  /usr/local/bin/netdiagnose -p "${WORKDIR}" start packetcapture 2>&1
  PCAP_STARTED=1
fi

#
# get-network-info
#
if [ -x /System/Library/Frameworks/SystemConfiguration.framework/Resources/get-network-info ];
  /bin/sh /System/Library/Frameworks/SystemConfiguration.framework/Resources/get-network-info

By the way, I was surprised by how many diagnostic tools depending on the non-existing directory /usr/local/bin.

The bug has been fixed in macOS 10.14.4 and iOS 12.2.

PoC

https://github.com/ChiChou/sploits/tree/master/CVE-2019-8565