CVE-2023-28197: inputcontrol

As September 2022 drew to a close, I set myself a challenge: find the most hilariously mundane way to somehow gain access to user data via some form of purely logical means. Specifically, to do such within the macOS Sandbox - although I hold experience with it as a developer, how is it truly implemented?

A month later, I created a chain of three sandbox-related vulnerabilities with the end result of obtaining the user's messages and attachments. I'm dubbing it "inputcontrol" because it's just as nonsense a name as some of the other titles for CVEs out there. As of writing, it is fixed in all of the latest macOS releases (macOS Sonoma 14.2 and macOS Ventura 13.6).

This blog post is somewhat needlessly long, and is largely an infodump. It primarily aims to detail some thoughts about how I went about this in hopes that others may be able to learn about some key parts of macOS. If you're familiar with specific components, feel free to skip over their "Overview" segments.

A brief overview of the sandbox

The macOS Sandbox is the much of the basis of security guarantees from the operating system. Its feature set involves regulating file operations, network access, XPC/Mach lookups (think cross-app communication), and so on. The rulesets specifying these restrictions are referred to as sandbox "profiles".

At runtime, certain applications can vend sandbox "extensions" (also known as "tokens") to permit extending accessible paths, services, etc. For example, when a user opens a file, the sandboxed application is issued a sandbox extension to permit reading (or writing) the user's chosen file - and no further.

Unlike its fellow platforms, macOS has a substantially different security model than other Apple platforms. Compared to iOS, the App Sandbox is opt-in1, and far more flexible (for example, with temporary exceptions). Its sandbox profiles additionally ship with their original source instead of in a pre-compiled state2. They're spread across two directories:

Typically, every first-party daemon has its own profile - either named after itself, or its bundle ID - to fine-tune restrictions. For us mere third-party developers, the generic sandbox profile named container is applied.

To begin my search, I initially went to manually sift through all of the source for profiles present. This took a while - it helped to create a checklist and take notes whilst analyzing daemons. It has proven quite valuable to keep a running documentation of possible daemon functionality, required entitlements, etc., and it additionally was a valuable learning experience on how many subsystems functioned.

InputMethodKit

InputMethodKit.framework alone probably deserves its own article3. I cannot professionally iterate how much grief it gave me, so I will not.

Perhaps the best summary of this framework is provided by Apple:

The Input Method Kit, introduced in OS X v10.5, provides a streamlined programming interface that lets you develop input methods with far less code than older Mac programming interfaces. It is fully integrated with the Text Services Manager. The Input Method Kit allows 32-bit applications to work with 64-bit applications.

In other words, this framework has largely gone untouched since its introduction in Mac OS X Leopard (10.5). No macOS version has (officially) supported 32-bit applications since macOS Catalina (10.15), and Apple Silicon Macs physically lacks AArch32. The "Text Services Manager" they refer to is part of their legacy, pre-Cocoa UI toolkit, Carbon.

Despite Carbon being "removed", it still very much lives on within Carbon.framework, HIToolbox.framework, and friends. (In fact, with macOS Sonoma's release, InputMethodKit is exempt from the new native AppKit menus, verses the legacy Carbon based menus.)

History aside, we can find Mach services related to it within the default container.sb profile:

(allow mach-lookup
  ;; [...]
  (global-name "com.apple.inputmethodkit.getxpcendpoint")
  (global-name "com.apple.inputmethodkit.launchagent")
  (global-name "com.apple.inputmethodkit.launcher")
  (global-name "com.apple.inputmethodkit.setxpcendpoint")
  ;; [...]
)

These were unknown to me at the time. Per /System/Library/xpc/launchd.plist, these service names are provided /System/Library/Frameworks/InputMethodKit.framework/Resources/imklaunchagent.

Under modern macOS, services are commonly XPC-based - either via the "lower-level" C APIs, such as xpc_connection_activate, or the higher-level, object-oriented NSXPCConnection. However, to my surprise, com.apple.inputmethodkit.launcher instead utilized CFMessagePort. Knowing its Leopard-era origin helps to explain the usage of CFMessagePort somewhat, as XPC began heavy adoption with its introduction in OS X Lion (10.7).

The applications and services providing text input logic are referred to as "input methods". If a picture is worth a thousand words, then here's 3,000:

A screenshot of the accents menu over TextEdit, obtained by holding down the 'a' key.
InputMethodKit is the framework providing accent selection...
A screenshot of the dictation menu over TextEdit.
...helping Dictation with its menu...
A screenshot of the facemarks candidate panel menu within TextEdit. Currently selected is a smiley face, written as ^_^.
...and providing the candidate selection menu for input methods.

To roughly sum up its relevant launch agent functionality, the application requiring input acts as a client, connecting to the input method via NSXPCConnection. To assist with this, Apple manages launching the application providing the selected input method via imklaunchagent, who relays an available connection to it. With the introduction of the sandbox in OS X Lion (10.7), functionality to to vend a Mach extension for a given input method was introduced.

Example

To understand what its communication looks like, consider Dictation (whose codename is ironwood). A given client may send a message (ID 8000) to imklaunchagent with the following XML property list:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>kInputMethodBundleIdentifierKey</key>
    <string>com.apple.inputmethod.ironwood</string>
    <key>kInputMethodExecutablePathKey</key>
    <string>/System/Library/Input Methods/DictationIM.app</string>
    <key>kInputMethodIsNSExtensionKey</key>
    <false/>
    <key>kInputMethodNeedSandboxExtensionKey</key>
    <true/>
</dict>
</plist>

It's important to note that, contrary to the key's name, we specify the bundle path instead of its executable path. For NSExtension-based input methods, this is the .appex typically within plug-ins.

The server then responds with something along the following4:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>kInputMethodLaunchStatusKey</key>
    <integer>0</integer>
    <key>kInputMethodSandboxTokenKey</key>
    <string>[...];com.apple.app-sandbox.mach;DictationInputMethod_1_Connection</string>
</dict>
</plist>

Vulnerability

Look closely at that sandbox token: given that we specified an entirely separate bundle ID, why was the Mach service extension issued for DictationInputMethod_1_Connection? Let's review Dictation's Info.plist:

<!-- [...] -->
<key>CFBundleIdentifier</key>
<string>com.apple.inputmethod.ironwood</string>
<key>InputMethodConnectionName</key>
<string>DictationInputMethod_1_Connection</string>
<!-- [...] -->

Ah - we control the name on the vended Mach extension via InputMethodConnectionName. We can forge our own bundle on disk - preserving the bundle identifier of a registered input method - and modify its value:

<key>kInputMethodSandboxTokenKey</key>
<string>[...];com.apple.app-sandbox.mach;com.apple.hello-world-from-spotlight</string>

Unlike WebKit and other daemons who have a custom extension "class" for their Mach service extensions, we're issued the standard class of com.apple.app-sandbox.mach. As such, we have a way to obtain an extension for any arbitrary Mach service name. That was... alarmingly simple.

SceneKit and COLLADA

Continuing my exploration through public frameworks, I stumbled across an XPC service utilized by SceneKit named C3DColladaResourcesCoordinator. As its name suggests, it assists SceneKit in obtaining access to image assets referenced by COLLADA files (an XML-based 3D model specification).

Unlike imklaunchagent's services, it is not actually permitted by sandbox rules: instead, the service is looked up ahead of time, and attached to your application's possible services by default (more specifically, attached as a service stub in its domain).

Despite only having a brief experience with the framework, I found it to be alarmingly unreliable. SceneKit continuously produced invalid COLLADA files when attempting to export a scene, and simply crashed when importing via Xcode. When attempting to create a model, it... also crashed.

Instead of a demo, please observe it failing to load a lightly-textured Blender cube within Quick Look:

A panel with Quick Look. A thumbnail shows the author's fursona overlayed across a three-dimensional cube. Text at the top informs the user that Quick Look could not load the scene.
The "Snoot cube", affectionately referred to as the Snoobe (fig. 1). Artwork by sirbarkalot.

Overview

Let's consider a possible COLLADA model present at /Users/spot/Desktop/snoobe.dae:

<?xml version="1.0" encoding="utf-8"?>
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <library_images>
    <image id="surface_texture" name="surface_texture">
      <init_from>surface_texture.png</init_from>
    </image>
  </library_images>
  <!-- ...actual model definitions, etc... -->
</COLLADA>

In order to facilitate loading the referenced PNG, SceneKit connects to com.apple.SceneKit.C3DColladaResourcesCoordinator using the C-style XPC APIs. Specifically, it leverages the private CoreFoundation function CFXPCCreateXPCObjectFromCFObject to convert a CFDictionary to an XPC object.

If we were to represent the resulting XPC object structure as JSON, it might look similar to the following:

{
  // A mandatory underlying wrapper.
  "kC3DColladaResourcesServiceRequestArgumentsKey": {
    // An array of possible asset URLs in CFURL form.
    // Here, we only request the parent directory - the user's Desktop folder.
    //
    // Due to CFURL lacking a direct XPC representation,
    // this is actually a special-cased dictionary.
    // (See `CFURLObject` within inputcontrol's source.)
    "kC3DColladaResourcesCoordinatorRequestAssetDirectoryURLsKey": [
      "file:///Users/spot/Desktop/"
    ],
    
    // A sandbox extension to access the .dae in question (on our desktop). 
    "kC3DColladaResourcesCoordinatorRequestExtensionKey": "[...];com.apple.app-sandbox.read;[...]/Users/spot/Desktop/snoobe.dae",

    // The CFURL path to the requested .dae directly.
    "kC3DColladaResourcesCoordinatorRequestURLKey": "file:///Users/spot/Desktop/snoobe.dae",
  }
}

Internally, a few things happen:

  1. The coordinator ensures you have access to the .dae.
  2. It passes that to its sibling "extractor" service, com.apple.SceneKit.C3DColladaResourcesExtractor.
  3. The extractor service parses the XML, and obtains two things:
    1. images registered under library_images
    2. shaders under library_effects
  4. Extracted assets are passed to the third "checker" service, com.apple.SceneKit.C3DColladaResourcesChecker.
  5. The checker service then:
    1. validates that all specified images are images via CGImageSourceCreateWithURL
    2. ...silently ignores all shaders5
  6. All valid results from the checker are then issued read-only sandbox extensions, and sent back to the client.

The service might respond to our application with something similar to the following:

{
  "kC3DColladaResourcesServiceReplyArgumentsKey": {
    "kC3DColladaResourcesCoordinatorReplyExtensionsKey": [
      {
        // The CFURL path to the actual asset.
        "url": "file:///Users/spot/Desktop/surface_texture.png",
        // Only possible to be "image", as "shader" is filtered out.
        "type": "image",
        // A consumable extension.
        "extension": "[...];com.apple.app-sandbox.read;[...]/Users/spot/Desktop/surface_texture.png"
      }
    ]
  },
  "kC3DColladaResourcesServiceReplyReturnCodeKey": 0
}

Vulnerability

Note that the user specifies the location of the assets directory (kC3DColladaResourcesCoordinatorRequestAssetDirectoryURLsKey) when requesting assets from a COLLADA model.

No access is validated for loaded assets: only the loaded .dae. Thus, we can specify a base asset directory URL of /, and provide an exact path to an image. For example, the following COLLADA model can be leveraged:

<?xml version="1.0"?>
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
  <library_images>
    <image id="technique" name="technique">
      <init_from>/literal/path/to/image/of/any.png</init_from>
    </image>
  </library_images>
  <scene>
    <instance_visual_scene url="#Scene"/>
  </scene>
</COLLADA>

This grants us read-only access to any image, but given that it is validated, any specified path must be a genuine image. This is annoyingly restrictive, given as we cannot readily guess the paths within the user's Photos Library and so on/so forth.

IMTransferAgent

Given that we now have a way to use any system XPC service we desire from within the sandbox, I expanded my search into private frameworks.

Overview

When you send attachments over iMessage, they're uploaded to a service titled "MMCS" - presumably "MobileMe Chunk Storage". To accomplish this, clients reach out to IMTransferAgent to send files around.

As a reminder, iMessage operates over Apple Push Notification service (APNs). Here, the term topic refers to the push notification topic. Per the entitlements on IMTransferAgent, there are 88 possible topics as of macOS Sonoma 14.0 beta 1 (23A5257q), covering a variety of usages from iMessage itself to Safari's Shared Group Tabs, Remote AirDrop, and etc6.

Let's take a look at an example of a request from a client to upload a file, representing the XPC dictionary as JSON once more:

{
  "transferURL": "/private/var/tmp/[...]/example.png",
  // A UUID of the transfer, used to refer to multiple going on asynchronously.
  "transferUUID": "123e4567-e89b-12d3-a456-426614174000",
  // The APNs topic.
  // There are several unit testing/example related topics configured, per entitlements.
  // We'll use this one as it's explicitly a test.
  "topic": "com.apple.private.alloy.test1",
  // Not certain how this field is utilized, but com.apple.MobileSMS works.
  "sourceAppID": "com.apple.MobileSMS",
  "isSend": true,
  // Encrypts the file prior to upload.
  //
  // Note that in order to encrypt, IMTransferAgent must have read/write access
  // to the directory the uploaded file is within, as it creates temporary files.
  // As it does not have r/w access to the user's Desktop (or similar locations),
  // this must be false when used.
  "encryptFile": true
}

Throughout upload, the XPC service asynchronously sends upload progress. Once successful, we then receive a response:

{
  // The uploaded file's URL.
  "requestURLString": "https://pXX-content.icloud.com/[...]",
  "additionalErrorInfo": "",
  // Generated for us, not supplied.
  "encryptionKey": /* data */,
  "ownerID": "",
  "fileSize": 1326785,
  "success": true,
  "signature": /* data */
}

Fairly straightforward. For a client to begin a download, the request is quite similar. (I will leave this as an exercise to the reader.)

Vulnerability

The majority of other agents within the IM family - IMDPersistenceAgent, IMTranscoderAgent, so forth - immediately require entitlements when connecting to their XPC service. To quite some surprise, there was no entitlement required to connect to IMTransferAgent. (More on that later.)

Given that this agent is the backbone of many of Apple's services, I presumed there would be very little wiggle room within its sandbox. My initial thought was to check the daemon's entitlements for possible exceptions:

<key>com.apple.security.exception.files.absolute-path.read-write</key>
<array>
  <string>/private/var/tmp/</string>
</array>

Unfortunately, this was ultimately a red herring: underneath macOS, the entitlement is com.apple.security.temporary-exception.files.absolute-path.read-write (emphasis on temporary-exception). The standalone exception variant appears to be used under iOS and derivatives. An emoji entitled 'blob cat scared'.

Continuing, I next reviewed its sandbox profile. It's understandably restrictive, and there are indeed only a select amount of locations it can read:

(allow file*
  (subpath temp-directory)
  (home-subpath "/Library/Messages")
  (darwin-user-root-subpath "/T/com.apple.imagent")
  (darwin-user-root-subpath "/T/com.apple.identityservicesd")
  (subpath "/private/var/tmp/com.apple.messages")
)

Oh, right - this is iMessage, after all! We can just... upload any of the user's text messages, attachments, [...] from within ~/Library/Messages.

In the above excerpt, darwin-user-root-subpath is not a standard operation. At the top of its sandbox profile, we see that it imports com.apple.iMessage.shared.sb, hinting it is the the parent directory of getconf DARWIN_USER_TEMP_DIR (i.e. /private/var/folders/XX/[...]).

In fact, this shared profile has a very insightful note:

;; TODO: For sharing from other apps via Messages, ensure they send us sandbox extensions instead of their suffixed darwin-user directories.
;; Until then, allow read access for the current darwin user directory. See: rdar://problem/55724745

Hm.. it has read-only access to our temporary directory. Now, we only need to find a location that both IMTransferAgent and our application have read-write access to.

Game plan

Realistically, if we were outside of the sandbox (like most applications are), we could just use IMTransferAgent directly and read from its own temporary directory. However, we're not. This complicates things: what directories do IMTransferAgent and our sandboxed client application both have read/write access to?

After struggling to find one without numerous workarounds, a solution dawned: Given that we are able to grant ourselves access to any image, we can have IMTransferAgent download an image to a location we know for certain, and subsequently gain access.

Our resulting chain looks like the following:

And it... works! Indeed, we could have somehow sent the uploaded attachment URL to another machine and avoided the need for COLLADA, but we didn't.

Note that we're restricted to the same limitations as iMessage - there is a hardcoded 100 MiB maximum on all files sent. ~/Library/Messages/chat.db can easily exceed such - my own certainly does, residing at about 200 MiB as of writing.

However, given that this is SQLite3, one can easily scrape pending writes via chat.db-wal and the like. These would easily remnants of the user's current messages, and provide paths to images and other attachments within that could subsequently be uploaded and obtained. Additionally, other files within ~/Library/Messages (such as various nickname caches) can house known associates/contacts for a given user.

Timeline

This report was submitted on November 20, 2022 to Apple's Security Bounty program. You can find the attached proof-of-concept on my GitHub repository "inputcontrol".

Upon submission to Apple:

It was silently appended to the macOS Ventura 13.3 security advisory on October 31, 2023. Apple informed me that some changes to IMTransferAgent were held off until 14.1, though I was unable to ascertain their contents.

Conclusion

Its resolution was not enough to sate my curiosity - given how trivial many of these issues are, I wanted to know the timeframe for their introduction.

To some surprise, these issues had been present for quite some time: imklaunchagent was vulnerable since day one with the release of OS X Lion (10.7, 2011). Similarly, SceneKit's C3DColladaResourcesCoordinator was introduced in its vulnerable state with the release of OS X Mountain Lion (10.8, 2012).

Perhaps most bafflingly is, with its introduction in 10.8, IMTransferAgent was introduced with the exact entitlement that was reintroduced in macOS Sonoma. At some point, it disappeared in OS X Yosemite (10.10, 2014), and... never returned. I am not certain as to why.

In other words, these three issues have been present for half the time I've walked foot on this planet. It's clear that the model of seperating functionality and sandboxing accordingly is effective, as it prevented immediately performing worse. As an industry, here's to hoping we continue this migration. 🎉

It's been about a year since I reported this, and the past year has contained an insane number of highs and lows. Closing out this year, it feels like I'm at a baseline.

Thank you to SW, LM, JS, DJ, AS, SR, NT, and countless many other wonderful people. I would not be here without you.


Footnotes

1

Like its fellow iOS derivatives, launchd does support setting a profile on launch in some ways: i.e. XPC services can specify _SandboxProfile within their bundle's Info.plist. However, at least as of macOS Ventura and macOS Sonoma, the majority of first-party daemons initialize the sandbox themselves before doing any further work... assuming they do such whatsoever 😰

2

This is not to say it does not: like the rest of its family, macOS ships with a "collection" of compiled profiles within Sandbox.kext. However, these are limited in number and primarily deal with things such as the older "Seatbelt" profiles from 10.5 (think kSBXProfilePureComputation). That's out of scope of this article though - there are infinitely better resources a search away!

3

Initially, I started writing an article exclusively over InputMethodKit and paused because of how frustrating testing was. Most of the neat stuff is behind private APIs. Honestly, it was easier to treat the entire thing as a private API. Maybe some day it will emerge from the drafts.

4

For NSExtension-based input methods, the response status will always be -50. Frustratingly, any failure is also response status -50. Good luck!

When experimenting, I discovered that the literal string .inputmethod. is required to be in your bundle identifier, otherwise InputMethodKit.framework refuses to process launching your app. This is entirely undocumented. At one point, I stumbled across a GitHub issue where someone complained directly to an engineer at WWDC and received no decent resolution.

Thankfully, there is reasonably verbose logging within imklaunchagent and InputMethodKit.framework - the preference key IMKLaunchAgentGeneralDebugging goes a long way! Good luck ;)

5

Somewhat surprisingly, I was unable to find an example of an external shader being utilized within a COLLADA model on GitHub, but... surely this probably ruined someone's day. This limitation seems to go undocumented.

6

When you open a .watchface file under macOS, an application effectively texts the watch face's file (using IMTransferAgent!) to all Apple Watches registered on your Apple ID via topic com.apple.private.alloy.clockface.sharing. Its codename is Greenfield, and the daemon has several references to soccer. This has no bearing to the rest of the blog post - I thought it was equal parts cute, and an excellent example of how Apple seems to focus on needlessly small parts.