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:
-
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.
-
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:
What happens
FileSeekerDir.search()builds the report-data destination from the source path and copiesinto it with no check for an existing, differently-sourced occupant:
search_files.py:116(destination built from the source path)search_files.py:119(the guard is keyed by SOURCE path)search_files.py:125(copy2 overwrites whatever is there)Two evidence files whose paths differ only in case are two distinct sources, so both pass the
if item not in self.copiedguard, and both resolve to the same destination on acase-insensitive output volume. The second
copy2destroys the first.FileSeekerZiphas thesame shape at
search_files.py:325(zip extract), andFileSeekerTarat its own write.This is not hypothetical. An iOS extraction can contain both
com.apple.MobileSMS.plistandcom.apple.mobileSMS.plistin 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:
The losing file's bytes are gone. Only one file survives under
data/, so the reportpreserves one of the two and silently drops the other. Which one survives depends on
directory or archive member order, which the acquisition tool decides.
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_infosends up with two entries whose keys differonly in case but which
stat()to the same inode, carrying two different claimed source pathsand 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:
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 sameinode.
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:
Aa/aAand see whether two files result) rather than inferring it fromos.path.normcase, which reports the platform's convention and not the volume's.overwriting, and record the mapping so the report's source path stays truthful.
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 fixesthe 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
pathspatterns differing only in case. That is a differentproblem with the same root cause, and the guard does nothing about the seeker.
Same issue in the sibling cores
scripts/search_files.pyis duplicated across the five extractors and every copy hasthis shape, so a fix wants levelling. Filed separately so each repo's maintainers see it: