WOODY'S
FINDINGS

Cocoa: implement a privileged Helper

Tutorial

Special thanks

I would like to thank Thijs Xhaflaire, Bob Gendler and Armin Briegel (Scripting OSX) for their useful feedback and review.

Exposition

Introduction

First of all, I wanted to say that I am not a macOS expert, neither a security professional. I may talk about things I do not entirely master in this tutorial. But I was able to make a privileged helper tool to work and I wanted to share the method I use in case someone would need it. There is not much documentation available online, and it is sometimes hard to have a full theory and practical usage example. I will especially try to give you a practical example, while instilling the theory along the way.
At the end of this tutorial, you should be able to write a privileged helper tool to execute actions with the root privileges, and decide whether to install it from your application or separately with a script.

Regarding the plan, the 1st part gives you an overview of the project files. The 2nd tries to cover the required theory around XPC services. The implementation will start at the 3rd part, but if you are only interested in the Helper signature setup, you should go directly to the 4th part. If you do so, you'll find the project with all the code written in the 3rd part in the Scriptex (code final) folder so that you can jump on the bandwagon.

Without further ado, let’s begin.

Security update

I have been recently contacted to update this post because XPC services could be exploited for malicious attacks. I added a new part Validating the connection to implement solution regarding this problem. It is not required for the Helper to work, but it's not long either. So I recommend to take the time to implement it.

Scriptex

You can find a starter folder in the repository materials (you can download the overall materials as a zip file). This folder contains a Xcode project named Scriptex. This is the one we are going to work with in this tutorial. Its purpose is to execute scripts for the user. Note that the script execution is just a simple example to use when building a privileged helper. But you might want to use it in production with further protection (like allowing scripts only in secure folders) to avoid malicious attacks.

Before I give an overview of the project files, you might want to run the app. To do so, navigate to the targets General tab and make sure to change the Scriptex bundle identifier by replacing the abridoux part to your company/name.

Then, go the Signing and capabilities tab to change the signing to your team and bundle identifier

You should be able to run the app now.

If you write an absolute path to a script, or drag and drop a script file in the text field, you can execute it by pressing Enter or clicking the ”Execute” button. In the materials Scripts folder, you can find a hello_scriptex.sh script which will display a message using whoami command.

In this tutorial, the goal is to execute a script which can only be executed with the root privileges. In the materials Scripts folder again, you can find a hello_scriptex_root.sh file, which can only be read, written and executed by the root. If you try to execute it with Scriptex, here is what you will get.

Note: You can launch the app with root privileges by executing the following command.

sudo path/to/the/app/folder/Scriptex.app/Contents/MacOS/Scriptex

And boom, you do not need a privileged helper.
End of the tutorial.

But

This is not recommended at all!
As explained in Apple documentation, an attacker could use your application to run malicious code with root privileges. This could obviously lead to security faults. Even if your app does not execute scripts (or even bash commands) as Scriptex does, there are still chances that the attacker can gain some control of the system, or degrade it. Thus, an application should never be ran with the root privileges.
Do not worry though, this is the purpose of the privileged helper tools, and we are about to write one!

Scriptex files overview

Except for the classic files for a Cocoa application, you can find three files in the project.

ExecutionService

ExecutionService holds the code to execute script. If you open it, you will see it launches a process to execute the script on a separate thread. The completion handler is called with the result. About the result, you can observe that both the output and execution errors are sent as the output:

process.standardOutput = outputPipe
process.standardError = outputPipe

This is simpler for this tutorial, but you might want to throw an error rather than outputting it in another project. This is the reason you can read the ”Permission denied” message when executing the restricted script. In the file ViewController, we setup the interface actions to execute the script the ExecutionService.

Results+Extensions

This file contains an extension to the Result enum to bridge it the completion handler (String?, Error?) -> Void we will have to use to communicate with the Helper.

ScriptexErrors

The file ScriptexErrors contains the possible errors which can be thrown in a function.

Pretty simple project yet, right? 😊
Now, let’s take it to the moon!

XPC services

Theory

But before the moon, let’s dive in the theory a bit. You would not want the ship to crash, would you?

When you install a Helper, the application will not access to it directly, even if it is the one which installed it. It will rather ask a daemon identified by a specific identifier MACH service and running in background to create a connection with the Helper. Then the daemon will forward the connection request to the Helper. It is up to the Helper to decide whether to accept a connection or not depending on its attributes. This part will be on our own.



On the other hand, a Helper will automatically reject connections that do not authenticate with the correct signature. This is an important part - as we will see - to sign both the app and the Helper with the same certificate. After that, the Helper will only accept connection from app signed with the same certificate. This means that if you change your production certificate, you will have to install again the Helper, with the updated signature.

Once the Helper accepted the connection with the application, it is almost transparent from the app point of view, and we will be able to execute code through a helper protocol as if it was a part of the app.



From the Helper point of view, the app communicates through a remote protocol. This protocols communication ensures that the required functions or variables will be found when the connection is established. Thus we will have to ensure that both components implement the protocol they are required to.

Protocols

Those protocols will be named HelperProtocol - implemented by the Helper - and RemoteApplicationProtocol - implemented by the app. The HelperProtocol will require a function to execute a script at a path, as well as a completion handler to execute when the script has executed. We will keep the ExecutionService functions while moving to the Helper target. Thus, the script execution will still be asynchronous. I prefer this solution as it allows the Helper to be reactive if the remote app makes several requests at the same time. This way, the Helper can handle request and dispatch them onto a separate thread.
The RemoteApplicationProtocol will have no requirements, as everything is already handled by the HelperProtocol.
We could have use this RemoteApplicationProtocol to require a function to execute with the result of the script. Thus, the Helper would call this function when the script has been executed. But it means to find a way to store the connections to know which script output should be sent to which connection. This is a possible solution but let’s try to keep things simple for this tutorial.

Alright! Enough theory, time to practice! Please open the Scriptex project and let’s start coding.

Helper implementation

The Helper is a separate program so we have to create a new target in our project to host. Create a new target.

Now choose the macOS Command Line Tool template. Name it Helper for now. Once the target is created, please move the project view and change the name of the created target from ”Helper” to ”com.your-company.Scriptex.helper” (the project creation does not allow to use dots so we have to rename it here).

For the company name part, you have to write the company name corresponding to your certificate. It should be the same as the one used to sign the app:

For me it is ”abridoux” but it can be the name of your company like ”acme”.

If you open the ”Helper” folder, you can notice the file main.swift which is created by Xcode when we choose the Command Line Tool template. Keep it here now now, we shall come back to it soon.

As long as we are here, please add the Helper target in the Scriptex scheme so that both targets will be built when with the project.

Then

Finally, make sure to set the target of the files ScriptexError.swift and Result+Extensions to both the two targets Scriptex and com.your-company.Scriptex.helper:


This will be easier to write the rest of the code.


XPC protocols

We will start by implementing the two protocols I mentioned above for the app and the Helper to be able to communicate properly. Create a new Swift file named ”HelperProtocol” in the Helper folder and with both the com.your-company.Scriptex.helper and Scriptex targets selected.

Once done, write the following in the file.

import Foundation

@objc(HelperProtocol)
public protocol HelperProtocol {
    @objc func executeScript(at path: String, then completion: @escaping (String?, Error?) -> Void)
}

Several remarks:
- Anything involved in the XPC connection has to be convertible to Objective-C.
- The @objc(HelperProtocol) is here to ensure the XPC connection accept the protocol as Objective-C.
- We have to add the @objc to all functions of the protocol
- We could use the Result enum to pass the result, and we will for the other parts of the program. But it will not work properly with Objective-C and even if no error will be thrown, the connection will not be established.

Now please do the same for the file RemoteApplicationProtocol (Helper folder, com.your-company.Scriptex.helper and Scriptex targets) and paste the following.

import Foundation

@objc(MainApplicationProtocol)
public protocol RemoteApplicationProtocol {
    // empty protocol but required for the XPC connection
}

Just before we implement the Helper, let’s create another file to store useful constants. Please name it HelperConstants (choose both Scriptex and helper target) and paste the following:

import Foundation

enum HelperConstants {
    static let helpersFolder = "/Library/PrivilegedHelperTools/"
    static let domain = "com.abridoux.Scriptex.helper"
    static let helperPath = helpersFolder + domain
}

Also, replace the company name to yours in the domain value.

The HelperConstants is an enum rather than a struct to make sure it cannot be instantiated.
This is a technique in Swift to have a similar behavior as namespaces to store constants or offer functions collections in a service.

Implementing the Helper

Before writing down the Helper.swift file, let's break down its code a bit. More than implementing the protocol HelperProtocol, the helper class will implement the NSXPCListenerDelegate protocol and hold a NSXPCListener to listen for incoming connection requests. This protocol has only one required function:

func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool

This function will be called by the system when a new connection request arrives. This is our opportunity to configure the connection to make sure it has the correct interface. At the end of the function, we have to call connection.resume() for the connection to be established, as the connections are paused at the beginning.

func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
    newConnection.exportedInterface = NSXPCInterface(with: HelperProtocol.self)
    newConnection.remoteObjectInterface = NSXPCInterface(with: RemoteApplicationProtocol.self)
    newConnection.exportedObject = self

    newConnection.resume()

    return true
}

The remote here is the calling application, so ”Scriptex”, while the exported interface is the one the helper says it implements.

To start listening to new connection requests with the NSXPCListener object, we will implement a run function, which we will call after having created a new Helper object in the main file:

To start listening to new connection requests with the NSXPCListener object, we will implement a run function, which we will call after having created a new Helper object in the main file:

func run() {
    // start listening on new connections
    self.listener.resume()
    // prevent the terminal application to exit
    RunLoop.current.run()
}

Also, we should implement the logic to actually execute scripts. Fortunately, this is something we already have in hand with the ExecutionService. In order to reuse it, we have to make the file ExecutionService.swift accessible in the target com.your-company.Scriptex.helper. Rather than simply moving the file and changing its target, we will create a new ExecutionService.swift file in the Helper folder/target and copy/paste the code. We will do so because we will still need the ExecutionService in the Scriptex target, although we will modify its content later.

So please create a new file named HelperExecutionService.swift in the Helper folder (so that the distinction with the app is clearer), and select the helper target. Then copy/paste the content of the file ExecutionService.swift in the Scriptex target inside the new file HelperExecutionService.swift. Finally rename the class ExecutionService to HelperExecutionService. Once done, here is the implementation of the HelperProtocol required function:

func executeScript(at path: String, then completion: @escaping (String?, Error?) -> Void) {
    NSLog("Executing script at \(path)")
    do {
        try HelperExecutionService.executeScript(at: path) { (result) in
            NSLog("Output: \(result.string ?? ""). Error: \(result.error?.localizedDescription ?? "")")
            completion(result.string, result.error)
        }
    } catch {
        NSLog("Error: \(error.localizedDescription)")
        completion(nil, error)
    }
}

Finally, we have to make the Helper class a subclass of NSObject for it to easily implement NSXPCListenerDelegate (which inherits from NSObjectProtocol), and we will override the empty initialiser:

override init() {
    self.listener = NSXPCListener(machServiceName: HelperConstants.domain)
    super.init()
    self.listener.delegate = self
}

Here is the overall implementation Helper class in the Helper.swift file:

import Foundation

class Helper: NSObject, NSXPCListenerDelegate, HelperProtocol {

    // MARK: - Properties

    let listener: NSXPCListener

    // MARK: - Initialisation

    override init() {
        self.listener = NSXPCListener(machServiceName: HelperConstants.domain)
        super.init()
        self.listener.delegate = self
    }

    // MARK: - Functions // MARK: HelperProtocol

    func executeScript(at path: String, then completion: @escaping (String?, Error?) -> Void) {
        NSLog("Executing script at \(path)")
        do {
            try HelperExecutionService.executeScript(at: path) { (result) in
                NSLog("Output: \(result.string ?? ""). Error: \(result.error?.localizedDescription ?? "")")
                completion(result.string, result.error)
            }
        } catch {
            NSLog("Error: \(error.localizedDescription)")
            completion(nil, error)
        }
    }

    func run() {
        // start listening on new connections
        self.listener.resume()
        // prevent the terminal application to exit
        RunLoop.current.run()
    }

