mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +00:00
* fix(process): re-read unreadable tokens before signaling and name the process Darwin KERN_PROCARGS2 samples a process's argument length and stack top before it reads the address space, and exec publishes the new length before it copies the new strings out (xnu sysctl_procargsx, exec_copyout_strings). A read during exec can return a zero-filled or stale strings area, and exec keeps the PID and start time that bracket the read. The final sweep already re-read such tokens, but the pre-signal group check read each member's token once, so one transient read of a live group member failed the whole cleanup. Move the bounded re-read into one shared fact-reading helper and use it for both the sweep and the pre-signal group check. The group check binds re-reads to the generation it first observed, so a reused PID stays unresolved; the sweep keeps classifying the PID's current process. An unreadable-token failure now carries the native identity read after the token and the summary names the candidate by its native name, parent, group and start identity. The Darwin helper and Linux procfs reader report the short native process name (p_comm/comm) for display. Fixes #8337 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(process): refresh other pre-signal members after token re-read waits The pre-signal group check re-read only members whose token was unavailable, so a readable member kept the relation it had before the retry waits. A member that moved to another process group during those waits could be validated on its old group, and the old group signaled while the moved member kept running. After any retry wait, the shared re-read helper now reads every other live pre-signal fact once more with the same generation binding. A moved member then takes the existing changed-group path, which retains its ownership evidence and fails cleanup. An inconclusive refresh replaces the stale fact so it fails closed. Nothing changes when no re-read ran, and the sweep keeps classifying current processes because every group it finds is revalidated before signaling. Also cover PID reuse that happens before the re-read starts, where both re-read identities agree and only the first-read generation rejects the replacement's token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(process): keep known identity changes final across token re-reads The pre-signal re-read treated a first read without a same-generation identity as unbound and replaced it with the re-read. On Linux the first read can already establish PID reuse (an ESRCH token whose exit check sees another generation); the re-read then classified the replacement, so a zombie replacement could let cleanup succeed. A pre-signal check now re-reads only same-generation facts. Changed, exited, exiting and foreign-token facts are final. Each attempt also re-reads the group's owned members instead of one separate refresh after the loop, so a member that moves groups or turns unreadable while another waits is seen within the same bound. The caller's run token selects the owned members, and the sweep keeps classifying current processes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
15 KiB
Swift
435 lines
15 KiB
Swift
import Darwin
|
|
import Foundation
|
|
|
|
struct RunTokenObservation: Encodable {
|
|
let pid: Int32
|
|
let state: String
|
|
let run_id: String?
|
|
let reason: String?
|
|
}
|
|
|
|
struct ProcessIdentityObservation: Encodable {
|
|
let pid: Int32
|
|
let state: String
|
|
let identity: String?
|
|
let effective_uid: UInt32?
|
|
let lifecycle: String?
|
|
let relation: ProcessRelationObservation?
|
|
/** Display-only kernel accounting name (p_comm, at most MAXCOMLEN bytes). */
|
|
let name: String?
|
|
let reason: String?
|
|
}
|
|
|
|
struct ProcessRelationObservation: Encodable {
|
|
let state: String
|
|
let parent_pid: Int32
|
|
let process_group_id: Int32
|
|
}
|
|
|
|
struct ProcessMetadata {
|
|
let identity: String
|
|
let effectiveUid: UInt32
|
|
let lifecycle: String
|
|
let parentPid: Int32
|
|
let processGroupId: Int32
|
|
let pointerSize: Int
|
|
let name: String
|
|
}
|
|
|
|
struct ProcessSnapshot {
|
|
let metadata: [Int32: ProcessMetadata]
|
|
let failure: String?
|
|
}
|
|
|
|
typealias ProcessTableSysctlCall = (
|
|
_ mib: inout [Int32],
|
|
_ output: UnsafeMutableRawPointer?,
|
|
_ size: inout Int
|
|
) -> Int32
|
|
|
|
private func readProcessSnapshot() -> ProcessSnapshot {
|
|
readProcessSnapshot { mib, output, size in
|
|
sysctl(&mib, u_int(mib.count), output, &size, nil, 0)
|
|
}
|
|
}
|
|
|
|
func readProcessSnapshot(
|
|
sysctlCall: ProcessTableSysctlCall
|
|
) -> ProcessSnapshot {
|
|
for _ in 0..<3 {
|
|
var mib = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
|
|
var size = 0
|
|
guard sysctlCall(&mib, nil, &size) == 0 else {
|
|
let failure = errno
|
|
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(failure)")
|
|
}
|
|
guard size >= MemoryLayout<kinfo_proc>.size,
|
|
size % MemoryLayout<kinfo_proc>.stride == 0 else {
|
|
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(errno)")
|
|
}
|
|
var processes = [kinfo_proc](
|
|
repeating: kinfo_proc(),
|
|
count: size / MemoryLayout<kinfo_proc>.stride
|
|
)
|
|
let result = processes.withUnsafeMutableBufferPointer { buffer in
|
|
let output = buffer.baseAddress.map { UnsafeMutableRawPointer($0) }
|
|
return sysctlCall(&mib, output, &size)
|
|
}
|
|
guard result == 0 else {
|
|
let failure = errno
|
|
if failure == ENOMEM { continue }
|
|
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(failure)")
|
|
}
|
|
guard size % MemoryLayout<kinfo_proc>.stride == 0 else {
|
|
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(errno)")
|
|
}
|
|
let processCount = size / MemoryLayout<kinfo_proc>.stride
|
|
let metadata = Dictionary(
|
|
processes.prefix(processCount).map { process in
|
|
let start = process.kp_proc.p_starttime
|
|
return (
|
|
process.kp_proc.p_pid,
|
|
ProcessMetadata(
|
|
identity: "\(start.tv_sec):\(start.tv_usec)",
|
|
effectiveUid: process.kp_eproc.e_ucred.cr_uid,
|
|
lifecycle: process.kp_proc.p_stat == SZOMB ? "zombie" : "non-zombie",
|
|
parentPid: process.kp_eproc.e_ppid,
|
|
processGroupId: process.kp_eproc.e_pgid,
|
|
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4,
|
|
name: processName(process)
|
|
)
|
|
)
|
|
},
|
|
uniquingKeysWith: { first, _ in first }
|
|
)
|
|
return ProcessSnapshot(metadata: metadata, failure: nil)
|
|
}
|
|
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(ENOMEM)")
|
|
}
|
|
|
|
/** Decode the NUL-terminated accounting name; invalid UTF-8 is replaced for display. */
|
|
private func processName(_ process: kinfo_proc) -> String {
|
|
withUnsafeBytes(of: process.kp_proc.p_comm) { bytes in
|
|
String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
|
|
}
|
|
}
|
|
|
|
func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] {
|
|
readProcessIdentities(pids: pids, snapshot: readProcessSnapshot())
|
|
}
|
|
|
|
func readProcessIdentities(
|
|
pids: [Int32],
|
|
snapshot: ProcessSnapshot
|
|
) -> [ProcessIdentityObservation] {
|
|
return pids.map { pid in
|
|
guard pid > 0 else {
|
|
return unavailableIdentity(pid: pid, reason: "invalid_process_id")
|
|
}
|
|
guard let metadata = snapshot.metadata[pid] else {
|
|
if let failure = snapshot.failure {
|
|
return unavailableIdentity(pid: pid, reason: failure)
|
|
}
|
|
// This records absence from this successful KERN_PROC_ALL snapshot only.
|
|
// Unlike the Linux procfs path, it has no PID-specific kill(0)
|
|
// corroboration; callers must not treat it as universal or future exit
|
|
// proof.
|
|
return ProcessIdentityObservation(
|
|
pid: pid,
|
|
state: "absent",
|
|
identity: nil,
|
|
effective_uid: nil,
|
|
lifecycle: nil,
|
|
relation: nil,
|
|
name: nil,
|
|
reason: "not-in-snapshot"
|
|
)
|
|
}
|
|
return ProcessIdentityObservation(
|
|
pid: pid,
|
|
state: "readable",
|
|
identity: metadata.identity,
|
|
effective_uid: metadata.effectiveUid,
|
|
lifecycle: metadata.lifecycle,
|
|
relation: ProcessRelationObservation(
|
|
state: "observed",
|
|
parent_pid: metadata.parentPid,
|
|
process_group_id: metadata.processGroupId
|
|
),
|
|
name: metadata.name,
|
|
reason: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityObservation {
|
|
ProcessIdentityObservation(
|
|
pid: pid,
|
|
state: "unavailable",
|
|
identity: nil,
|
|
effective_uid: nil,
|
|
lifecycle: nil,
|
|
relation: nil,
|
|
name: nil,
|
|
reason: reason
|
|
)
|
|
}
|
|
|
|
enum ProcargsMalformedStage: String, CaseIterable {
|
|
case header
|
|
case argumentCount = "argument_count"
|
|
case executablePath = "executable_path"
|
|
case pointerSize = "pointer_size"
|
|
case pathPadding = "path_padding"
|
|
case arguments
|
|
case terminator
|
|
case trailingEntries = "trailing_entries"
|
|
}
|
|
|
|
enum RunTokenReadError: Error {
|
|
case invalidBuffer
|
|
case invalidBufferAt(ProcargsMalformedStage)
|
|
case duplicateToken
|
|
case invalidTokenEncoding
|
|
case environmentUnavailable
|
|
case appleVectorUnavailable
|
|
case ambiguousEnvironmentBoundary
|
|
}
|
|
|
|
struct ParsedRunToken {
|
|
let runId: String?
|
|
let environmentEntryCount: Int
|
|
}
|
|
|
|
@_silgen_name("csops")
|
|
private func csops(
|
|
_ pid: Int32,
|
|
_ ops: UInt32,
|
|
_ addr: UnsafeMutableRawPointer?,
|
|
_ size: Int
|
|
) -> Int32
|
|
|
|
private let csOpsStatus: UInt32 = 0
|
|
private let csPlatformBinary: UInt32 = 0x0400_0000
|
|
private let csRestrict: UInt32 = 0x0000_0800
|
|
|
|
/** Read the target's current code-signing flags when csops permits inspection. */
|
|
private func codeSigningFlags(pid: Int32) -> UInt32? {
|
|
var flags: UInt32 = 0
|
|
let result = withUnsafeMutablePointer(to: &flags) { pointer in
|
|
csops(pid, csOpsStatus, UnsafeMutableRawPointer(pointer), MemoryLayout<UInt32>.size)
|
|
}
|
|
return result == 0 ? flags : nil
|
|
}
|
|
|
|
/** Derive one process-token observation from parser facts and native flags. */
|
|
func processRunTokenObservation(
|
|
pid: Int32,
|
|
parse: Result<ParsedRunToken, RunTokenReadError>,
|
|
codeSigningFlags: () -> UInt32?
|
|
) -> RunTokenObservation {
|
|
switch parse {
|
|
case .success(let parsed):
|
|
if let runId = parsed.runId {
|
|
return RunTokenObservation(pid: pid, state: "readable", run_id: runId, reason: nil)
|
|
}
|
|
// A non-empty environment without REA's token proves this candidate is
|
|
// unrelated. CS_RESTRICT cannot turn that readable fact into uncertainty.
|
|
if parsed.environmentEntryCount > 0 {
|
|
return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil)
|
|
}
|
|
let flags = codeSigningFlags()
|
|
if let flags, (flags & csPlatformBinary) != 0 {
|
|
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
|
|
}
|
|
if let flags, (flags & csRestrict) != 0 {
|
|
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
|
|
}
|
|
return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil)
|
|
case .failure(let failure):
|
|
switch failure {
|
|
case .environmentUnavailable:
|
|
let flags = codeSigningFlags()
|
|
if let flags, (flags & csPlatformBinary) != 0 {
|
|
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
|
|
}
|
|
if let flags, (flags & csRestrict) != 0 {
|
|
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
|
|
}
|
|
return unavailable(pid: pid, reason: "environment_unavailable")
|
|
case .ambiguousEnvironmentBoundary:
|
|
if let flags = codeSigningFlags(), (flags & csRestrict) != 0 {
|
|
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
|
|
}
|
|
return unavailable(pid: pid, reason: "ambiguous_environment_boundary")
|
|
case .duplicateToken:
|
|
return unavailable(pid: pid, reason: "duplicate_run_token")
|
|
case .invalidTokenEncoding:
|
|
return unavailable(pid: pid, reason: "invalid_run_token_encoding")
|
|
case .appleVectorUnavailable:
|
|
return unavailable(pid: pid, reason: "apple_vector_unavailable")
|
|
case .invalidBuffer:
|
|
return unavailable(pid: pid, reason: "malformed_procargs")
|
|
case .invalidBufferAt(let stage):
|
|
return unavailable(pid: pid, reason: "malformed_procargs_\(stage.rawValue)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func readRunToken(pid: Int32, pointerSize: Int) -> RunTokenObservation {
|
|
var mib = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
|
|
var size = 0
|
|
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size >= MemoryLayout<Int32>.size else {
|
|
return unavailable(pid: pid, reason: "sysctl_failed_\(errno)")
|
|
}
|
|
var bytes = [UInt8](repeating: 0, count: size)
|
|
let result = bytes.withUnsafeMutableBytes { buffer in
|
|
sysctl(&mib, u_int(mib.count), buffer.baseAddress, &size, nil, 0)
|
|
}
|
|
guard result == 0 else {
|
|
return unavailable(pid: pid, reason: "sysctl_failed_\(errno)")
|
|
}
|
|
bytes = Array(bytes.prefix(size))
|
|
let parse: Result<ParsedRunToken, RunTokenReadError>
|
|
do {
|
|
parse = .success(try parseProcessEnvironment(in: bytes, pointerSize: pointerSize))
|
|
} catch let failure as RunTokenReadError {
|
|
parse = .failure(failure)
|
|
} catch {
|
|
parse = .failure(.invalidBuffer)
|
|
}
|
|
return processRunTokenObservation(
|
|
pid: pid,
|
|
parse: parse,
|
|
codeSigningFlags: { codeSigningFlags(pid: pid) }
|
|
)
|
|
}
|
|
|
|
func readRunTokens(pids: [Int32]) -> [RunTokenObservation] {
|
|
let before = readProcessSnapshot()
|
|
let observations = pids.map { pid -> RunTokenObservation in
|
|
guard let metadata = before.metadata[pid] else {
|
|
return unavailable(pid: pid, reason: before.failure ?? "process_not_in_snapshot")
|
|
}
|
|
return readRunToken(pid: pid, pointerSize: metadata.pointerSize)
|
|
}
|
|
let after = readProcessSnapshot()
|
|
return zip(pids, observations).map { pid, observation in
|
|
guard let initial = before.metadata[pid], let current = after.metadata[pid] else {
|
|
return unavailable(pid: pid, reason: after.failure ?? "process_not_in_snapshot")
|
|
}
|
|
guard initial.identity == current.identity,
|
|
initial.pointerSize == current.pointerSize else {
|
|
return unavailable(pid: pid, reason: "process_identity_changed_during_token_read")
|
|
}
|
|
return observation
|
|
}
|
|
}
|
|
|
|
func parseProcessEnvironment(
|
|
in bytes: [UInt8],
|
|
pointerSize: Int = MemoryLayout<UnsafeRawPointer>.size
|
|
) throws -> ParsedRunToken {
|
|
guard bytes.count >= MemoryLayout<Int32>.size else { throw RunTokenReadError.invalidBufferAt(.header) }
|
|
let argc = bytes.withUnsafeBytes { $0.loadUnaligned(as: Int32.self).littleEndian }
|
|
guard argc >= 0, Int(argc) <= bytes.count else { throw RunTokenReadError.invalidBufferAt(.argumentCount) }
|
|
var offset = MemoryLayout<Int32>.size
|
|
guard let executablePath = readString(in: bytes, offset: &offset) else {
|
|
throw RunTokenReadError.invalidBufferAt(.executablePath)
|
|
}
|
|
guard pointerSize == 4 || pointerSize == 8 else { throw RunTokenReadError.invalidBufferAt(.pointerSize) }
|
|
let pathRecordSize = Array("executable_path=".utf8).count + executablePath.count + 1
|
|
let padding = (pointerSize - pathRecordSize % pointerSize) % pointerSize
|
|
guard offset + padding <= bytes.count,
|
|
bytes[offset..<(offset + padding)].allSatisfy({ $0 == 0 }) else {
|
|
throw RunTokenReadError.invalidBufferAt(.pathPadding)
|
|
}
|
|
offset += padding
|
|
let tokenPrefix = Array("REA_PROCESS_RUN_ID=".utf8)
|
|
var tokenInArgument = false
|
|
for _ in 0..<argc {
|
|
guard let argument = readString(in: bytes, offset: &offset, allowEmpty: true) else {
|
|
throw RunTokenReadError.invalidBufferAt(.arguments)
|
|
}
|
|
tokenInArgument = tokenInArgument || argument.starts(with: tokenPrefix)
|
|
}
|
|
guard bytes.last == 0 else { throw RunTokenReadError.invalidBufferAt(.terminator) }
|
|
if offset == bytes.count {
|
|
throw RunTokenReadError.environmentUnavailable
|
|
}
|
|
var trailingEntries: [ArraySlice<UInt8>] = []
|
|
var zeroedAppleVectorStart: Int?
|
|
while offset < bytes.count {
|
|
if bytes[offset] == 0 {
|
|
let start = offset
|
|
while offset < bytes.count, bytes[offset] == 0 {
|
|
offset += 1
|
|
}
|
|
if offset - start > 1 {
|
|
zeroedAppleVectorStart = zeroedAppleVectorStart ?? trailingEntries.count
|
|
}
|
|
continue
|
|
}
|
|
guard let entry = readString(in: bytes, offset: &offset) else {
|
|
throw RunTokenReadError.invalidBufferAt(.trailingEntries)
|
|
}
|
|
trailingEntries.append(entry)
|
|
}
|
|
// XNU appends its Apple vector after the environment. libc clears the
|
|
// initial `pfz=` record after startup, leaving a multi-NUL run in its place.
|
|
// A caller can also set `pfz`, so prefer the final intact sentinel when it
|
|
// remains available and otherwise stop at the cleared record.
|
|
let intactAppleVectorStart = trailingEntries.lastIndex(where: {
|
|
$0.starts(with: Array("pfz=".utf8))
|
|
})
|
|
let preferZeroedBoundary = zeroedAppleVectorStart.flatMap { zeroedStart in
|
|
intactAppleVectorStart.map { intactStart in intactStart < zeroedStart } ?? true
|
|
} ?? false
|
|
let appleVectorStart = preferZeroedBoundary
|
|
? zeroedAppleVectorStart
|
|
: intactAppleVectorStart
|
|
guard let appleVectorStart else {
|
|
throw RunTokenReadError.appleVectorUnavailable
|
|
}
|
|
if trailingEntries[appleVectorStart...].contains(where: {
|
|
$0.starts(with: tokenPrefix)
|
|
}) {
|
|
throw RunTokenReadError.ambiguousEnvironmentBoundary
|
|
}
|
|
let environmentEntries = trailingEntries[..<appleVectorStart]
|
|
var found: String?
|
|
for entry in environmentEntries {
|
|
if entry.starts(with: tokenPrefix) {
|
|
guard found == nil else { throw RunTokenReadError.duplicateToken }
|
|
let value = entry.dropFirst(tokenPrefix.count)
|
|
guard let decoded = String(bytes: value, encoding: .utf8) else {
|
|
throw RunTokenReadError.invalidTokenEncoding
|
|
}
|
|
found = decoded
|
|
}
|
|
}
|
|
// A changed process title can overwrite the original argument boundaries,
|
|
// causing a real environment entry to be consumed as an argument. A real
|
|
// argument can have this form too, so it proves neither ownership nor absence.
|
|
if tokenInArgument {
|
|
throw RunTokenReadError.ambiguousEnvironmentBoundary
|
|
}
|
|
return ParsedRunToken(runId: found, environmentEntryCount: environmentEntries.count)
|
|
}
|
|
|
|
private func readString(
|
|
in bytes: [UInt8],
|
|
offset: inout Int,
|
|
allowEmpty: Bool = false
|
|
) -> ArraySlice<UInt8>? {
|
|
guard offset < bytes.count, let end = bytes[offset...].firstIndex(of: 0),
|
|
allowEmpty || end > offset else { return nil }
|
|
let string = bytes[offset..<end]
|
|
offset = end + 1
|
|
return string
|
|
}
|
|
|
|
private func unavailable(pid: Int32, reason: String) -> RunTokenObservation {
|
|
RunTokenObservation(pid: pid, state: "unavailable", run_id: nil, reason: reason)
|
|
}
|