Unter macOS läuft eine Applikation, genau wie ein im Terminal aufgerufenes Kommando, stets im Kontext des ausführenden Benutzers. Während im Terminal ein Kommando durch vorangestelltes sudo mit root-Berechtigungen ausgeführt werden kann, fehlt dem Swift-Entwickler ein äquivalenter Mechanismus. Und zwar aus gutem Grund, handelt es sich dabei doch um ein potenzielles Sicherheitsrisiko.

Apple hat daher einen Mechanismus geschaffen, der das Sicherheitsrisiko eliminiert und es einer Anwendung dennoch ermöglicht, für einzelne Aktionen erweiterte Berechtigungen zu erlangen (privilege escalation). Das Prinzip besteht darin, aus der Anwendung heraus ein Helper-Tool (auch: Privileged Helper) zu starten. Der Helper wird nicht von der Anwendung selbst gestartet (die Berechtigungen würden sich dadurch nicht ändern), sondern von einem LaunchDaemon, Apples Mechanismus zum Starten von Systemdiensten, d. h. solchen Diensten, die mit root-Berechtigungen ausgeführt werden müssen.

Anwendung und Helper sind so miteinander verknüpft, dass der Helper sich nur von dieser Anwendung starten lässt und auch nur von ihr Anweisungen entgegen nimmt. Sowohl Anwendung als auch Helper sind per kryptografischer Signatur vor Code-Manipulationen geschützt. In Folge kann auch keine andere Anwendung sich als diese Anwendung ausgeben und den Helper missbrauchen.

Die Installation von LaunchDaemon und Helper-Tool nimmt man über die Funktion SMJobBless(_:_:_:_:) vor. SMJobBless ist Teil des ServiceManagement-Frameworks. Das ServiceManagement-Framework exponiert seine Funktionen leider in Form eines C-APIs – Swift-Klassen erwartet man hier vergebens.

Die folgende Schritt-für-Schritt-Anleitung umschifft nicht nur diese Hürde, sondern zeigt auch, an welchen (zahlreichen!) Stellen Sie von Hand eingreifen müssen, um den Privileged Helper erfolgreich an den Start zu bringen.

Im Anschluss ist es erforderlich, einen Kommunikationsmechanismus zwischen App und Helper zu etablieren, damit beide Kommandos und Informationen austauschen können. Hierfür kommt mit XPC ein etabliertes Verfahren zum Einsatz, das allerdings eine Reihe von nicht offensichtlichen Schritten erfordert. Auch hierfür finden Sie weiter unten eine genaue Anleitung.

Über die Demo-App

Die Privilege-Escalation-Demo-App besteht aus einem einfachen Hauptfenster mit einer Reihe an Buttons und Textfeldern (s. Abbildung rechts). Der Button Install Helper ist mit einer @IBAction verknüpft, die den folgenden Code ausführt:

import ServiceManagement

@IBAction func installHelper(_ sender: Any) {
    var status: OSStatus = noErr
    let helperID = Prefs.helperID as CFString

    var authItem = AuthorizationItem(name: kSMRightBlessPrivilegedHelper, valueLength: 0, value: nil, flags: 0)
    var authRights = AuthorizationRights(count: 1, items: &authItem)
    let authFlags: AuthorizationFlags = [.interactionAllowed, .preAuthorize, .extendRights]
    var authRef: AuthorizationRef? = nil
    status = AuthorizationCreate(&authRights, nil, authFlags, &authRef)
    if status != errAuthorizationSuccess {
        print("Error:", String(status))
    }

    var error: Unmanaged<cferror>? = nil
    SMJobBless(kSMDomainSystemLaunchd, helperID, authRef, &error)
    if let e = error?.takeRetainedValue() {
        print("Domain:", CFErrorGetDomain(e))
        print("Code:", CFErrorGetCode(e))
        print("UserInfo:", CFErrorCopyUserInfo(e))
        print("Description:", CFErrorCopyDescription(e))
        print("Reason:", CFErrorCopyFailureReason(e))
        print("Suggestion:", CFErrorCopyRecoverySuggestion(e))
    }
}

