Hello. These issues were privately reported to the EDK II maintainers on 2024-12-25 (via GHSA-85f5-mjx9-mg54). Affected versions were: <= edk2-stable202411. I'm not aware of any fixes, so any later version is expected to be vulnerable.
There was some technical discussion, which ended on 2025-01-22. On 2025-12-30, I asked for updates, also asked if my report should be marked as "WONTFIX", but got no reply to these questions. So, here is the disclosure. In short, one must not trust the PCR[5] measurements recording the expected GUID Partition Table (GPT) layout. This is expected to affect TPM-based FDE instances when the underlying GPT layout is somehow security-relevant. My original report (converted to plaintext, PoC removed): The DxeTpm2MeasureBootLib library can measure a partition table different from one parsed by the PartitionDxe driver # Summary I have found that a logic error and relaxed checks (and one possible violation of the TCG specification) can result in the Tcg2MeasureGptTable() function (DxeTpm2MeasureBootLib/DxeTpm2MeasureBootLib.c) measuring (into the PCR[5]) a partition table which is different from the one parsed by the PartitionInstallGptChildHandles() function (PartitionDxe/Gpt.c) from the same block device. And these two partition table instances can be different from another one used by the operating system. In particular, the DxeTpm2MeasureBootLib library, the PartitionDxe driver, and the operating system can parse the same data on the same block device in three different ways, leading to three different, attacker-controlled partition table instances being used in different pieces of code (here, "instance" refers to the partition layout observed by one of those implementations listed after parsing on-disk data). Since a partition table can include security-related metadata, these issues are reported as a vulnerability. For example, at least one fixed (no longer embargoed) vulnerability, CVE-2024-43513, relies on an attacker being capable of setting the "noautomount" bit in the GUID partition metadata. So, it's critical to measure the same partition table instance as used by other firmware components and operating system components. Or, at least, detect possible edge cases and bail out early. The vulnerability described here allows an attacker to change the entire partition layout or metadata of a specific partition in a way that is invisible to the measurements done using the PCR[5] and recorded in the TPM log. (An "expected" partition layout is measured, but a "malicious" partition layout is used, without executing anything unexpected... This is a data-only attack.) # Details behind vulnerability The code responsible for parsing the GUID partition table starts here: https://github.com/tianocore/edk2/blob/1cc78814cd8812c459115749409882b7243e5581/MdeModulePkg/Universal/Disk/PartitionDxe/Gpt.c#L233. The code responsible for measuring the GUID partition table starts here: https://github.com/tianocore/edk2/blob/1cc78814cd8812c459115749409882b7243e5581/SecurityPkg/Library/DxeTpm2MeasureBootLib/DxeTpm2MeasureBootLib.c#L188. The GUID partition table layout is described here (for reference): https://en.wikipedia.org/wiki/GUID_Partition_Table. The TCG specification mentioned is here: https://trustedcomputinggroup.org/wp-content/uploads/TCG-PC-Client-Platform-Firmware-Profile-Version-1.06-Revision-52_pub-2.pdf. By design, the code for parsing the GUID partition table runs before the code that measures it (because the boot device is unknown at that stage, so it's unclear what block device to use). The code that measures the partition table (in the DxeTpm2MeasureBootLib library) does the following: 1.1. it reads data from LBA 1 (the GPT header); 1.2. it validates the GPT header using relaxed checks (e.g., it doesn't validate the CRC32 checksums); 1.3. it reads data from LBAs containing the GPT partition entry array; 1.4. it builds a structure containing both the GPT header and the GPT partition entry array, empty partition entries are ignored (see: Note 1); 1.5. it measures and logs that structure. This code is pretty straightforward. It assumes that the right GPT header is located in LBA 1, the code doesn't try to read it from a backup location (LBA MAX — this is the highest LBA available on the underlying drive). And the checks are relaxed (I mean, they are very relaxed compared to the PartitionDxe driver). --- NOTE 1 --- The corresponding condition (used to check if the partition array member is empty) is: if (!IsZeroGuid (&PartitionEntry->PartitionTypeGUID)) This behavior violates the TCG PC Client Platform Firmware Profile Specification, table 15 (page 101). The number of partition entries to be measured "corresponds to the GPT Header NumberOfPartitonsEntires. See the UEFI Specification GPT Header". This fields counts the number of all partition slots in the array, not just allocated (used) partition entries. Such a violation allows an attacker to change one byte in an unused (empty) partition entry, after the "PartitionTypeGUID" field (i.e., in the data area not included in the measurement), leading to the checksum validation failure. In other words, an attacker can corrupt the partition entry array in a way that isn't covered by the PCR[5] measurement. --- END OF NOTE --- The code that parses the partition table (in the PartitionDxe driver) does the following: 2.1. it reads data from LBA 0 (the MBR); 2.2. it checks if the MBR contains a protective partition; 2.3. it reads data from LBA 1 (the GPT header); 2.4. it validates the GPT header using stricter checks (these include the GPT partition entry array checks); 2.5. it reads data from LBA MAX (the backup GPT header); 2.6. it validates the backup GPT header using the same (strict) checks; 2.7. if one of those GPT headers is invalid, it tries to repair the GPT header using its valid copy; 2.8. it reads data from LBAs containing the GPT partition entry array; 2.9. it validates the GPT partition entry array and then creates child device handles for valid partition entries (there is no failure if any partition entry is invalid for some reason, but this is okay). As you can see, the code in the DxeTpm2MeasureBootLib library assumes that the partition table was checked and repaired, if required, in the PartitionDxe driver. This is a wrong assumption, because the underlying drive could be write-protected (e.g., it's a degraded RAID volume). In this case, an attempt to write (by calling the DiskIo->WriteDisk() method in the PartitionRestoreGptTable() function) the "repaired" GPT header data to LBA 1 will result in an I/O error, so the invalid GPT header would remain there (and, thus, read later by the DxeTpm2MeasureBootLib library). However, this is an edge case. So, let's continue... At steps #2.3-#2.7, the following code gets executed: // // Check primary and backup partition tables // if (!PartitionValidGptTable (BlockIo, DiskIo, PRIMARY_PART_HEADER_LBA, PrimaryHeader)) { DEBUG ((DEBUG_INFO, " Not Valid primary partition table\n")); if (!PartitionValidGptTable (BlockIo, DiskIo, LastBlock, BackupHeader)) { DEBUG ((DEBUG_INFO, " Not Valid backup partition table\n")); goto Done; } else { DEBUG ((DEBUG_INFO, " Valid backup partition table\n")); DEBUG ((DEBUG_INFO, " Restore primary partition table by the backup\n")); if (!PartitionRestoreGptTable (BlockIo, DiskIo, BackupHeader)) { DEBUG ((DEBUG_INFO, " Restore primary partition table error\n")); } if (PartitionValidGptTable (BlockIo, DiskIo, BackupHeader->AlternateLBA, PrimaryHeader)) { DEBUG ((DEBUG_INFO, " Restore backup partition table success\n")); } } } else if (!PartitionValidGptTable (BlockIo, DiskIo, PrimaryHeader->AlternateLBA, BackupHeader)) { DEBUG ((DEBUG_INFO, " Valid primary and !Valid backup partition table\n")); DEBUG ((DEBUG_INFO, " Restore backup partition table by the primary\n")); if (!PartitionRestoreGptTable (BlockIo, DiskIo, PrimaryHeader)) { DEBUG ((DEBUG_INFO, " Restore backup partition table error\n")); } if (PartitionValidGptTable (BlockIo, DiskIo, PrimaryHeader->AlternateLBA, BackupHeader)) { DEBUG ((DEBUG_INFO, " Restore backup partition table success\n")); } } DEBUG ((DEBUG_INFO, " Valid primary and Valid backup partition table\n")); // My note: the "PrimaryHeader" variable is then used to read and parse the GPT partition entry array. If both GPT headers are valid, the code jumps to the last line quoted above. If both GPT headers are invalid, the code bails out ("goto Done;"). If one GPT header is invalid but the other one is valid, the invalid GPT header is repaired using the valid one (by calling the PartitionRestoreGptTable() function). After this, the repaired GPT header (and the corresponding) is read and validated again (by calling the PartitionValidGptTable() function). The logic error here is that that both functions, PartitionRestoreGptTable() and PartitionValidGptTable(), can fail (return false) and leave data referenced by the "PrimaryHeader" variable intact (in the untrusted, known to be invalid state). The code just jumps to the next block (marked with the "Valid primary and Valid backup partition table" message), which uses the "PrimaryHeader" variable. Fortunately, this doesn't lead to out-of-bounds access in the current code. So, the code must never assume successful writes in the PartitionRestoreGptTable() function and successful reads in the PartitionValidGptTable() function, it must bail out as soon as one error condition is encountered. Previously, I mentioned one case when the PartitionRestoreGptTable() function returns false: when there is a write error because of write-protected media. There is another case resulting in write and subsequent read errors: when an attempt is made to restore the GPT header beyond the end of the corresponding block device. The PartitionRestoreGptTable() function tries to restore the GPT header to the "alternate LBA" as specified in the GPT header that passed the checks (the valid one). And this "alternate LBA" can point to any location, beyond the end of the block device too. See: https://github.com/tianocore/edk2/blob/1cc78814cd8812c459115749409882b7243e5581/MdeModulePkg/Universal/Disk/PartitionDxe/Gpt.c#L637. This is not an edge case, because all conditions here are software-only and attacker-controlled. # Vulnerability The idea is to keep the GPT header (LBA 1) intact, keep the measured part (everything but unused partition entries) of the GPT partition entry array intact, then invalidate the GPT partition entry array by modifying one byte within any unused partition entry (this will break the CRC32 check at step #2.4, but the final PCR[5] value will remain the same). This will force the recovery logic (step #2.7). During the recovery, the malformed GPT backup header is processed (and it passes all of the checks at step #2.6). Its "alternate LBA" points somewhere in the middle of the underlying drive. After the recovery, the real GPT header (LBA 1) is intact (it wasn't overwritten during the recovery process) and the "recovered" malformed GPT header is used (its data is now referenced by the "PrimaryHeader" variable). In particular, the "PartitionRestoreGptTable (BlockIo, DiskIo, BackupHeader)" call places the malformed GPT header into the middle of the drive and the "PartitionValidGptTable (BlockIo, DiskIo, BackupHeader->AlternateLBA, PrimaryHeader)" call reads that header into the "PrimaryHeader" variable. This allows the attacker to "replace" the GPT header used by the PartitionDxe driver: it's taken from an unusual location (somewhere in the middle of the drive). The real GPT header, which is later used by the DxeTpm2MeasureBootLib library, is left intact (at LBA 1). In order to "replace" the GPT partition entry array, the following actions should be performed: (When recovering from the backup GPT header, the PartitionRestoreGptTable() function writes this array to LBAs 2+ unconditionally, see: https://github.com/tianocore/edk2/blob/1cc78814cd8812c459115749409882b7243e5581/MdeModulePkg/Universal/Disk/PartitionDxe/Gpt.c#L631.) * if the original GPT partition entry array starts at LBA different from 2 (this isn't the default layout), the attacker can simply force the recovery of a small "new" array occupying one sector only (which is going to be written to LBA 2); * if the original GPT partition entry array starts at LBA 2 (this is the default layout), the attacker can force the recovery of a larger (containing more sectors) array starting with the same entries as the original one (in this case, the attacker can covertly "append" entries into the existing partition entry array — the PartitionDxe driver will use a larger array, containing "new" entries, the DxeTpm2MeasureBootLib library will use a smaller array, without "new" entries); * alternatively, if the attacker doesn't care about the final PCR[5] value but wants to bypass TPM-based attestation checks, the attacker can rewrite the "PartitionEntryLBA" field (hopefully, attestation checks don't care about its value) and move the GPT partition entry array to a different location, while keeping its contents intact (thus, allowing the recovery of a "new" array to LBAs 2+). # PoC [SECTION REMOVED] # List of issues reported For clarity, here is a list of all issues (including edge cases) mentioned before: 1. One possible TCG specification violation covering unused (empty) GPT partition entries. Such entries are ignored in the current code. 2. One failure to repair the invalid GPT header when the underlying drive is write-protected (e.g., it's a degraded RAID volume). 3. One logic error leading to the usage of untrusted (known to be invalid) values in the GPT header (because of a failed restore attempt when the "alternate LBA" field of the valid GPT header points beyond the end of the corresponding block device). 4. One vulnerability leading to the usage of a partition table other than measured. # Possible fixes In general, the vulnerability reported is caused by two implementations of the GPT parser, something like: 1. ProcessPartitions(ParseGPT_1(InputData)), 2. MeasurePartitions(ParseGPT_2(InputData)). Here, "InputData" is the same in two cases, but "ParseGPT_1" and "ParseGPT_2" are functions that parse that data in different ways, returning different results in unusual cases. One solution is to implement stricter checks in the DxeTpm2MeasureBootLib library, stopping immediately if anything is wrong. Another solution is to share the code between the PartitionDxe driver and the DxeTpm2MeasureBootLib library. Two implementations must be equal. # Attached files [SECTION REMOVED]
