Skip to content

Add support for unencrypted container key bag #90

Description

@willmcginnis

Both key bag readers in libfsapfs decrypt unconditionally. libfsapfs_container_key_bag_read_file_io_handle always AES-128-XTS decrypts the container key bag using the container UUID as both key and tweak key, and libfsapfs_volume_key_bag_read_file_io_handle does the same with the volume UUID. Neither has a branch that can skip the decrypt, and in each function read_data is called only on the decrypted buffer. On images where the key bag is already stored in the clear, and those exist, the decrypt turns a valid checksum-correct object into noise, and the parse then fails. On the container key bag that failure is recorded as an encryption property of the image rather than as a read error; on the volume key bag it is fatal and aborts the open.

All line numbers below are against c5afe4d, current main at the time of writing.

The symptom, and why it reads as something other than a parse failure

On the container side the parse failure is converted into a flag and then printed as a finding:

libfsapfs_container_key_bag.c:504    object type != 0x6b657973   -> return( 0 )
libfsapfs_container.c:1791           result == 0                 -> key_bag->is_locked = 1
libfsapfs_container.c:2090           libfsapfs_container_is_locked() returns that flag
fsapfstools/info_handle.c:4365       prints "Is locked (uses hardware encryption)"

That string is the object-type mismatch restated. It is not a reading of any on-disk flag: libfsapfs_container.c:1791 is the only assignment in the tree that makes key_bag->is_locked non-zero, and the struct is zeroed at libfsapfs_container_key_bag.c:89.

The volume line printed under it is not independent either. At libfsapfs_volume.c:557, for a volume not marked unencrypted on disk, the volume inherits the container key bag's flag before its own volume key bag is read, so a single container-level parse failure surfaces at two levels and reads like corroboration:

libfsapfs_volume.c:557               internal_volume->is_locked = internal_volume->container_key_bag->is_locked;
fsapfstools/info_handle.c:4100       prints "Is locked"

On a specimen where the container key bag really is encrypted, that same volume line comes from the genuine path at libfsapfs_volume.c:640, after the volume key bag has been read successfully. Same string, two different causes, and nothing in the output distinguishes them.

The same symptom, from an image that is not corrupt

documentation/Apple File System (APFS).asciidoc carries this under == Corruption scenarios:

=== Container key bag is hardware encrypted but volume is not encrypted

Seen in APFS containers created by certain digital forensics tools. The
container key bag is either hardware encrypted or contains random data but
the volume is not encrypted.

I hit exactly that symptom on an image nothing had corrupted, and the raw block is neither hardware encrypted nor random. It is an intact, well-formed plaintext container key bag carrying a Fletcher-64 checksum that verifies over its own contents. The scenario above is written for an unencrypted volume, and the volume on this image is in fact encrypted, so the specimen matches neither half of it, yet libfsapfs destroys the block by decrypting it and then prints the diagnostic your notes file under that corruption heading.

So the library produces that same container diagnostic for an image whose container key bag is valid plaintext, and the two can be told apart cheaply. Random data agreeing with a stored 64-bit checksum of itself is a 2^-64 event. The three fields the container reader already reads are therefore enough to separate the cases before deciding whether to decrypt: object type, object subtype, and the checksum. If the raw on-disk block passes all three it is intact plaintext, and decrypting it is the thing that breaks it. If it does not pass, the current path is correct and nothing changes.

I am not suggesting the forensics-tool observation is wrong. The narrower point is that libfsapfs produces the identical output for an image that matches neither half of that scenario, and cannot presently tell the two apart because it never inspects the raw block. A reader who sees that string is pointed at a corruption heading, and on this specimen nothing is corrupt. That says nothing about the images the documented scenario was written from; it is only that the same diagnostic now covers a non-corrupt case too.

Site 1, the container key bag

libfsapfs_container_key_bag_read_file_io_handle at libfsapfs_container_key_bag.c:183 reads data_size bytes, initialises AES-128-XTS at :304, sets key and tweak key to the container identifier at :316, decrypts at :333 with the tweak start derived from the file offset, and calls read_data on the decrypted buffer at :383. That is the only call site of libfsapfs_container_key_bag_read_data, so there is no path that ever parses the block as read.

Measured on a macOS 14.8.7 FileVault guest, blocks pulled off the disk image with dd, no attach and no boot:

container key bag block object type at +24 subtype stored Fletcher-64 computed verdict
macOS 14.8.7, transaction 3384 0x6b657973 ("keys") 0 0x754b431577e3645a identical plaintext
macOS 14.8.7, transaction 320 0x6b657973 ("keys") 0 0xab0fbc3104475b86 identical plaintext
macOS 15.7.7, raw block 0xa966db36 n/a 0x7ede6fefcbc49884 0xa0b2e5bec81a94f9 encrypted
macOS 15.7.7, after UUID decrypt 0x6b657973 ("keys") 0 0x5a227d649f300ed6 identical control