Zunächst wird mit der Funktion AuthorizationCreate(_:_:_:_:) eine Authorization Reference erstellt. AuthorizationCreate ist Teil des Security-Frameworks und ist, genauso wie die Funktionen des ServiceManagement-Frameworks, über ein reines C-API zugänglich. Hier die Signatur der AuthorizationCreate-Funktion:

func AuthorizationCreate(_ rights: UnsafePointer?,
    _ environment: UnsafePointer?,
    _ flags: AuthorizationFlags,
    _ authorization: UnsafeMutablePointer<authorizationref?>?) -> OSStatus

Das AuthorizationItem, aus dem die AuthorizationRights gebildet werden, muss den Namen kSMRightBlessPrivilegedHelper bekommen. Der Environment-Parameter kann auf nil gesetzt werden. Die Parameter rights und authorization sind inout-Parameter und müssen per Adressoperator (&) übergeben werden. Die Funktion liefert einen Statuswert vom Typ OSStatus (Int) zurück. Hat der Status den Wert errAuthorizationSuccess, dann steckt im Anschluss die fertige AuthorizationRef im Inout-Parameter authorization.

Mit dieser AuthorizationRef wird im Anschluss die Funktion SMJobBless(_:_:_:_:) aufgerufen, d. h. der Mechanismus, der Helper-Tool und LaunchDaemon im Austausch für die korrekte Admin-Authentifizerung installiert. Hier die Signatur der AuthorizationCreate-Funktion:

func SMJobBless(_ domain: CFString!,
    _ executableLabel: CFString,
    _ auth: AuthorizationRef!,
    _ outError: UnsafeMutablePointer<unmanaged?>!) -> Bool

Als domain trägt man den festen Wert kSMDomainSystemLaunchd< ein. Das executableLabel ist die App-ID des Helper-Targets. Der auth-Parameter ist die AuthorizationRef aus dem vorherigen Schritt. outError schließlich ist ein inout-Parameter, der ggf. auf ein CFError-Objekt zeigt. Mit der Methode takeRetainedValue() kommt man an das Objekt heran (s. Code).

Bei erfolgreichem Ablauf nimmt SMJobBless folgende Veränderungen vor:

  1. Das Helper-Tool wird in /Library/PrivilegedHelperTools/de.mydomain.appID.Helper kopiert
  2. Der dazugehörige LaunchDaemon wird in /Library/LaunchDaemons/de.mydomain.appID.Helper.plist kopiert.

Sobald der Helper installiert ist, stellt sich die Frage, wie App und Helper miteinander kommunizieren können. Die Antwort auf diese Frage finden Sie weiter unten.

PrivEscalationDemoMain
Install Helper

Privileged Helper: Schritt-für-Schritt-Anleitung

Schritt 1: Helper-Target erstellen

Legen Sie in Ihrem Xcode-Projekt ein neues Target mit dem Template Command Line Tool an. Setzen Sie Name und Bundle-Identifier auf denselben Wert, im Beispiel de.mydomain.appID.Helper. Wählen Sie im Bereich Signing die passende Identität aus, im Beispiel Mac Developer: John Doe (12AA34BB56CC). Erstellen Sie eine Info.plist für den Helper und nennen Sie sie Helper-Info.plist.

Erstellen Sie für den Helper schließlich eine zweite plist-Datei und nennen Sie sie Helper-Launchd.plist. Die Datei sollen folgenden Inhalt haben:


<!--?xml version="1.0" encoding="UTF-8"?-->

    <key>Label</key>
    <string>de.mydomain.appID.Helper</string>
    <key>MachServices</key>
    <dict>
        <key>de.mydomain.appID.helper</key>
        <true></true>
    </dict>
Schritt 2: App-Target anpassen

Wählen Sie in Xcode das App-Target aus und wechseln Sie in den Reiter Build Phases. Geben Sie folgende Informationen im Bereich Copy Files ein:

  • Destination: Wrapper
  • Subpath: Contents/Library/LaunchServices
  • Copy only when installing: Nicht ausgewählt
  • Name: de.mydomain.appID.Helper
  • Code sign On Copy: Ausgewählt

Diese Angaben sorgen dafür dass beim Build der App der Helper signiert und anschließend in den Ordner LaunchServices im App-Bundle kopiert wird.

Schritt 3: Info.plist-Dateien erstellen bzw. anpassen

