Skip to content

checkPSelectFlag() reports reverse engineering for any app with a background thread blocked in poll() #159

Description

@zhuyu99

Edited shortly after posting to correct the title and summary and to add measured data.
The original wording said "select()/poll()"; measurement shows the two are not symmetric —
only poll() holds P_SELECT across the blocking wait. Details below.

Summary

ReverseEngineeringToolsChecker.checkPSelectFlag() tests kinfo_proc.kp_proc.p_flag & P_SELECT.

P_SELECT is a process-wide kernel bookkeeping bit. XNU's poll_nocancel() sets it before
registering kevents and clears it only after kqueue_scan() returns — i.e. it stays set for the
entire duration of a blocking poll(). It carries no information about reverse engineering
tooling.

Measured effect: a single background thread parked in poll() makes checkPSelectFlag()
report reverse engineering in 100% of samples, on a clean machine with no instrumentation
attached. The file descriptor does not even have to be a socket — a pipe() that nobody ever
writes to is enough.

Current master: ReverseEngineeringToolsChecker.swift lines 135-151, dispatched at lines 43-44.

What P_SELECT actually means

From XNU bsd/kern/sys_generic.c (apple-oss-distributions/xnu):

poll_nocancel() — flag is held across the whole wait:

1730:  /* JMM - all this P_SELECT stuff is bogus */
1731:  OSBitOrAtomic(P_SELECT, &p->p_flag);
       ... register kevents ...
1826:  error = kqueue_scan(kq, kectx->kec_process_flags, kectx, poll_callback);  // blocks here
       done:
1832:  OSBitAndAtomic(~((uint32_t)P_SELECT), &p->p_flag);

selprocess() — flag is cleared before sleeping, so it is only set during the brief scan phase:

1380:  OSBitOrAtomic(P_SELECT, &p->p_flag);
       ... selscan() ...
1431:  OSBitAndAtomic(~((uint32_t)P_SELECT), &p->p_flag);
1437:  wait_result = waitq_assert_wait64_leeway(...);   // blocks after the flag is already cleared
1444:  error = tsleep1(NULL, PSOCK | PCATCH, "select", 0, selcontinue);

Note the comment the Apple source itself carries at the poll site: /* JMM - all this P_SELECT stuff is bogus */.

Three properties make this unusable as a detection signal:

  1. Process-wide, not thread-local. kinfo_proc.kp_proc describes the whole process, so any
    thread entering poll() sets the bit that the calling thread then reads.
  2. Driven by ordinary I/O waiting, which every networking SDK, push client, reachability monitor
    or long-lived connection does continuously.
  3. Unrelated to the thing being detected. It answers "is a thread currently blocked in poll?",
    not "is this process being reverse engineered?".

For contrast, DebuggerChecker.amIDebugged() in this same library issues the same sysctl call
against the same struct, but reads P_TRACED — a correct and reliable signal. The two differ only
by the bitmask.

Measured reproduction

Self-contained, no dependencies, no jailbreak, no Frida, no debugger, no third-party SDK.
Measured on macOS 26.5.1 / arm64 (same XNU as iOS); the Swift equivalent for iOS follows.

// clang -O0 -o pselect_test pselect_test.c && ./pselect_test <none|poll|select>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <poll.h>
#include <sys/select.h>
#include <sys/sysctl.h>

static int read_p_flag(void) {
    struct kinfo_proc kinfo;
    int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid() };
    size_t size = sizeof(kinfo);
    memset(&kinfo, 0, sizeof(kinfo));
    if (sysctl(mib, 4, &kinfo, &size, NULL, 0) != 0) { perror("sysctl"); exit(1); }
    return kinfo.kp_proc.p_flag;
}

// A thread that does nothing but wait on a pipe nobody ever writes to.
static void *poll_worker(void *arg) {
    (void)arg;
    int fds[2]; if (pipe(fds) != 0) { perror("pipe"); exit(1); }
    struct pollfd pfd = { .fd = fds[0], .events = POLLIN, .revents = 0 };
    for (;;) { poll(&pfd, 1, 100); }
    return NULL;
}

