Files

244 lines
12 KiB
Markdown

# The host binary split: vphone-cli / vphone-vm
> 2026-09-23, branch `vphone-intg-update`.
> Supersedes the single-executable layout.
>
> **Corrected later the same day.** This document originally argued the split
> alongside `vphone-letmein`, a helper that opened a global AMFI window for the
> length of one exec. The split is unchanged and still right. The helper is
> removed: it cannot work on a host that enforces code signing, and the reason
> it gave for being global was wrong. Both corrections are in *[What changed,
> and what it cost](#what-changed-and-what-it-cost)*; the original reasoning is
> kept in place so the correction has something to correct.
## The problem it solves
All seven private entitlements used to be signed onto `vphone-cli` — the binary
a user types. amfid will not accept Apple-private entitlements on an ad-hoc
signature, so the kernel killed the entry point at exec. `vphone-cli --help`
printed nothing and exited 137.
That is a chicken-and-egg: the tool could not run in order to arrange the
conditions under which it could run. The only way out was to have the bypass
already in place before anything of ours executed — arranged out of band, by
hand, and kept up for as long as you wanted to use the tool. Nothing in the
project could take part in its own admission, and a user who got it wrong got
`Killed: 9` with no diagnosis, because the binary that would have explained was
the binary being refused.
## The shape now
| binary | entitlements | what it is |
| --- | :---: | --- |
| `vphone-cli` | **none** | argument parsing and orchestration. Launches anywhere. |
| `vphone-vm` | **all 7** | a parse and an `NSApplication` run loop over `VPhoneVirtualMachineKit`. |
| `vphone-archive` | none | libarchive front end. Unrelated to amfid; listed for completeness. |
`vphone-cli` is now always able to start, which is what lets it say something
about amfid instead of being the thing amfid stops. When it is asked for a
guest it hands the boot to `vphone-vm`; if amfid refuses that exec, the entry
point is alive to print why and what to do about it.
> **Superseded paragraph, kept for the record.** What stood here was: *"The
> compensating control moved from scope to time. The old helper claimed a
> path/CDHash allowlist; that is not reproducible on macOS 26, because deciding
> per-validation means interrupting amfid, and amfid carries
> `com.apple.developer.hardened-process`, which gates exactly those debugger
> operations behind Apple-private entitlements. So the switch is global while it
> is open — and the answer is to keep it open for one exec rather than all day.
> **Do not describe `vphone-letmein` as scoped to a binary or a path.** It is
> not."*
>
> "Not reproducible on macOS 26" is false — `amfidont` does exactly that, and
> does it under code-signing enforcement as well. The tool the paragraph defends
> is gone. See below.
## What changed, and what it cost
Two things were learned after the split shipped. Neither touches the split
itself; both kill the helper that shipped with it.
### 1. Writing amfid's `__TEXT` is fatal under `vm.cs_system_enforcement`
`vphone-letmein` opened its window by patching amfid's `__TEXT` in place. That
makes the page private, dirty and unsigned. On a host where the kernel enforces
code signing system-wide, the next fault into that page is validated, finds no
signature, and the kernel kills amfid:
```
exception EXC_BAD_ACCESS, SIGKILL (Code Signature Invalid)
termination namespace CODESIGNING, code 2, indicator "Invalid Page"
fault 0x23cea8c68, inside -[AMFIPathValidator_macos validateWithError:]
region __TEXT 23cea8000-23ceb0000 r-x/rwx SM=COW
```
Measured twice on macOS 27.0 (26A428), arm64e, with SIP `enabled --without
debug` plus `allow-research-guests enable` — the configuration the README calls
Option B. The write lands, the read-back verifies, amfid dies at that instant,
and the guest is `SIGKILL`ed anyway because amfid never answered its
validation. The gate is the read-only sysctl `vm.cs_system_enforcement`, which
reads **1** there, so nothing can relax it at runtime and no amount of care in
the tool changes the outcome. `csrutil enable --without debug` does not clear
it: that flag buys `task_for_pid`, not permission to execute a modified page.
Do not re-derive this. The approach is closed on such a host, and that is why
the tool was removed rather than fixed.
### 2. "A per-binary allowlist is impossible" was wrong
The superseded paragraph above reasoned from `vphone-letmein`'s own position —
it could not decide per validation, so it concluded nobody could. `amfidont`
does exactly that, by driving amfid through LLDB: `debugserver` carries the
Apple-private debugger entitlements, and a debugger sets arm64 breakpoints in
the CPU's debug registers rather than writing the page. That is the same reason
it survives enforcement where a text patch cannot — no page is ever dirtied.
So the trade was never scope *versus* time. A text patch buys neither scope nor
a host that enforces signing; the debugger route buys both.
Measured on the same host, with `vm.cs_system_enforcement` still reading 1:
with `sudo amfidont daemon --cdhash <vphone-vm's> --spoof-apple --verbose`
running, `vphone-vm --help` exits 0 instead of being `SIGKILL`ed, and amfid is
still alive afterwards. That is the case `vphone-letmein` could not reach at
all.
**`amfidont` is an allowlist, not a global switch.** It decides per binary, by
path prefix or by CDHash. The "global switch, not an allowlist" wording that
`vphone-letmein` carried was true of `vphone-letmein` and must not be copied
onto its replacement.
`amfidont` is by this project's own author —
<https://github.com/zqxwce/amfidont>, PyPI `amfidont`, 0.0.3 at the time of
writing — but it is a separate program, not a component of this repo.
### 3. The bypass is now outside this project
Policy, decided 2026-09-23: `vphone-cli` does not install, spawn, supervise or
depend on any AMFI bypass. It probes, and on a refusal it prints the exact
command to run. Arranging AMFI is the user's own business — either relax it at
boot, or run `amfidont` and allow `vphone-vm`'s CDHash:
```bash
xcrun python3 -m pip install --user amfidont # Apple's python3 is 3.9
codesign -dv --verbose=4 <path>/vphone-vm 2>&1 | sed -n 's/^CDHash=//p' | head -1
sudo amfidont daemon --cdhash <cdhash> --spoof-apple --verbose
```
`make amfi_command` renders that second and third line for the binaries the
current build produced, and does nothing else — it installs nothing, starts
nothing, and does not check whether `amfidont` is even on the machine. Re-run it
after every build: the CDHash changes with the bytes.
It is `vphone-vm`'s CDHash that matters, never `vphone-cli`'s — `vphone-cli`
carries no entitlements and amfid never objects to it. `amfidont` re-execs
Xcode's python3, so Xcode is required; Homebrew's python refuses the install
under PEP 668, which is why the system interpreter is the documented one. No
Python dependency is added to this repo for any of it.
### 4. One lesson worth keeping from the helper
Upstream's `exec --hold N` restored the patch after N seconds and returned
immediately, leaving the child running. That loses the guest's exit status and
every signal path to it, so the caller could neither report nor cancel a boot;
a Ctrl-C took down the supervisor and left the guest parentless, which looks
exactly like a hang. Anything that ever wraps the guest again — a sudo shim, a
supervisor, a launchd job — has to forward `SIGINT`/`SIGTERM`/`SIGHUP` and
surface the child's exit status, or it will reintroduce that.
## Why the split was cheap
The CLI/VM boundary was already a process boundary. `vm launch` and four sites
in the create orchestrator all spawned the running executable through
`VPhoneResources.runningExecutable()`. The change is mostly *what* they spawn.
## Things that are easy to get wrong
### `Bundle.main.executableURL` cannot find this process any more
It reads `CFBundleExecutable`, which is now `vphone-vm`. Ask it while running
`vphone-cli` — in the same `Contents/MacOS` — and it answers `vphone-vm`.
`runningExecutable()` uses `_NSGetExecutablePath`, which is the path the kernel
exec'd and owes nothing to any plist.
### `CFBundleExecutable` is `vphone-vm`, deliberately
The `.app` is never opened through Launch Services; every caller runs a binary
inside it directly. It exists to give the process that becomes an
`NSApplication` a bundle — icon, `LSUIElement`, the `NSLocation*UsageDescription`
strings. That process is `vphone-vm`.
### An unentitled `vphone-vm` is worse than a broken one
It launches perfectly. The AMFI probe therefore concludes nothing is wrong, the
boot proceeds, and it fails much later trying to create a PV=3 machine — far
from the cause. A bare `swift build -c release` leaves exactly that state
behind, because only `make build` / `Scripts/build.sh` sign. Both now verify
the entitlements actually landed and fail if they did not. This was found by
walking into it.
### Bridged networking nearly broke silently
`availableBridgeInterfaces()` returns an empty list without
`com.apple.vm.networking` — which `vphone-cli` no longer has. The old code read
that as "this host has no bridgeable interfaces" and rejected `--network
bridged` on a machine full of them. An empty list is now treated as *cannot
tell*: the requested name is recorded and `vphone-vm`, which is entitled,
validates it at boot where the error can name the real problem. With no name
given and nothing to enumerate, `bridgeInterfaceMustBeNamed` says so.
This is the general hazard of the split — **anything that read host state
through an entitled API from the CLI side is now reading it unprivileged.**
Nothing else in `VPhoneCore` does, but new code might.
## The probe
`vphone-vm --help`. amfid decides at exec, before any of the target's own code
runs, so a `--help` that never prints is the same refusal a real boot would
hit. It costs nothing and touches no VM state. A refusal is `SIGKILL`, which
Foundation reports as termination status 9. Any *other* non-zero exit is raised
as itself — we recognise exactly one signature and do not guess at the rest.
The probe is now the whole of what the CLI does about amfid. It never arranges
a bypass, and it never asks for root; on a refusal it explains the refusal and
names the command the user has to run. Skipping the probe would hand back a
bare exit 9 with no explanation, which is the failure this path exists to
remove, so there is no way to turn it off.
## Verified, and not
**Verified on 2026-09-23** (amfid refusing `vphone-vm`, no bypass in place):
| | |
| --- | --- |
| `vphone-cli` entitlements | 0 keys |
| `vphone-vm` entitlements | 7 keys |
| `vphone-cli --help` | exits 0 and prints |
| `vphone-vm --help` | exits 137 — amfid refuses it, as expected |
| probe on a refusal | reports it in full, never touches sudo |
| sibling resolution | resolves through the `.build/release` symlink to the real `Products/Release` directory |
| entitlement guard | fails as required on an unentitled binary |
| `VPhoneCoreTests` | 130/130 |
Two rows that stood here — the `VPHONE_LETMEIN` env var and the `sudo …
vphone-letmein exec --hold 10 --` command line confirmed in the process tree —
measured a tool that no longer exists. They were true when taken; they describe
nothing in the tree now.
**Not verified — needs root and a real guest:**
1. **Whether `vphone-vm` can actually start a VM holding the entitlements
alone.** — **settled**, see `Research/Restore/virtual_dfu_probe.md`: it booted a guest
and libirecovery saw its DFU endpoint. Everything here rested on this, and
it is the one item the DFU spike closed on the way past.
2. **That the instructions printed on a refusal are the ones that work**, on a
host with `vm.cs_system_enforcement` = 1 and nothing installed yet. The
`amfidont` install and daemon invocation were measured on this machine (see
`Research/0_binary_patch_comparison.md`); the CLI's rendering of them into
an error message was not measured against a fresh host.
3. **Location and TouchID**, which depend on TCC attributing the usage strings
to `vphone-vm`. It is `CFBundleExecutable`, so it should — but TCC's view of
a binary inside someone else's bundle is worth confirming rather than
assuming.
4. **Bridged networking**, now that the name is validated at boot instead of at
config time.