Info.plist der App

Ergänzen Sie die Info.plist um den Key SMPrivilegedExecutables. Xcode zeigt diesen Key als Tools owned after installation an. Dieser Key ist vom Typ Dictionary. Fügen Sie dem Dictionary einen String-Eintrag mit folgendem Aufbau hinzu:

  • Key: de.mydomain.appID.Helper
  • Value: identifier "de.mydomain.appID.Helper" and anchor apple generic and certificate leaf[subject.CN] = "Mac Developer: John Doe (12AA34BB56CC)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */

Für Ihre eigene Anwendung tragen Sie natürlich Ihre eigene App-ID und Ihre Entwicklerkennung ein. Alle anderen Angaben müssen einschließlich aller Leerzeichen dagegen exakt so lauten wie hier angegeben.

Info.plist des Helpers

Ergänzen Sie die Info.plist des Helpers um den Key SMAuthorizedClients. Xcode zeigt diesen Key als Clients allowed to add and remove tool an. Bei diesem Key handelt es sich um ein Array. Fügen Sie einen String-Eintrag mit folgendem Aufbau ein:

identifier "de.mydomain.appID" and anchor apple generic and certificate leaf[subject.CN] = "Mac Developer: John Doe (12AA34BB56CC)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */

Auch dieser String muss, abgesehen von App- und Developer-ID, exakt so lauten wie hier angegeben.

Schritt 4: Build Settings anpassen

Build-Settings der App

Wählen Sie das App-Target aus, wechseln Sie in den Reiter Build Settings und blättern Sie zum Abschnitt Packaging.

  • Info.plist File: Relative App Path/Info.plist
  • Product Bundle Identifier: de.mydomain.appID

Relative App Path

Den Relative App Path finden Sie in Xcode im Identity Inspector der Info.plist-Datei unter Full Path: Es ist der übergeordnete Ordner der Datei (genauer: Der Anteil des Pfades unterhalb des Projektordners).

Build-Settings des Helpers

Wählen Sie das Helper-Target aus und wechseln Sie in den Reiter Build Settings. Für den Helper müssen Sie Einträge in den Abschnitten Packaging und Linking anpassen.

Abschnitt Packaging

  • Info.plist File: Relative App Path/Helper-Info.plist
  • Product Bundle Identifier: de.mydomain.appID.Helper
  • Product Name: de.mydomain.appID.Helper

Einige dieser Einträge entsprechen möglicherweise bereits den hier genannten Empfehlungen.

Abschnitt Linking

  • Other Linker Flags: -sectcreate __TEXT __info_plist $(SRCROOT)/Relative\ App\ Path/Helper-Info.plist -sectcreate __TEXT __launchd_plist $(SRCROOT)/Relative\ App\ Path/Helper-Launchd.plist

Mit diesen Angaben weiß der Linker, welche Info.plist zum Helper-Binary gehört und welches die Vorlage für den LaunchDaemon ist, der für den privilegierten Start des Helpers gebaut werden muss. Etwaige Leerzeichen in den Pfadangaben müssen Sie maskieren (»\ «).

Code-Signing: Zusammenfassung

Hier finden Sie nochmals die Code-Signing-relevanten Einträge der Info.plist-Dateien beider Targets in der Gegenüberstellung.

App-Info.plist:

...
<key>SMPrivilegedExecutables</key>
<dict>
    <key>de.mydomain.appID.Helper</key>
    <string>identifier "de.mydomain.appID.Helper" and anchor apple generic and certificate leaf[subject.CN] = "Mac Developer: John Doe (12AA34BB56CC)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */</string>
</dict>

Helper-Info.plist:

...
<key>SMAuthorizedClients</key>
<array>
    <string>identifier "de.mydomain.appID" and anchor apple generic and certificate leaf[subject.CN] = "Mac Developer: John Doe (12AA34BB56CC)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */</string>
</array>

Wenn es nicht funktionieren will…

Wenn die diversen IDs von App und Helper in den Info.plist-Dateien nicht 100-prozentig übereinstimmen, funktioniert das Code-Signing nicht und SMJobBless gibt eine Fehlermeldung aus. Hier ein Beispiel:

