Skip to content

Seeker overwrites evidence files whose paths differ only in case, so one file is silently lost from the report #131

Description

@abrignoni

What happens

FileSeekerDir.search() builds the report-data destination from the source path and copies
into it with no check for an existing, differently-sourced occupant:

Two evidence files whose paths differ only in case are two distinct sources, so both pass the
if item not in self.copied guard, and both resolve to the same destination on a
case-insensitive output volume. The second copy2 destroys the first. FileSeekerZip has the
same shape at search_files.py:325 (zip extract), and FileSeekerTar at its own write.

This is not hypothetical. An iOS extraction can contain both com.apple.MobileSMS.plist and
com.apple.mobileSMS.plist in one directory as two different files with different contents:
6 of 20 local iOS corpora carry exactly that pair, from iOS 14.3 through 26.5.2, so the
acquired filesystem clearly preserved both. Windows and stock macOS report volumes are
case-insensitive, so that pair collides on extraction for most users.

Why it matters for a forensic report

Two consequences, both confirmed by running the real seekers:

  1. The losing file's bytes are gone. Only one file survives under data/, so the report
    preserves one of the two and silently drops the other. Which one survives depends on
    directory or archive member order, which the acquisition tool decides.

  2. A row's source path can point at a file that does not contain what the row reports.
    An artifact that reads each file before the next copy overwrites it gets the correct
    values, but the preserved copy at the path shown in the report then holds the other
    file's contents. An examiner who opens the cited path sees something that does not support
    the row. That is a defensible-evidence problem, not a cosmetic one.

There is a third, quieter effect: self.file_infos ends up with two entries whose keys differ
only in case but which stat() to the same inode, carrying two different claimed source paths
and two different timestamps. Those timestamps reach examiner-facing output.

Reproducing it

Any two files differing only in case will do. On macOS, note the default volume is
case-insensitive, so stage the input on a case-sensitive one or read from a zip:

import zipfile, plistlib
PREF = "private/var/mobile/Library/Preferences/"
with zipfile.ZipFile("ev.zip", "w") as zf:
    zf.writestr(PREF + "com.apple.MobileSMS.plist", plistlib.dumps({"SSKeepMessages": 365}))
    zf.writestr(PREF + "com.apple.mobileSMS.plist", plistlib.dumps({"Other": True}))

Point a seeker at it with the report folder on a case-insensitive volume, search for both
names, then list the data folder: one file, and both returned paths stat() to the same
inode.

Suggested direction, for whoever owns the core

This is core code and the LEAPPs treat core changes as opt-in, so I have not touched it. The
shape of a fix, for discussion rather than as a proposal to merge:

  • Probe the data folder's real case behaviour once per seeker at construction (create
    Aa/aA and see whether two files result) rather than inferring it from
    os.path.normcase, which reports the platform's convention and not the volume's.
  • When a destination is already claimed by a different source, disambiguate it rather than
    overwriting, and record the mapping so the report's source path stays truthful.
  • Whatever is chosen wants to land in all five cores together, since the code is duplicated.

Related

An artifact-level workaround exists for the one case found so far: read each match before
searching for the next, so a file is read before the next copy can overwrite it. That is what
abrignoni/iLEAPP#1946 does for messageRetention (merged). It fixes
the reported values; it does not fix the preserved copies, which is why this issue is
still worth having.

A separate open PR, abrignoni/iLEAPP#1947, adds a CI guard that stops
an artifact declaring two paths patterns differing only in case. That is a different
problem with the same root cause, and the guard does nothing about the seeker.

Same issue in the sibling cores

scripts/search_files.py is duplicated across the five extractors and every copy has
this shape, so a fix wants levelling. Filed separately so each repo's maintainers see it:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions