Learn how to improve the security of your Mac app by adopting environment constraints. We'll show you how to set limits on how processes are launched, make sure your Launch Agents and Launch Daemons aren't tampered with, and prevent unwanted code from running in your address space.
♪ ♪ Robert: Hello My name is Robert Kendall-Kuppe and today I’ll be talking to you about a new macOS feature we’re calling "environment constraints." Feature-rich Mac apps are often more than just one process or file. Frameworks and libraries allow you to reuse code across your apps or from other developers. Helper tools, helper apps, and XPC services allow you to split work up to reduce attack surface. Launch agents, launch daemons, and login items allow you to do work in the background or at user login. And app extensions allow you to provide useful functionality in other apps. But your apps run in a potentially hostile environment. App architects need to consider the potential effects of unknown software running alongside their software or using their frameworks. Could execution of your helper tool or XPC service give an attacker access to your keychain data? What about your iCloud data or some other privilege? What could happen if unexpected code was injected into your process? Just like in real parent-child relationships, parent processes have a huge amount of influence over how a child behaves. On macOS, the power to posix_spawn another process gives the parent the ability to control nearly all input to the child. The parent process can also limit the child’s access to system resources. This level of control can cause the child to load unexpected code, to run unexpected features, or to behave in ways that make the process more vulnerable to attack. Beyond the parent-child relationship, though, processes place trust in the disk layout in which they spawn. Malicious processes that can modify files on disk may be able to feed unexpected data to a victim process, remove runtime protections from a victim process, gain persistent execution on the system, or steal the privileges of a process. In the face of all those threats, macOS provides you with tools to secure your apps. In particular, you can adopt the App Sandbox to limit the impact if your app is compromised. And you can adopt Hardened Runtime and library validation to protect the integrity of your processes at run time. Gatekeeper and Notarization also help to keep customer systems free of known malicious code. Thinking about the threats I mentioned before, we realized that our existing protections focus on running processes rather than their execution environment. That's why we’re introducing environment constraints. Environment constraints give you a new level of control over the circumstances under which your processes can run and the ways you can mix code in a process. In the remainder of this talk, we’ll discuss how environment constraints fit into the security architecture of macOS, how environment constraints are structured, and how you can adopt environment constraints in your apps. Let’s take a step back for a moment and talk about the macOS security stack. By default, macOS leverages all these technologies to secure the boot chain, ensure the integrity of the OS, enforce privilege separation, and protect users from malicious software. In macOS Ventura, we started using environment constraints to better protect the relationships between operating system processes. They provide a new dimension of security for the OS. In macOS Sonoma, we’ve expanded our use of environment constraints and opened them up for you to use in your apps. So what are environment constraints? Fundamentally, they are a way to describe code, not just what the code is, but how the code is expected to exist and run on the system. In macOS, we use environment constraints for a variety of purposes. For example, to help ensure processes are using trusted bundle resources, we require OS processes to run from the Signed System Volume. To ensure that privileged daemons aren’t run with unexpected arguments or Mach ports, we require that system daemons be run only based on their protected launchd.plist. To reduce the attack surface of system apps, we require that they be run from Launch Services as applications and to ensure that user approval for background items has teeth, we use environment constraints to detect changes. Now you may be asking. "Does my app need to adopt environment constraints?" I want to emphasize that environment constraints are completely optional, but they can reduce the attack surface of any app. We’ll discuss some more specific example use cases later in this talk. I’ll note, though, that environment constraints may be particularly useful if your app has multiple processes or loads code signed by different developer teams. There are a few different types of environment constraints, so let’s talk about launch constraints first. Launch constraints are embedded into a specific binary and define either properties of that process, properties of the processes that can be its parent, or properties of the processes that can be responsible for it. We’ll refer to these properties as "self constraints", "parent process constraints", and "responsible process constraints". You can apply all three to your binary or pick the ones that make the most sense. A process with an embedded launch constraint will not run if any of the required properties are not satisfied. Now let's walk through some process relationships and talk about how you can use launch constraints to secure them. First assume that MyDemo.app is your app. You can set a self constraint on my MyDemo.app to require that it launch as an application from Launch Services. When your app requests a connection to your XPC service, launchd spawns the XPC service and is the parent of that XPC service but your app is "responsible" for that XPC service. You could set a responsible process constraint on MyXPCDemo.xpc to indicate that only MyDemo.app should be responsible for it. If your app later uses NSTask or posix_spawn to launch a helper, then it is both the parent of and responsible for that helper. You could set a parent process constraint on MyFirstHelper to require that only MyDemo.app can be its parent. Then if the first helper posix_spawns a second helper, the first helper is the parent of the second helper, but the app is responsible for the second helper. For MySecondHelper, you could set a parent process constraint to require that it only be launched by MyFirstHelper and you could set a responsible process constraint to require that only MyDemo.app be responsible for it. You can also specify an environment constraint in your launchd plists for launch agents and launch daemons. When you register your plist using the SMAppService API, the OS will enforce that only a process that meets the constraint will be launched on behalf of your plist. This feature is useful to ensure that malicious code doesn’t gain persistent execution based on user approval of your app’s background activities. Finally, you can use library load constraints to specifically control the code that can load in your address space. Before library load constraints, you could either adopt library validation or not. Library validation allows your process to load code that you signed or code that is signed by Apple. With library load constraints, you can describe a less restrictive set of code than library validation would allow while preventing arbitrary code from being loaded into your process. Note, though, that you cannot exclude Apple-signed code from loading in your process and you need to specify one or more properties to allow your own code. Now that you know what environment constraints are and how they can be used, let's discuss how they are defined. Environment constraints describe a set of conditions that code must meet. They are encoded as a dictionary where keys represent either facts that must be true about the code or operators that indicate a required relationship between facts or predicates. At the top level, implicitly, the result of each key-value pair is ANDed together to decide whether the constraint is satisfied. Note that because these are dictionaries, each key can only appear once per dictionary level. Let's take a look at some facts you might want to use. On the left are the relevant environment constraint keys and on the right is output from the codesign command. The signing-identifier key allows you to specify a string that should be unique to a given piece of code, but which stays the same across versions of that code. The signing-identifier key refers to the identifier field in codesign output. The cdhash key allows you to specify a unique hash for the code that should be allowed, and the team-identifier key allows you to specify code signed by a specific development team. While facts indicate specific properties of code, operators can be used to logically combine sets of facts or to define sets of acceptable values for facts. As you might expect, the $and and $or operators allow you to specify dictionaries of predicates that will be logically combined after they are decided. The $and-array and $or-array operators exist to limit dictionary nesting in cases where you may want to AND multiple $or predicates or OR multiple $and predicates. And finally, the $in operator allows you to specify an array of values that will satisfy a fact. Let's walk through this example constraint. On the left, we have a plist representation of the constraint. On the right is pseudo-code showing what the XML means. At the top level of the plist, there is one key, $or-array. The value is an array of three tuples. Each tuple contains an operator and a dictionary to which that operator will be applied, so that means that this constraint allows all code signed by your team identifier or library B signed by the second team identifier or library C signed by the third team identifier. For the first tuple, since it is a single element, we could also have used the $or operator. Now that we can define constraints, let's look at how you can adopt them in your project. For the purpose of this discussion, consider a main app that contains a launch agent, a helper tool, a framework that includes an XPC service, and a library signed by another development team. Now let's consider some potential problems that environment constraints can mitigate. Perhaps you have assigned some privilege to a helper tool, like access to your keychain data or access to your iCloud container. You may want to ensure that the helper tool can only be launched by your app and not anything else. You can ensure that only your app can launch your helper tool by setting a parent process constraint on the helper tool. To do this, create a code requirement plist file that requires your team identifier and the signing identifier of your main app. Then add the constraint to your helper tool’s signing configuration in the "Launch Constraint Parent Process Plist" setting. Let’s take a closer look. Here I have a demo project in Xcode with the properties I mentioned. MyDemo.app is the Main app target and demohelper is a helper tool. Let’s launch the app. When I press this button, the app spawns the helper tool, then the helper tool does some work and provides a response to the app. Let's look at the signature of demohelper in Terminal.
We can see that it has no launch constraints set and we can run demohelper. But look at that. If we run demohelper with the --cloud argument, then demohelper can access the app’s iCloud data. We don't want an arbitrary process to be able to run demohelper and alter our iCloud data. Let’s go back to Xcode and set a parent constraint on demohelper.
Here I have a constraint plist file already populated to identify the main app MyDemo.
Let's add the constraint to our signing configuration.
Now let’s rebuild the app.
We can launch the app again...
And the main app can still spawn the helper.
But when we go back to Terminal... We can see that now demohelper has a launch constraint... and it can no longer be run from Terminal. When a launch is blocked because of a launch constraint violation, a crash report is generated showing that the constraint was violated. Now let's talk about a few more problems that environment constraints can mitigate. We encourage you to use XPC services to separate privileges between different processes. But when you build XPC services, it is possible to extract those services from your bundle and call them from other code. If you have assigned some privilege to your XPC service, you need to ensure that only expected processes can gain access to that privilege. One way to ensure that only your code can access that XPC service is to set a responsible process launch constraint. Here we show a launch constraint plist that allows code signed by your team with a list of signing identifiers for each of the processes in the bundle that should be able to access the XPC service. You can add the constraint to your signing configuration in the "Launch Constraint Responsible Process Plist" setting. Another problem to consider. Starting in macOS Ventura, users are asked to approve background tasks installed on behalf of your app. This means that users expect that work to happen only on behalf of your app. If an attacker can replace the code that your plist expects to run, the attacker may gain persistent background execution on behalf of your app. To ensure that your registered plist can only be used to run the code you expect, you can set a launchd plist constraint using the SpawnConstraint key. Here you can see a complete launchd plist with a SpawnConstraint key. This constraint identifies our team and the DemoMenuBar agent. Finally, let's talk about library loading. If you are obligated to link a library from another development team without modification, then to get your app notarized with hardened runtime, you'll have to adopt the disable-library-validation entitlement. Unfortunately, that means your app can now load code signed by anyone, not just the trusted developer you got the library from. To address this problem, you can adopt a library load constraint. Here we show a constraint that will allow you to load any code signed by your team or the trusted library supplier. You could also constrain this further to a specific library or set of libraries using one or more signing-identifier facts. As with launch constraints, library load constraints get signed in to your process when you set the "Library Load Constraint Plist" setting in your Xcode signing configuration. So where are environment constraints available? Library load constraints and launchd plist constraints can be included in apps targeting any macOS version. They are enforced starting in macOS Sonoma. Launch constraints can be added to apps targeting macOS 13.3 or later and they are enforced starting in macOS 13.3. Note the supported set of keys and values that can be used in environment constraints may change across macOS versions. Refer to the documentation for complete availability information. Take a look at the process relationships, launchd plists, and libraries in your app to see if you can use environment constraints to make your app more secure. Thanks for watching. ♪ ♪
// Example constraint