    func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
        newConnection.exportedInterface = NSXPCInterface(with: HelperProtocol.self)
        newConnection.remoteObjectInterface = NSXPCInterface(with: RemoteApplicationProtocol.self)
        newConnection.exportedObject = self

        newConnection.resume()

        return true
    }
}

Now we can update the main.swift file to run the helper:

import Foundation

NSLog("Scriptex Helper started")
let helper = Helper()
helper.run()

And... that’s it for the Helper code! We shall now make Scriptex use it.

An additional part has been added to the Helper implementation at the end of this tutorial to check the connection identity and thus improve security.

Connect to the Helper

To connect to the Helper, we will implement a HelperRemote structure, which will do the heavy lifting. It will have one public function getRemote() which can throw an error, will create a connection to the Helper and hand over a HelperProtocol object which will send instructions directly to the Helper. Also, our HelperRemote struct will try to install the Helper if it not already installed. Thus, things will be very transparent for the rest of our app as we will be able to use the Helper very easily:

let remote = try HelperRemote().getRemote()

remote.executeScript(at: path) { (output, error) in
    completion(Result(string: output, error: error))
}

We will implement 4 functions:
- installHelper() to install the Helper
- createConnection() to establish a connection with the Helper
- getConnection() which will either install the Helper if not already installed or directly call the createConnection function
- getRemote() which will retrieve the HelperProtocol object from the connection

Ready? Let’s get started!

Install the Helper

This is certainly the hardest and less intuitive part, especially if you are coming like me from the wonderful world of Swift/iOS (if you are: high-five 🤚). But working on macOS, I have learned that authorisation and privileges escalation processes are far from being straightforward. It’s a bit old, not developer-friendly but to be honest, when it works, it does it great. And when you take a look at the documentation last update, I find it amazing that such APIs are still working perfectly - when you give them the right inputs.

I’ll try to explain the authorisation process as I understand it. Again, this is me trying to find purpose with the documentation. To authorise a process (here the blessing of a privileged Helper to install it), we have to work with an AuthorizationRef. We then configure it with objects like AuthorizationItems (what specific process we want to launch) held by a AuthorizationRights, AuthorizationFlags to specify how the authorisation should be asked to the user, and OSStatus to handle errors.

This is how it looks like:

// try to get a valid empty authorisation
var authRef: AuthorizationRef?
var authStatus = AuthorizationCreate(nil, nil, [.preAuthorize], &authRef)

guard authStatus == errAuthorizationSuccess else {
    throw ScriptexError.helperInstallation("Unable to get a valid empty authorization reference to load Helper daemon")
}

// create an AuthorizationItem to specify we want to bless a privileged Helper
let authItem = kSMRightBlessPrivilegedHelper.withCString { authorizationString in
    AuthorizationItem(name: authorizationString, valueLength: 0, value: nil, flags: 0)
}

// it's required to pass a pointer to the call of the AuthorizationRights.init function
let pointer = UnsafeMutablePointer<AuthorizationItem>.allocate(capacity: 1)

pointer.initialize(to: authItem)

defer {
    // as we instantiate a pointer, it's our responsibility to make sure it's deallocated
    pointer.deinitialize(count: 1)
    pointer.deallocate()
}

// store the authorization items inside an AuthorizationRights object
var authRights = AuthorizationRights(count: 1, items: pointer)

let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize]
authStatus = AuthorizationCreate(&authRights, nil, flags, &authRef)

guard authStatus == errAuthorizationSuccess else {
    throw ScriptexError.helperInstallation("Unable to get a valid loading authorization reference to load Helper daemon")
}

The defer statement guarantees that that the code inside the closure will be execute before leaving the function scope

Now that we have the blessing for a privileged helper, we will try to install it with SMJobBless(). This function will try to install a program identifier by a label. Here it is HelperConstants.domain: com.your-company.Scriptex.helper. This is why we renamed the Helper target, remember? It will then setup a launch daemon to listen to connection requests to the Helper. This function will take 4 parameters:
- the domain of the job, which strangely, can only be kSMDomainSystemLaunchd (which is strange as it has no reasons to be a parameter then?)
- the label of the program to install as a CFString (a specific string wrapper when working with authorization and other security macOS APIs)
- the Authorization Reference to make sure we have the right to do it
- an pointer to an Unmanaged<CFError> error for us to be able to know what went wrong if it actually did go wrong

So we end up with:

var error: Unmanaged<CFError>?
if SMJobBless(kSMDomainSystemLaunchd, HelperConstants.domain as CFString, authRef, &error) == false {
    let blessError = error!.takeRetainedValue() as Error
    throw ScriptexError.helperInstallation("Error while installing the Helper: \(blessError.localizedDescription)")
}

And we are done with installing (pheeew!). Just before we jump to the next functions, we have to release the Authorization Reference to avoid a memory leak:

// Release the authorization, as mentionned in the doc
AuthorizationFree(authRef!, [])

And here is the final function:

// try to get a valid empty authorisation
var authRef: AuthorizationRef?
var authStatus = AuthorizationCreate(nil, nil, [.preAuthorize], &authRef)

guard authStatus == errAuthorizationSuccess else {
    throw ScriptexError.helperInstallation("Unable to get a valid empty authorization reference to load Helper daemon")
}

// create an AuthorizationItem to specify we want to bless a privileged Helper
let authItem = kSMRightBlessPrivilegedHelper.withCString { authorizationString in
    AuthorizationItem(name: authorizationString, valueLength: 0, value: nil, flags: 0)
}

// it's required to pass a pointer to the call of the AuthorizationRights.init function
let pointer = UnsafeMutablePointer<AuthorizationItem>.allocate(capacity: 1)
pointer.initialize(to: authItem)

defer {
    // as we instantiate a pointer, it's our responsibility to make sure it's deallocated
    pointer.deinitialize(count: 1)
    pointer.deallocate()
}

// store the authorization items inside an AuthorizationRights object
var authRights = AuthorizationRights(count: 1, items: pointer)

let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize]
authStatus = AuthorizationCreate(&authRights, nil, flags, &authRef)

