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.

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

struct 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.

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.

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 installer 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 install the Helper")
}

// create an AuthorizationItem to specify we want to bless a privileged Helper
var authItem = AuthorizationItem(name: kSMRightBlessPrivilegedHelper, valueLength: 0, value: nil, flags: 0)

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

// Specify that the system can ask the authorization to the user directly with .interactionAllowed, that the system will try to grant the right (to bless a privileger Helper),
// and for the .preAuthorize one I am not sure but it seems to be required.
let flags: AuthorizationFlags = [[], .interactionAllowed, .extendRights, .preAuthorize]

// request the blessing
authStatus = AuthorizationCreate(&authRights, nil, flags, &authRef)

// if an error occurred, we will, know that by inspecting the status
guard authStatus == errAuthorizationSuccess else {
    throw ScriptexError.helperInstallation("Unable to get a valid loading authorization reference to load Helper daemon")
}

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:

/// Install the Helper in the privileged helper tools folder and load the daemon
private func installHelper() throws {
    // We will ask to the user to validate the helper installation in /Library/PrivilegedHelperTools and daemon loading

    // Empty authorization to test
    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")
    }

    // Validation for SMJobBless, which will install the helper and load the daemon
    var authItem = AuthorizationItem(name: kSMRightBlessPrivilegedHelper, valueLength: 0, value: nil, flags: 0)
    var authRights = AuthorizationRights(count: 1, items: &authItem)
    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)")
    }

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


Create the connection

Things should be easier now, as we will see. The code is very similar to what we have in listener(_:shouldAcceptNewConnection:), although we will specify a closure for the connection invalidationHelper. This will be executed when the connection has successfully or unsuccessfully been established. So this is our opportunity to know if the connection was successfully established. Unfortunately, this does not allow to throw an error, so we have to find another way to forward the failure message. In this tutorial, we will simply print a message, but feel free to use another method if you want, like executing a failing function. In this closure, we will check whether the Helper is installed or not. To do so, please add a computed variable at the beginning of the HelperRemote struct (we will use it again later):

var isHelperInstalled: Bool { FileManager.default.fileExists(atPath: HelperConstants.helperPath) }

With this variable ready, here is how the function createConnection() looks like.

private func createConnection() -> NSXPCConnection {
    let connection = NSXPCConnection(machServiceName: HelperConstants.domain, options: .privileged)
    connection.remoteObjectInterface = NSXPCInterface(with: HelperProtocol.self)
    connection.exportedInterface = NSXPCInterface(with: RemoteApplicationProtocol.self)
    connection.exportedObject = self

    connection.invalidationHandler = { [isHelperInstalled] in
        if isHelperInstalled {
            print("Unable to connect to Helper although it is installed")
        } else {
            print("Helper is not installed")
        }
    }

    connection.resume()

    return connection
}

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, here is the overall code:

import Foundation
import XPC
import ServiceManagement

struct HelperRemote {

  // MARK: - Properties

    var isHelperInstalled: Bool { FileManager.default.fileExists(atPath: HelperConstants.helperPath) }

    // MARK: - Functions

    /// Install the Helper in the privileged helper tools folder and load the daemon
    private func installHelper() throws {
        // We will ask to the user to validate the helper installation in /Library/PrivilegedHelperTools and daemon loading

        // Empty authorization to test
        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")
        }

        // Validation for SMJobBless, which will install the helper and load the daemon
        var authItem = AuthorizationItem(name: kSMRightBlessPrivilegedHelper, valueLength: 0, value: nil, flags: 0)
        var authRights = AuthorizationRights(count: 1, items: &authItem)
        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)")
        }

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

    private func createConnection() -> NSXPCConnection {
        let connection = NSXPCConnection(machServiceName: HelperConstants.domain, options: .privileged)
        connection.remoteObjectInterface = NSXPCInterface(with: HelperProtocol.self)
        connection.exportedInterface = NSXPCInterface(with: RemoteApplicationProtocol.self)
        connection.exportedObject = self

        connection.invalidationHandler = { [isHelperInstalled] in
            if isHelperInstalled {
                print("Unable to connect to Helper although it is installed")
            } else {
                print("Helper is not installed")
            }
        }

        connection.resume()

        return connection
    }

    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()
    }

    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")
        }
    }
}

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/.

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. A simple way to achieve that is to have a secure folder, which only the root user has the write permission on it. In the application, you can then make sure that the script to be executed is in the correct folder.

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. When working on Octory, I had to find a way for the admin to install the Helper with the 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.