Preface
Apple introduced Swift Package Plugin, a new SPM feature, at WWDC 22 last year. With the Swift Package Plugin, developers can extend the menu items and build process in Xcode to customize and automate some of the development process.
We know that Apple deprecated the previously unconstrained third-party plugin mechanism in Xcode 8 with a new extension mechanism called Xcode Extensions. All extensions run in their own separate processes and cannot tamper with the behavior of the main Xcode program. This is Apple’s ancestral art, and of course greatly improves the security of third-party extensions.
Last year’s Swift Package Plugin gave Xcode a different kind of extension, so I was curious about what the limitations were and what developers could do with it, and this article will discuss the Command Plugin.
First Swift Package Plugin
Project configuration
To add a plugin to an existing package is very simple. First you create a Plugins
directory, create a directory with the same name as the plugin in it, and then you can write the specific code files. At this point the directory structure is as follows.
Then modify Package.swift
and add the following to the targets
array.
This is all configured, wait for the package to resolve again, then you can see our plugin in the project’s right-click menu.
Code writing
Swift Package Plugin is not much different from a normal CLI program, we need to declare entry functions for the plugin. Here we use the module PackagePlugin
and implement the CommandPlugin
protocol, which conforms to the Type-Based Program Entry Points. The code is as follows.
Once the plugin is running, arguments
are the command line arguments that Xcode passes in when it calls us. In context
we get the complete, parsed package information and the working directory where the plugin is currently running. Here we try to write a temporary file to the plugin’s working directory.
After running it, you can see that test.txt
has been created. When we change the target path and write a file to the desktop directory, the plugin will throw an exception.
|
|
At this point, we can actually tentatively conclude that the Swift Package Plugin is running in a sandboxed environment, and that file reading and writing is restricted. I later verified that this is true in Activity Monitor.
Verify the available permissions for the Swift Package plugin
Network access
Use the Network framework to access a local service at localhost:3000
.
|
|
The output POSIXErrorCode: Operation not permitted
, thus verifying that the network cannot be accessed.
Create child processes
Use Process
to run the git
command.
|
|
Output git version 2.36.1
, thus verifying that child processes can be created.
Access to system services
|
|
Simply tested a few basic services and all the above operations failed.
GUI
Start the local calculator app as a derived child process. The calculator process is killed by the SIGILL
signal (crashes at runtime).
The mechanism of macOS Sandbox
sandbox-exec(1)
Similar to iOS, macOS provides process sandboxing support at the kernel level, allowing precise control over the permissions of each sandboxed process (e.g. file access, Mach IPC, and other system calls). Unlike iOS, macOS provides a command line tool sandbox-exec
to expose the sandboxing capabilities to the user. With sandbox-exec
and a profile file describing permissions, we can execute arbitrary processes in a custom sandbox environment.
sandbox-exec
is quite widely used, for example by Bazel to implement sandboxed builds to ensure stability of build products and determinism of input and output dependencies.
Of course, in addition to sandbox-exec
in the user state, we can also use the Sandbox API (sandbox.h
) to perform sandbox-related operations, and sandbox-exec
is also based on the Sandbox API + execvp
in its implementation.
Sandbox Profile
Here we focus on the profile file required by sandbox-exec
. Under the system directory /System/Library/Sandbox/Profiles
you can see many *.sb
files, which are Sandbox profiles.
Let’s check one at random.
|
|
Sandbox Profile is written in the SBPL language, which has a very Lisp-like syntax and is relatively easy to read. The syntax and API of the Sandbox Profile can be found in this PDF, which is a very complete introduction.
The core operations of the Sandbox Profile are allow
and deny
, which are two methods with parameters that are both operations and filters (optional). For example, the statement (allow signal (target self))
means: allow execution of an operation that sends a signal and whose target is itself**. For some strict runtime environments, we can also use (deny default)
to disable all operations and then whitelist the required operations using the allow
method.
We can also use wildcards to control a set of operations, for example the statement (deny file-write*)
will disable all operations prefixed with file-write
.
Process Model
It is worth noting that Sandbox is inherited process-wise, i.e., the parent process passes its Sandbox state to all child processes derived from it. This property is also very well understood. If a process derives a child process that can escape the sandbox, then the parent process is also equivalent to indirectly escaping the sandbox. If so, the parent process controls the child processes outside the sandbox through a pipeline, the sandbox mechanism is completely useless.
In macOS, a sandboxed application can be launched in non-sandboxed mode with open(1)
or NSWorkspace.open(_:)
. This is a deliberate “back door” because Apple understands that this is a manageable situation - after all, as a desktop device, the Mac is more permissive than a mobile device like the iPhone. Does this violate Sandbox’s process model? It doesn’t. open(1)
or other similar ways of launching applications rely on Launch Services, a system service provided by the launchd
process. The application interacts with launchd
through the Mach IPC. The application is eventually launched by launchd
, which “escapes the sandbox” (in fact, in terms of process relationships, this “child process” is parented to launchd
, which does not conflict with the Sandbox process model).
Swift Package Plugin startup process analysis
We now know that the Swift Package Plugin is running in a sandbox environment, but we don’t know its exact profile yet. So here I will extract the Sandbox Profile of the Swift Package Plugin by reverse analyzing its startup process.
The first step is to find an entry point. Since the Xcode code is becoming increasingly large, it is difficult to quickly locate the logic that starts the Swift Package Plugin by static analysis alone, so I am going to use a dynamic analysis approach here. First of all, to start a process, it is usually done through fork
+ exec*
or posix_spawn
system calls. So here we start by intercepting these syscalls with dtrace
.
After some experimentation, I found the
posix_spawn
syscall to be the one used, and I’ll skip the rest of the experimentation here.
|
|
Get the following stack.
|
|
Here we get the launch logic for the Swift Package Plugin, which looks like the upper-level API uses NSTask
. For extracting the Sandbox Profile, we just need to get the startup parameters for sandbox-exec
.
Place a breakpoint in the LLDB.
|
|
Check the running variables after breaking.
|
|
You can see that the plugin’s runtime environment disables all permissions by default, and (import "system.sb")
turns on only a few permissions necessary for system processes, not including the Mach IPC for any file read/write and any namespace, and then adds a few restricted file read/write operations and process operations to make it easier to modify files in the plugin or use subprocesses (like Git, where some operations are file I/O only).
The reason why the above attempt to start the calculator failed was not because it could not spawn a process, but because the calculator process could not create an NSWindow
, a process that requires establishing a CGSConnectionID
with the WindowServer
. Since the plug-in process does not have lookup access to its namespace, it cannot find the Mach Port and communicate with it.
The same is true for other system services that cannot be used. Most of the system services are provided by daemon processes called xxxxxxd
, and clients communicate with the services through the Mach Port to use the capabilities they provide. The system frameworks actually encapsulate these communications into High-Level APIs for developers.
Summary
This article has briefly introduced the Swift Package Plugin and explored what it can do. As you can see, the plugin is still very limited in what it can do due to the limitations of the sandbox environment. But it’s true that this is in line with Apple’s usual style of providing the ability to extend the system or an application in a restricted and controlled environment. Last year iPadOS also got the ability to load a three-way driver, quite unexpectedly, but predictably this driver is also based on DriverKit
s restricted environment, and does not have the ability to interact directly with the kernel.
But I believe that the Swift Package Plugin we saw last year must not be its ultimate form, just like SwiftUI, we can see it become open and flexible little by little.
Ref
https://unixzii.github.io/spm-plugin-n-sandbox/