CVE-2026-28890: An Out-of-Bounds Read in otool via a Crafted Universal Binary
Apple Security Advisory: Xcode 26.4
Introduction
This is an out-of-bounds read I found in otool-classic, the classic Mach-O inspector shipped as part of the Xcode toolchain. otool dumps load commands, disassembles sections, and reads the headers of binaries you do not fully trust, which means it spends most of its time parsing untrusted files. Given a crafted FAT (universal) Mach-O, it reads one byte past the end of the mapped file.
A “universal” or “FAT” Mach-O is a wrapper that bundles several architecture-specific Mach-O binaries into a single file. When you build for both x86_64 and arm64, the linker combines both slices and prepends a small table of contents describing where each slice lives. That table starts with a fat_header:
struct fat_header {
uint32_t magic; // 0xCAFEBABE, stored big-endian
uint32_t nfat_arch; // number of fat_arch entries that follow
};
Immediately after the header comes an array of nfat_arch entries, each one a fat_arch:
struct fat_arch {
cpu_type_t cputype; // e.g. 0x01000007 for x86_64
cpu_subtype_t cpusubtype; // subtype refinement
uint32_t offset; // byte offset of this slice inside the file
uint32_t size; // byte length of this slice
uint32_t align; // alignment, as a power of two
};
The important field is offset. It tells the parser where inside the wrapper file the nested Mach-O for that architecture begins. To read the slice’s own mach_header, otool maps the whole file into memory and then dereferences mmap_base + offset. That addition is where the problem lies.
The Vulnerability
When otool iterates the fat_arch array, it relies on one thing about each entry: that offset points somewhere the file actually reaches. A valid slice has its entire nested Mach-O contained within the file, which means both of these have to hold:
offset + sizeof(mach_header) <= file_length
offset + size <= file_length
The bug is that otool-classic does not enforce the first condition strictly enough. It accepts a slice whose offset is exactly equal to the file length.
At first glance offset == file_length looks harmless. It does not point past the end, and offset is not larger than the file, so a naive offset > file_length check accepts it. But offset == file_length points one byte past the last valid byte in the mapping. When otool dereferences mmap_base + offset to read the nested mach_header, it touches the first byte of that header, which sits outside the mapped region.
This is an off-by-one boundary bug. The tool checks whether the slice starts inside the file, but it does not check that the slice has enough room after offset to hold the structure it is about to read. offset equal to the file length means zero bytes are available, while sizeof(mach_header) bytes are required.
Root Cause
The slice-iteration accepts offset == file_length because the only bound it validates is that offset starts inside the file. With zero bytes available at that position, otool still forms mmap_base + offset and reads sizeof(mach_header) bytes from it, the first of which lands one byte past the end of the mapping. Nothing forces the required sizeof(mach_header) bytes to fit after offset, so the read walks off the end of the file.
The fault address is not fixed. It tracks the attacker-supplied offset from the malformed fat_arch entry: change the declared offset (and pad the file to match) and the address otool faults on moves with it. The input does not just crash the tool, it controls where the invalid read lands, which in a more permissive memory layout is the kind of primitive that could be developed into an information leak.
Reproduction
The crafted file is a two-architecture universal binary. The first fat_arch is a valid x86_64 slice contained entirely within the file, present mainly to make the file look like a real multi-arch binary. The second fat_arch is the malformed entry: it declares itself as an arm64 slice whose offset equals the file length, so the nested Mach-O this slice claims to hold begins one byte past the end of the file.
When otool is asked to dump load commands, it iterates every slice and reads each nested Mach-O header. When it reaches the second slice, it computes mmap_base + offset and dereferences it. Because that address lands one byte past the end of the mapped file, the read touches unmapped memory and the process takes an EXC_BAD_ACCESS with KERN_INVALID_ADDRESS, at a fault address that tracks the attacker-supplied offset.
The fix is to reject any slice whose offset + sizeof(mach_header) or offset + size exceeds the file length, computed so as to resist integer overflow of the attacker-controlled uint32_t fields.