SecCodeCreateWithXPCMessage: sender identity across concurrent receives, exec, and deferred replies

I am evaluating a local macOS C XPC design. The target under consideration is macOS 27.0.1, build 26A434, on Apple silicon. This is a source-design question; I have not run a reproducer or established that the public source below matches that OS build.

The public SecCode.h comment describes SecCodeCreateWithXPCMessage as obtaining a code reference for the sender of the supplied XPC message using associated audit identity. The implementation at Apple Security commit 97c3a4296c1ea06b0fe1877a7e616aa84450b5b2 obtains the dictionary's remote connection, takes that connection's audit token, and uses it for code lookup:

https://github.com/apple-oss-distributions/Security/blob/97c3a4296c1ea06b0fe1877a7e616aa84450b5b2/OSX/libsecurity_codesigning/lib/SecCode.h

https://github.com/apple-oss-distributions/Security/blob/97c3a4296c1ea06b0fe1877a7e616aa84450b5b2/OSX/libsecurity_codesigning/lib/SecCode.cpp

Function anchor in both files: SecCodeCreateWithXPCMessage. I am treating this as a question about the supported API contract, not evidence of a vulnerability or proof of installed implementation.

For an actual received C XPC dictionary M, retained for deferred processing, I need to understand the supported identity and reply guarantees:

  1. Does SecCodeCreateWithXPCMessage(M, ...) resolve audit identity fixed to M's original sender occurrence, or can the associated connection's identity at lookup time differ? In particular, what is guaranteed if another message arrives or processing occurs concurrently on another queue?
  2. If the sending process performs exec before the lookup, or after lookup but before a reply, what does the resulting SecCode reference identify? Is there a documented supported way to distinguish the original message-sending occurrence and executable from a later executable or replacement peer without using PID-based reconstruction or private XPC APIs?
  3. If I retain M and later use its supported C reply context, does the reply remain addressed to that same original sending occurrence? What are the documented effects of connection invalidation, exec, forwarding/delegation of relevant rights and outstanding aliases? I am not assuming that retaining M or the returned SecCode reference locks peer identity or revokes other holders.
  4. If these APIs do not jointly provide those guarantees, which public API and ownership pattern should a design use to validate the actual eventual reply recipient before sending sensitive data? A statement that a guarantee is unavailable or requires a different design would also help. Please distinguish C XPC behavior from Swift XPCReceivedMessage; I am not assuming an undocumented conversion between them.

Could you point to the applicable documentation and supported macOS versions, and clarify whether the cited public source revision is representative of the target? If exact-build behavior cannot be established publicly, please identify that limitation.

This question concerns transport identity and context lifetime only. A valid code identity would still need separate application authorization; I am not asking XPC to issue role, run or resource authority. I would prefer a documented contract over an inference from a successful test. I can prepare a separately scoped minimal example if one is necessary, but no executable example is included in this question.

Answered by DTS Engineer in 911976022

So, lemme share some factoids that I think it’s safe to rely on:

  • XPC is based on top of Mach messaging [1].
  • The audit token associated with an XPC message comes from the Mach message trailer.
  • Which is set by the kernel when the client process sends the message.
  • The audit token includes the pid (hence audit_token_to_pid), so calling fork (or posix_spawn) changes that.
  • The audit token includes the pid version (audit_token_to_pidversion) and, less obviously [2], calling exec* changes that.

Given all of the above, I think you can answer your own question here (-: but if you have follow-up questions feel free to reply with them here.

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Actually, there are variants of XPC that don’t use Mach messaging. However, these are not API, and if they ever do become API:

  • I suspect they’ll be an opt in.
  • But, regardless, the subtle details of audit tokens will be the least of your concerns (-:

[2] Consider this program:

#include <mach/mach.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <bsm/libbsm.h>
#include <mach-o/dyld.h>

int main(int argc, char **argv) {
    #pragma unused(argc)
    #pragma unused(argv)
    mach_msg_type_number_t count = TASK_AUDIT_TOKEN_COUNT;
    audit_token_t token = {};
    kern_return_t kr = task_info(mach_task_self(), TASK_AUDIT_TOKEN, (task_info_t) &token, &count);
    if ( (kr != KERN_SUCCESS) || (count != TASK_AUDIT_TOKEN_COUNT) ) { abort(); }
    char toolPath[PATH_MAX] = {};
    uint32_t toolPathSize = sizeof(toolPath);
    int ok = _NSGetExecutablePath(toolPath, &toolPathSize) >= 0;
    if (!ok) { abort(); }
    char * toolName = strrchr(toolPath, '/');
    if (toolName == NULL) {
        toolName = toolPath;
    } else {
        toolName = toolName + 1;
    }
    fprintf(stdout, "%s:\n", toolName);
    fprintf(stdout, "  pid: %d\n", (int) getpid());
    fprintf(stdout, "  audit token:");
    for (size_t i = 0; i < sizeof(token); i++) {
        uint8_t b = ((const uint8_t *) &token)[i];
        fprintf(stdout, "%02x", b);
    }
    fprintf(stdout, "\n");
    fprintf(stdout, "    pid: %d\n", (int) audit_token_to_pid(token));
    fprintf(stdout, "    pidversion: %d\n", (int) audit_token_to_pidversion(token));
    fprintf(stdout, "    asid: %d\n", (int) audit_token_to_asid(token));
    fprintf(stdout, "    auid: %d\n", (int) audit_token_to_auid(token));
    fprintf(stdout, "    egid: %d\n", (int) audit_token_to_egid(token));
    fprintf(stdout, "    euid: %d\n", (int) audit_token_to_euid(token));
    fprintf(stdout, "    rgid: %d\n", (int) audit_token_to_rgid(token));
    fprintf(stdout, "    ruid: %d\n", (int) audit_token_to_ruid(token));
    fflush(stdout);
    
    if (strcmp(toolName, "replacement") == 0) {
        return EXIT_SUCCESS;
    }
    char replacementPath[PATH_MAX] = {};
    strlcpy(replacementPath, toolPath, sizeof(replacementPath));
    char * slash = strrchr(replacementPath, '/');
    if (slash == NULL) { abort(); }
    slash[1] = 0;
    strlcat(replacementPath, "replacement", sizeof(replacementPath));
    // fprintf(stdout, "%s\n", replacementPath);
    char * replacementArgs[] = {
        replacementPath,
        NULL,
    };
    execv(replacementPath, replacementArgs);
    abort();
}

I built this into two different tools, original and replacement, and then ran the original tool:

% ./original
original:
  pid: 15579
  audit token: f6010000f601000014000000f601000014000000db3c0000b68601000cb30000
    pid: 15579
    pidversion: 45836
    asid: 100022
    auid: 502
    egid: 20
    euid: 502
    rgid: 20
    ruid: 502
replacement:
  pid: 15579
  audit token: f6010000f601000014000000f601000014000000db3c0000b68601000db30000
    pid: 15579
    pidversion: 45837
    asid: 100022
    auid: 502
    egid: 20
    euid: 502
    rgid: 20
    ruid: 502

Note that everything in the audit token stays the same except the pid version.

This is was using Xcode 27.1 or macOS 26.7.1, but AFAICT this behaviour has been consistent across all versions of macOS with audit token support.

So, lemme share some factoids that I think it’s safe to rely on:

  • XPC is based on top of Mach messaging [1].
  • The audit token associated with an XPC message comes from the Mach message trailer.
  • Which is set by the kernel when the client process sends the message.
  • The audit token includes the pid (hence audit_token_to_pid), so calling fork (or posix_spawn) changes that.
  • The audit token includes the pid version (audit_token_to_pidversion) and, less obviously [2], calling exec* changes that.

Given all of the above, I think you can answer your own question here (-: but if you have follow-up questions feel free to reply with them here.

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Actually, there are variants of XPC that don’t use Mach messaging. However, these are not API, and if they ever do become API:

  • I suspect they’ll be an opt in.
  • But, regardless, the subtle details of audit tokens will be the least of your concerns (-:

[2] Consider this program:

#include <mach/mach.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <bsm/libbsm.h>
#include <mach-o/dyld.h>

int main(int argc, char **argv) {
    #pragma unused(argc)
    #pragma unused(argv)
    mach_msg_type_number_t count = TASK_AUDIT_TOKEN_COUNT;
    audit_token_t token = {};
    kern_return_t kr = task_info(mach_task_self(), TASK_AUDIT_TOKEN, (task_info_t) &token, &count);
    if ( (kr != KERN_SUCCESS) || (count != TASK_AUDIT_TOKEN_COUNT) ) { abort(); }
    char toolPath[PATH_MAX] = {};
    uint32_t toolPathSize = sizeof(toolPath);
    int ok = _NSGetExecutablePath(toolPath, &toolPathSize) >= 0;
    if (!ok) { abort(); }
    char * toolName = strrchr(toolPath, '/');
    if (toolName == NULL) {
        toolName = toolPath;
    } else {
        toolName = toolName + 1;
    }
    fprintf(stdout, "%s:\n", toolName);
    fprintf(stdout, "  pid: %d\n", (int) getpid());
    fprintf(stdout, "  audit token:");
    for (size_t i = 0; i < sizeof(token); i++) {
        uint8_t b = ((const uint8_t *) &token)[i];
        fprintf(stdout, "%02x", b);
    }
    fprintf(stdout, "\n");
    fprintf(stdout, "    pid: %d\n", (int) audit_token_to_pid(token));
    fprintf(stdout, "    pidversion: %d\n", (int) audit_token_to_pidversion(token));
    fprintf(stdout, "    asid: %d\n", (int) audit_token_to_asid(token));
    fprintf(stdout, "    auid: %d\n", (int) audit_token_to_auid(token));
    fprintf(stdout, "    egid: %d\n", (int) audit_token_to_egid(token));
    fprintf(stdout, "    euid: %d\n", (int) audit_token_to_euid(token));
    fprintf(stdout, "    rgid: %d\n", (int) audit_token_to_rgid(token));
    fprintf(stdout, "    ruid: %d\n", (int) audit_token_to_ruid(token));
    fflush(stdout);
    
    if (strcmp(toolName, "replacement") == 0) {
        return EXIT_SUCCESS;
    }
    char replacementPath[PATH_MAX] = {};
    strlcpy(replacementPath, toolPath, sizeof(replacementPath));
    char * slash = strrchr(replacementPath, '/');
    if (slash == NULL) { abort(); }
    slash[1] = 0;
    strlcat(replacementPath, "replacement", sizeof(replacementPath));
    // fprintf(stdout, "%s\n", replacementPath);
    char * replacementArgs[] = {
        replacementPath,
        NULL,
    };
    execv(replacementPath, replacementArgs);
    abort();
}

I built this into two different tools, original and replacement, and then ran the original tool:

% ./original
original:
  pid: 15579
  audit token: f6010000f601000014000000f601000014000000db3c0000b68601000cb30000
    pid: 15579
    pidversion: 45836
    asid: 100022
    auid: 502
    egid: 20
    euid: 502
    rgid: 20
    ruid: 502
replacement:
  pid: 15579
  audit token: f6010000f601000014000000f601000014000000db3c0000b68601000db30000
    pid: 15579
    pidversion: 45837
    asid: 100022
    auid: 502
    egid: 20
    euid: 502
    rgid: 20
    ruid: 502

Note that everything in the audit token stays the same except the pid version.

This is was using Xcode 27.1 or macOS 26.7.1, but AFAICT this behaviour has been consistent across all versions of macOS with audit token support.

Thanks, Quinn. That clarifies the kernel-supplied audit token and the PID-version change across exec.

Two points remain unclear:

  1. For a retained received dictionary M, does SecCodeCreateWithXPCMessage(M, …) use identity fixed to that message’s original sender, even after concurrent receives or sender exec? The cited implementation’s connection-token lookup is what prompted this question.

  2. Does retaining M’s reply context bind delivery to that original sending occurrence, or could a later executable or another rights holder receive the reply? If it doesn’t provide that guarantee, what supported public API and ownership pattern should validate the eventual recipient before sensitive data is sent?

Please identify any supported-version limits.