Domain: CFErrorDomainLaunchd
Code: 4
UserInfo: {}
Description: The operation couldn’t be completed. (CFErrorDomainLaunchd error 4.)
Reason: nil
Suggestion: nil

Bei der Fehlersuche hilft ein Tool von Apple mit der Bezeichnung SMJobBlessUtil.py, das Sie hier herunterladen können:

https://developer.apple.com/library/mac/samplecode/SMJobBless/

Hier ein Beispiel für die Ausgabe von SMJobBlessUtil.py im Falle einer fehlerhaften Konfiguration:

./SMJobBlessUtil.py check /Path/to/Privilege\ Escalation\ Sample.app

SMJobBlessUtil.py: tool designated requirement (identifier "$(PRODUCT_BUNDLE_IDENTIFIER)" and anchor apple generic and certificate leaf[subject.CN] = "Mac Developer: someone@somewhere.com (12345678AB)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */) doesn't match entry in 'SMPrivilegedExecutables' (identifier "de.fastdevel.Privilege-Escalation-Sample.Helper" and anchor apple generic and certificate leaf[subject.CN] = "Mac Developer: someone@somewhere.com (12345678AB)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */)

Das Tool meldet dass die von Xcode generierten Angaben der App für das Code-Signing des Helpers (»tool designated requirement«) nicht mit der Angabe in der Info.plist der App (»entry in 'SMPrivilegedExecutables'«) übereinstimmen. Im Beispiel liegt dies daran, dass hier anstelle des Platzhalters (»$(PRODUCT_BUNDLE_IDENTIFIER)«) die Bundle-ID (»de.fastdevel.Privilege-Escalation-Sample.Helper«) explizit eingetragen werden muss – aus irgendeinem Grund funktionieren Platzhalter nicht.

XPC: Kommunikation zwischen App und Helper

Apple beschreibt XPC als einen »Low-Level-Mechanismus zur Interprozesskommunikation, der auf serialisierten Property-Listen basiert.« Erfreulicherweise muss man nicht das C-basierte XPC-Service-API bemühen, sondern kann (seit OS X 10.8) auf das modernere und komfortablere Foundation-API NSXPCConnection zurückgreifen.

App

Im ViewController der App ist die Instanzvariable helperToolConnection deklariert:

lazy var helperToolConnection: NSXPCConnection = {
    let connection = NSXPCConnection(machServiceName: "Privilege-Escalation-Sample.Helper", options: .privileged)
    connection.remoteObjectInterface = NSXPCInterface(with: HelperToolProtocol.self)
       
    connection.resume()
    return connection
}()

Damit ist die XPC-Connection definiert. Alle XPC-Connections starten im im inaktivierten Zustand (»suspended«) und müssen mit resume() aktiviert werden, bevor XPC-Nachrichten hereingeschickt werden.

Der Button Check Version ist mit einer @IBAction verknüpft, die den folgenden Code ausführt:

@IBAction func checkVersion(_ sender: Any) {
    if let helper = helperToolConnection.remoteObjectProxyWithErrorHandler({ (error) in
        let e = error as NSError
        print("Remote proxy error \(e.code): \(e.localizedDescription) \(e.localizedRecoverySuggestion ?? "---")")
        self.receiveMessage.append("Remote proxy error \(e.code): \(e.localizedDescription) \(e.localizedRecoverySuggestion ?? "---")")
    }) as? HelperToolProtocol {
        helper.getVersion(withReply: { (version) in
            print("Version:", version)
            self.receiveMessage.append("Version: \(version)\n")
        })
    }
}

Die Verbindung zum Helper wird mit der Funktion remoteObjectProxyWithErrorHandler(_:) aufgebaut. Sie liefert ein »Proxy«-Objekt für den Helper zurück, also das Remote-Objekt, das der Helper »exportiert«, d. h. für die XPC-Kommunikation bereitstellt. Das Remote-Objekt muss im Helper in Form eines Objective-C-Protokolls (»HelperToolProtocol«, s. weiter unten im Abschnitt »Helper«) definiert werden.