The 15.7.7 rows are the negative control, same instrument, same class of block, opposite verdict: its raw block fails on object type and its raw checksum does not verify, and after the container-UUID decrypt it becomes a valid keys object whose checksum does verify. That is what makes the 14.8.7 reading a property of the specimen rather than an artifact of the test. As a mutation control, flipping a single bit in the verifying 14.8.7 block makes the checksum test reject it, so the test discriminates rather than returning a constant.

Site 2, the volume key bag

The same anti-pattern sits one layer down in a sibling function. libfsapfs_volume_key_bag_read_file_io_handle at libfsapfs_volume_key_bag.c:182 initialises AES-128-XTS at :302, sets key and tweak key to the volume identifier at :314, decrypts at :331, and calls read_data on the decrypted buffer at :381, again the only call site.

Two differences from the container site, both of which leave the volume reader with less to work with:

  1. libfsapfs_volume_key_bag_read_data does not verify Fletcher-64 at all. It reads the stored checksum only inside the HAVE_DEBUG_OUTPUT block at :522, to print it. So this reader has strictly less evidence available to it than the container reader does, which verifies the checksum at libfsapfs_container_key_bag.c:572.
  2. The object-type mismatch at :494 does goto on_error and returns -1 rather than 0, and the caller at libfsapfs_volume.c:606 treats any non-1 return as fatal and jumps to on_error at :624, so the volume open aborts rather than degrading to a flag.

Measured offline and read-only on the same 14.8.7 guest, on volume 5, the FileVault Data volume:

volume key bag block raw type at +24 raw Fletcher-64 after volume-UUID decrypt
macOS 14.8.7, volume 5 0x72656373 ("recs"), subtype 0 matches, 0x031b29a9c4fbcd55 type 0x44a5bcb7, checksum fails
macOS 15.7.7, Data volume 0x54c6c28b does not match type 0x72656373 ("recs"), matches 0x33415d62c4eeea4c

A mirror image of the container table, and libfsapfs agrees with it: calling libfsapfs's own libfsapfs_volume_key_bag_read_data directly against the built library returns 1 on the 14.8.7 raw block and -1 on the decrypted one. The raw block is a complete volume key bag, not just a plausible header: format version 2, two entries, data size 400, two 162-byte type-3 KEK records, with the canonical APFS personal-recovery-key UUID ebc6c064-0000-11aa-aa11-00306543ecac in one of them. It is the same shape as the 15.7.7 volume key bag after that one is decrypted.

0x44a5bcb7 is not an object type. It is the XTS image of that block's plaintext recs type field under the volume UUID, which I reproduced byte for byte using libfsapfs's own encryption context, so the second site is the same story as the first rather than a separate puzzle.

One reason this is awkward to see from the outside: every debug dump is post-decrypt. libfsapfs_container_key_bag.c:375 labels the decrypted buffer unencrypted container key bag data, :488 dumps the object header from that same decrypted buffer, and libfsapfs_volume_key_bag.c:373 and :482 are the equivalents. Running with -v therefore never shows the bytes as they are on disk, which is exactly the information needed to notice this.

A read-only probe is enough, and apfs-fuse already does it this way

apfs-fuse checks the raw key-bag object type before decrypting, inside KeyManager::LoadKeybag, in commit b595551 of 2023-03-11, whose message is Add ability to mount Apple M2 VM images:

 	m_container.ReadBlocks(data.data(), block, blockcnt);
-
-	DecryptBlocks(data.data(), block, blockcnt, uuid);
+	if (mk->mk_obj.o_type == type)
+		m_is_unencrypted = true;
+	else
+		DecryptBlocks(data.data(), block, blockcnt, uuid);

Worth noting for the two-sites point: LoadKeybag there is a single shared function, called with the container key bag type 0x6b657973 for the container bag and with APFS_VOL_KEYBAG_OBJ (0x72656373) for volume bags, so that one check covers both. libfsapfs has the two readers written out separately, which is why the same defect has to be fixed twice here.

For libfsapfs I would not use read_data itself as the probe. It appends entries to the bag as it goes (libfsapfs_container_key_bag.c:680) and its error cleanup at :748 frees only the current entry and header without rolling previous appends back, so a try-raw-then-fall-back-to-decrypted design can leave partial state behind and compound it on the second parse. A read-only predicate over the buffer avoids that by construction:

is_plaintext_key_bag( data, data_size ):
      data_size >= sizeof( fsapfs_object_t )
  &&  object type    at offset 24 == 0x6b657973   /* 0x72656373 for a volume key bag */
  &&  object subtype at offset 28 == 0x00000000
  &&  fletcher64( data + 8, data_size - 8 ) == stored checksum at offset 0

It touches no libfsapfs object, so a negative result leaves nothing to free, and read_data is then called exactly once on whichever buffer the predicate selected. It also needs nothing new: the type and subtype tests are the ones read_data already performs, and libfsapfs_checksum_calculate_fletcher64 is already used by the container superblock, volume superblock, checkpoint map and container key bag readers.

The obvious caveat is that Fletcher-64 checks consistency and not authenticity, so on a hostile image it is not a security boundary. The structural validation in read_data still does that work, and nothing here weakens it.

What I have built, and what I have not

I have a patch for the container site, and it is compiled and run rather than only reviewed. It adds the predicate above as libfsapfs_container_key_bag_data_is_unencrypted, and read_file_io_handle then either takes ownership of the buffer as read or allocates and decrypts as before; the single read_data call is untouched.

image stock fsapfsinfo patched fsapfsinfo
macOS 14.8.7, container key bag plaintext prints Is locked (uses hardware encryption), key bag never parses, yet 6 volumes print and the run exits 0 with empty stderr that line is gone, the container key bag parses, then the run stops at the volume key bag: 4 volumes print, exit 1
macOS 15.7.7, container key bag encrypted parses, 6 volumes, exit 0 stdout and stderr byte-identical to stock

Stock there is the patch's parent commit rather than upstream main, so the table isolates this one change. On the encrypted image the predicate rejects the raw block on object type alone and falls through to the unchanged decrypt path, so that case is literally unaltered.

The exit 0 on the stock plaintext run is the defect succeeding quietly rather than the tool working: because the container key bag never parses, its entries array stays empty, the volume key bag extent is never found, and the volume key bags are never read at all, so the volumes still open, just without their key bags. The container patch is what first reaches the volume key bag, so on this specimen it moves the run from a clean-but-mislabelled exit 0 to exit 1 at the second, genuinely separate defect. The two sites therefore want fixing together: the container patch is not independently shippable against this image without the volume-side fix.

I have not built a fix for the volume site. The shape carries over unchanged with 0x72656373 in place of 0x6b657973, but I have only measured that site read-only and I would rather say so than imply it is tested. On the patched 14.8.7 run the container key bag now parses and the run proceeds until it stops on exactly that second site:

libfsapfs_volume_key_bag_read_data: invalid object type: 0x44a5bcb7.
libfsapfs_volume_key_bag_read_file_io_handle: unable to read volume key bag.
libfsapfs_internal_volume_open_read: unable to read volume key bag at offset: 22694916096 (0x548b8f000).

Also not done: a regression test in the library's own suite. A plaintext and an encrypted block for each reader, since the container and volume key bag object types differ, would cover both sites with no image involved.

Reproducing it

The specimen is a disposable Apple Virtualization framework guest: install macOS 14 in a VM, enable FileVault, shut down cleanly, then read the block named by the key bag block number in the container superblock straight off the disk image.

# block number and count come from the container superblock; 5540804 and
# container offset 524308480 are this specimen's values
dd if=disk.img bs=4096 skip=$(( (524308480 + 5540804 * 4096) / 4096 )) count=1 of=kb.bin
xxd -l 32 kb.bin
# bytes 24 to 27 read 73 79 65 6b, little endian 0x6b657973, and the
# Fletcher-64 over kb.bin[8:] equals the value stored in kb.bin[0:8]

For context rather than as a claim about libfsapfs: across four guests, two per major version, on one host, one hypervisor, and one image family (the cirruslabs vanilla line), macOS 14 (14.7.7 and 14.8.7) wrote the container key bag in the clear while macOS 15 (15.6.1 and 15.7.7) wrote it encrypted. I have not established a mechanism for that, it is a single-author measurement, and I would not want the issue to rest on it. It matters here only in that the symptom looks deterministic across a class of images rather than being a one-off, which is why a guard seems better value than a note.

What would be most useful to you

I am happy to open a PR with the container patch as it stands, extend the same predicate to the volume key bag, and add a regression test. If you would take test data as raw blocks rather than as an image, a plaintext and an encrypted block for each of the two readers make a self-contained fixture. And if you would rather keep the format-notes entry as a corruption scenario with the valid-plaintext case called out separately from the random-data case, that works too; I mostly want the library to stop destroying a block it could have read.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions