Observe owned Recovery process without a guest SDK or task control rights

This commit is contained in:
dh committed 2026-10-04 19:32:31 +02:00
1 parent 81a3b9c7d2
commit 4b7fd160ef
7 files changed
+374 -11

No files matched your search

@@ -0,0 +1,106 @@
// Disposable pre-SDK observation boundary. No control task port or process writes.
#include <errno.h>
#include <limits.h>
#include <libproc.h>
#include <mach/mach.h>
#include <mach/mach_vm.h>
#include <mach/i386/thread_status.h>
#include <mach-o/dyld_images.h>
#include <stddef.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <unistd.h>
// Private exported BSD API/selector, verified against XNU10063.141.1. Its
// return is BSD int + errno, not a Mach kern_return_t. See manifest sources.
extern int task_read_for_pid(mach_port_name_t, int, mach_port_name_t *) __attribute__((weak_import));
#define READ_ONLY_DARWIN_ROLE 6
static unsigned output_bytes;
static void emit(const char *format, ...) {
char line[256]; va_list args; va_start(args, format);
int length = vsnprintf(line, sizeof line, format, args); va_end(args);
if (length < 0 || length >= (int)sizeof line || output_bytes + (unsigned)length > 32768) exit(70);
output_bytes += (unsigned)length; fwrite(line, 1, (size_t)length, stderr);
}
static void phase(const char *name, const char *point) { emit("[phase] %s %s\n", name, point); }
int main(int argc, char **argv) {
setvbuf(stderr, NULL, _IONBF, 0);
emit("[probe-entry] self=%d architecture=%s snapshot-only=true\n", getpid(),
#if defined(__x86_64__)
"x86_64"
#else
"other"
#endif
);
if (argc != 3) { emit("[invalid-pid] require target and expected-parent decimal PIDs greater than1\n"); return 64; }
long parsed[2];
for (int argument = 1; argument <= 2; ++argument) {
if (!argv[argument][0]) { emit("[invalid-pid] empty PID\n"); return 64; }
for (const char *p = argv[argument]; *p; ++p) if (*p < '0' || *p > '9') { emit("[invalid-pid] decimal digits required\n"); return 64; }
errno = 0; char *end; parsed[argument - 1] = strtol(argv[argument], &end, 10);
if (errno || *end || parsed[argument - 1] <= 1 || parsed[argument - 1] > INT_MAX) { emit("[invalid-pid] PID outside permitted numeric range\n"); return 64; }
}
int pid = (int)parsed[0], expected_parent = (int)parsed[1]; struct proc_bsdinfo bsd = {0};
phase("proc_pidinfo", "before"); errno = 0;
int bytes = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsd, sizeof bsd); int bsd_errno = errno;
emit("[phase] proc_pidinfo after return=%d errno=%d\n", bytes, bsd_errno);
if (bytes != sizeof bsd) { emit("[bsd-unavailable] identity was not established\n"); return 66; }
emit("[bsd] pid=%u ppid=%u flags=0x%x nice=%d status=%u\n", bsd.pbi_pid, bsd.pbi_ppid, bsd.pbi_flags, bsd.pbi_nice, bsd.pbi_status);
if (bsd.pbi_pid != (unsigned)pid || bsd.pbi_ppid != (unsigned)expected_parent) { emit("[ownership-rejected] target=%d expected-parent=%d actual-pid=%u actual-parent=%u\n", pid, expected_parent, bsd.pbi_pid, bsd.pbi_ppid); return 65; }
phase("getpriority-role", "before"); errno = 0;
int role = getpriority(READ_ONLY_DARWIN_ROLE, (id_t)pid); int role_errno = errno;
emit("[role] selector=6 value=%d errno=%d\n", role, role_errno); phase("getpriority-role", "after");
struct proc_taskinfo taskinfo = {0}; phase("proc_pidinfo-task", "before"); errno = 0;
bytes = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskinfo, sizeof taskinfo); int taskinfo_errno = errno;
emit("[phase] proc_pidinfo-task after return=%d errno=%d\n", bytes, taskinfo_errno);
if (bytes == sizeof taskinfo) {
emit("[bsd-task] total-user-raw=%llu total-system-raw=%llu threads-user-raw=%llu threads-system-raw=%llu\n", taskinfo.pti_total_user, taskinfo.pti_total_system, taskinfo.pti_threads_user, taskinfo.pti_threads_system);
emit("[bsd-task] threads=%d running=%d policy=%d priority=%d faults=%d pageins=%d virtual-bytes=%llu resident-bytes=%llu\n", taskinfo.pti_threadnum, taskinfo.pti_numrunning, taskinfo.pti_policy, taskinfo.pti_priority, taskinfo.pti_faults, taskinfo.pti_pageins, taskinfo.pti_virtual_size, taskinfo.pti_resident_size);
}
if (!task_read_for_pid) {
emit("[task-read-unavailable] optional private symbol absent; public BSD snapshot remains observational\n");
emit("[probe-end] snapshot-only=true qualified-readiness=false\n"); return 0;
}
mach_port_t task = MACH_PORT_NULL; phase("task_read_for_pid", "before"); errno = 0;
int read_result = task_read_for_pid(mach_task_self(), pid, &task); int read_errno = errno;
emit("[task-read] return=%d errno=%d port=0x%x\n", read_result, read_errno, task); phase("task_read_for_pid", "after");
if (read_result != 0 || task == MACH_PORT_NULL) {
emit("[mach-unavailable] read-only capability denied; BSD snapshot remains observational\n");
if (task != MACH_PORT_NULL) mach_port_deallocate(mach_task_self(), task);
emit("[probe-end] snapshot-only=true qualified-readiness=false\n");
return 0;
}
emit("[mach-capability] read-only=true; unsuspended snapshots may be incomplete\n");
task_dyld_info_data_t dyld = {0}; mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT;
phase("task_info-dyld", "before"); kern_return_t kr = task_info(task, TASK_DYLD_INFO, (task_info_t)&dyld, &count);
emit("[phase] task_info-dyld after kern=%d count=%u\n", kr, count);
size_t prefix = offsetof(struct dyld_all_image_infos, jitInfo);
if (kr == KERN_SUCCESS && count == TASK_DYLD_INFO_COUNT && dyld.all_image_info_format == TASK_DYLD_ALL_IMAGE_INFO_64 && dyld.all_image_info_size >= prefix) {
struct dyld_all_image_infos info = {0}; mach_vm_size_t received = 0; phase("dyld-prefix-read", "before");
kr = mach_vm_read_overwrite(task, dyld.all_image_info_addr, prefix, (mach_vm_address_t)(uintptr_t)&info, &received);
emit("[phase] dyld-prefix-read after kern=%d bytes=%llu\n", kr, (unsigned long long)received);
if (kr == KERN_SUCCESS && received == prefix) emit("[dyld] version=%u images=%u array=0x%llx libSystemInitialized=%d dyld=0x%llx array-null-is-pending=true\n", info.version, info.infoArrayCount, (unsigned long long)(uintptr_t)info.infoArray, info.version >= 2 ? info.libSystemInitialized : -1, info.version >= 2 ? (unsigned long long)(uintptr_t)info.dyldImageLoadAddress : 0);
}
thread_act_array_t threads = NULL; mach_msg_type_number_t thread_count = 0;
phase("task_threads", "before"); kr = task_threads(task, &threads, &thread_count);
emit("[phase] task_threads after kern=%d count=%u observed-limit=32\n", kr, thread_count);
if (kr == KERN_SUCCESS) {
for (unsigned i = 0; i < thread_count && i < 32; ++i) {
thread_basic_info_data_t basic = {0}; count = THREAD_BASIC_INFO_COUNT; phase("thread_info", "before");
kr = thread_info(threads[i], THREAD_BASIC_INFO, (thread_info_t)&basic, &count);
emit("[phase] thread_info after index=%u kern=%d count=%u\n", i, kr, count);
if (kr == KERN_SUCCESS && count == THREAD_BASIC_INFO_COUNT) emit("[thread-basic] index=%u run-state=%d policy=%d cpu=%d user=%d.%06d system=%d.%06d\n", i, basic.run_state, basic.policy, basic.cpu_usage, basic.user_time.seconds, basic.user_time.microseconds, basic.system_time.seconds, basic.system_time.microseconds);
x86_thread_state64_t registers = {0}; count = x86_THREAD_STATE64_COUNT; phase("thread_get_state-x86_64", "before");
kr = thread_get_state(threads[i], x86_THREAD_STATE64, (thread_state_t)&registers, &count);
emit("[phase] thread_get_state-x86_64 after index=%u kern=%d count=%u\n", i, kr, count);
if (kr == KERN_SUCCESS && count == x86_THREAD_STATE64_COUNT) emit("[thread-registers] index=%u rip=0x%llx rbp=0x%llx rsp=0x%llx\n", i, registers.__rip, registers.__rbp, registers.__rsp);
}
for (unsigned i = 0; i < thread_count; ++i) mach_port_deallocate(mach_task_self(), threads[i]);
vm_deallocate(mach_task_self(), (vm_address_t)threads, thread_count * sizeof *threads);
}
mach_port_deallocate(mach_task_self(), task);
emit("[probe-end] snapshot-only=true qualified-readiness=false\n"); return 0;
}
@@ -0,0 +1,116 @@
#:property PublishAot=false
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
// Offline build/owned-child validation only. No guest, Docker or services.
if (!OperatingSystem.IsMacOS()) throw new InvalidOperationException("The disposable build driver uses the existing Apple SDK on this local host.");
var folder = Path.GetFullPath(args.Single());
var source = Path.Combine(folder, "NativeProcessProbe.c");
var output = Path.Combine(folder, "artifacts"); Directory.CreateDirectory(output);
var sourceHash = Hash(File.ReadAllBytes(source));
var sdk = (await Run("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-path"], "sdk-path")).Stdout.Trim();
var sdkVersion = (await Run("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-version"], "sdk-version")).Stdout.Trim();
var compiler = await Run("/usr/bin/xcrun", ["--sdk", "macosx", "clang", "--version"], "compiler-version");
var binary = Path.Combine(output, "native-process-probe-x86_64");
string[] build = ["--sdk", "macosx", "clang", "-arch", "x86_64", "-mmacosx-version-min=14.0", "-std=c11", "-Os", "-Wall", "-Wextra", "-Werror", source, "-lproc", "-o", binary];
await Run("/usr/bin/xcrun", build, "build-x86_64");
await Run("/usr/bin/codesign", ["--force", "--sign", "-", binary], "adhoc-sign");
await Run("/usr/bin/codesign", ["--verify", "--strict", binary], "signature-verify");
var signature = await Run("/usr/bin/codesign", ["-d", "--verbose=4", "--entitlements", ":-", binary], "signature-details");
var architectures = await Run("/usr/bin/lipo", ["-archs", binary], "architectures");
var imports = await Run("/usr/bin/otool", ["-L", binary], "imports");
var loadCommands = await Run("/usr/bin/otool", ["-l", binary], "load-commands");
var symbols = await Run("/usr/bin/nm", ["-m", "-u", binary], "undefined-symbols");
if (architectures.Stdout.Trim() != "x86_64" || !Regex.IsMatch(loadCommands.Stdout, @"cmd LC_BUILD_VERSION\s+cmdsize [0-9]+\s+platform [0-9]+\s+minos 14\.0\b")) throw new Exception("Actual architecture/minimum OS differs.");
var importedLibraries = imports.Stdout.Split('\n').Skip(1).Where(line => line.Trim().Length != 0).Select(line => line.Trim().Split(' ')[0]).ToArray();
if (importedLibraries.Length != 1 || importedLibraries[0] != "/usr/lib/libSystem.B.dylib") throw new Exception("Probe imports another runtime/framework: " + string.Join(",", importedLibraries));
if (!Regex.IsMatch(symbols.Stdout, @"weak external _task_read_for_pid\b") || symbols.Stdout.Contains("_task_for_pid", StringComparison.Ordinal)
|| new[] { "_task_suspend", "_task_resume", "_thread_suspend", "_thread_resume", "_mach_vm_write" }.Any(symbols.Stdout.Contains)) throw new Exception("Read-only import contract failed.");
if (signature.Stdout.Contains("<dict>", StringComparison.Ordinal) || signature.Stderr.Contains("<dict>", StringComparison.Ordinal)) throw new Exception("Probe must not acquire entitlements.");
var cases = new List<object>(); bool x86Executed = false; string? executionUnavailable = null;
using var target = Process.Start(new ProcessStartInfo("/bin/sleep") { ArgumentList = { "30" }, UseShellExecute = false })!;
try
{
Result positive;
try { positive = await Run(binary, [target.Id.ToString(), Environment.ProcessId.ToString()], "owned-sleep", requireSuccess: false); x86Executed = true; }
catch (System.ComponentModel.Win32Exception exception) { executionUnavailable = exception.Message; positive = new(-1, "", ""); }
if (x86Executed)
{
var evidence = positive.Stdout + positive.Stderr;
if (positive.ExitCode != 0 || !evidence.Contains("[probe-entry]", StringComparison.Ordinal)
|| !Regex.IsMatch(evidence, @"\[bsd\] pid=" + target.Id + " ppid=" + Environment.ProcessId + @" flags=0x[0-9a-f]+ nice=-?[0-9]+ status=[0-9]+")
|| !evidence.Contains("[role] selector=6", StringComparison.Ordinal) || !evidence.Contains("[probe-end] snapshot-only=true qualified-readiness=false", StringComparison.Ordinal)
|| !(Regex.IsMatch(evidence, @"\[task-read\] return=-?[0-9]+ errno=[0-9]+ port=0x[0-9a-f]+") || evidence.Contains("[task-read-unavailable] optional private symbol absent", StringComparison.Ordinal))) throw new Exception("Owned BSD snapshot/read-capability receipt failed: " + evidence);
var read = Regex.Match(evidence, @"\[task-read\] return=(-?[0-9]+) errno=([0-9]+) port=0x([0-9a-f]+)");
cases.Add(new { name = "owned-sleep", success = true, targetPid = target.Id, expectedParentPid = Environment.ProcessId, positive.ExitCode, outputBytes = Encoding.UTF8.GetByteCount(evidence), privateReadSymbolAvailable = read.Success, readReturn = read.Success ? int.Parse(read.Groups[1].Value) : (int?)null, readErrno = read.Success ? int.Parse(read.Groups[2].Value) : (int?)null, readPort = read.Success ? read.Groups[3].Value : null, targetIsApplePlatformBinary = true, targetArchitectureNotAsserted = true, recoveryPermissionProven = false });
var wrongParent = await Run(binary, [target.Id.ToString(), target.Id.ToString()], "owned-sleep-wrong-parent", requireSuccess: false);
var rejected = wrongParent.Stdout + wrongParent.Stderr;
if (wrongParent.ExitCode != 65 || !rejected.Contains("[ownership-rejected]", StringComparison.Ordinal)
|| !Regex.IsMatch(rejected, @"\[bsd\] pid=" + target.Id + " ppid=" + Environment.ProcessId + @"\b")
|| rejected.Contains("getpriority-role", StringComparison.Ordinal) || rejected.Contains("proc_pidinfo-task", StringComparison.Ordinal) || rejected.Contains("task_read_for_pid", StringComparison.Ordinal)) throw new Exception("Wrong parent reached role/task/Mach observation.");
cases.Add(new { name = "owned-sleep-wrong-parent", success = true, targetPid = target.Id, suppliedExpectedParentPid = target.Id, actualParentPid = Environment.ProcessId, wrongParent.ExitCode, furtherReadsReached = false });
var ownedPid = target.Id.ToString(); var parentPid = Environment.ProcessId.ToString();
string[][] invalid = [[], [ownedPid], ["", parentPid], ["0", parentPid], ["1", parentPid], ["-1", parentPid], ["+2", parentPid], ["2x", parentPid], [" 2", parentPid], ["999999999999999999999999", parentPid],
[ownedPid, ""], [ownedPid, "0"], [ownedPid, "1"], [ownedPid, "-1"], [ownedPid, "+2"], [ownedPid, "2x"], [ownedPid, " 2"], [ownedPid, "999999999999999999999999"], [ownedPid, parentPid, "extra"]];
for (var i = 0; i < invalid.Length; i++)
{
var result = await Run(binary, invalid[i], "invalid-pid-" + i, requireSuccess: false);
var text = result.Stdout + result.Stderr;
if (result.ExitCode != 64 || !text.Contains("[probe-entry]", StringComparison.Ordinal) || !text.Contains("[invalid-pid]", StringComparison.Ordinal) || text.Contains("proc_pidinfo", StringComparison.Ordinal)) throw new Exception("Invalid PID reached observation.");
cases.Add(new { name = "invalid-pid-" + i, success = true, result.ExitCode, nativeProcessObserved = false });
}
if (target.HasExited) throw new Exception("Own sleep exited unexpectedly during the snapshot.");
}
}
finally
{
if (!target.HasExited) target.Kill();
await target.WaitForExitAsync();
}
if (!target.HasExited) throw new Exception("Owned local child was not cleaned up.");
var manifest = new
{
success = x86Executed, buildVerified = true, selftestPassed = x86Executed, purpose = "Disposable offline native read-only process diagnostic; no CI runner requirement", sourcePath = source, sourceSha256 = sourceHash,
driverSha256 = Hash(File.ReadAllBytes(Path.Combine(folder, "ProbeDriver.cs"))), binaryPath = binary, binarySha256 = Hash(File.ReadAllBytes(binary)),
hostArchitecture = RuntimeInformation.OSArchitecture.ToString(), compiler = compiler.Stdout.Trim(), sdk, sdkVersion, minimumMacOS = "14.0", architecture = "x86_64", compilerArguments = build,
importedLibraries, signature = "ad-hoc; no entitlements", outputMaximumBytes = 32768, threadObservationMaximum = 32, frameWalkUsed = false, imageArrayReadUsed = false,
readOnlyTaskPortOnly = true, taskReadWeakImportVerified = true, taskReadReturnContract = "BSD int/errno", darwinRolePrioritySelector = 6, targetAndExpectedParentMandatory = true, bsdIdentityRequiredBeforeFurtherReads = true, snapshotIsReadiness = false, qualifiedReadiness = false,
x86Executed, executionUnavailable, localOwnedChildCleanedUp = target.HasExited, selftests = cases, guestExecuted = false, dockerExecuted = false, recoveryPermissionsProven = false,
primarySources = new[] { "https://github.com/apple-oss-distributions/xnu/blob/xnu-10063.141.1/bsd/kern/kern_resource.c#L691-L721", "https://github.com/apple-oss-distributions/xnu/blob/xnu-10063.141.1/bsd/sys/resource.h", "https://raw.githubusercontent.com/apple-oss-distributions/xnu/xnu-10063.141.1/bsd/vm/vm_unix.c", "https://raw.githubusercontent.com/apple-oss-distributions/xnu/main/bsd/kern/syscalls.master" },
abiSourceIsExactGuestBinary = false, actualSdkExport = Path.Combine(sdk, "usr/lib/system/libsystem_kernel.tbd"), completedUtc = DateTimeOffset.UtcNow
};
File.WriteAllText(Path.Combine(output, "manifest.json"), JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine(JsonSerializer.Serialize(manifest));
async Task<Result> Run(string executable, string[] arguments, string label, bool requireSuccess = true)
{
using var process = new Process { StartInfo = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false } };
foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument);
process.Start();
async Task<string> Read(StreamReader reader)
{
var text = new StringBuilder(); var buffer = new char[2048];
while (await reader.ReadAsync(buffer) is var count && count != 0)
{
text.Append(buffer, 0, count);
if (Encoding.UTF8.GetByteCount(text.ToString()) > 32768) { if (!process.HasExited) process.Kill(); throw new Exception("Disposable probe/tool output exceeded32KiB."); }
}
return text.ToString();
}
var stdout = Read(process.StandardOutput); var stderr = Read(process.StandardError);
using var bound = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try { await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(bound.Token)); }
catch { if (!process.HasExited) process.Kill(); await process.WaitForExitAsync(); throw; }
var result = new Result(process.ExitCode, await stdout, await stderr);
File.WriteAllText(Path.Combine(output, label + ".stdout.log"), result.Stdout);
File.WriteAllText(Path.Combine(output, label + ".stderr.log"), result.Stderr);
if (requireSuccess && result.ExitCode != 0) throw new Exception(label + " failed: " + result.Stderr);
return result;
}
static string Hash(byte[] value) => Convert.ToHexStringLower(SHA256.HashData(value));
sealed record Result(int ExitCode, string Stdout, string Stderr);
@@ -0,0 +1,26 @@
{
"purpose": "Disposable prebuilt diagnostic; no macOS compiler or runner is required by CI",
"sourceSha256": "38acf83b05694f9931c41ff1749e9b6b836f04c740b7f1a1c60e32332678cb1f",
"driverSha256": "d9d87415c12398f29b35697109f18d9b16f121dae969a4a05d0ec6a8cb874d25",
"binarySha256": "b8d54e2945eeefb3e0c22468feb7aef7efa50813909058b1e4b40144dd7e3d3e",
"compiler": "Apple clang 21.0.0 (clang-2100.3.34.2)",
"sdkVersion": "27.0",
"minimumMacOS": "14.0",
"architecture": "x86_64",
"importedLibraries": ["/usr/lib/libSystem.B.dylib"],
"signature": "ad-hoc",
"entitlements": false,
"offlineVerified": true,
"targetAndExpectedParentMandatory": true,
"readOnlyTaskPortOnly": true,
"taskReadWeakImportVerified": true,
"taskReadReturnContract": "BSD int/errno",
"darwinRolePrioritySelector": 6,
"outputMaximumBytes": 32768,
"threadObservationMaximum": 32,
"frameWalkUsed": false,
"imageArrayReadUsed": false,
"localPlatformReadPortDenied": true,
"recoveryPermissionsProven": false,
"qualifiedReadiness": false
}
Binary file not shown.