Poppy: dynamic XPC observability for the PAC era
An open-source macOS toolkit pairing Frida instrumentation, DTrace probes and a small set of custom injectors to produce a unified JSONL trace of XPC daemon behaviour. Designed for the arm64e platform, where Pointer Authentication Codes have made static call graphs unreliable and live observation is no longer optional.
The platform problem
The standard recipe for understanding a macOS daemon used to be straightforward: extract the binary from the dyld shared cache, run it through a disassembler, walk the call graph, identify the XPC handler vtable, follow the dispatch logic. On x86_64 that recipe was sound. On arm64e — the silicon Apple has shipped on consumer hardware since 2020 — it is no longer sufficient.
Pointer Authentication Codes embed a cryptographic signature in the high bits of indirect-branch targets. Signed pointers in live memory do not resemble the unsigned pointers a disassembler shows. Dynamic dispatch through PAC-signed vtables becomes invisible to static analysis — the disassembler sees that a virtual call is happening, but not where it lands. This is a deliberate hardening property, working as designed. It is also a hard problem for static-only research methodology.
Poppy is the practice’s answer to that problem: observe the daemon at runtime, capture what it actually calls, and reconstruct the call graph from runtime evidence.
Capabilities
- XPC handler tracing. Every
xpc_connection_*handler that fires is recorded with the message type, sender PID, and entitlement context at the moment of dispatch. - Message tracing. XPC dictionary contents — payloads, keys, types — are emitted as structured JSONL, subject to configured size limits.
- Entitlement check monitoring. Calls to entitlement-checking APIs are intercepted and logged. The result is the daemon’s effective entitlement surface at runtime, which is almost always a smaller set than the static
codesign -d --entitlementsoutput would suggest. - Controlled fault injection. A corpus of malformed XPC messages is delivered to the target one variant at a time; the daemon’s response is captured. This is the step that turns observation into research.
- Anomaly detection. A downstream analyser walks the JSONL output looking for behaviour that diverges from baseline: handlers that fire only under fault conditions, entitlements consulted only on edge cases, response patterns associated with unusual paths.
- Coverage diffing and entitlement mapping. Two runs can be compared to surface what changed. An entitlement map can be emitted as Markdown for downstream documentation.
A typical workflow
# 1. Trace the daemon under ordinary use
sudo python3 poppy.py run --daemon tipsd --duration 60
# 2. Apply the standard fault-injection corpus
sudo python3 poppy.py inject --daemon tipsd --variants all
# 3. Identify behaviour that deviates from baseline
python3 analysers/anomaly.py runs/poppy_tipsd_*.jsonl
# 4. Produce the effective entitlement map
python3 analysers/entitlement_map.py runs/poppy_*.jsonl --md > entitlements.md
Each step writes timestamped JSONL into runs/. Steps three and four operate over any earlier trace and can be re-run as analysis posture evolves.
Design choices worth flagging
JSONL as the lingua franca. Every component — Frida agent, DTrace probe, Python harness — emits one JSON object per line. Downstream consumption is streaming. The format is deliberately boring: a one-hour trace produces a file that grep handles sensibly, and the analyser pipeline does not need to be Python-aware to participate.
Frida and DTrace, paired. Frida supplies the high-level dispatch view — it can hook the Objective-C runtime, inspect message arguments, modify state. DTrace supplies the system-call view — what the daemon actually asks the kernel for. Neither alone is sufficient; the two streams are merged on timestamp at analysis time.
Root and SIP posture. The toolchain requires root for Frida injection into system daemons. DTrace probes against Apple-signed binaries require SIP to be disabled, or the equivalent nvram boot-args. This is a research-box posture and the documentation is unambiguous about it; Poppy is not designed to run on a workstation.
Optional GUI. A PySide6 dashboard for live trace viewing is provided as an optional dependency. The command line is canonical; the GUI is convenience.
Honest scope
Poppy is early-stage. The repository is small, the test coverage is limited, and the public release lags the practice’s working tree by a non-trivial margin. The core workflow — trace, inject, analyse — is solid in operational use; the rough edges are around configuration, error reporting and the GUI dashboard.
It is published openly because the underlying technique — pairing Frida and DTrace, normalising on JSONL, building a methodology around live observation of XPC behaviour — is worth being part of the public conversation about macOS security research in the PAC era. The exact code is secondary to that methodology.
What it does not do
- Not cross-platform. XPC is Apple-specific; so is almost everything in Poppy.
- Not a bug-finder. Poppy tells you what the daemon does. Judgement on whether that behaviour is incorrect remains the researcher’s.
- Not suitable for a daily-driver Mac. Root, SIP-disabled posture, and active fault injection mean this belongs on a dedicated research machine.
- Fault corpus is intentionally minimal. The shipped malformed-message set covers obvious classes. Serious campaigns will want to grow it.
Engagement
Patches, issues and methodology discussion are welcome through the GitHub project. Commercial support is not offered.