Skip to content

Feature: BitLocker FVEK extraction plugin#1950

Open
forensicxlab wants to merge 3 commits intovolatilityfoundation:developfrom
forensicxlab:feature/bitlocker
Open

Feature: BitLocker FVEK extraction plugin#1950
forensicxlab wants to merge 3 commits intovolatilityfoundation:developfrom
forensicxlab:feature/bitlocker

Conversation

@forensicxlab
Copy link
Copy Markdown
Contributor

@forensicxlab forensicxlab commented Mar 5, 2026

Bitlocker plugin ported from the Volatility 2 BitLocker key extractor to recover FVEKs from Windows memory pools (Windows 7 through Windows 10/Server generations) and inspired by the latest version in memprocfs.

Example output:

Volatility 3 Framework 2.27.0
Progress:  100.00		PDB scanning finished
Address	Cipher	FVEK	TWEAK	DumpFile	DislockerFile

0xd284f3d83890	AES-XTS 128 bit (Win 10+)	929baf9supersecretbitlockerkeyredactedfordemonstration263d	Not Applicable	N/A	N/A
0xd28508294440	AES 256-bit (Win 8+)	0373a4supersecretbitlockerkeyredactedfordemonstrationb304ecdae1a2b	Not Applicable	N/A	N/A
0xd2851008f7b0	AES 128-bit (Win 8+)	98supersecretbitlockerkeyn9835b	Not Applicable	N/A	N/A
0xd2851d335000	AES 128-bit (Win 8+)	3fd7fsupersecretbitlockerkeynf7b698ffa920	Not Applicable	N/A	N/A

Then works to decrypt the volume:

 exhume_filesystem --body /Volumes/Forensics/Suspect.aff -o 0x7500000 -s 0x743af00000  --fvek 929baf9supersecretbitlockerkeyredactedfordemonstration263d --record 5

[2026-03-05T21:32:03Z INFO  exhume_body::aff] AFF: parsed 29809 pages, pagesize=16777216, imagesize=500107862016
[2026-03-05T21:32:03Z INFO  exhume_body] Detected an AFF disk image.
[2026-03-05T21:32:03Z ERROR exhume_ntfs] The partition is BitLocker-encrypted (OEM ID: -FVE-FS-). NTFS metadata cannot be read without decryption.
[2026-03-05T21:32:03Z INFO  exhume_filesystem::detected_fs] BitLocker detected. Attempting to decrypt with provided FVEK...
[2026-03-05T21:32:04Z INFO  exhume_filesystem::detected_fs] Successfully detected BitLocker-decrypted NT filesystem.
+--------------------------+-----------------------+
| MFT Entry Header Values  |                       |
+--------------------------+-----------------------+
| Sequence                 | 5                     |
+--------------------------+-----------------------+
| $LogFile Sequence Number | 2776238966            |
+--------------------------+-----------------------+
| Flags                    | Allocated | Directory |
+--------------------------+-----------------------+
| Links                    | 1                     |
+--------------------------+-----------------------+


+--------------------------------+-----------+--------------+------+
| Attributes                     | Name      | Status       | Size |
+--------------------------------+-----------+--------------+------+
| StandardInformation (0x10‑#0)  | N/A       | Resident     | 72   |
+--------------------------------+-----------+--------------+------+
| FileName (0x30‑#1)             | N/A       | Resident     | 68   |
+--------------------------------+-----------+--------------+------+
| IndexRoot (0x90‑#6)            | $I30      | Resident     | 176  |
+--------------------------------+-----------+--------------+------+
| IndexAllocation (0xA0‑#8)      | $I30      | Non‑resident | 8192 |
+--------------------------------+-----------+--------------+------+
| Bitmap (0xB0‑#7)               | $I30      | Resident     | 8    |
+--------------------------------+-----------+--------------+------+
| LoggedUtilityStream (0x100‑#9) | $TXF_DATA | Resident     | 56   |
+--------------------------------+-----------+--------------+------+

Note that another bitlocker container was present on the machine. Which was also retrieved.

Copy link
Copy Markdown
Member

@ikelos ikelos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is pretty nice, but there's two major points that stick out to me. Firstly it writes files directly, which is bad if the plugin's run via a web UI or some other mechanism that doesn't have local access, so plugins should always write out file using the self.open mechanism, rather than just plain open. They should be similar (although not identical, so please test it) and that will allow web UIs to provide the files back to users if they wish.

The second thing is there's a lot of manual image reading and processing of raw bytes, which sometimes is an indication of things that should really be done by volatility but aren't for some reason. A lot of hardcoded specific values is also generally bad (in case they change in the future, figuring out where those numbers came from or why they were chosen, etc). If they're structure offsets, ideally the structure would be defined in a local JSON symbol table that would be loaded up when this plugin starts (see windows.getsids as an example). Otherwise comments referencing where those values came from would be useful. Otherwise it's all a bit black magic and difficult for people to audit/verify...

class Bitlocker(interfaces.plugins.PluginInterface):
"""Extracts BitLocker FVEK keys from Windows memory."""

_required_framework_version = (2, 0, 0)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this definitely work with frameworks right back to 2.0.0? If you're not sure, then this should be the current framework version, so...

Suggested change
_required_framework_version = (2, 0, 0)
_required_framework_version = (2, 28, 0)

Comment on lines +306 to +307
os.makedirs(dump_dir, exist_ok=True)
dump_file = os.path.join(dump_dir, f"{result.offset:#010x}.fvek")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
os.makedirs(dump_dir, exist_ok=True)
dump_file = os.path.join(dump_dir, f"{result.offset:#010x}.fvek")
dump_file = f"{result.offset:#010x}.fvek"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be writing to a directory since we'll be using self.open...

text = self._key_hex(result.fvek)
if result.tweak is not None:
text = f"{text}:{self._key_hex(result.tweak)}"
with open(dump_file, "w", encoding="utf-8") as fvek_file:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
with open(dump_file, "w", encoding="utf-8") as fvek_file:
with self.open(dump_file, "w", encoding="utf-8") as fvek_file:

I don't recall whether self.open supports modes or encodings, so worth testing, but we shouldn't be directly opening files in plugins for the reasons listed above (support as a library).

dislocker_file = os.path.join(
dislocker_dir, f"{result.offset:#010x}-Dislocker.fvek"
)
with open(dislocker_file, "wb") as dislocker_handle:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
with open(dislocker_file, "wb") as dislocker_handle:
with self.open(dislocker_file, "wb") as dislocker_handle:

Comment on lines +316 to +319
os.makedirs(dislocker_dir, exist_ok=True)
dislocker_file = os.path.join(
dislocker_dir, f"{result.offset:#010x}-Dislocker.fvek"
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
os.makedirs(dislocker_dir, exist_ok=True)
dislocker_file = os.path.join(
dislocker_dir, f"{result.offset:#010x}-Dislocker.fvek"
)
dislocker_file = f"{result.offset:#010x}-Dislocker.fvek"

name="dump-dir",
description="Directory in which to dump FVEK values",
optional=True,
default=None,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
default=None,
action="store_true",

description="Windows kernel",
architectures=["Intel32", "Intel64"],
),
requirements.StringRequirement(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't open or dump to directories in plugins anymore, since that would break if the plugin were executed through a web API, so this should be producing files using the self.open() mechanism. I've provided suggestions below but they should be tested locally to ensure they work correctly, rather than just applied.

@staticmethod
def _version_tuple(
context: interfaces.context.ContextInterface, symbol_table_name: str
) -> Tuple[int, int, int, int]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
) -> Tuple[int, int, int, int]:
) -> Tuple[int, int, int, int]:
"""Extracts the kernel symbol table's windows version"""


def _iter_none_pools(
self, kernel: interfaces.context.ModuleInterface, is_64bit: bool
) -> Iterable[FvekResult]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could do with a docstring to explain what the function's purpose is, _iter_none_pools isn't immediately obvious.

layer = self.context.layers[header.vol.layer_name]
base_offset = header.vol.offset

f1 = layer.read(base_offset + 0x9C, 64, pad=True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These offsets seem a little brittle? Would it not be better to store them as data, with in a JSON file, or perhaps as a data structure that can be instantiated (and therefore updated based on the version of windows, in case they ever change?).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants