Skip to content

Commit 750b11f

Browse files
authored
Merge pull request #344 from abrignoni/feat/adcrypt
Read FTK Imager AD-encrypted images with their password
2 parents 5103853 + 2a8343d commit 750b11f

7 files changed

Lines changed: 589 additions & 61 deletions

File tree

‎admin/docs/raw_image_input.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ or a 4Kn drive, has its partitions counted in 4096-byte sectors.
4040
one, a damaged encrypted header and a `.dmgpart` opened on its own that must be
4141
refused.
4242

43-
## Encrypted Apple disk images
43+
## Encrypted images
4444

4545
An Apple disk image encrypted with a password (`hdiutil -encryption`: a `.dmg`, a
4646
split one, a `.sparseimage` or a sparse bundle) opens with that password, which the
@@ -57,6 +57,13 @@ taken as an argument's value, which would put it in the process list and the she
5757
history. An image that will not open for another reason (a damaged header, no cipher
5858
package) is reported by name rather than asked about again.
5959

60+
An E01, SMART or raw (dd) set FTK Imager encrypted with AD encryption opens the same
61+
way, with the same prompts and options. Every file of such a set is encrypted and only
62+
the first carries the header, so an E01 or SMART set is opened from its first file and
63+
a raw set from any of its numbered files (`.001`, `.002`, ...); the reader decrypts and
64+
joins them itself, and the run log names the set as the reader reports it rather than
65+
as a split image. `needs_password(path)` answers for both kinds.
66+
6067
## How it reads
6168

6269
`FileSeekerRaw` opens the image through the reader's `open_image()` (which joins

‎admin/test/scripts/test_raw_image_seeker.py‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
The E01, AFF, AFD, Apple disk image and split-segment paths are exercised by
1111
wrapping the same NTFS fixture at test time: small EWF, AFF, UDIF and sparse image
1212
writers live in this file (copied from ewfprobe's own test suite, MIT), and a split
13-
set is the raw image cut into numbered pieces.
13+
set is the raw image cut into numbered pieces. FTK Imager's AD encryption is written
14+
here too, around a raw set and an E01 set, with the cipher library's CTR mode.
1415
The expected values are the fixture's, written out, never read back from the
1516
seeker.
1617
"""
@@ -339,6 +340,38 @@ def write_encrypted_sparsebundle(folder, data, band=1 << 20):
339340
return folder
340341

341342

343+
AD_PASSWORD = 'raw-image-ad-password'
344+
345+
346+
def write_adcrypt(plain_files, out_files, password=AD_PASSWORD):
347+
"""Each of ``plain_files`` encrypted into ``out_files`` as FTK Imager's AD
348+
encryption writes a set: one AES-256 key, the 512-byte header in the first file
349+
only, file i in CTR mode from counter i << 64, the counter little endian, and the
350+
key encrypted under PBKDF2-HMAC-SHA1 of the password's SHA-512."""
351+
import hmac # pylint: disable=import-outside-toplevel
352+
from Crypto.Cipher import AES # pylint: disable=import-outside-toplevel
353+
from Crypto.Util import Counter # pylint: disable=import-outside-toplevel
354+
355+
def ctr(key, data, first):
356+
counter = Counter.new(128, initial_value=first, little_endian=True)
357+
return AES.new(key, AES.MODE_CTR, counter=counter).encrypt(data)
358+
359+
rng = __import__('random').Random(9)
360+
file_key, salt = rng.randbytes(32), rng.randbytes(16)
361+
made = hashlib.pbkdf2_hmac('sha1', hashlib.sha512(password.encode()).digest(), salt,
362+
1000, 32)
363+
wrapped = ctr(made, file_key, 0)
364+
header = (struct.pack('<8sIIhhh2sIIIIII', b'ADCRYPT\x00', 1, 512, -1, -1, -1,
365+
b'\x00\x00', 3, 2, 1000, 16, 32, 64)
366+
+ salt + wrapped + hmac.new(made, wrapped, 'sha512').digest()).ljust(512, b'\0')
367+
for index, (src, dst) in enumerate(zip(plain_files, out_files)):
368+
with open(src, 'rb') as handle:
369+
body = ctr(file_key, handle.read(), index << 64)
370+
with open(dst, 'wb') as handle:
371+
handle.write((header if index == 0 else b'') + body)
372+
return out_files
373+
374+
342375
def with_mbr(volume_bytes, start_lba=2048):
343376
"""The volume behind an MBR whose one entry covers it in full, as a disk carries it."""
344377
entry = bytearray(16)
@@ -948,6 +981,68 @@ def test_a_damaged_encrypted_dmg_is_reported_not_asked_about_again(self):
948981
with mock.patch.dict(os.environ, {'RAW_IMAGE_TEST_PW': ENC_PASSWORD}):
949982
raw_image.cli_image_password(path, password_env='RAW_IMAGE_TEST_PW')
950983

984+
def _ad_raw_set(self):
985+
"""The NTFS fixture as a raw set of two files FTK Imager encrypted."""
986+
with open(self.ntfs, 'rb') as handle:
987+
data = handle.read()
988+
folder = tempfile.mkdtemp(prefix='raw_image_ad_', dir=self.work)
989+
half = len(data) // 2 // 512 * 512
990+
plain = []
991+
for index, piece in enumerate((data[:half], data[half:]), start=1):
992+
plain.append(os.path.join(folder, f'plain.{index:03d}'))
993+
with open(plain[-1], 'wb') as handle:
994+
handle.write(piece)
995+
out = [os.path.join(folder, f'evidence.{index:03d}') for index in (1, 2)]
996+
return write_adcrypt(plain, out)
997+
998+
def test_an_ad_encrypted_raw_set_reads_with_its_password_from_either_file(self):
999+
first, second = self._ad_raw_set()
1000+
self.assertTrue(raw_image.needs_password(first))
1001+
self.assertTrue(raw_image.needs_password(second))
1002+
seeker = FileSeekerRaw(second, self.data, password=AD_PASSWORD)
1003+
self.addCleanup(seeker.cleanup)
1004+
text = self.log.text()
1005+
self.assertIn('a raw (dd) image of 2 segments, joined by the reader: evidence.001 '
1006+
'.. evidence.002, encrypted (AES-256-CTR) and opened with its password',
1007+
text)
1008+
self.assertNotIn('one segment of a split image', text)
1009+
self.assertNotIn('segments joined in order', text)
1010+
self.assertEqual(seeker.name_list, self._seeker(self.ntfs).name_list)
1011+
for rel in ('many/file_0007.txt', 'many/file_0400.txt'):
1012+
found = seeker.search(f'*/{rel}')
1013+
self.assertEqual(_sha256(found[0]), self.ntfs_hashes[rel])
1014+
self.assertNotIn(AD_PASSWORD, repr(vars(seeker)))
1015+
1016+
def test_an_ad_encrypted_e01_set_reads_with_its_password(self):
1017+
with open(self.ntfs, 'rb') as handle:
1018+
data = handle.read()
1019+
folder = tempfile.mkdtemp(prefix='raw_image_ad_e01_', dir=self.work)
1020+
plain = write_ewf(folder, 'plain', data, chunks_per_segment=200)
1021+
self.assertGreater(len(plain), 1)
1022+
out = write_adcrypt(plain, [p.replace('plain.', 'evidence.') for p in plain])
1023+
self.assertTrue(raw_image.needs_password(out[0]))
1024+
seeker = FileSeekerRaw(out[0], self.data, password=AD_PASSWORD)
1025+
self.addCleanup(seeker.cleanup)
1026+
self.assertIn(f'an EWF acquisition of {len(out)} segments', self.log.text())
1027+
found = seeker.search('*/many/file_0007.txt')
1028+
self.assertEqual(_sha256(found[0]), self.ntfs_hashes['many/file_0007.txt'])
1029+
1030+
def test_an_ad_encrypted_set_without_its_password_is_refused_as_such(self):
1031+
first, _second = self._ad_raw_set()
1032+
with self.assertRaises(qnxprobe.ImagePasswordError) as caught:
1033+
FileSeekerRaw(first, self.data)
1034+
self.assertFalse(caught.exception.wrong)
1035+
with self.assertRaises(qnxprobe.ImagePasswordError) as caught:
1036+
FileSeekerRaw(first, self.data, password='not it')
1037+
self.assertTrue(caught.exception.wrong)
1038+
with mock.patch.object(raw_image.sys, 'stdin', None):
1039+
with self.assertRaisesRegex(ValueError, 'evidence.001 is an acquisition FTK Imager '
1040+
'encrypted with AD encryption and opens only'):
1041+
raw_image.cli_image_password(first)
1042+
with mock.patch.dict(os.environ, {'RAW_IMAGE_TEST_PW': AD_PASSWORD}):
1043+
self.assertEqual(raw_image.cli_image_password(first, password_env='RAW_IMAGE_TEST_PW'),
1044+
AD_PASSWORD)
1045+
9511046
def test_an_l01_is_refused_as_logical_evidence(self):
9521047
folder = tempfile.mkdtemp(prefix='raw_image_l01_', dir=self.work)
9531048
path = os.path.join(folder, 'evidence.L01')

‎dleapp.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,13 @@ def main():
220220
help='Path to base output folder (this must exist)')
221221
parser.add_argument('-i', '--input_path', required=False, action="store", help='Path to input file/folder')
222222
parser.add_argument('--image_password_file', required=False, action="store",
223-
help='For an encrypted Apple disk image (-t raw): read its password '
224-
'from the first line of this file')
223+
help='For an encrypted image (-t raw; an Apple disk image or an FTK Imager '
224+
'AD-encrypted set): read its password from the first line '
225+
'of this file')
225226
parser.add_argument('--image_password_env', required=False, action="store",
226-
help='For an encrypted Apple disk image (-t raw): take its password '
227-
'from this environment variable. Without either, it is asked '
228-
'for at a terminal')
227+
help='For an encrypted image (-t raw): take its password from this '
228+
'environment variable. Without either, it is asked for at a '
229+
'terminal')
229230
parser.add_argument('-w', '--wrap_text', required=False, action="store_false", default=True,
230231
help='Do not wrap text for output of data files')
231232
parser.add_argument('-m', '--load_profile', required=False, action="store", help="Path to DLEAPP Profile file (.rlprofile).")

‎scripts/raw_image.py‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
``scripts/vendor/`` (qnxprobe, with ewfprobe beside it for the acquisitions).
99
EnCase logical evidence (``.L01``) holds copies of files rather than a disk, and the
1010
reader refuses it with a message saying so. An Apple disk image encrypted with a
11-
password opens with that password, which the GUI asks for and the command line
12-
takes from ``--image_password_file`` or ``--image_password_env``, or asks for at a
13-
terminal; it is checked against the image before the run and never stored. The reader
11+
password, and an E01, SMART or raw set FTK Imager encrypted with AD encryption, open
12+
with that password, which the GUI asks for and the command line takes from
13+
``--image_password_file`` or ``--image_password_env``, or asks for at a terminal; it
14+
is checked against the image before the run and never stored. The reader
1415
finds the partitions, identifies each volume by its own on-disk structure and
1516
walks its directory tree; this module turns that into the same contract the zip
1617
seeker offers: a list of member names to match artifact patterns against, and a
@@ -104,9 +105,17 @@ def names_an_image_folder(path):
104105

105106

106107
def needs_password(path):
107-
"""True when ``path`` is an Apple disk image encrypted with a password, which the
108-
reader opens only with that password."""
109-
return qnxprobe.acquisition_format(path) == 'DMG_ENCRYPTED'
108+
"""True when ``path`` is an image the reader opens only with its password: an
109+
encrypted Apple disk image, or an E01, SMART or raw set FTK Imager encrypted with
110+
AD encryption (a raw set from any of its numbered files)."""
111+
return qnxprobe.needs_password(path)
112+
113+
114+
def _encrypted_kind(path):
115+
"""What an encrypted image is, as a sentence names it."""
116+
if qnxprobe.acquisition_format(path) == 'AD_ENCRYPTED':
117+
return 'an acquisition FTK Imager encrypted with AD encryption'
118+
return 'an encrypted Apple disk image'
110119