Der withReply-Block wird asynchron ausgeführt, sobald der Helper über die XPC-Verbindung eine Antwort an die App schickt. Im Beispiel wird der vom Helper empfangene Text einfach an eine Stringvariable receiveMessage angefügt. Per Property Observer landet dieser String dann im receiveMessageTextField der App. Wichtig: Manipulationen am grafischen Frontend der App müssen aus der Main-Queue heraus vorgenommen werden. Hier die Deklaration von receiveMessage:

    var receiveMessage = "" {
        didSet {
            DispatchQueue.main.async {
                self.receiveMessageTextField.stringValue = self.receiveMessage
                if self.receiveMessage.isEmpty {
                    self.clearButton.isEnabled = false
                } else {
                    self.clearButton.isEnabled = true
                }
            }
        }
    }

Helper

Deklaration des Helper-Protokolls

Das Protokoll für die XPC-Kommunikation zwischen App und Helper wird in einer separaten Datei »HelperToolProtocol« (Vorschlag) definiert. Stellen Sie sicher, dass diese Datei in beiden Targets bekannt ist (Target Membership).

@objc(HelperToolProtocol) protocol HelperToolProtocol {
    func getVersion(withReply reply: (String) -> Void)
}

Das Protokoll definiert in diesem Beispiel eine Methode: getVersion(withReply:). Aufgrund der asynchronen Natur der XPC-Kommunikation ergäbe ein Rückgabewert keinen Sinn, weswegen jede XPC-Protokoll-Funktion ohne Rückgabewert definiert werden muss. Sollen Daten zurückgeliefert werden, kann man einen Rückgabeblock mit dem Namen withReply und der Signatur (Any) -> Void angeben – die Beispielfunktion verwendet einen solchen.

main.swift

In der main-Datei müssen zwei Dinge erledigt werden: Die Aktivierung des XPC-Listeners sowie die Deklaration des NSXPCListenerDelegate-Protokolls, dem der Helper folgen muss. Hier der vollständige Code der main.swift:

class HelperDelegate: NSObject, NSXPCListenerDelegate {
    func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
        newConnection.exportedInterface = NSXPCInterface(with: HelperToolProtocol.self)
        newConnection.exportedObject = HelperTool()
        newConnection.resume()
        return true
    }    
}

let delegate = HelperDelegate()
let listener = NSXPCListener(machServiceName: "de.fastdevel.Privilege-Escalation-Sample.Helper")
listener.delegate = delegate
listener.resume()
RunLoop.current.run()

Die Klasse HelperDelegate hat einen einzigen Zweck: Sie dient der Implementierung des NSXPCListenerDelegate-Protokolls, d. h. der darin deklarierten Funktion listener(_ listener:shouldAcceptNewConnection:). Sobald eine XPC-Verbindungsanfrage eingeht, befragt der Listener diese Funktion. An dieser Stelle konfiguriert man folgende zwei Parameter der NSXPCConnection: Die Variable exportedInterface erhält das durch das Protokoll definierte NSXPCInterface zugewiesen; die Variable exportedObject verweist auf eine Instanz des »HelperTool«-Objekts – Die HelperTool-Klasse implementiert das Helper-Protokoll (s. Implementierung des Helper-Protokolls).

Der verbliebene main-Code ist nicht weiter kompliziert. Hier wird ein XPC-Listener instanziiert (listener). Dessen machServiceName ist derselbe, den Sie zuvor in der Launchd.plist definiert haben. Eine HelperDelegate-Instanz wird als Delegate des Listeners eingetragen.

Zum Aktivieren des Listeners schicken Sie ihm eine resume()-Nachricht. Der Listener würde allerdings sofort zurückkehren (und der Helper wäre beendet), weswegen Sie ihn noch in die aktuelle Runloop stellen müssen. Von nun an läuft der Helper in einer Endlosschleife.

Implementierung des Helper-Protokolls

Das Helper-Protokoll wird in der Datei HelperTool.swift implementiert.

class HelperTool: NSObject, HelperToolProtocol {
    func getVersion(withReply reply: (String) -> Void) {
        let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString" as String) as? String ?? "(unknown version)"
        let build = Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as? String ?? "(unknown build)"
       
        reply("v\(version) (\(build))")
    }
}

Die Implementierung des Protokolls hält keine Überraschungen bereit. Sie stellt die angefragt Informationen zusammen und ruft am Schluss den reply-Block auf, was dann zu einer Rückmeldung auf App-Seite führt.