guard authStatus == errAuthorizationSuccess else {
    throw ScriptexError.helperInstallation("Unable to get a valid loading authorization reference to load Helper daemon")
}

// Try to install the helper and to load the daemon with authorization
var error: Unmanaged<CFError>?
if SMJobBless(kSMDomainSystemLaunchd, HelperConstants.domain as CFString, authRef, &error) == false {
    let blessError = error!.takeRetainedValue() as Error
    throw ScriptexError.helperInstallation("Error while installing the Helper: \(blessError.localizedDescription)")
}

// Release the authorization
AuthorizationFree(authRef!, [])

Return the remote

We have two remaining functions to implement: getConnection() and getRemote(). In the first one, we will simply check if the Helper is installed. If it is, we will directly call createConnection(), otherwise, we will try to install the Helper first:

private func getConnection() throws -> NSXPCConnection {
    if !isHelperInstalled {
        // we’ll try to install the Helper if not already installed, but we need to get the admin authorization
        try installHelper()
    }
    return createConnection()
}

Regarding getRemote(), we will simply call getConnection() and try to extract the remote from it, using its remoteObjectProxyWithErrorHandler function which returns an object conforming to the remoteObjectInterface we set (so HelperProtocol). Similarly with the invalidationHandler we encountered above, we can retrieve the error if any in a closure when calling this function. We will store it in the variable proxyError and use that in the error to throw if one is found. Also, as this function does not throw, it will return an optional object, so we have to unwrap it. Here is the code.

func getRemote() throws -> HelperProtocol {
    var proxyError: Error?

    // Try to get the helper
    let helper = try getConnection().remoteObjectProxyWithErrorHandler({ (error) in
        proxyError = error
    }) as? HelperProtocol

    // Try to unwrap the Helper
    if let unwrappedHelper = helper {
        return unwrappedHelper
    } else {
        throw ScriptexError.helperConnection(proxyError?.localizedDescription ?? "Unknown error")
    }
}

And that’s it! 🙌 To make sure you have the correct HelperRemote struct, don't hesitate to download the Scriptex (code final) folder or simply the Scripte final folder.

As you might notice, all the functions are marked as private to enforce the fact that when using a HelperRemote object, we only care about the remote, not the heavy lifting behind.

Now, for the easy and rewarding part, please change the implementation of executeScript(at:then:) in the ExecutionService struct:

static func executeScript(at path: String, then completion: @escaping Handler) throws {
    let remote = try HelperRemote().getRemote()

    remote.executeScript(at: path) { (output, error) in
        completion(Result(string: output, error: error))
    }
}

Pretty nice, don’t you think? Any time you will need the Helper in the project, it will be accessible with a single line of code. This is obviously possible for any other projects where you need a Helper.


Install and sign the Helper

If you have skipped the previous part, you can find the project with all the code in the Scriptex (code final) folder.

Install the Helper

Setup files

We have to add two files in the Helper folder before we can install it with the code we wrote in the previous part. The first file is the Info.plist of the Helper program. Similarly with the application Info.plist file, this one will contain relevant information regarding the Helper program, and we will need automatically add a new key to it when signing the Helper. So please go on and create a new PropertyList file named Info.plist in the Helper folder and with the Helper target selected. When created, fill it with those lines, specifying your own company name in the identifiers, as usual:

The second file is the Launch Daemon which will act as a Mach service and forward the connection request to the Helper program. So create a new PropertyList file, name it Launchd and fill it with the following lines (once again setting your own company name). The key StandardOutputPath is where the Helper will write the NSLog if you have some. If you do not see any message, try adding the key StandardErrorPath with the same value.

Fig11.png

This Launchd file will look for the program named com.abridoux.Scriptex.helper in the folder /Library/PrivilegedHelperTools. This is where the system will install the Helper when asked by the application. Also, the key MachServicescom.your-company.Scriptex.helper with a true (YES) value indicates that the daemon will listen to the mach service with the same label as the one we specified in the Helper initialisation:

self.listener = NSXPCListener(machServiceName: HelperConstants.domain)

with

HelperConstant.domain="com.abridoux.Scriptex.helper"

Last step: the Python script we will use to sign the Helper does not seem to play well with the $(BUNDLE_IDENTIFIER) variable set by default in the app Info.plist file for the Bundle identifier key by Xcode when the project is created. The reason might be because the Python script does not know it's variable to replace to variable. So please change this value yo your hardcoded bundle identifier:

Copy the binary

Now we have to remaining tasks before we can properly use the Helper after installation. First, we have to copy the binary Xcode generate from our code in the product folder. Here, we will use the standard destination for program bind to a Launch service. So please move to the com.your-company.Scriptex.helper target Build Phases, and add a Copy Files build phase.

When done, write the following path in the Subpath text field: "Scriptex.app/Contents/­Library/LaunchServices"; and add the Helper target item. Check the case Code Sign On Copy. Thus, Xcode will copy the binary into the specified file.

When your archive and export the app, you will easily find the Helper program, which we will use in the final part of this tutorial to install it manually. When you build the app for debugging, you will rather find it in a similar path (the identifier part after Scriptex should be different):
~/Library/Developer/Xcode/DerivedData/­Scriptex-hfwe.../Build­/Products/Debug.
I am telling you that because we will need to access to the Helper at this path to sign it.

Embed the setup files

The second task is a bit trickier. Remember the setup files Info.plist and Launchd.plist we created? When the Helper binary is created, it does not contain the information in those files by default, and it requires them to run properly. Also, when we will sign the Helper, the signature will be contained in the Info.plist file. This will ensure the Helper does not accept connections from another application that the one we will specify. So we need to embed those two files. I do not know for sure why we have to embed the Launcd.plist file, although it is required as mentioned in the Apple documentation.

To do so, we are setting a specific Link Flag in Build SettingsLinking. If you can't find the Linking section, try to set the build setting filter to All.

Fig13.png

Find the row Other Linker Flags, double click on the row in the com.your-company.Scriptex.helper column, click on the plus + button and add the following (you can copy and paste)

-sectcreate __TEXT __info_plist "$(SRCROOT)/Helper/Info.plist" -sectcreate __TEXT __launchd_plist "$(SRCROOT)/Helper/Launchd.plist"

