macOS processes — What to do when your daemon gets blocked by permissions dialogues.
I’m creating an app for macOS that needs to open exclusive access to USB HID devices, by seizing their input — a feature provided by IOKit. However, I soon learned that seizing is an operation that only a root
user can do in macOS.
But my app doesn’t require elevated permissions in any other circumstances, nor should it…
macOS is not designed to have general applications run as the root user. This would be viewed as a security risk by Apple’s engineers. Source
Enter the daemon
The most common method of running code as the root user (outside of installers that request elevated permissions), is to run it as a daemon.
Side note: Apple recommend that daemons or “Priveleged Helpers” are loaded by using SMJobBless. This isn’t used for agents though, which by design, are run as the signed in user.
In this article, I’ll be running both an agent and a daemon, so to simplify things, I am taking the approach that a typical macOS installer package script would take, which is to copy the agent and daemon into their respective library directories and running
launchctl
explicitly.
No problem, I got the daemon running, but then came up against the next hurdle. The daemon was running fine, but the device open was never coming back successfully.
Digging through the macOS console logs, I discovered that the call to IOHIDDeviceOpen
was being blocked by Apple’s “Transparency, Consent, and Control” protection, or TCC for short. The message was “TCC deny IOHIDDeviceOpen”.
This is because when you call IOHIDDeviceOpen
, typically what happens is that the user is presented with the “Keystroke Receiving” dialogue, that requests input monitoring permission for the code directly from the user.
That dialogue takes the user to system preferences where they’ll see the app in the list of all apps requesting permission. From there, the user can manually choose to allow our app to access input monitoring features.
The user is effectively warned that the code being run is trying to get access to their keystrokes. It can be a perfectly useful and legitimate thing for an app to utilise keystroke monitoring, but of course it can be horribly abused in obvious ways, such as key loggers.
TCC protects the user from this threat by first giving them a chance to explicitly allow for this to happen, on an app by app basis. Running as root, the most powerful user in the system, cannot circumvent this.
We really should appreciate how these behaviours in macOS protect us as users, despite the inconvenience it causes us as developers.
So then, why aren’t we seeing the dialogue when the daemon runs?
You can’t you show a UI prompt if there is no UI
Daemons are run as the root user, but the root user does not have a GUI session *
* I’m not so sure on my nomenclature here, but I’ve read around the internet people reference this as a GUI session — update 11/3/20 I’ve since seen this referred to as “Login Session” in About Daemons and Services.
Think about it for a second. Daemons are typically loaded in by the root user before the GUI is even loaded. If the code being run is accessing SDKs that are TCC protected, there’s no way for macOS to prompt the user for permission.
And even after that, if you were signed in when the daemon started, your daemon process is still running as the root user. There’s no way for it to trigger the TCC prompt.
So this left me in a pickle.
How can you make a call to a macOS API that can only be executed by the root user, when at the same time that API is protected by TCC prompts which can’t be triggered by the root user?
It’s a catch 22, but fortunately it is solvable (as of macOS Big Sur 11.0.1).
We can use a companion agent to trigger the prompt on behalf of the daemon
The solution is to use an agent tasked with the sole purpose of triggering the TCC prompt for the daemon.
Why can this work?
- Agents are run as the current signed in user, and that user does have a UI. Meaning the TCC prompt will be displayed to the user.
- Agents and daemons can both run the same program, meaning that if the user grants permission to the application running as an agent, then after that point it will still have permission when it’s running as the daemon. It’s the same program, it’s just that at one point it runs as the current user, and at a different point it runs as the root user.
- We can do a simple check in the program to determine if the code is running as the root or the user. We can then fork different code paths based on that user to facilitate this “companionship”, so to speak.
This might seem a bit “hackish”… it did originally for me.
However the more I got used to this approach, the more I realised that it’s actually quite legitimate. After all, we are still 100% respecting the purpose of TCC. The user is not getting cut out here, we are not going behind their back in a shady fashion. Rather it’s the opposite — we’re going to the extra effort of ensuring they’re involved.
I hope that Apple sees it this way in later versions of MacOS. At least for now the ever helpful eskimo over at the official Apple developer forums recommends a similar approach.
So, what does this look like in practice?
Here’s a quick run down on how this is achieved.
Let’s run through how to do this in code. Note that I’m roughing it here, I haven’t run this code exactly as is, but it will guide you conceptually in the right direction.
Create a simple console app.
In the main.swift
file we’ll create a pretty basic device opening method in order to trigger the TCC prompt.
Let’s break this down.
The tryOpenHidDevice
method simply finds the first matching USB HID device in the system, and then attempts to open it. It’s the action of opening it that will trigger the TCC prompt.
Something import to note. If you adapt this code, you are better off taking an approach where you iterate all matching devices using
IOGetMatchingServices
(plural) and also, in the case of keyboards, specify the keyboard HID usage page and usage id in the matching dictionary.Otherwise results would be unpredictable. For example, first device that comes back may fail opening because it’s seized by the system, but the next one might open fine, triggering the prompt. Also at least one device that I know of (the Keyboard backlight) opens fine without triggering any TCC prompt.
Alas, this does somewhat make this method a little bit of a dark art.
We’re using the BSD getuid
method to get the id of the user that is running the code. The root will always have id 0
. So if the user executing the code is root, then we run daemon code. Otherwise, we assume it’s a gui session user, and we run the agent code.
The core principle here is that the agent triggers the TCC prompt, and at some point later on the daemon takes over.
In a real world usage of this approach, you’d orchestrate the coordination between the agent and the daemon perhaps by using process id checking, timers, run loops and/or launchd keep alive. There’s a few ways to do it, so I’ve not been too opinionated on that.
Of course, we’ve not seen how to run the daemon and agent yet. We’ll do that, but first make sure you build your XCode project so that the application is compiled.
The agent
This is a basic plist file that will be place into /Library/LaunchAgents
⚠️⚠️⚠️ FIRST: The program key here is pointing to the build output directory of the Xcode project. In a production ready agent, this would likely be pointing to your app’s Application Support folder. For now, you’ll need to update this to your actual local path. You can do this by finding the executable in XCode under the “products” folder.
To run and start the agent
sudo cp IOServicesTestLaunchAgent.plist /Library/LaunchAgents
launchctl load /Library/LaunchAgents/IOServicesTestLaunchAgent.plist
With a bit of luck, you’ll now see a TCC prompt asking for input monitoring.
Remember not to use sudo
when calling launchctl for the agent, otherwise you’ll start the agent as root. We must start the agent as your user, which has a GUI session.
If you need to stop the agent, you can use
launchctl unload /Library/LaunchAgents/IOServicesTestLaunchAgent.plist
The daemon
Similar pattern above, but this time we’re putting the below plist into /Library/LaunchDaemons
Same rules apply regarding the program path, as for the agent.
The only difference here in running it is that we will be using sudo
to run launchctl
sudo cp IOServicesTestLaunchDaemon.plist /Library/LaunchDaemons
sudo launchctl load /Library/LaunchDaemons/IOServicesTestLaunchDaemon.plist
If the agent already ran, and TCC access was granted, then the agent should be able to exclusively open hid devices.
To see the os_log
statements in the main.swift
file as they’re executed, use the Console app in macOS. You can filter by process.
In summary
Apple continue to take steps to lock down the macOS kernel, and are actively pushing developers to stay in user space. In many cases, the APIs they provide make this possible.
In other cases, such as input monitoring, we have to take special measures as developers in order to support (well, adhere to) these protections, but still be able to access the low level APIs that we need for our applications.
Of course, these protections are crucial to user privacy, and I appreciate them as a Mac user.
Though it does make me (and any developer) err with caution anytime you are accessing something low level that starts to feel a little too “off the well trodden” path to use.
Apple could pull the rug out from under you in any future release, which of course has happened to many developers.
Let’s see how this technique to allow daemons to access TCC protected apis holds up.