From f0eade0b44c8c886f9f25435d0f8cbae8b9b2c38 Mon Sep 17 00:00:00 2001 From: joost-j <12032793+joost-j@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:49:33 +0200 Subject: [PATCH] Initial commit --- dissect/hypervisor/__init__.py | 3 +- dissect/hypervisor/disk/c_dmg.py | 143 ++++++++++ dissect/hypervisor/disk/c_dmg.pyi | 254 ++++++++++++++++++ dissect/hypervisor/disk/dmg.py | 336 ++++++++++++++++++++++++ tests/_data/disk/dmg/aes128_udzo.dmg.gz | 3 + tests/_data/disk/dmg/aes256_udzo.dmg.gz | 3 + tests/_data/disk/dmg/udbz.dmg.gz | 3 + tests/_data/disk/dmg/udco.dmg.gz | 3 + tests/_data/disk/dmg/udro.dmg.gz | 3 + tests/_data/disk/dmg/udzo.dmg.gz | 3 + tests/_data/disk/dmg/ulfo.dmg.gz | 3 + tests/_data/disk/dmg/ulmo.dmg.gz | 3 + tests/disk/test_dmg.py | 64 +++++ 13 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 dissect/hypervisor/disk/c_dmg.py create mode 100644 dissect/hypervisor/disk/c_dmg.pyi create mode 100644 dissect/hypervisor/disk/dmg.py create mode 100644 tests/_data/disk/dmg/aes128_udzo.dmg.gz create mode 100644 tests/_data/disk/dmg/aes256_udzo.dmg.gz create mode 100644 tests/_data/disk/dmg/udbz.dmg.gz create mode 100644 tests/_data/disk/dmg/udco.dmg.gz create mode 100644 tests/_data/disk/dmg/udro.dmg.gz create mode 100644 tests/_data/disk/dmg/udzo.dmg.gz create mode 100644 tests/_data/disk/dmg/ulfo.dmg.gz create mode 100644 tests/_data/disk/dmg/ulmo.dmg.gz create mode 100644 tests/disk/test_dmg.py diff --git a/dissect/hypervisor/__init__.py b/dissect/hypervisor/__init__.py index aa22d24..52bcfff 100644 --- a/dissect/hypervisor/__init__.py +++ b/dissect/hypervisor/__init__.py @@ -1,11 +1,12 @@ from __future__ import annotations from dissect.hypervisor.descriptor import hyperv, ovf, pvs, vbox, vmx -from dissect.hypervisor.disk import asif, hdd, qcow2, vdi, vhd, vhdx, vmdk +from dissect.hypervisor.disk import asif, dmg, hdd, qcow2, vdi, vhd, vhdx, vmdk from dissect.hypervisor.util import envelope, vmtar __all__ = [ "asif", + "dmg", "envelope", "hdd", "hyperv", diff --git a/dissect/hypervisor/disk/c_dmg.py b/dissect/hypervisor/disk/c_dmg.py new file mode 100644 index 0000000..5dbea04 --- /dev/null +++ b/dissect/hypervisor/disk/c_dmg.py @@ -0,0 +1,143 @@ +# References: +# - https://newosxbook.com/DMG.html +# - https://github.com/nlitsme/encrypteddmg +# - https://github.com/Lekensteyn/dmg2img +from __future__ import annotations + +from dissect.cstruct import cstruct + +dmg_def = """ +#define KOLY_MAGIC b"koly" /* Marks the trailer at the very end of the file */ +#define MISH_MAGIC 0x6d697368 /* Marks a BLKX table inside the property list */ +#define ENCRCDSA_MAGIC b"encrcdsa" /* Marks a password encrypted image */ +#define SECTOR_SIZE 512 + +enum BLOCK_TYPE : uint32 { + ZERO_FILL = 0x00000000, /* Written out as zeroes, nothing stored in the data fork */ + RAW = 0x00000001, /* Stored verbatim, used by UDRW and UDRO */ + IGNORE = 0x00000002, /* Never allocated, reads back as zeroes */ + COMMENT = 0x7ffffffe, /* Marker carrying no sector data */ + ADC = 0x80000004, /* Apple Data Compression, used by UDCO */ + ZLIB = 0x80000005, /* zlib deflate, used by UDZO */ + BZLIB = 0x80000006, /* bzip2, used by UDBZ */ + LZFSE = 0x80000007, /* LZFSE, used by ULFO */ + LZMA = 0x80000008, /* LZMA, used by ULMO */ + TERMINATOR = 0xffffffff, /* Closes a BLKX table, carries no sector data */ +}; + +typedef struct { + uint32 Type; /* Algorithm the checksum was calculated with, 2 for CRC-32 */ + uint32 Size; /* How many bits of Data are meaningful */ + uint32 Data[32]; /* The checksum itself, zero padded to a fixed 128 bytes */ +} UDIFChecksum; + +typedef struct { + char Signature[4]; /* Always 'koly' */ + uint32 Version; /* 4 for every image seen so far */ + uint32 HeaderSize; /* Size of this trailer, fixed at 512 */ + uint32 Flags; /* Image wide flags, bit 0 marks the image as flattened */ + uint64 RunningDataForkOffset; /* Offset of this segment's data within the combined data fork */ + uint64 DataForkOffset; /* Where the data fork starts, 0 for a single segment image */ + uint64 DataForkLength; /* How many bytes of data fork follow, normally up to XMLOffset */ + uint64 RsrcForkOffset; /* Binary resource fork holding the blkx tables, 0 when XML is used */ + uint64 RsrcForkLength; /* Length of that resource fork, 0 when XML is used */ + uint32 SegmentNumber; /* 1 based index of this segment within the set */ + uint32 SegmentCount; /* Total segments, anything above 1 means a split .dmgpart set */ + char SegmentID[16]; /* GUID shared by every segment of the same image */ + + UDIFChecksum DataChecksum; /* Covers the data fork bytes as stored, still compressed */ + + uint64 XMLOffset; /* Where the property list holding the blkx tables begins */ + uint64 XMLLength; /* Byte length of that property list */ + char Reserved1[120]; /* Padding, zeroed */ + + UDIFChecksum Checksum; /* Covers the concatenated checksums of all blkx tables */ + + uint32 ImageVariant; /* 1 for a whole device image, 2 for a single partition */ + uint64 SectorCount; /* Length of the reconstructed disk, in sectors */ + + uint32 reserved2; + uint32 reserved3; + uint32 reserved4; +} UDIFResourceFile; + +typedef struct { + BLOCK_TYPE EntryType; /* How this run is stored, see BLOCK_TYPE */ + uint32 Comment; /* Holds "+beg" or "+end" for COMMENT runs, otherwise unused */ + uint64 SectorNumber; /* First sector of this run, counted from the table start */ + uint64 SectorCount; /* Length of this run, in sectors */ + uint64 CompressedOffset; /* Position of the stored bytes within the data fork */ + uint64 CompressedLength; /* How many bytes are stored there */ +} BLKXChunkEntry; + +typedef struct { + uint32 Signature; /* Always 'mish' */ + uint32 Version; /* 1 for every image seen in the wild */ + uint64 SectorNumber; /* First sector of the region this table describes */ + uint64 SectorCount; /* Length of that region, in sectors */ + + uint64 DataOffset; /* Base offset the entries below are relative to */ + uint32 BuffersNeeded; /* Decompression buffer hint, unused when reading */ + uint32 BlockDescriptors; /* Index of the descriptor this table belongs to */ + + uint32 reserved1; + uint32 reserved2; + uint32 reserved3; + uint32 reserved4; + uint32 reserved5; + uint32 reserved6; + + UDIFChecksum checksum; /* Covers this table's decompressed data, skipping IGNORE runs */ + + uint32 NumberOfBlockChunks; /* How many entries follow */ + BLKXChunkEntry Entries[NumberOfBlockChunks]; +} BLKXTable; + +typedef struct { + char Signature[8]; /* Always 'encrcdsa' */ + uint32 Version; /* 2 for the password wrapped scheme handled here */ + uint32 BlockIVSize; /* Length of the per block IV, 16 for AES-CBC */ + uint32 EncryptionMode; + uint32 EncryptionAlgorithm; + uint32 KeyBits; /* Bit size of the data AES key (128 or 256) */ + uint32 PRNGAlgorithm; + uint32 PRNGKeyBits; + char UUID[16]; + uint32 BlockSize; /* Size of each independently encrypted data block (typically 512) */ + uint64 DataSize; /* Size of the (decrypted) UDIF image in bytes */ + uint64 DataOffset; /* Offset in the file where the encrypted data starts */ + uint32 KeyCount; /* Number of key blobs that follow */ +} EncrcdsaHeader; + +typedef struct { + uint32 Type; /* Key blob type (1 for a passphrase-wrapped key) */ + uint32 _reserved1; + uint32 Offset; /* Offset in the file to the key blob (password header) */ + uint32 _reserved2; + uint32 Size; /* Size of the key blob */ +} EncrcdsaKeyPointer; + +typedef struct { + uint32 KDFAlgorithm; /* 103 = PKCS#5 PBKDF2 */ + uint64 KDFIterationCount; + uint32 KDFSaltLen; + char KDFSalt[32]; + uint32 BlobEncIVSize; + char BlobEncIV[32]; + uint32 BlobEncKeyBits; /* Bit size of the key-encryption key (192 -> AES-192) */ + uint32 BlobEncAlgorithm; + uint32 BlobEncPadding; + uint32 BlobEncMode; + uint32 EncryptedKeyblobSize; + char EncryptedKeyblob[EncryptedKeyblobSize]; +} EncrcdsaKeyBlob; +""" + +c_dmg = cstruct(endian=">").load(dmg_def) + +BLOCK_TYPE = c_dmg.BLOCK_TYPE +KOLY_MAGIC = c_dmg.KOLY_MAGIC +MISH_MAGIC = c_dmg.MISH_MAGIC +SECTOR_SIZE = c_dmg.SECTOR_SIZE +ENCRCDSA_MAGIC = c_dmg.ENCRCDSA_MAGIC +KOLY_SIZE = c_dmg.UDIFResourceFile.size diff --git a/dissect/hypervisor/disk/c_dmg.pyi b/dissect/hypervisor/disk/c_dmg.pyi new file mode 100644 index 0000000..c96fa8b --- /dev/null +++ b/dissect/hypervisor/disk/c_dmg.pyi @@ -0,0 +1,254 @@ +# Generated by cstruct-stubgen +from typing import BinaryIO, Literal, TypeAlias, overload + +import dissect.cstruct as __cs__ + +class _c_dmg(__cs__.cstruct): + KOLY_MAGIC: Literal[b"koly"] = ... + MISH_MAGIC: Literal[1835627368] = ... + ENCRCDSA_MAGIC: Literal[b"encrcdsa"] = ... + SECTOR_SIZE: Literal[512] = ... + class BLOCK_TYPE(__cs__.Enum): + ZERO_FILL = ... + RAW = ... + IGNORE = ... + COMMENT = ... + ADC = ... + ZLIB = ... + BZLIB = ... + LZFSE = ... + LZMA = ... + TERMINATOR = ... + + class UDIFChecksum(__cs__.Structure): + Type: _c_dmg.uint32 + Size: _c_dmg.uint32 + Data: __cs__.Array[_c_dmg.uint32] + @overload + def __init__( + self, + Type: _c_dmg.uint32 | None = ..., + Size: _c_dmg.uint32 | None = ..., + Data: __cs__.Array[_c_dmg.uint32] | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class UDIFResourceFile(__cs__.Structure): + Signature: __cs__.CharArray + Version: _c_dmg.uint32 + HeaderSize: _c_dmg.uint32 + Flags: _c_dmg.uint32 + RunningDataForkOffset: _c_dmg.uint64 + DataForkOffset: _c_dmg.uint64 + DataForkLength: _c_dmg.uint64 + RsrcForkOffset: _c_dmg.uint64 + RsrcForkLength: _c_dmg.uint64 + SegmentNumber: _c_dmg.uint32 + SegmentCount: _c_dmg.uint32 + SegmentID: __cs__.CharArray + DataChecksum: _c_dmg.UDIFChecksum + XMLOffset: _c_dmg.uint64 + XMLLength: _c_dmg.uint64 + Reserved1: __cs__.CharArray + Checksum: _c_dmg.UDIFChecksum + ImageVariant: _c_dmg.uint32 + SectorCount: _c_dmg.uint64 + reserved2: _c_dmg.uint32 + reserved3: _c_dmg.uint32 + reserved4: _c_dmg.uint32 + @overload + def __init__( + self, + Signature: __cs__.CharArray | None = ..., + Version: _c_dmg.uint32 | None = ..., + HeaderSize: _c_dmg.uint32 | None = ..., + Flags: _c_dmg.uint32 | None = ..., + RunningDataForkOffset: _c_dmg.uint64 | None = ..., + DataForkOffset: _c_dmg.uint64 | None = ..., + DataForkLength: _c_dmg.uint64 | None = ..., + RsrcForkOffset: _c_dmg.uint64 | None = ..., + RsrcForkLength: _c_dmg.uint64 | None = ..., + SegmentNumber: _c_dmg.uint32 | None = ..., + SegmentCount: _c_dmg.uint32 | None = ..., + SegmentID: __cs__.CharArray | None = ..., + DataChecksum: _c_dmg.UDIFChecksum | None = ..., + XMLOffset: _c_dmg.uint64 | None = ..., + XMLLength: _c_dmg.uint64 | None = ..., + Reserved1: __cs__.CharArray | None = ..., + Checksum: _c_dmg.UDIFChecksum | None = ..., + ImageVariant: _c_dmg.uint32 | None = ..., + SectorCount: _c_dmg.uint64 | None = ..., + reserved2: _c_dmg.uint32 | None = ..., + reserved3: _c_dmg.uint32 | None = ..., + reserved4: _c_dmg.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class BLKXChunkEntry(__cs__.Structure): + EntryType: _c_dmg.BLOCK_TYPE + Comment: _c_dmg.uint32 + SectorNumber: _c_dmg.uint64 + SectorCount: _c_dmg.uint64 + CompressedOffset: _c_dmg.uint64 + CompressedLength: _c_dmg.uint64 + @overload + def __init__( + self, + EntryType: _c_dmg.BLOCK_TYPE | None = ..., + Comment: _c_dmg.uint32 | None = ..., + SectorNumber: _c_dmg.uint64 | None = ..., + SectorCount: _c_dmg.uint64 | None = ..., + CompressedOffset: _c_dmg.uint64 | None = ..., + CompressedLength: _c_dmg.uint64 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class BLKXTable(__cs__.Structure): + Signature: _c_dmg.uint32 + Version: _c_dmg.uint32 + SectorNumber: _c_dmg.uint64 + SectorCount: _c_dmg.uint64 + DataOffset: _c_dmg.uint64 + BuffersNeeded: _c_dmg.uint32 + BlockDescriptors: _c_dmg.uint32 + reserved1: _c_dmg.uint32 + reserved2: _c_dmg.uint32 + reserved3: _c_dmg.uint32 + reserved4: _c_dmg.uint32 + reserved5: _c_dmg.uint32 + reserved6: _c_dmg.uint32 + checksum: _c_dmg.UDIFChecksum + NumberOfBlockChunks: _c_dmg.uint32 + class BLKXChunkEntry(__cs__.Structure): + EntryType: _c_dmg.BLOCK_TYPE + Comment: _c_dmg.uint32 + SectorNumber: _c_dmg.uint64 + SectorCount: _c_dmg.uint64 + CompressedOffset: _c_dmg.uint64 + CompressedLength: _c_dmg.uint64 + @overload + def __init__( + self, + EntryType: _c_dmg.BLOCK_TYPE | None = ..., + Comment: _c_dmg.uint32 | None = ..., + SectorNumber: _c_dmg.uint64 | None = ..., + SectorCount: _c_dmg.uint64 | None = ..., + CompressedOffset: _c_dmg.uint64 | None = ..., + CompressedLength: _c_dmg.uint64 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + Entries: __cs__.Array[BLKXChunkEntry] + @overload + def __init__( + self, + Signature: _c_dmg.uint32 | None = ..., + Version: _c_dmg.uint32 | None = ..., + SectorNumber: _c_dmg.uint64 | None = ..., + SectorCount: _c_dmg.uint64 | None = ..., + DataOffset: _c_dmg.uint64 | None = ..., + BuffersNeeded: _c_dmg.uint32 | None = ..., + BlockDescriptors: _c_dmg.uint32 | None = ..., + reserved1: _c_dmg.uint32 | None = ..., + reserved2: _c_dmg.uint32 | None = ..., + reserved3: _c_dmg.uint32 | None = ..., + reserved4: _c_dmg.uint32 | None = ..., + reserved5: _c_dmg.uint32 | None = ..., + reserved6: _c_dmg.uint32 | None = ..., + checksum: _c_dmg.UDIFChecksum | None = ..., + NumberOfBlockChunks: _c_dmg.uint32 | None = ..., + Entries: __cs__.Array[BLKXChunkEntry] | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class EncrcdsaHeader(__cs__.Structure): + Signature: __cs__.CharArray + Version: _c_dmg.uint32 + BlockIVSize: _c_dmg.uint32 + EncryptionMode: _c_dmg.uint32 + EncryptionAlgorithm: _c_dmg.uint32 + KeyBits: _c_dmg.uint32 + PRNGAlgorithm: _c_dmg.uint32 + PRNGKeyBits: _c_dmg.uint32 + UUID: __cs__.CharArray + BlockSize: _c_dmg.uint32 + DataSize: _c_dmg.uint64 + DataOffset: _c_dmg.uint64 + KeyCount: _c_dmg.uint32 + @overload + def __init__( + self, + Signature: __cs__.CharArray | None = ..., + Version: _c_dmg.uint32 | None = ..., + BlockIVSize: _c_dmg.uint32 | None = ..., + EncryptionMode: _c_dmg.uint32 | None = ..., + EncryptionAlgorithm: _c_dmg.uint32 | None = ..., + KeyBits: _c_dmg.uint32 | None = ..., + PRNGAlgorithm: _c_dmg.uint32 | None = ..., + PRNGKeyBits: _c_dmg.uint32 | None = ..., + UUID: __cs__.CharArray | None = ..., + BlockSize: _c_dmg.uint32 | None = ..., + DataSize: _c_dmg.uint64 | None = ..., + DataOffset: _c_dmg.uint64 | None = ..., + KeyCount: _c_dmg.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class EncrcdsaKeyPointer(__cs__.Structure): + Type: _c_dmg.uint32 + _reserved1: _c_dmg.uint32 + Offset: _c_dmg.uint32 + _reserved2: _c_dmg.uint32 + Size: _c_dmg.uint32 + @overload + def __init__( + self, + Type: _c_dmg.uint32 | None = ..., + _reserved1: _c_dmg.uint32 | None = ..., + Offset: _c_dmg.uint32 | None = ..., + _reserved2: _c_dmg.uint32 | None = ..., + Size: _c_dmg.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class EncrcdsaKeyBlob(__cs__.Structure): + KDFAlgorithm: _c_dmg.uint32 + KDFIterationCount: _c_dmg.uint64 + KDFSaltLen: _c_dmg.uint32 + KDFSalt: __cs__.CharArray + BlobEncIVSize: _c_dmg.uint32 + BlobEncIV: __cs__.CharArray + BlobEncKeyBits: _c_dmg.uint32 + BlobEncAlgorithm: _c_dmg.uint32 + BlobEncPadding: _c_dmg.uint32 + BlobEncMode: _c_dmg.uint32 + EncryptedKeyblobSize: _c_dmg.uint32 + EncryptedKeyblob: __cs__.CharArray + @overload + def __init__( + self, + KDFAlgorithm: _c_dmg.uint32 | None = ..., + KDFIterationCount: _c_dmg.uint64 | None = ..., + KDFSaltLen: _c_dmg.uint32 | None = ..., + KDFSalt: __cs__.CharArray | None = ..., + BlobEncIVSize: _c_dmg.uint32 | None = ..., + BlobEncIV: __cs__.CharArray | None = ..., + BlobEncKeyBits: _c_dmg.uint32 | None = ..., + BlobEncAlgorithm: _c_dmg.uint32 | None = ..., + BlobEncPadding: _c_dmg.uint32 | None = ..., + BlobEncMode: _c_dmg.uint32 | None = ..., + EncryptedKeyblobSize: _c_dmg.uint32 | None = ..., + EncryptedKeyblob: __cs__.CharArray | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + +# Technically `c_dmg` is an instance of `_c_dmg`, but then we can't use it in type hints +c_dmg: TypeAlias = _c_dmg diff --git a/dissect/hypervisor/disk/dmg.py b/dissect/hypervisor/disk/dmg.py new file mode 100644 index 0000000..9b798eb --- /dev/null +++ b/dissect/hypervisor/disk/dmg.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import bz2 +import hashlib +import hmac +import io +import lzma +import plistlib +import struct +import zlib +from bisect import bisect_right +from functools import cached_property, lru_cache +from typing import BinaryIO, NamedTuple +from uuid import UUID + +from dissect.util.compression import adc, lzfse +from dissect.util.stream import AlignedStream + +from dissect.hypervisor.disk.c_dmg import ( + BLOCK_TYPE, + ENCRCDSA_MAGIC, + KOLY_MAGIC, + KOLY_SIZE, + MISH_MAGIC, + SECTOR_SIZE, + c_dmg, +) +from dissect.hypervisor.exceptions import Error, InvalidSignature + +try: + from Crypto.Cipher import AES + + HAS_PYCRYPTODOME = True +except ImportError: + HAS_PYCRYPTODOME = False + +# Block types that carry no sector data and can be skipped while building the chunk map. +_SKIP_TYPES = frozenset({BLOCK_TYPE.COMMENT, BLOCK_TYPE.TERMINATOR}) + +# Block types that read back as zeroes without consuming any bytes from the data fork. +_ZERO_TYPES = frozenset({BLOCK_TYPE.ZERO_FILL, BLOCK_TYPE.IGNORE}) + + +class Chunk(NamedTuple): + """A single, contiguous run of sectors in the (decompressed) disk image.""" + + sector: int + count: int + type: BLOCK_TYPE + offset: int + length: int + + +class DMG: + """Apple Universal Disk Image Format (UDIF) disk image. + + A DMG (``.dmg``) file is a container that exposes a raw disk image (typically wrapping an APFS or HFS+ volume). The + actual disk data lives in a "data fork" at the start of the file, optionally split into compressed chunks, and is + described by a set of ``BLKX`` (``mish``) tables stored in an XML property list. A fixed 512 byte ``koly`` trailer + at the very end of the file ties everything together. + + Currently supported (UDIF) variants are uncompressed (``UDRW``/``UDRO``), zlib (``UDZO``), bzip2 (``UDBZ``), + LZFSE (``ULFO``), LZMA (``ULMO``) and ADC (``UDCO``), including the zeroed and unallocated runs such an image + uses to describe empty space. Note that the sparse image containers (``.sparseimage`` and ``.sparsebundle``) + are a different format and are not supported. + + Password-encrypted (``encrcdsa``, AES-128 and AES-256) DMGs are transparently decrypted when a ``password`` is + provided. This requires the optional ``pycryptodome`` dependency. + + Args: + fh: File-like object containing the DMG image. + password: Optional password to decrypt an encrypted (``encrcdsa``) DMG. + + Raises: + InvalidSignature: If the file does not have a valid UDIF trailer (koly block). + Error: If the DMG has no property list to describe the image with. + RuntimeError: If the DMG is encrypted and the ``pycryptodome`` dependency is not available. + ValueError: If the DMG is encrypted and no or a wrong password was provided. + + Resources: + - https://newosxbook.com/DMG.html + - https://github.com/nlitsme/encrypteddmg + - https://github.com/Lekensteyn/dmg2img + """ + + def __init__(self, fh: BinaryIO, password: str | bytes | None = None): + fh.seek(0) + if fh.read(len(ENCRCDSA_MAGIC)) == ENCRCDSA_MAGIC: + fh = _open_encrypted(fh, password) + + self.fh = fh + + # Only the koly trailer is read up front. It is a fixed 512 byte block at the very end of the file and holds + # everything needed to describe the image; the (potentially large) XML property list is only parsed on demand. + fh.seek(-KOLY_SIZE, io.SEEK_END) + self.koly = c_dmg.UDIFResourceFile(fh) + if self.koly.Signature != KOLY_MAGIC: + raise InvalidSignature(f"Not a valid UDIF image (expected {KOLY_MAGIC!r}, got {self.koly.Signature!r})") + + if not self.koly.XMLLength: + raise Error("DMG has no XML property list") + + self.guid = UUID(bytes=self.koly.SegmentID) + self.sector_count = self.koly.SectorCount + self.size = self.sector_count * SECTOR_SIZE + + @cached_property + def chunks(self) -> list[Chunk]: + """The flattened, sorted list of :class:`Chunk` runs that make up the disk image.""" + self.fh.seek(self.koly.XMLOffset) + plist = plistlib.loads(self.fh.read(self.koly.XMLLength)) + + try: + blkx = plist["resource-fork"]["blkx"] + except KeyError: + raise Error("DMG property list does not contain any blkx resources") + + chunks: list[Chunk] = [] + for resource in blkx: + table = c_dmg.BLKXTable(resource["Data"]) + if table.Signature != MISH_MAGIC: + continue + + for entry in table.Entries: + if entry.EntryType in _SKIP_TYPES: + continue + + chunks.append( + Chunk( + sector=table.SectorNumber + entry.SectorNumber, + count=entry.SectorCount, + type=entry.EntryType, + offset=self.koly.DataForkOffset + entry.CompressedOffset, + length=entry.CompressedLength, + ) + ) + + # Despite that the blocks are usually in the correct order, + # this is not guaranteed, so sort them just to be safe. + chunks.sort(key=lambda chunk: chunk.sector) + return chunks + + def open(self) -> DMGStream: + """Open the reconstructed raw disk image as a file-like object.""" + return DMGStream(self.fh, self.chunks, self.size) + + def __repr__(self) -> str: + return f"" + + +class DMGStream(AlignedStream): + """A file-like object that transparently decompresses a UDIF DMG data fork into a raw disk image. + + Args: + fh: File-like object of the DMG file. + chunks: The list of :class:`Chunk` runs, sorted by start sector. + size: The total size of the (decompressed) disk image in bytes. + """ + + def __init__(self, fh: BinaryIO, chunks: list[Chunk], size: int): + super().__init__(size) + self.fh = fh + self.chunks = chunks + + # Precompute the start sectors so the read path can binary search them. + self._sectors = [chunk.sector for chunk in chunks] + + # Decompressing a chunk is relatively expensive, and a single chunk usually spans several aligned reads, so we + # cache the most recently used decompressed chunks. Only compressed chunks are cached, as sparse and raw runs + # (which may cover many gigabytes in a single chunk) are served without ever materializing the full chunk. + self._read_compressed_chunk = lru_cache(maxsize=8)(self._read_compressed_chunk) + + def _read(self, offset: int, length: int) -> bytes: + result = [] + + length = min(length, self.size - offset) + pos = offset + + # Find the first chunk that could contain ``offset``. + idx = max(bisect_right(self._sectors, offset // SECTOR_SIZE) - 1, 0) + + while length > 0: + if idx >= len(self.chunks): + # Past the last described chunk, pad with zeroes. + result.append(b"\x00" * length) + break + + chunk = self.chunks[idx] + chunk_start = chunk.sector * SECTOR_SIZE + chunk_size = chunk.count * SECTOR_SIZE + chunk_end = chunk_start + chunk_size + + if pos < chunk_start: + # There is a gap before this chunk that is not described, treat it as sparse (zeroes). + gap = min(chunk_start - pos, length) + result.append(b"\x00" * gap) + pos += gap + length -= gap + continue + + if pos >= chunk_end: + idx += 1 + continue + + inner = pos - chunk_start + take = min(chunk_size - inner, length) + + if chunk.type in _ZERO_TYPES: + # Sparse/zeroed run, no data in the data fork. Only emit the bytes we actually need. + result.append(b"\x00" * take) + elif chunk.type == BLOCK_TYPE.RAW: + # Uncompressed, read only the requested slice straight from the data fork. + self.fh.seek(chunk.offset + inner) + result.append(self.fh.read(take)) + else: + # Compressed, the whole chunk has to be decompressed. This is cached, so slicing it is cheap. + result.append(self._read_compressed_chunk(idx)[inner : inner + take]) + + pos += take + length -= take + idx += 1 + + return b"".join(result) + + def _read_compressed_chunk(self, idx: int) -> bytes: + chunk = self.chunks[idx] + size = chunk.count * SECTOR_SIZE + + self.fh.seek(chunk.offset) + data = self.fh.read(chunk.length) + + if chunk.type == BLOCK_TYPE.ZLIB: + buf = zlib.decompress(data) + elif chunk.type == BLOCK_TYPE.BZLIB: + buf = bz2.decompress(data) + elif chunk.type == BLOCK_TYPE.LZFSE: + buf = lzfse.decompress(data) + elif chunk.type == BLOCK_TYPE.LZMA: + buf = lzma.decompress(data) + elif chunk.type == BLOCK_TYPE.ADC: + buf = adc.decompress(data) + else: + raise Error(f"Unsupported DMG block type: {chunk.type}") + + if len(buf) != size: + raise Error(f"Decompressed chunk size mismatch: got {len(buf)}, expected {size}") + + return buf + + +# Marker at the end of a correctly unwrapped keyblob, right before the PKCS#7 padding. +_CKIE_MARKER = b"CKIE\x00" + + +def _open_encrypted(fh: BinaryIO, password: str | bytes | None) -> EncryptedStream: + """Derive the encryption keys of an ``encrcdsa`` DMG and return a stream that decrypts the wrapped UDIF image. + + The key derivation follows Apple's version 2 password-wrapped scheme: a key-encryption-key is derived from the + password with PBKDF2-HMAC-SHA1, used to AES-unwrap a keyblob holding the AES data key and the HMAC-SHA1 key. + + Args: + fh: File-like object positioned at an ``encrcdsa`` image. + password: The password to decrypt the image with. + """ + if not HAS_PYCRYPTODOME: + raise RuntimeError("No crypto module available") + if password is None: + raise ValueError("DMG is encrypted but no password was provided") + if isinstance(password, str): + password = password.encode() + + fh.seek(0) + header = c_dmg.EncrcdsaHeader(fh) + # The key blob(s) are located through a small pointer table that follows the header. + pointer = c_dmg.EncrcdsaKeyPointer(fh) + fh.seek(pointer.Offset) + blob = c_dmg.EncrcdsaKeyBlob(fh) + + # Derive the key-encryption-key and AES-unwrap the keyblob. + kek = hashlib.pbkdf2_hmac( + "sha1", password, blob.KDFSalt[: blob.KDFSaltLen], blob.KDFIterationCount, blob.BlobEncKeyBits // 8 + ) + unwrapped = AES.new(kek, AES.MODE_CBC, blob.BlobEncIV[:16]).decrypt(blob.EncryptedKeyblob) + + # A correct unwrap ends in PKCS#7 padding followed by a fixed marker. Anything else means a wrong password. + pad = unwrapped[-1] if unwrapped else 0 + if not 1 <= pad <= 16 or unwrapped[:-pad][-len(_CKIE_MARKER) :] != _CKIE_MARKER: + raise ValueError("Failed to decrypt DMG keyblob (wrong password?)") + + keydata = unwrapped[: -pad - len(_CKIE_MARKER)] + key_bytes = header.KeyBits // 8 + aes_key = keydata[:key_bytes] + hmac_key = keydata[key_bytes : key_bytes + 20] + + return EncryptedStream(fh, aes_key, hmac_key, header.DataOffset, header.BlockSize, header.DataSize) + + +class EncryptedStream(AlignedStream): + """A file-like object that transparently decrypts the UDIF image inside an ``encrcdsa`` DMG. + + The data is stored as independently AES-CBC encrypted blocks of ``block_size`` bytes. The IV for each block is + derived from an HMAC-SHA1 of its (big-endian) block number, which makes random access straightforward. + + Args: + fh: File-like object of the encrypted DMG. + aes_key: The AES key for the data blocks. + hmac_key: The HMAC-SHA1 key used to derive the per-block IVs. + data_offset: Offset in the file where the encrypted data starts. + block_size: Size of each independently encrypted block. + size: Size of the decrypted image in bytes. + """ + + def __init__(self, fh: BinaryIO, aes_key: bytes, hmac_key: bytes, data_offset: int, block_size: int, size: int): + super().__init__(size, align=block_size) + self.fh = fh + self.aes_key = aes_key + self.hmac_key = hmac_key + self.data_offset = data_offset + self.block_size = block_size + + def _read(self, offset: int, length: int) -> bytes: + length = min(length, self.size - offset) + + first = offset // self.block_size + count = (length + self.block_size - 1) // self.block_size + + self.fh.seek(self.data_offset + first * self.block_size) + encrypted = self.fh.read(count * self.block_size) + + result = [] + for i in range(count): + block = encrypted[i * self.block_size : (i + 1) * self.block_size] + iv = hmac.new(self.hmac_key, struct.pack(">L", first + i), hashlib.sha1).digest()[:16] + result.append(AES.new(self.aes_key, AES.MODE_CBC, iv).decrypt(block)) + + return b"".join(result)[:length] diff --git a/tests/_data/disk/dmg/aes128_udzo.dmg.gz b/tests/_data/disk/dmg/aes128_udzo.dmg.gz new file mode 100644 index 0000000..bf9ef32 --- /dev/null +++ b/tests/_data/disk/dmg/aes128_udzo.dmg.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5176aa00cdb5c17d3e3473d0dd3c2f823d452ffafcec589399f0b2356e30d561 +size 17496 diff --git a/tests/_data/disk/dmg/aes256_udzo.dmg.gz b/tests/_data/disk/dmg/aes256_udzo.dmg.gz new file mode 100644 index 0000000..6b304b4 --- /dev/null +++ b/tests/_data/disk/dmg/aes256_udzo.dmg.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cb25ed7c93699d0665cd302bb5c501861f3ce4484ba9d65652115caa368514c +size 17518 diff --git a/tests/_data/disk/dmg/udbz.dmg.gz b/tests/_data/disk/dmg/udbz.dmg.gz new file mode 100644 index 0000000..436aff2 --- /dev/null +++ b/tests/_data/disk/dmg/udbz.dmg.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe62f770704aa825f31bfd5218c60c2425ef0c998f66006504f98ea356dfabed +size 4335 diff --git a/tests/_data/disk/dmg/udco.dmg.gz b/tests/_data/disk/dmg/udco.dmg.gz new file mode 100644 index 0000000..01f6fe1 --- /dev/null +++ b/tests/_data/disk/dmg/udco.dmg.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa99688021b69e80940ffd168dc0cc22fdc50fec405abe5fa6194727d98f74be +size 4997 diff --git a/tests/_data/disk/dmg/udro.dmg.gz b/tests/_data/disk/dmg/udro.dmg.gz new file mode 100644 index 0000000..8f4755a --- /dev/null +++ b/tests/_data/disk/dmg/udro.dmg.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8225acac234e20b2ed8c856add54efb3defcb4a2efcd1daa88361d82f4802a1f +size 5523 diff --git a/tests/_data/disk/dmg/udzo.dmg.gz b/tests/_data/disk/dmg/udzo.dmg.gz new file mode 100644 index 0000000..465d762 --- /dev/null +++ b/tests/_data/disk/dmg/udzo.dmg.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da436ca1865a231550f508b2f1cb850a8767c4824fd4b250d0f299d4e59232dd +size 5694 diff --git a/tests/_data/disk/dmg/ulfo.dmg.gz b/tests/_data/disk/dmg/ulfo.dmg.gz new file mode 100644 index 0000000..dfb8369 --- /dev/null +++ b/tests/_data/disk/dmg/ulfo.dmg.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49ae0a26e4d6dcc340448673922225ae498c47af0a8757d74839ea42f922b10d +size 4925 diff --git a/tests/_data/disk/dmg/ulmo.dmg.gz b/tests/_data/disk/dmg/ulmo.dmg.gz new file mode 100644 index 0000000..fe4141c --- /dev/null +++ b/tests/_data/disk/dmg/ulmo.dmg.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d73711f45b62f34a6be53fe4bed33a86adf38fcb70924dc18d6963e6a495fb3 +size 4045 diff --git a/tests/disk/test_dmg.py b/tests/disk/test_dmg.py new file mode 100644 index 0000000..5621e26 --- /dev/null +++ b/tests/disk/test_dmg.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import gzip +import io + +import pytest + +from dissect.hypervisor.disk.dmg import DMG +from tests._util import absolute_path + +ENCRYPTED_VARIANTS = ["aes128_udzo", "aes256_udzo"] +COMPRESSED_VARIANTS = ["udro", "udzo", "udbz", "udco", "ulfo", "ulmo"] +PASSWORD = "dissect" + + +def _open(name: str) -> io.BytesIO: + # The DMG parser seeks to the koly trailer at the end of the file, which a GzipFile does not support. + with gzip.open(absolute_path(f"_data/disk/dmg/{name}.dmg.gz"), "rb") as fh: + return io.BytesIO(fh.read()) + + +@pytest.fixture +def reference() -> bytes: + return DMG(_open("udro")).open().read() + + +def test_dmg_udro(reference: bytes) -> None: + """The uncompressed UDRO image parses and exposes the raw disk.""" + dmg = DMG(_open("udro")) + + assert dmg.size == dmg.sector_count * 512 + assert dmg.size == len(reference) + assert reference[:2] == b"\x00\x00" # APFS container starts with a zeroed block 0 + assert b"README.txt" in reference + + +@pytest.mark.parametrize("variant", COMPRESSED_VARIANTS) +def test_dmg_variant_matches_reference(variant: str, reference: bytes) -> None: + """Every compressed variant decompresses byte-identical to the uncompressed reference.""" + dmg = DMG(_open(variant)) + + assert dmg.size == len(reference) + assert dmg.open().read() == reference + + +@pytest.mark.parametrize("variant", ENCRYPTED_VARIANTS) +def test_dmg_encrypted(variant: str, reference: bytes) -> None: + """Password-encrypted (encrcdsa AES-128/256) DMGs decrypt and decode to the same raw disk.""" + pytest.importorskip("Crypto") + + dmg = DMG(_open(variant), password=PASSWORD) + assert dmg.size == len(reference) + assert dmg.open().read() == reference + + +def test_dmg_encrypted_wrong_or_no_password() -> None: + """Decrypting without a password, or with the wrong one, is rejected.""" + pytest.importorskip("Crypto") + + with pytest.raises(ValueError, match="wrong password"): + DMG(_open("aes256_udzo"), password="notthepassword") + + with pytest.raises(ValueError, match="no password was provided"): + DMG(_open("aes128_udzo"))