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:
- 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.
- Driven by ordinary I/O waiting, which every networking SDK, push client, reachability monitor
or long-lived connection does continuously.
- 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:
- 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.
- Keep the enum case for source compatibility but make the check a no-op returning
(true, ""),
with a comment explaining why.
- 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
Summary
ReverseEngineeringToolsChecker.checkPSelectFlag()testskinfo_proc.kp_proc.p_flag & P_SELECT.P_SELECTis a process-wide kernel bookkeeping bit. XNU'spoll_nocancel()sets it beforeregistering kevents and clears it only after
kqueue_scan()returns — i.e. it stays set for theentire duration of a blocking
poll(). It carries no information about reverse engineeringtooling.
Measured effect: a single background thread parked in
poll()makescheckPSelectFlag()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 everwrites to is enough.
Current master:
ReverseEngineeringToolsChecker.swiftlines 135-151, dispatched at lines 43-44.What
P_SELECTactually meansFrom XNU
bsd/kern/sys_generic.c(apple-oss-distributions/xnu):poll_nocancel()— flag is held across the whole wait:selprocess()— flag is cleared before sleeping, so it is only set during the brief scan phase:Note the comment the Apple source itself carries at the
pollsite:/* JMM - all this P_SELECT stuff is bogus */.Three properties make this unusable as a detection signal:
kinfo_proc.kp_procdescribes the whole process, so anythread entering
poll()sets the bit that the calling thread then reads.or long-lived connection does continuously.
not "is this process being reverse engineered?".
For contrast,
DebuggerChecker.amIDebugged()in this same library issues the samesysctlcallagainst the same struct, but reads
P_TRACED— a correct and reliable signal. The two differ onlyby 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.
Results, three runs per mode:
poll()select()The
pollresult is not flaky — it is deterministic. One idle thread waiting on a pipe is enough tomake
amIReverseEngineered()returntruepermanently.The
select()row is the counterpart of the source above and worth noting because the check's namesuggests otherwise: a thread blocked in
select()does not keep the bit set, becauseselprocess()clears it before sleeping.iOS equivalent, same structure:
This is also a false negative
The converse does not hold either. Frida and comparable tooling use
kevent/Mach ports rather thanpoll, so an actually-instrumented process is not guaranteed to haveP_SELECTset at sample time.And as measured above, tooling that waits in
select()would never set it at all. The check isunreliable in both directions.
Impact
amIReverseEngineered()combines all four checks into a singleBool, so one unreliable check makesthe 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_detectionCHANGELOG 1.0.0: "Remove the function 'checkPSelectFlag' forthe reverse engineering checker".
Suggested fix
In rough order of preference:
checkPSelectFlagand the.pSelectFlagcase. The signal carries no information aboutreverse engineering, so removing it costs no detection capability. This is what downstream forks
have converged on. Note that
FailedCheckispublic, so removing the case is source-breaking —option 2 avoids that.
(true, ""),with a comment explaining why.
amIReverseEngineered(), so the aggregateBoolstays trustworthywhile the raw signal remains available through
amIReverseEngineeredWithFailedChecks()for anyonewho wants it, and document the limitation in the README under Experimental features.
Happy to open a PR for whichever option you prefer.
Environment
master(same code in 2.2.0)