Malware Blocked: how twelve bytes of Xo's output matched an XProtect rule
On the morning of 2026-10-16 the project owner’s Mac showed a dialog none of us expected: “Malware Blocked: ‘http_stream’ was not opened because it contains malware.” http_stream is one of Xo’s golden test programs, a small HTTP client compiled by Xo’s own arm64 back end and linked by its own Mach-O linker, selfhost/macho. This post is the story of finding out why, including the parts where we were wrong. As usual, AI agents did the work and the project owner set the direction.
The symptom
Before anyone saw the dialog, the symptom was a hang. A freshly built
program never started: the process slept with its only thread at
_dyld_start + 0, before the dynamic loader had run a single
instruction of it. sample reported “Binary images description not
available”, codesign -v said the signature was valid, and the test
harnesses reported “did not finish” or “run: timed out”. Whole test
packages timed out on it, because every test behind the stuck program
waited too.
The wrong theories
The first theory was the code signature. Xo signs its binaries ad hoc,
and log stream during a stalled exec showed the kernel rejecting it:
AMFI: '<path>': Unrecoverable CT signature issue, bailing out.
AMFI: code signature validation failed.
After that, syspolicyd assessed the binary with UI allowed, looked for a notarization ticket, found none, and waited on the outcome, which is a dialog for the person at the Mac. Later assessments logged “waiting on another evaluation”: one pending dialog held up every exec behind it.
The stalls also seemed to depend on layout. A test program with a
7,300 word constant ran and the same program with a 7,600 word constant
did not; its __TEXT segment had grown from 0x230000 to 0x234000. That
looked like a property of the Mach-O file that macOS disliked, and it
pointed at selfhost/macho. So we shipped a mitigation (643b8261): link
darwin/arm64 programs with the system’s cc by default again, and keep
selfhost/macho behind --ld=macho.
It did not hold up. A binary that Apple’s own linker produced through
cc stalled the same way, and re-signing with codesign -f -s - or
changing the minimum OS version did not change the kernel’s verdict.
The owner enabled the terminal under Developer Tools, which exempts the
programs it starts from these assessments, and the hangs stopped on
that Mac. That fixed the symptom for us, not the cause, and the cause
was what users would hit.
Reading the rules
Then the owner saw the dialog itself. It did not say the program was unsigned or unverified; it said malware. That is XProtect, Apple’s signature based scanner, and its signatures are YARA rules on disk.
There are two copies. The one most guides point at,
/Library/Apple/System/Library/CoreServices/XProtect.bundle/.../XProtect.yara,
was the older one on this Mac. The rules XProtect actually used were in
/private/var/protected/xprotect/XProtect.bundle/Contents/Resources/XProtect.yara,
version 5318. We copied both, installed yara in a Linux container, and
scanned every golden program. With the live rules, 3 of the 179 goldens linked by selfhost/macho
matched one rule, MACOS.SOMA.AUENB, and none of the same programs
linked by cc or built by the LLVM back end did:
XProtect_MACOS_SOMA_AUENB http_stream
0x24c0c8:$b0: 6B 01 22 91 CC 09 00 90 8C 01 22 91
The rule has several conditions. The one that fired needs no Mach-O structure at all, only this byte pattern in a file under 5 MB:
6b 01 22 91 cc 09 00 ?0 8c ?1 ?? 91
Why Xo’s output matched
Decoded as arm64, those twelve bytes are three instructions:
add x11, x11, #0x880 ; 6b 01 22 91
adrp x12, <page> ; cc 09 00 90
add x12, x12, #imm ; 8c 01 22 91
That is the standard two instruction address load: adrp puts a 4 KB
page’s address in a register and add adds the symbol’s offset within
the page. The pattern is the second half of a load into x11 of a symbol
at page offset 0x880, followed by the start of a load into x12.
In selfhost/macho’s layout, __DATA began with two runtime tables:
xo_sys_words (0x80 bytes) and xo_static (0x800 bytes since the
static word table grew to 256 words). The next symbol was
_xo_empty_list, the shared, immortal empty list every Xo program
uses, at 0x80 + 0x800 = 0x880, in every program, always. Code that sets
several list fields to empty loads that address into consecutive
registers. Two functions in the TLS client, tls13.retry and
tls13.dummy_ccs, did exactly that into x11 and then x12, and any
program that linked the TLS client and stayed under 5 MB carried the
pattern. Apple’s linker orders the data differently, which is why cc’s
output never matched.
The fix
The fix is a layout change in the arm64 emitter (13345d68): the empty list and the zero handle now come first in a program’s data, at page offset 0, ahead of the runtime tables. After it, none of the 179 goldens matches the live or the older rules, on either linker or either back end.
Two tests keep it that way. TestEmptyListAtPageStart checks the empty list’s page offset in a macho linked program on any Mac. It fails with a compiler from before the fix. TestXProtectRules builds the three programs that matched, with both linkers, and scans them with the Mac’s own rules. Most Macs have no yara, so since d3e14abd the test runs yara in the Linux container our Docker scripts already use when yara is not installed. With a compiler from before the fix it reports all three matches; with the fix it passes.
The fix is not a guarantee. Any address at page offset 0x880 loaded into x11 and then x12 can still match, and other rules may match other layouts. That is why the scan is a test and not a one time check.
A second stall that looked the same
With the false positive gone, one program still hung at _dyld_start
in the dev goldens harness: http_tls_keys, linked by cc, and no rule
matched it. This one turned out to be about a path, not a file. The
cached binary waited on every exec, seven in a row and six of them at
once. A copy at another path, a copy with the same name in another
directory, and a hard link to the same inode at another path all
started in under 0.4 s. Some minutes later the original path started
again too.
The harness rebuilt every program at one fixed path in a directory that
several agent sessions share. One session could remove and relink a
binary while another session was starting it, and that path’s
assessment wedged for a while. Now each run builds into a fresh
run-<random> directory and removes it at the end (c22f3266).
With both causes fixed, we ran every golden linked by selfhost/macho in three rounds, one serial and two with 8 jobs: 179 of 179 each time, none past 20 s. selfhost/macho is the darwin/arm64 default again (decision 0182’s addendum), so arm64 builds on macOS need no C toolchain. On the same programs, built one at a time without caches, the goldens built in 234 s against cc’s 282 s, at 1.0055 times cc’s bytes.
Lessons for compiler authors
- Read the dialog’s exact words. “Unverified developer” and “contains malware” are different systems. We chased the first while the owner’s screen said the second.
- Scan with the live rules. On this Mac the well known path held an
older copy of the rules. The live one is under
/private/var/protected/xprotect. - A deterministic layout makes a fixed fingerprint. Every program had the same symbol at the same page offset, so one unlucky instruction sequence repeated across every program that used it. Byte rules can match ordinary code; ours matched an address load.
- Put the scan in the test suite. A false positive like this comes back with any layout change, and it is cheap to catch before users do.
- Separate the stalls. Two different causes produced the same
_dyld_starthang. Copying the binary to a new path, and hard linking it, told them apart in seconds.