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.

Written by full_sphere in 912097022

1- For a retained received dictionary M, does SecCodeCreateWithXPCMessage(M, …) use identity fixed to that message’s original sender … ?

Yes. This resulting code object is created from the audit token taken from the XPC message.

Written by full_sphere in 912097022

2- Does retaining M’s reply context bind delivery to that original sending occurrence … ?

I don’t know for sure. In a pure Mach messaging world this depends on the reply port, which could potentially be moved to a different process. However, I think XPC keeps you safe from this, based on the discussion in Conforming to Mach IPC security restrictions.

Regardless, I’m not really sure about where you’re going with this. Who are you trying to protect yourself from? An external attacker? Or a the client process itself? Because:

  • If an external attacker has the ability to monkey with reply ports, you have bigger concerns.
  • If you’re worried about the client process, it doesn’t need to mess with reply ports because it can just get the content of the reply and leak that.

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

Thanks, Quinn. The concern is the service sending a delayed reply after the recipient no longer matches its authorization decision.

The proposed sequence is:

  1. A local client sends a request.
  2. The service checks that message’s sender identity and separately decides what that client is allowed to receive.
  3. The service retains the message while it prepares a reply.
  4. Before delivery, the client may exec a different executable, or the reply right may have moved to another process.

I agree that an authorized client can copy or leak information after receiving it. This design cannot prevent that. The narrower goal is to avoid delivering information to a different executable or process that was never authorized to receive it.

Your sender-identity clarification answers the first part. I also read Conforming to Mach IPC security restrictions. It recommends XPC to mitigate reply interception, but I could not establish the delayed-delivery boundary above from that article alone.

For supported C XPC, does retaining the received message’s reply context keep delivery bound to the original authorized process and executable occurrence? If it does not, is there a supported way to check the actual recipient before sending, or should the design avoid putting sensitive data in a delayed reply when that identity cannot be established?

This remains a design question. I am not claiming a reproduced vulnerability.

So, as I mentioned above, I think that XPC protects you from this based on that doc. Regardless, I think that this that both of these are overkill:

Written by full_sphere in 912919022

is there a supported way to check the actual recipient before sending, or should the design avoid putting sensitive data in a delayed reply when that identity cannot be established?

This is the point I was making in my previous reply:

  • If the requester is under attack and the attacker can mess with reply ports then you have bigger problems.
  • If not, meaning that your trusted the requester in your initial assessment, then there’s no need to worry about it becoming untrustworthy between when you did that initial assessment and when you reply to its request.

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