Endpoint Security Framework Bug: setuid Event Incorrectly Attributed to Parent Process During posix_spawn

Feedback ticket ID: FB21797397

Summary

When using posix_spawn() with posix_spawnattr_set_uid_np() to spawn a child process with a different UID, the eslogger incorrectly reports a setuid event as an event originating from the parent process instead of the child process.

Steps to Reproduce

  1. Create a binary that do the following:
    1. Configure posix_spawnattr_t that set the process UIDs to some other user ID (I'll use 501 in this example).
    2. Uses posix_spawn() to spawn a child process
  2. Run eslogger with the event types setuid, fork, exec
  3. Execute the binary as root process using sudo or from root owned shell
  4. Terminate the launched eslogger
  5. Observe the process field in the setuid event

Expected behavior

  • The eslogger will report events indicating a process launch and uid changes so the child process is set to 501. i.e.:
    • fork
    • setuid - Done by child process
    • exec

Actual behavior

The process field in the setuid event is reported as the parent process (that called posix_spawn) - indicating UID change to the parent process.

Attachments

I'm attaching source code for a small project with a 2 binaries:

I'll add the source code for the project at the end of the file + attach filtered eslogger JSONs

  1. One that runs the descirbed posix_spawn flow
  2. One that produces the exact same sequence of events by doing different operation and reaching a different process state:
    1. Parent calls fork()
    2. Parent process calls setuid(501)
    3. Child process calls exec()

Why this is problematic

Both binaries in my attachment do different operations, achieving different process state (1 is parent with UID=0 and child with UID=501 while the other is parent UID=501 and child UID=0), but report the same sequence of events.

Code

#include <cstdio>
#include <spawn.h>

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>

// environ contains the current environment variables
extern char **environ;

extern "C" {
int posix_spawnattr_set_uid_np(posix_spawnattr_t *attr, uid_t uid);
int posix_spawnattr_set_gid_np(posix_spawnattr_t *attr, gid_t gid);
}

int main() {
    pid_t pid;
    int status;
    posix_spawnattr_t attr;

    // 1. Define the executable path and arguments
    const char *path = "/bin/sleep";
    char *const argv[] = {(char *)"sleep", (char *)"1", NULL};

    // 2. Initialize spawn attributes
    if ((status = posix_spawnattr_init(&attr)) != 0) {
        fprintf(stderr, "posix_spawnattr_init: %s\n", strerror(status));
        return EXIT_FAILURE;
    }

    // 3. Set the UID for the child process (e.g., UID 501)
    // Note: Parent must be root to change to a different user
    uid_t target_uid = 501; 
    if ((status = posix_spawnattr_set_uid_np(&attr, target_uid)) != 0) {
        fprintf(stderr, "posix_spawnattr_set_uid_np: %s\n", strerror(status));
        posix_spawnattr_destroy(&attr);
        return EXIT_FAILURE;
    }

    // 4. Spawn the process
    printf("Spawning /bin/sleep 1 as UID %d...\n", target_uid);
    status = posix_spawn(&pid, path, NULL, &attr, argv, environ);

    if (status == 0) {
        printf("Successfully spawned child with PID: %d\n", pid);
        
        // Wait for the child to finish (will take 63 seconds)
        if (waitpid(pid, &status, 0) != -1) {
            printf("Child process exited with status %d\n", WEXITSTATUS(status));
        } else {
            perror("waitpid");
        }
    } else {
        fprintf(stderr, "posix_spawn: %s\n", strerror(status));
    }

    // 5. Clean up
    posix_spawnattr_destroy(&attr);

    return (status == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
}
#include <cstdio>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
#include <string.h>

// This program demonstrates fork + setuid + exec behavior for ES framework bug report
// 1. Parent forks
// 2. Parent does setuid(501) 
// 3. Child waits with sleep syscall
// 4. Child performs exec

int main() {
    printf("Parent PID: %d, UID: %d, EUID: %d\n", getpid(), getuid(), geteuid());
    
    pid_t pid = fork();
    
    if (pid < 0) {
        // Fork failed
        perror("fork");
        return EXIT_FAILURE;
    }
    
    if (pid == 0) {
        // Child process
        printf("Child PID: %d, UID: %d, EUID: %d\n", getpid(), getuid(), geteuid());
        
        // Child waits for a bit with sleep syscall
        printf("Child sleeping for 2 seconds...\n");
        sleep(2);
        
        // Child performs exec
        printf("Child executing child_exec...\n");
        
        // Get the path to child_exec (same directory as this executable)
        char *const argv[] = {(char *)"/bin/sleep", (char *)"2", NULL};
        
        // Try to exec child_exec from current directory first
        execv("/bin/sleep", argv);
        
        // If exec fails
        perror("execv");
        return EXIT_FAILURE;
    } else {
        // Parent process
        printf("Parent forked child with PID: %d\n", pid);
        
        // Parent does setuid(501)
        printf("Parent calling setuid(501)...\n");
        if (setuid(501) != 0) {
            perror("setuid");
            // Continue anyway to observe behavior
        }
        printf("Parent after setuid - UID: %d, EUID: %d\n", getuid(), geteuid());
        
        // Wait for child to finish
        int status;
        if (waitpid(pid, &status, 0) != -1) {
            if (WIFEXITED(status)) {
                printf("Child exited with status %d\n", WEXITSTATUS(status));
            } else if (WIFSIGNALED(status)) {
                printf("Child killed by signal %d\n", WTERMSIG(status));
            }
        } else {
            perror("waitpid");
        }
    }
    
    return EXIT_SUCCESS;
}

Answered by DTS Engineer in 874769022
Feedback ticket ID: FB21797397

Thanks for that. Honestly, I think a bug report is the best path forward for this, and I took quick look and it seems that it’s landed in the right place.

Share and Enjoy

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

Feedback ticket ID: FB21797397

Thanks for that. Honestly, I think a bug report is the best path forward for this, and I took quick look and it seems that it’s landed in the right place.

Share and Enjoy

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

I was curious about this and, with a bit of looking, it's actually fairly easy to explain what's going on. So, getting into things...

When using posix_spawn() with posix_spawnattr_set_uid_np() to spawn a child process with a different UID, the eslogger incorrectly reports a setuid event as an event originating from the parent process instead of the child process.

...the answer here is basically "yes, that's exactly what's going on". That is, ES is saying the parent called setuid() because the parent IS in fact who called setuid. More specifically, ES_EVENT_TYPE_NOTIFY_SETUID is triggered by this MAC check inside the kernel's setuid() function. Critically, the kernel setuid() function is a general-purpose function which allows the setting of an "arbitrary" process ID. The user-space setuid() function works by passing in its own process as the target. However, posix_spawn's implementation is calling it here, inside the kernel as part of the posix_spawn syscall, to set the UID of the process it's creating.

Looking through the MAC check chain, it looks like this (what posix_spawn is doing) is an edge case that simply wasn't considered, as the proc_t (target process) is dropped by the MAC callback which the EndpointSecurity system hooks onto. So, the ES system is giving you the only information it has any access to.

That leads to the final issue, which is that I think this may be a bit of a "which came first” situation (chicken or egg?). All of this is happening inside posix_spawn before it's actually returned... which is, in theory at least, is happening somewhat before the process actually exists. Notably, exec() has NOT yet occurred on the new process, so you'd end receiving a setuid() call from a process you haven't yet approved the creation of. That assumes you get an exec all, as I think it's possible (though probably unlikely) for posix_spawn to fail after it's called setuid() but before it exec's.

Finally, I haven't tested this but I think it might be possible to at least partially differentiate these two cases, at least for the common case. Looking at your two cases, if you could use audit_token_to_ruid on es_message_t.process.audit_token to retrieve the UID of the audit token. Looking at the two cases:

(1)

posix_spawn: "parent with UID=0 and child with UID=501"

audit_token_to_ruid-> 0
es_event_setuid_t.uid-> 501

(2)

fork: "parent UID=501 and child UID=0"

audit_token_to_ruid-> 501
es_event_setuid_t.uid-> 501

One other point here— one thing to note here is that in both cases child's UID never actually changes. That's obvious for #2 (since it never called setuid) but in the case of #1 the child will ALREADY have UID 501 when you get the auth exec call.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Endpoint Security Framework Bug: setuid Event Incorrectly Attributed to Parent Process During posix_spawn
 
 
Q