When you close the popover and open it again, it should look like this:

Fig14

Setup the certificate

To sign the binary. At least a development certificate is required. So please select the Development option if “Automatically manage signing” is checked:

If “Automatically manage signing” is unchecked, please select a Mac developer certificate:

Later if you notarize the application, you will need to sign the Helper again with a Developer ID Application certificate.

Now we are ready to build!

Build the binary a first time

In order to sign the binary, we first have to generate it. Then we will use the Python script SMJobBlessUtil.py which you can find in the materials Scripts folder to sign the binary. So please go on and build the project. To make sure everything went well after the build, open the folder similar to ~/Library/Developer/Xcode/DerivedData/­Scriptex-hfweqj...­/Build/Products/Debug and navigate to Scriptex.app/Contents/­Library/LaunchServices/. You should find the Helper with the name of the target.

Now let's make the final touch, shall we?

Sign the Helper

The script SMJobBlessUtil.py - which is provided by Apple - will add new keys in the app and Helper Info.plist files. The key added in the application Info.plist file will state that the program com.your-company.Scriptex.helper is owned by the application. This allows to make sure the application does not use another malicious Helper program. Similarly, the key added in the Helper Info.plist file will state that the Helper is owned by the application, and thus make sure that no other application can use it. This is the key that ensures security and prevent another app to gain the root privileges the Helper offers. So let's do it right.

Make sure you can execute the Python script if it is not already the case, and execute it with the setreq command this way:

./SMJobBlessUtil.py setreq \
"/Users/alexisbridoux/Library/Developer/Xcode/DerivedData/Scriptex-hfw.../Build/Products/Debug/Scriptex.app" \ "~/Documents/Blog/Tutos/Privileged Helper/Scriptex final/Scriptex/Info.plist" \ "~/Documents/Blog/Tutos/Privileged Helper/Scriptex final/Helper/Info.plist"

