https://github.com/qiyao created
https://github.com/llvm/llvm-project/pull/204471
This fixes two denial-of-service issues in the universal (fat) Mach-O
container parser, both found by `lldb-target-fuzzer` and both reachable from
`SBDebugger::CreateTarget` → `ObjectFile::GetModuleSpecifications` →
`ObjectContainerUniversalMachO::{GetModuleSpecifications,ParseHeader}`.
### 1. Bound the fat header arch loop by available data
`ParseHeader` read the untrusted 32-bit `nfat_arch` field and used it directly
as the loop bound when indexing the `fat_arch` records, without validating it
against the data actually present. A crafted header claiming
`nfat_arch = 0xFFFFFFFF` with only a few bytes of payload made the loop spin
~4.29 billion times — `ValidOffsetForDataOfSize()` fails on every iteration
past the real data, but the loop keeps going up to `nfat_arch`, so the call
never returns in practice (~20 minutes under ASan). The reduced reproducer is
40 bytes.
Fix: break out of the loop as soon as the next `fat_arch` record no longer fits
in the remaining data. This is behavior-preserving — a failed
`ValidOffsetForDataOfSize` never advanced the offset, so the set of parsed
`fat_archs` is unchanged; it just stops the useless spinning.
### 2. Reject self-referential slices in the fat container
`GetModuleSpecifications` re-parses every fat slice by recursing into
`ObjectFile::GetModuleSpecifications` at the slice's offset, with no
recursion-depth limit and no check that a slice advances past the current
offset. A slice whose offset is 0 produces a recursive call with identical
arguments (same file, same offset, same size) and recurses until the stack
overflows. Fixing the `nfat_arch` loop bound above unmasked this for the same
input: the initial 512-byte read zero-pads a short file, `ParseHeader` then
parses a trailing all-zero arch record whose offset is 0, and the slice
recurses on itself.
Fix: require `slice_file_offset > file_offset` before recursing, so every
recursive call strictly advances into the file. Offsets at or before the
current offset (including 0) and offsets inside the fat header are skipped.
### Tests
Two API tests in `TestTargetCommand.py`, each with a `yaml2obj` fixture:
- `fat_nfat_arch_huge.yaml` — `nfat_arch = 0xFFFFFFFF` with a single real
slice; `target create` must complete promptly.
- `fat_slice_offset_zero.yaml` — a self-referential slice at offset 0;
`target create` must skip it and fail gracefully ("is not a valid
executable") rather than crashing.
>From a91eba0305efd74e97d99daf37599e2e14abe329 Mon Sep 17 00:00:00 2001
From: Yao Qi <[email protected]>
Date: Tue, 16 Jun 2026 22:30:34 +0100
Subject: [PATCH 1/3] [lldb][Mach-O] Bound the fat header arch loop by
available data
`ObjectContainerUniversalMachO::ParseHeader` reads the 32-bit `nfat_arch`
field straight from the file and uses it as the loop bound when indexing
the fat_arch records, without validating it against the amount of data
actually present. A crafted fat header that claims nfat_arch = 0xFFFFFFFF
while providing only a few bytes makes the loop iterate ~4.29 billion
times: `ValidOffsetForDataOfSize()` fails on every iteration past the real
data, but the loop keeps spinning up to `nfat_arch`, so the call does not
return in any practical amount of time (~20 minutes under ASan).
Found by `lldb-target-fuzzer`, reachable from `SBDebugger::CreateTarget`
-> `ObjectFile::GetModuleSpecifications` ->
`ObjectContainerUniversalMachO::{GetModuleSpecifications,ParseHeader}`. The
reduced reproducer is 40 bytes: a `FAT_MAGIC_64` header with
`nfat_arch = 0xFFFFFFFF` followed by a single, partial arch record.
Break out of the loop as soon as the next fat_arch record no longer fits
in the remaining data. This is behavior-preserving, a failed
`ValidOffsetForDataOfSize` never advances the offset, so the set of parsed
fat_archs is unchanged. It just stops the useless spinning.
---
.../Universal-Mach-O/ObjectContainerUniversalMachO.cpp | 5 +++++
1 file changed, 5 insertions(+)
diff --git
a/lldb/source/Plugins/ObjectContainer/Universal-Mach-O/ObjectContainerUniversalMachO.cpp
b/lldb/source/Plugins/ObjectContainer/Universal-Mach-O/ObjectContainerUniversalMachO.cpp
index d7b1710dce05d..6193fc5bdd39d 100644
---
a/lldb/source/Plugins/ObjectContainer/Universal-Mach-O/ObjectContainerUniversalMachO.cpp
+++
b/lldb/source/Plugins/ObjectContainer/Universal-Mach-O/ObjectContainerUniversalMachO.cpp
@@ -122,6 +122,11 @@ bool ObjectContainerUniversalMachO::ParseHeader(
arch.align = extractor.GetU32(&offset);
fat_archs.emplace_back(arch);
}
+ } else {
+ // nfat_arch is read from the file and is untrusted (up to 0xFFFFFFFF).
+ // Once the data is exhausted the offset can no longer advance, so the
+ // remaining iterations would spin uselessly; stop here.
+ break;
}
}
return true;
>From 8f6e4598b5062a6664b7c55c7a4ac40553779d26 Mon Sep 17 00:00:00 2001
From: Yao Qi <[email protected]>
Date: Tue, 16 Jun 2026 22:31:46 +0100
Subject: [PATCH 2/3] [lldb][Mach-O] Reject self-referential slices in the fat
container
`ObjectContainerUniversalMachO::GetModuleSpecifications` re-parses every fat
slice by recursing into `ObjectFile::GetModuleSpecifications` at the slice's
offset. There is no recursion-depth limit and no check that a slice
advances past the current offset, so a slice whose offset is 0 produces a
recursive call with identical arguments (same file, same file_offset, same
size) and recurses until the stack overflows.
Found by `lldb-target-fuzzer`, reachable from `SBDebugger::CreateTarget`
-> `ObjectFile::GetModuleSpecifications` ->
`ObjectContainerUniversalMachO::GetModuleSpecifications`, which calls back
into `ObjectFile::GetModuleSpecifications`. Fixing the `nfat_arch` loop bound
(previous commit) unmasked this for the same input: the initial 512-byte
read zero-pads a short file, `ParseHeader` then parses a trailing all-zero
arch record whose offset is 0, and the slice recurses on itself.
Require `slice_file_offset > file_offset` before recursing, so every
recursive call strictly advances into the file. Offsets that land at or
before the current offset, including 0, and offsets inside the fat
header, are skipped.
---
.../Universal-Mach-O/ObjectContainerUniversalMachO.cpp | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git
a/lldb/source/Plugins/ObjectContainer/Universal-Mach-O/ObjectContainerUniversalMachO.cpp
b/lldb/source/Plugins/ObjectContainer/Universal-Mach-O/ObjectContainerUniversalMachO.cpp
index 6193fc5bdd39d..f3127ef920982 100644
---
a/lldb/source/Plugins/ObjectContainer/Universal-Mach-O/ObjectContainerUniversalMachO.cpp
+++
b/lldb/source/Plugins/ObjectContainer/Universal-Mach-O/ObjectContainerUniversalMachO.cpp
@@ -207,7 +207,12 @@ ModuleSpecList
ObjectContainerUniversalMachO::GetModuleSpecifications(
for (const FatArch &fat_arch : fat_archs) {
const lldb::offset_t slice_file_offset =
fat_arch.GetOffset() + file_offset;
- if (fat_arch.GetOffset() < file_size && file_size > slice_file_offset)
{
+ // The slice must start strictly past the current offset. A slice
whose
+ // offset is 0 (or otherwise does not advance) is self-referential: the
+ // recursive call below would re-parse the same range and recurse
+ // without bound. Skip such slices instead of overflowing the stack.
+ if (slice_file_offset > file_offset &&
+ fat_arch.GetOffset() < file_size && file_size > slice_file_offset)
{
ModuleSpecList arch_specs = ObjectFile::GetModuleSpecifications(
file, slice_file_offset, file_size - slice_file_offset);
specs.Append(arch_specs);
>From 7b65b6f52bca6310c26343ffe1dd2633dee25bd9 Mon Sep 17 00:00:00 2001
From: Yao Qi <[email protected]>
Date: Wed, 17 Jun 2026 20:37:56 +0100
Subject: [PATCH 3/3] Two test cases
---
.../target/basic/TestTargetCommand.py | 26 ++++++++++++++++
.../target/basic/fat_nfat_arch_huge.yaml | 30 +++++++++++++++++++
.../target/basic/fat_slice_offset_zero.yaml | 30 +++++++++++++++++++
3 files changed, 86 insertions(+)
create mode 100644 lldb/test/API/commands/target/basic/fat_nfat_arch_huge.yaml
create mode 100644
lldb/test/API/commands/target/basic/fat_slice_offset_zero.yaml
diff --git a/lldb/test/API/commands/target/basic/TestTargetCommand.py
b/lldb/test/API/commands/target/basic/TestTargetCommand.py
index dd6bdb234108a..f6870dc2e7efe 100644
--- a/lldb/test/API/commands/target/basic/TestTargetCommand.py
+++ b/lldb/test/API/commands/target/basic/TestTargetCommand.py
@@ -147,6 +147,32 @@ def test_target_create_unsupported_platform(self):
patterns=["error: no matching platforms found for this file"],
)
+ @no_debug_info_test
+ def test_target_create_fat_macho_huge_nfat_arch(self):
+ """A fat Mach-O header whose nfat_arch is larger than the data must not
+ send the arch-indexing loop spinning."""
+ yaml = os.path.join(self.getSourceDir(), "fat_nfat_arch_huge.yaml")
+ exe = self.getBuildArtifact("fat_nfat_arch_huge")
+ self.yaml2obj(yaml, exe)
+ # Before the fix this loops ~0xFFFFFFFF times and test timeout. The
+ # test "passing" here just means target create completes promptly.
+ self.expect("target create {}".format(exe))
+
+ @no_debug_info_test
+ def test_target_create_fat_macho_self_referential_slice(self):
+ """A fat Mach-O slice at offset 0 is self-referential. Parsing it must
+ not recurse until the stack overflows."""
+ yaml = os.path.join(self.getSourceDir(), "fat_slice_offset_zero.yaml")
+ exe = self.getBuildArtifact("fat_slice_offset_zero")
+ self.yaml2obj(yaml, exe)
+ # Before the fix this recurses on identical arguments and crashes. The
+ # non-advancing slice is now skipped, leaving nothing loadable.
+ self.expect(
+ "target create {}".format(exe),
+ error=True,
+ patterns=["is not a valid executable"],
+ )
+
@no_debug_info_test
def test_target_create_invalid_platform(self):
self.buildB()
diff --git a/lldb/test/API/commands/target/basic/fat_nfat_arch_huge.yaml
b/lldb/test/API/commands/target/basic/fat_nfat_arch_huge.yaml
new file mode 100644
index 0000000000000..4052d044d2449
--- /dev/null
+++ b/lldb/test/API/commands/target/basic/fat_nfat_arch_huge.yaml
@@ -0,0 +1,30 @@
+--- !fat-mach-o
+# Regression fixture: a universal (fat) Mach-O whose header claims
+# nfat_arch = 0xFFFFFFFF while the file holds a single arch slice. The arch
+# loop in ObjectContainerUniversalMachO::ParseHeader used nfat_arch as its
+# bound without checking it against the available data, so this header sent it
+# spinning ~4.29 billion times. "target create" must instead parse the one
+# real slice and return promptly. Found by lldb-target-fuzzer.
+FatHeader:
+ magic: 0xCAFEBABF
+ nfat_arch: 0xFFFFFFFF
+FatArchs:
+ - cputype: 0x01000007
+ cpusubtype: 0x00000003
+ offset: 0x0000000000004000
+ size: 4
+ align: 14
+ reserved: 0x00000000
+Slices:
+ - !mach-o
+ FileHeader:
+ magic: 0xFEEDFACF
+ cputype: 0x01000007
+ cpusubtype: 0x00000003
+ filetype: 0x00000002
+ ncmds: 0
+ sizeofcmds: 0
+ flags: 0x00000000
+ reserved: 0x00000000
+ LoadCommands: []
+...
diff --git a/lldb/test/API/commands/target/basic/fat_slice_offset_zero.yaml
b/lldb/test/API/commands/target/basic/fat_slice_offset_zero.yaml
new file mode 100644
index 0000000000000..ea5d120026791
--- /dev/null
+++ b/lldb/test/API/commands/target/basic/fat_slice_offset_zero.yaml
@@ -0,0 +1,30 @@
+--- !fat-mach-o
+# Regression fixture: a universal (fat) Mach-O with a self-referential slice
+# whose offset is 0. ObjectContainerUniversalMachO::GetModuleSpecifications
+# re-parsed each slice by recursing into ObjectFile::GetModuleSpecifications at
+# the slice offset; a slice at offset 0 produced a recursive call with
+# identical arguments and recursed until the stack overflowed. "target create"
+# must skip the non-advancing slice and fail gracefully instead of crashing.
+# Found by lldb-target-fuzzer.
+FatHeader:
+ magic: 0xCAFEBABE
+ nfat_arch: 1
+FatArchs:
+ - cputype: 0x00000007
+ cpusubtype: 0x00000003
+ offset: 0x00000000
+ size: 0x00001000
+ align: 12
+Slices:
+ - !mach-o
+ FileHeader:
+ magic: 0xFEEDFACF
+ cputype: 0x00000007
+ cpusubtype: 0x00000003
+ filetype: 0x00000002
+ ncmds: 0
+ sizeofcmds: 0
+ flags: 0x00000000
+ reserved: 0x00000000
+ LoadCommands: []
+...
_______________________________________________
lldb-commits mailing list
[email protected]
https://lists.llvm.org/cgi-bin/mailman/listinfo/lldb-commits