111120

112121
def _open_errors():
@@ -127,7 +136,7 @@ def password_opens(path, password):
127136

128137

129138
def cli_image_password(path, password_file=None, password_env=None):
130-
"""The password for an encrypted Apple disk image, for the command line: the
139+
"""The password for an encrypted image, for the command line: the
131140
first line of ``password_file``, else the environment variable ``password_env``,
132141
else, at a terminal, asked for (three tries). None for an image that needs none.
133142
@@ -155,7 +164,7 @@ def cli_image_password(path, password_file=None, password_env=None):
155164
print('That password does not open the image.', file=sys.stderr)
156165
raise ValueError(f'the password does not open {name}')
157166
else:
158-
raise ValueError(f'{name} is an encrypted Apple disk image and opens only '
167+
raise ValueError(f'{name} is {_encrypted_kind(path)} and opens only '
159168
f'with its password; give it with --image_password_file '
160169
f'or --image_password_env, or run at a terminal to be '
161170
f'asked for it')
@@ -167,12 +176,12 @@ def cli_image_password(path, password_file=None, password_env=None):
167176

168177

169178
def ask_image_password(parent, path):
170-
"""The password for an encrypted Apple disk image, asked for in a dialog over
179+
"""The password for an encrypted image, asked for in a dialog over
171180
``parent`` until it opens the image. None when the examiner cancels, or when the
172181
image will not open for another reason, which is shown."""
173182
from tkinter import messagebox, simpledialog # pylint: disable=import-outside-toplevel
174183
name = os.path.basename(os.path.normpath(path))
175-
prompt = f'{name} is an encrypted Apple disk image. Its password:'
184+
prompt = f'{name} is {_encrypted_kind(path)}. Its password:'
176185
while True:
177186
password = simpledialog.askstring('Encrypted disk image', prompt, show='*',
178187
parent=parent)
@@ -278,7 +287,7 @@ def __init__(self, image_path, data_folder, password=None):
278287
self._entries = {}
279288
self._image = None
280289
try:
281-
# the password opens an encrypted Apple disk image and is not kept
290+
# the password opens an encrypted image and is not kept
282291
self._open(password)
283292
except BaseException:
284293
# A failed or interrupted (Ctrl-C on a slow walk) build never binds an
@@ -294,15 +303,18 @@ def _open(self, password=None):
294303
logfunc(f'Reading {name} in place with the vendored qnxprobe '
295304
f'{qnxprobe.QNXPROBE_VERSION}; only the files an artifact asks for '
296305
f'leave the image.')
297-
sibling = split_image_sibling(self.image_path)
306+
# an acquisition's reader joins its own files; a raw set FTK Imager encrypted
307+
# is numbered like a split image and its files are ciphertext until then
308+
acquisition = qnxprobe.acquisition_format(self.image_path)
309+
sibling = None if acquisition else split_image_sibling(self.image_path)
298310
if sibling:
299311
logfunc(f'{name} is one segment of a split image: '
300312
f'{os.path.basename(sibling)} sits beside it. The reader joins '
301313
f'every segment of the set in order.')
302314
# A set with a hole in its numbering raises SplitImageError here, by
303315
# name, and the run stops with that message rather than reading half a
304316
# disk as though it were whole.
305-
segments = qnxprobe.split_segments(self.image_path)
317+
segments = [] if acquisition else qnxprobe.split_segments(self.image_path)
306318
self._image = qnxprobe.open_image(self.image_path, segments, password=password)
307319
size = qnxprobe.image_size(self._image)
308320
if segments:

0 commit comments

Comments
 (0)