The first argument if the location of the build. The script will then look in the folder Contents/Library/LaunchServices starting from this endpoint (that's why we made Xcode copy the Helper binary here). So the final path will be /Users/alexisbridoux/Library/Developer/­Xcode/DerivedData/Scriptex-hfwe...­/Build/Products/Debug/Scriptex.app but the scripts is kind enough to avoid us to write it fully.
The second argument is the location of the application Info.plist file, and the third and final argument is the location of the Helper Info.plist file. If we did everything right, the terminal should output something similar:

/Users/alexisbridoux/Documents/Blog/Tutos/Privileged Helper/Scriptex final/Scriptex/Info.plist: updated
/Users/alexisbridoux/Documents/Blog/Tutos/Privileged Helper/Scriptex final/Helper/Info.plist: updated

You can checkout the Info.plist files. They should now contain a new key:

Application Info.plist
Application Info.plist
Helper Info.plist
Helper Info.plist

And now, ladies and gentlemen, for the final part of this tutorial, please run the app, and specify the path to the script which requires the root privileges to be executed:
Fig17.png
You should be prompted a similar alert:
Fig18.png
This is the part when you dealt with all the strange key: this is the part when we try to install the Helper.
Once you enter your password (make sure you enter the credentials of an admin), and click the Install button, you should be able to see the result in the Scriptex window:
Fig19.png
We did it!

In case you see something like "Operation not permitted" in Scriptex output, this might mean that the Helper is not able to access to the script you want to execute. Rather than trying to request a full disk access in this tutorial, you can test the script execution by putting it inside /Users/Shared/.


Validating the connection

I had the opportunity to talk with some other macOS developers, and they advised me to add a security check to this Helper implementation. In this new part we'll implement a ConnectionIdentityService to make sure only the companion app can connect to the Helper.
I could not make sure this is required, and the offered solution uses a hack. But I have been told this what Apple recommends under the table. So it's up to you to decide whether you should implement this additional security check or not. Just know that it's not a big deal and will not prevent the Helper to work.

The solution we are going to implement is based on the work of Wojciech Reguła and his dedicated repository. Other resources that helped me understanding the problem are linked at the end.

Identity

To make sure the program trying to connect to the Helper is the companion application, we will use the auditToken property of the incoming NSXPCConnection.
The thing is, this property is private, which means that we can't access it normally. Private properties are not supposed to be used and the code using it should not rely on it as it could be changed or removed without a warning. This is why I mention I was not sure of this solution. But as I mentioned above, this is why Apple recommends "under the table" from what I have been told. So, let's use it.

Accessing a private property from a class is not possible in Swift, and the solution that I found from M. Reguła and elsewhere uses an Objc-C hack to retrieve it.
We are going to read this property at runtime using the runtime behaviour of Obj-C. To do so, we'll add some Obj-C code and bridge it to Swift.

Bridging Obj-C

Start by creating an Objective-C file and name it "AuditTokenHack.m". Make sure to select the Helper target.

Fig28.png

If a popover appears offering to create a bridge, click on Create Bridging Header.

Fig29.png

Copy and paste the following Obj-C code in the file AuditTokenHack.m.

#import "AuditTokenHack.h"

@implementation AuditTokenHack

+ (NSData *)getAuditTokenDataFromNSXPCConnection:(NSXPCConnection *)connection {
    audit_token_t auditToken = connection.auditToken;
    return [NSData dataWithBytes:&auditToken length:sizeof(audit_token_t)];
}

@end
What it does is it retrieves the property auditToken from the connection, which is of type audit_token_t and then returns it's NSData version.

Now we have to make that accessible to our Swift code. So add a new "Header" file named "AuditTokenHack" (the .h is not required since it's added automatically).

Fig30.png

Now copy and paste the following inside this header file.

#import <Foundation/Foundation.h>

// Hack to get the private auditToken property
@interface NSXPCConnection(PrivateAuditToken)

@property (nonatomic, readonly) audit_token_t auditToken;

@end

// Interface for AuditTokenHack
@interface AuditTokenHack : NSObject

+(NSData *)getAuditTokenDataFromNSXPCConnection:(NSXPCConnection *)connection;

@end
The first block is the real hack. It wil make the property auditToken visible although it is private. The second block will make the class AuditTokenHack available to any code importing the header.

Finally, to make the header available, it has to be added to the bridge file so add the following line to the file com.your-company.Scriptex.helper-Bridging-Header.h.

#import "AuditTokenHack.h"

If you did not see the popup to create a bridging header file, add a new header file to the project (in the Helper folder) and name it com.your-company.Scriptex.helper-Bridging-Header.h. Then make sure to add a link to it in the Helper build settings if it's not automatically added by Xcode.

Fig31.png

To make sure everything went fine, write the following line anywhere before the return statement in the function Helper.listener(:shouldAcceptNewConnection)

AuditTokenHack.getAuditTokenData(from: newConnection)
If you build the project, you should see no errors. You can then remove this line.

Use the token

Now it's time to use this hard earned token! To not pollute the Helper class, we will implement this logic in a service named ConnectionIdentityService. This service will have one visible static function: isConnectionValid(connection: NSXPCConnection) -> Bool. As mentioned above, a service is a collection of functions with no state. As the service should not be initialised to be used, the functions are static. This means that to run the function isConnectionValid, we will write

ConnectionIdentityService.isConnectionValid(connection: newConnection)
So please create a new Swift file named ConnectionIdentityService and add the following:
enum ConnectionIdentityService {
    
}
As long as we are creating files, please create another one named OSStatus+Extensions (Helper target) and write the folllowing inside it.
extension OSStatus {

    var hasSecError: Bool { self != errSecSuccess }

    var secErrorDescription: String {
        let error = SecCopyErrorMessageString(self, nil) as String? ?? "Unknown error"
        return error
    }
}
This will make the code we are about to write a bit clearer.

To validate the connection in the sevice will be performed in two steps. First, we will get the SecCode value of the connection from the auditToken value. The SecCode class is like the signature of a program.
Then, we will put this signature against the one we are excpeting, under the form of a SecRequirement class, instantiated from a requirement string. This string is similar to the one written in the Info.plist file by the python script. It specifies that some informations are required, and what should be their values. For instance, it will specifies the certificate type and the team identifier that it belongs to.

Let's start by implementing the first function to get the SecCode class. Add the following to ConnectionIdentityService

private static func secCodeFrom(token: Data) -> SecCode? {
    // 1
    let attributesDict = [kSecGuestAttributeAudit: token]
    var secCode: SecCode?

    // 2
    let status = SecCodeCopyGuestWithAttributes(
        nil,
        attributesDict as CFDictionary,
        SecCSFlags(rawValue: 0),
        &secCode
    )

    // 3  
    if status.hasSecError {
        // unable to get the (running) code from the token
        NSLog("🛑 Could not get 'secCode' with the audit token. \(status.secErrorDescription)")
        return nil
    }

    // 4
    return secCode
}
This function will return nil if the SecCode could not be retrieved, while logging the error with NSLog. It's fine for this tutorial, but you might prefer to throw an error. Here is a breakdown.
  1. The secCode is declared to be filled when calling the SecCodeCopyGuestWithAttributes function. The attributesDict is used to pass the token with a specific key.
  2. The function SecCodeCopyGuestWithAttributes will identify the program corresponding to the provided token. Passing nil as the first parameter indicates that it's the system kernel that should be asked (according to the doc). The last parameter is a pointer to the value where the identity/signature should be put. The flags parameter are not relevant to our use case.
  3. We check if the status returned by the funtion has an error (when dealing with C APIs, it's common to handle errors this way, as you might have notice when installing the Helper). If there is an error, we log it and return nil.
  4. Everything went fine up to this point, and we are able to return the secCode.


Now that we can get the SecCode from the token, we can validate it against our requirements built from a requirementString.
Let's start by adding this requirement string. Add the following to the ConnectionIdentityService enum.

static private let requirementString =
    #"anchor apple generic and identifier "\#(HelperConstants.bundleID)" and certificate leaf[subject.OU] = "\#(HelperConstants.subject)""# as CFString

Using a string enclosed with sharp signs '#' is called "raw strings" and allows to more easily use double quotes in the string, while requiring to add a sharp sign in the interpolated strings \#().
Learn more

The compiler should complain that HelperConstants does not have a bundleID and subjectOU property. So let's add them with some explanations.

enum HelperConstants {
    static let bundleID = "com.abridoux.Scriptex"
    static let subject = "7V89H3739L"
    // other constants
}
For the bundleID, nothing new, it's what identifies the app and we have already dealt with it. Just make sure to change the company name 😉
The subjectOU corresponds to the team bundle identifier. If you don't know where to find it, it's the string between brackets in the certificate name in your keychain.
Some examples I have found use the common name of the certificate (which require more attention to avoid typos). This is useful when you want to make sure a specific certificate is used, and not only validate the team identifier. In this case, the part "subject[OU]" in the requirement string should be replaced with "subject[CN]" and the value of subject should be changed to something like "Developer ID Installer: Alexis Bridoux (7V89H3739L)".

Now that the requirement string is ready, let's use to build the requirements and check the code signature againts them. Go back to ConnectionIdentityService and add the following function.

static private func verifyWithRequirementString(secCode: SecCode) -> Bool {
    var secRequirement: SecRequirement?

    // 1
    let reqStatus = SecRequirementCreateWithString(
        requirementString,
        SecCSFlags(rawValue: 0),
        &secRequirement
    )
    
    // 2
    if reqStatus.hasSecError {
        NSLog("🛑 Unable to create the requirement string. \(reqStatus.secErrorDescription)")
        return false
    }

    // 3    
    let validityStatus = SecCodeCheckValidity(
        secCode,
        SecCSFlags(rawValue: 0),
        secRequirement
    )
   
    // 4
    if validityStatus.hasSecError {
        NSLog("🛑 NSXPC client does not meet the requirements. \(reqStatus.secErrorDescription)")
        return false
    }

    return true
}
  1. The requirements are built from the requirement string. As this is a C API, it does not know about Swift String. That's why we had to cast it as CFString (CoreFoundation String)
  2. If the result of the requirement build function has an error, it logged and we return false to not validate the connection
  3. Now that everything is ready, we can call the function to check the validatity of the code signature secCode against our requirements.
  4. Again, if the result of the function call has en error, it's logged and and we exit by returning false. Otherwise, we can at last return true to validate the connection.

Now for the final part, it's time to connect those two functions in a single public one that will be called by in the Helper to know if the connection is valid.
Please add this function to ConnectionIdentityService.

static func isConnectionValid(connection: NSXPCConnection) -> Bool {
    // 1
    guard let token = AuditTokenHack.getAuditTokenData(from: connection) else {
        NSLog("⚠️ Unable to get the property 'auditToken' from the connection")
        return true
    }
    
    guard // 2
        let secCode = secCodeFrom(token: token),
        verifyWithRequirementString(secCode: secCode)
    else { return false }
    
    // 3
    return true
}
  1. We use the Objc-C hack to retrieve the connection auditToken property. Has there is no guarantee that get a non nil value (the hack could not work), the value has to be unwrapped. If the value is nil and can't be uwnrapped, it's up to you to decide whether to let the Helper work or not. If not, the risk is to prevent the app to use it and thus break the features that rely on it. If you decide to let the Helper work, it's a good idea to log that and/or to implement a way to be alerted. For the purpose of this tutorial, the Helper will keep working.
  2. Here we get the code signature with the first function, and we pass it to the second to validate it. If one of those two steps fails, this means that we were not able to validate the connection so we return false.
  3. At this point, we know that the connection is valid, so we return true.

We can now call the ConnectionIdentityService.isConnectionValid() function in the Helper function that accepts a new connection.
This should come first.

func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
    guard ConnectionIdentityService.isConnectionValid(connection: newConnection) else { return false }
    
    // interfaces...
    
    newConnection.resume()
    
    return true
}
And... that's it! We have added a strong safety check to prevent the Helper to be misused! Don't forget to remove and install again the Helper if it is already installed for the update to take effect.

The solution from M. Reguła will also check the entitlements of the application and not only its signature. This tutorial is already long enough to explain this part, so feel free to take a look at his solution if that's something you want to implement.


Optionally log the application's infos

I wanted to add a last part from the solution of M. Reguła to log some infos regarding the connection. To do so, we have to work on a new class SecStaticCode, to inspect an application installed in the system. Fortunately, this will require only a function call. Then we will be able to get the informations from this static code.
This part can be found in the final project, and here is a breakdown.

private static func logInfoAbout(secCode: SecCode) {
    // 1
    var secStaticCode: SecStaticCode?
    var cfDictionary: CFDictionary?
    
    // 2
    SecCodeCopyStaticCode(secCode, SecCSFlags(rawValue: 0), &secStaticCode)

    guard let staticCode = secStaticCode else {
        NSLog("Unable to copy the signature of the running app")
        return
    }

    // 3
    let copyStatus = SecCodeCopySigningInformation(
        staticCode,
        SecCSFlags(rawValue: 0), 
        &cfDictionary
    )

    // 4
    if copyStatus.hasSecError {
        NSLog("⚠️ Unable to get info about connection. \(copyStatus.secErrorDescription)")
    } else if let dict = cfDictionary {
        let dict = dict as NSDictionary
        let info = dict["info-plist"] as? NSDictionary
        let bundleIdAny = info?["CFBundleIdentifier"] ?? "Unknown"
        let bundleId = String(describing: bundleIdAny)
        NSLog("Received connection request from app with bundle ID '\(bundleId)'")
    }
}
  1. To store the SecStaticCode and the info about the app, we declare two variables. Similarly to CFString, CFDictionary is required to work with C APIs, and can be casted as NSDictionary for us to work with.
  2. Here the secCode is copied to a SecStaticCode
  3. The informations for the static code are retrieved
  4. If the informations could not be retrieved, an error is logged. Otherwise, we play with dictionary to get the peiec of informations we want. Here, we get the bundle ID of the app trying to connect to the Helper, which could be useful to check if another program tried to use it - in a malicious way.
To use this function requires to change a bit the isConnectionValid function:
static func isConnectionValid(connection: NSXPCConnection) -> Bool {
    guard let token = AuditTokenHack.getAuditTokenData(from: connection) else {
        NSLog("⚠️ Unable to get the property 'auditToken' from the connection")
        return true
    }
    
    guard let secCode = secCodeFrom(token: token) else { return false }
    logInfoAbout(secCode: secCode)
    
    return verifyWithRequirementString(secCode: secCode)
}



What a journey. Before we say good bye, I would like to add a few remarks regarding this tutorial and the topic we covered. Also, an optional part is here if you are an admin and want to deploy your application with the Helper without asking the user to enter the credentials of an admin.


A few remarks

Execute scripts

Executing a script with root privileges is certainly to be avoided in a production environment, as you can hardly make sure the script is not harmful. I used this example here because it was the simplest I could think of. That said, you could use a Helper to execute script, if you ensure that only the root user can create and edit it. This way, a malicious program could not take advantage of the application to execute a script with root privileges. In the application, you can then make sure that the script to be executed is has the proper permissions (an upcoming tutorial should tackle this issue).

Remove the Helper

When you application is done with the Helper, or when it is removed from the system, you should think about deleting the Helper too to avoid a useless security risk. The last part gives the required 3 lines to do so (launchctl unload, rm...)

Notarise the application and deploy it

To make sure that your application and the Helper can run on your users Macs, you should make sure to set the signing certificate to your Developer ID Application certificate before executing the Python script. Then build the project, execute the Python script, and build again to include the change into the binary. You can then send the app to the Apple notarisation service.

If you want to notarise the application with the Xcode interface, you should remove the Copy Files phase we setup earlier. Otherwise, Xcode will not let you send the application to the notarisation service as the archive will also contain another program (here the Helper).

That's all I could think of at the moment! This is the end of this tutorial.
If you liked it, or if it has been helpful, feel free to share a few words on Twitter (@Bigby_Woody to reach me), Reddit (u/Alexis-Bridoux) or contact me by email.

Still there, uh? This last part will explain how to bypass the alert when installing the Helper, by installing it before launching the app.


Install the Helper manually

First of all, this will require root privileges. Though this should not be a problem when installing the Helper with a MDM. Here is the solution.

You will simply install the Helper in the /Library/PrivilegedHelperTools folder, install the Launch Daemon in /Library/LaunchDaemons and load it. Easier said than done, right? That's why I wrote a script to handle this, inspired from a script written by Marc Thielemann. Also, thanks to Mark Lamont for his editing propositions.

You can find it in the repo in the Install Helper folder.

#!/bin/zsh
# install_helper v1.0.0
# (Alexis Bridoux) based on # Marc Thielemann script: https://github.com/autopkg/rtrouton-recipes/blob/master/Privileges/Scripts/postinstall

# ---- Colors ----
COLOR_FAILURE='\e[38;5;196m'
COLOR_SUCCESS='\e[38;5;70m'
COLOR_NC='\033[0m' # No Color

function print_error {
    >&;2 echo -e  "${COLOR_FAILURE}$1${COLOR_NC}"
    exit 1
}

function print_success {
    echo -e  "${COLOR_SUCCESS}$1${COLOR_NC}"
}
# ---------

# ---- Constants ----
CURRENT_DIR=$(pwd)

HELPER="com.abridoux.Scriptex.helper"
HELPERS_FOLDER="/Library/PrivilegedHelperTools"
HELPER_PATH="$HELPERS_FOLDER/$HELPER"

DAEMON="com.abridoux.Scriptex.helper.plist"
DAEMONS_FOLDER="/Library/LaunchDaemons"
DAEMON_PATH="$DAEMONS_FOLDER/$DAEMON"
# --------

# ---- Main ----

# test if root
# https://scriptingosx.com/2018/04/demystifying-root-on-macos-part-3-root-and-scripting/
if [[ $EUID -ne 0 ]]; then
    print_error "This script requires super user privileges, exiting..."
    exit 1
fi

# -- Helper --
echo "-- Helper -- "

if [[ ! -f "$HELPER_PATH" ]]; then
    # the Helper does not exist in the Helpers folder so copy it
    echo "Did not find the Helper at $HELPER_PATH. Copying it..."

    # create the Helper tools folder directory if needed
    if [[ ! -d $HELPERS_FOLDER ]]; then
        /bin/mkdir -p "$HELPERS_FOLDER"
        /bin/chmod 755 "$HELPERS_FOLDER"
        /usr/sbin/chown -R root:wheel "$HELPERS_FOLDER"
    fi

    # move the privileged helper into place
    /bin/cp -f "$CURRENT_DIR/$HELPER" "$HELPERS_FOLDER"

    if [[ -f "$HELPER_PATH" ]]; then
        print_success "Successfully copied $HELPER to $HELPERS_FOLDER"
    else
        print_error "Failed to copy $HELPER to $HELPERS_FOLDER"
        exit 1
    fi

    echo "Settings the correct rights to the Helper..."
    echo ""

    /usr/sbin/chown root:wheel "$HELPER_PATH"
    /bin/chmod 755 "$HELPER_PATH"
    # -- remove the quarantine if any
    /usr/bin/xattr -d com.apple.quarantine "$HELPER_PATH" 2>/dev/null
else
    # the Helper exists. Don't do anything
    print_success "$HELPERS_FOLDER already in place"
    echo ""
fi

# --- Daemon ---
echo "-- Daemon -- "
if [ ! -f  "$DAEMON_PATH" ]; then
    # the daemon does not exist in the daemons folder so copy it
    echo "Did not find the LaunchDaemon at $HELPER_PATH. Copying it..."

    # copy the daemon
    /bin/cp -f "$CURRENT_DIR/$DAEMON" "$DAEMONS_FOLDER"

    # ensure the daemon has beensuccessfully copied
    if [ -f  "$DAEMON_PATH" ]; then
        print_success  "Successfully copied $DAEMON to $DAEMONS_FOLDER"
    else
        print_error "Failed to copy $DAEMON to $DAEMONS_FOLDER"
        exit 1
    fi
else
    print_success "The daemon $DAEMON is already in place in $DAEMONS_FOLDER"
fi

# Set the rights to the daemon
echo "Settings the correct rights to the LaunchDaemon..."

/bin/chmod 644 "$DAEMON_PATH"
/usr/sbin/chown root:wheel "$DAEMON_PATH"
# -- remove the quarantine if any
/usr/bin/xattr -d com.apple.quarantine "$DAEMON_PATH" 2>/dev/null

# Load the daemon
loaded=`launchctl list | grep ${HELPER}`
if [ ! -n "$loaded" ]; then
    echo -e "Daemon not loaded. Loading it..."
    /bin/launchctl load -wF "$DAEMON_PATH"
else
    print_success "Daemon already loaded, exiting..."
    exit 0
fi

loaded=`launchctl list | grep ${HELPER}`

if [ -n "$loaded" ]; then
    print_success "Successfully loaded the Daemon at $DAEMON_PATH"
else
    print_error "Failed to load the Daemon at $DAEMON_PATH"
    exit 1
fi

To use this script, you will have to put it in a folder with the Helper and its dedicated Launchd.plist launch daemon. You can find those two files in the HelperFolder in the materials. Do notice that the Launchd.plist launch daemon is renamed to the name it will be assigned when install by the application: com.your-company.Scriptex.helper. Also, the file contains two new keys, Program and ProgramArguments which automatically are set by the system when setting up the daemon through the application. So we need to add them manually here. Finally, make sure to change the “abridoux” parts in the Constants section of the script with your company name, once again.

This script will require the root rights to be executed, as you might imagine. If you have already installed the Helper with the application, you should enter those commands before executing the install_helper.sh script to make sure it works fine:

launchctl unload /Library/LaunchDaemons com.abridoux.Scriptex.helper.plist
rm /Library/LaunchDaemons/com.abridoux.Scriptex.helper.plist
rm /Library/PrivilegedHelperTools/com.abridoux.Scriptex.helper

Those commands unload the launch daemon and delete it, as well as the Helper. If you run the script from the InstallHelper folder, you should see the following.

Now if you run the application, you should not be asked to enter admin credentials, although you deleted the Helper and its launch daemon. This way, things will seem seamless for your users.

And that's the end of this optional part! I hope you found it useful.

Where to go from here?

Reach out to me

Should you need help, or if there is some unclear part in this tutorial, you can find me on Twitter, Reddit, or contact me by email. Also, if you found this tutorial helpful, feel free to share it on Twitter.