static void *select_worker(void *arg) {
    (void)arg;
    int fds[2]; if (pipe(fds) != 0) { perror("pipe"); exit(1); }
    for (;;) {
        fd_set rfds; FD_ZERO(&rfds); FD_SET(fds[0], &rfds);
        struct timeval tv = { .tv_sec = 0, .tv_usec = 100000 };
        select(fds[0] + 1, &rfds, NULL, NULL, &tv);
    }
    return NULL;
}

int main(int argc, char **argv) {
    const char *mode = (argc > 1) ? argv[1] : "none";
    pthread_t t;
    if (!strcmp(mode, "poll"))   { pthread_create(&t, NULL, poll_worker,   NULL); pthread_detach(t); }
    if (!strcmp(mode, "select")) { pthread_create(&t, NULL, select_worker, NULL); pthread_detach(t); }
    usleep(300000);

    int hits = 0;
    for (int i = 0; i < 200; i++) { if (read_p_flag() & P_SELECT) hits++; usleep(20000); }
    printf("mode=%-7s  P_SELECT observed in %3d/200 samples (%.1f%%)\n", mode, hits, hits / 2.0);
    return 0;
}

Results, three runs per mode:

worker thread run 1 run 2 run 3 rate
none 0/200 0/200 0/200 0%
blocked in poll() 200/200 200/200 200/200 100%
blocked in select() 0/200 0/200 0/200 0%

The poll result is not flaky — it is deterministic. One idle thread waiting on a pipe is enough to
make amIReverseEngineered() return true permanently.

The select() row is the counterpart of the source above and worth noting because the check's name
suggests otherwise: a thread blocked in select() does not keep the bit set, because
selprocess() clears it before sleeping.

iOS equivalent, same structure:

import Darwin
import Foundation
import IOSSecuritySuite

Thread.detachNewThread {
    var fds: [Int32] = [0, 0]
    _ = pipe(&fds)                                    // nothing is ever written
    var pfd = pollfd(fd: fds[0], events: Int16(POLLIN), revents: 0)
    while true { _ = poll(&pfd, 1, 100) }
}

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    var hits = 0
    for _ in 0..<200 {
        let status = IOSSecuritySuite.amIReverseEngineeredWithFailedChecks()
        if status.failedChecks.contains(where: { $0.check == .pSelectFlag }) { hits += 1 }
        usleep(20_000)
    }
    print("pSelectFlag flagged \(hits)/200 samples")
}

This is also a false negative

The converse does not hold either. Frida and comparable tooling use kevent/Mach ports rather than
poll, so an actually-instrumented process is not guaranteed to have P_SELECT set at sample time.
And as measured above, tooling that waits in select() would never set it at all. The check is
unreliable in both directions.

Impact

amIReverseEngineered() combines all four checks into a single Bool, so one unreliable check makes
the whole API unreliable for any app that does background I/O.

amIReverseEngineeredWithFailedChecks() (added in #79 / 5bc05ed, Feb 2023) makes this attributable,
but before that release there was no way for an integrator to tell which check fired — which is
plausibly why this has gone unreported since the check was introduced in 0a4bee8 (Feb 2020).

Some downstream consumers have already removed the check locally rather than report it, e.g.
w3connext/jailbreak_root_detection CHANGELOG 1.0.0: "Remove the function 'checkPSelectFlag' for
the reverse engineering checker"
.

Suggested fix

In rough order of preference:

  1. Remove checkPSelectFlag and the .pSelectFlag case. The signal carries no information about
    reverse engineering, so removing it costs no detection capability. This is what downstream forks
    have converged on. Note that FailedCheck is public, so removing the case is source-breaking —
    option 2 avoids that.
  2. Keep the enum case for source compatibility but make the check a no-op returning (true, ""),
    with a comment explaining why.
  3. Keep it but exclude it from amIReverseEngineered(), so the aggregate Bool stays trustworthy
    while the raw signal remains available through amIReverseEngineeredWithFailedChecks() for anyone
    who wants it, and document the limitation in the README under Experimental features.

Happy to open a PR for whichever option you prefer.

Environment

  • IOSSecuritySuite: current master (same code in 2.2.0)
  • Kernel behaviour measured on macOS 26.5.1, arm64 — XNU shared with iOS

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions