In this blog post we discuss the details of two vulnerabilities we discovered in the Trusted Platform Module (TPM) 2.0 reference implementation code. These two vulnerabilities, an out-of-bounds write (CVE-2023-1017) and an out-of-bounds read (CVE-2023-1018), affected several TPM 2.0 software implementations (such as the ones used by virtualization software) as well as a number of hardware TPMs.
In October 2021, Microsoft released Windows 11. One of the installation requirements that stood out was the need for a Trusted Platform Module (TPM) 2.0. An implication of this requirement is that, in order to be able to run Windows 11 within a virtual machine, virtualization software must provide a TPM to VMs, either by doing passthrough to the hardware TPM on the host machine, or by supplying a virtual TPM to them.
We found this to be an interesting topic for vulnerability research, since the addition of virtual TPMs means extended attack surface on virtualization software that can be reached from within a guest, and so it could potentially be used for a virtual machine escape. As a result of the research effort, we discovered two security issues: an out-of-bounds write identified as CVE-2023-1017, and an out-of-bounds read identified as CVE-2023-1018. They can be triggered from user-mode applications by sending malicious TPM 2.0 commands with encrypted parameters. Interestingly, these two vulnerabilities turned out to have a way longer reach than we initially thought: given that they originate in the reference implementation code published by the Trusted Computing Group (TCG for short, the nonprofit organization that publishes and maintains the TPM specification), these security bugs affected not only every virtualization software we tested, but hardware implementations as well.
Note that most of our assessments in this blog post (e.g. regarding exploitability, impact, or which platforms are affected) are based on our analysis of software-based virtual TPMs, because we can debug them in an easy way to perform dynamic analysis (well, debugging Hyper-V's virtual TPM is harder because it runs as an IUM process, but that's another story). On the contrary, getting visibility of what's happening at runtime in the firmware of a TPM, running in a separate chip without debugging interfaces, is an entirely different problem to tackle. Even doing static analysis of the firmware of a hardware TPM proved to be difficult: the few TPM firmware updates we attempted to analyze happened to be encrypted. Therefore, the lack of specific assessment on hardware TPMs doesn't mean that they are not affected; it just means that we couldn't evaluate how most of them are impacted due to the lack of observability. However, using the Proof-of-Concept code published in this blog post, we have verified that at least some discrete TPM chips are vulnerable. After attempting the OOB write, the chip would stop responding (i.e. it didn't recognize commands anymore) and require a hard reboot of the computer to be operational again, thus confirming its vulnerable condition.
This is a non-exhaustive list of affected software and hardware platforms. Products listed here are those in which we could certainly demonstrate the existence of the vulnerabilities with the help of the PoC provided within this blog post, but it's very likely for other TPMs - either virtual or physical- to be vulnerable as well.
TPMEngUM.dll
version 10.0.19041.1415);tpm2emu.exe
- no version information in the executable);520a2fa27d27a4ab18f4cf1c597662c6a468565f
);All the major cloud computing providers offer instances with virtual TPMs. This exposes an interesting scenario, since a malicious actor could attempt to exploit these vulnerabilities in the virtual TPM in order to escape from a virtual machine and compromise the host system.
Those providers using a virtual TPM based on the TCG reference implementation are expected to be vulnerable. In the case of Google Cloud, the blog post linked above mentions that the core of their virtual TPM comes from code published by IBM, which is extracted automatically from the full source code of the TPM 2.0 spec, and we verified that the bugs in the CryptParameterDecryption
function are present in it. In the case of Microsoft Azure, the documentation linked before mentions that their virtual TPM is "compliant with the TPM 2.0 spec", and we have verified that the virtual TPM included in the version of Hyper-V that is available on Windows 10 is indeed vulnerable. The bugs were also present in Microsoft's open source reference implementation.
Regarding Amazon AWS and Oracle Cloud Infrastructure, we don't have much details about what they use, except that the NitroTPM documentation mentions that it "conforms to the TPM 2.0 specification" with a link to the TCG website.
Check the website of your computer manufacturer for TPM firmware updates.
As described in the Trusted Platform Module Library Specification, Family 2.0, Part 1: Architecture document, Section 21 - "Session-based encryption", several TPM 2.0 commands have parameters that may need to be encrypted going to or from the TPM. Session-based encryption may be used to ensure confidentiality of these parameters. Quoting the specification:
Not all commands support parameter encryption. If session-based encryption is allowed, only the first
parameter in the parameter area of a request or response may be encrypted. That parameter must have
an explicit size field. Only the data portion of the parameter is encrypted. The TPM should support
session-based encryption using XOR obfuscation. Support for a block cipher using CFB mode is platform
specific. These two encryption methods (XOR and CFB) do not require that the data be padded for
encryption, so the encrypted data size and the plain-text data size is the same.
[...]
Session-based encryption uses the algorithm parameters established when the session is started and
values that are derived from the session-specific sessionKey.
[...]
If sessionAttributes.decrypt is SET in a session in a command, and the first parameter of the command is
a sized buffer, then that parameter is encrypted using the encryption parameters of the session.
A TPM 2.0 command with encrypted parameters is composed of a base command header, followed by a handleArea
, then a sessionArea
, finishing with the (encrypted) parameterArea
. The following diagram illustrates said structure:
+---------------------+
| |
| |
| Base command header |
| |
| |
+---------------------+
| |
| handleArea |
| |
+---------------------+
| |
| sessionArea |
| |
+---------------------+
| |
| parameterArea |
| |
+---------------------+
In the TPM 2.0 reference implementation, the ExecuteCommand
function in ExecCommand.c
checks that the authorizationSize
field of the sessionArea
is at least 9
([1]). After that, at [2], it calculates the start of the parameterArea
(located right after the sessionArea
) and saves it to the parmBufferStart
variable. At [3]
it calculates the size of the parameterArea
, and saves it to the parmBufferSize
variable. Then it calls ParseSessionBuffer()
([3]), passing parmBufferStart
and parmBufferSize
as parameters ([5], [6]).
// ExecuteCommand()
//
// The function performs the following steps.
// a) Parses the command header from input buffer.
// b) Calls ParseHandleBuffer() to parse the handle area of the command.
// c) Validates that each of the handles references a loaded entity.
//
// d) Calls ParseSessionBuffer() () to:
// 1) unmarshal and parse the session area;
// 2) check the authorizations; and
// 3) when necessary, decrypt a parameter.
[...]
LIB_EXPORT void
ExecuteCommand(
unsigned int requestSize, // IN: command buffer size
unsigned char *request, // IN: command buffer
unsigned int *responseSize, // OUT: response buffer size
unsigned char **response // OUT: response buffer
)
{
[...]
// Find out session buffer size.
result = UINT32_Unmarshal(&authorizationSize, &buffer, &size);
if(result != TPM_RC_SUCCESS)
goto Cleanup;
// Perform sanity check on the unmarshaled value. If it is smaller than
// the smallest possible session or larger than the remaining size of
// the command, then it is an error. NOTE: This check could pass but the
// session size could still be wrong. That will be determined after the
// sessions are unmarshaled.
[1] if( authorizationSize < 9
|| authorizationSize > (UINT32) size)
{
result = TPM_RC_SIZE;
goto Cleanup;
}
// The sessions, if any, follows authorizationSize.
sessionBufferStart = buffer;
// The parameters follow the session area.
[2] parmBufferStart = sessionBufferStart + authorizationSize;
// Any data left over after removing the authorization sessions is
// parameter data. If the command does not have parameters, then an
// error will be returned if the remaining size is not zero. This is
// checked later.
[3] parmBufferSize = size - authorizationSize;
// The actions of ParseSessionBuffer() are described in the introduction.
[4] result = ParseSessionBuffer(commandCode,
handleNum,
handles,
sessionBufferStart,
authorizationSize,
[5] parmBufferStart,
[6] parmBufferSize);
[...]
Function ParseSessionBuffer
in SessionProcess.c
parses the sessionArea
of the command. If a session has the Decrypt
attribute set ([1]), and if the command code allows for parameter encryption, then ParseSessionBuffer
calls CryptParameterDecryption()
([2]), propagating the parmBufferSize
([3]) and parmBufferStart
([4]) parameters:
// ParseSessionBuffer()
//
// This function is the entry function for command session processing. It iterates sessions in session area
// and reports if the required authorization has been properly provided. It also processes audit session and
// passes the information of encryption sessions to parameter encryption module.
//
// Error Returns Meaning
//
// various parsing failure or authorization failure
//
TPM_RC
ParseSessionBuffer(
TPM_CC commandCode, // IN: Command code
UINT32 handleNum, // IN: number of element in handle array
TPM_HANDLE handles[], // IN: array of handle
BYTE *sessionBufferStart, // IN: start of session buffer
UINT32 sessionBufferSize, // IN: size of session buffer
BYTE *parmBufferStart, // IN: start of parameter buffer
UINT32 parmBufferSize // IN: size of parameter buffer
)
{
[...]
// Decrypt the first parameter if applicable. This should be the last operation
// in session processing.
// If the encrypt session is associated with a handle and the handle's
// authValue is available, then authValue is concatenated with sessionAuth to
// generate encryption key, no matter if the handle is the session bound entity
// or not.
[1] if(s_decryptSessionIndex != UNDEFINED_INDEX)
{
// Get size of the leading size field in decrypt parameter
if( s_associatedHandles[s_decryptSessionIndex] != TPM_RH_UNASSIGNED
&& IsAuthValueAvailable(s_associatedHandles[s_decryptSessionIndex],
commandCode,
s_decryptSessionIndex)
)
{
extraKey.b.size=
EntityGetAuthValue(s_associatedHandles[s_decryptSessionIndex],
&extraKey.t.buffer);
}
else
{
extraKey.b.size = 0;
}
size = DecryptSize(commandCode);
[2] result = CryptParameterDecryption(
s_sessionHandles[s_decryptSessionIndex],
&s_nonceCaller[s_decryptSessionIndex].b,
[3] parmBufferSize, (UINT16)size,
&extraKey,
[4] parmBufferStart);
Function CryptParameterDecryption
in CryptUtil.c
performs in-place decryption of an encrypted command parameter.
// 10.2.9.9 CryptParameterDecryption()
//
// This function does in-place decryption of a command parameter.
//
// Error Returns Meaning
//
// TPM_RC_SIZE The number of bytes in the input buffer is less than the number of
// bytes to be decrypted.
//
TPM_RC
CryptParameterDecryption(
TPM_HANDLE handle, // IN: encrypted session handle
TPM2B *nonceCaller, // IN: nonce caller
UINT32 bufferSize, // IN: size of parameter buffer
UINT16 leadingSizeInByte, // IN: the size of the leading size field in
// byte
TPM2B_AUTH *extraKey, // IN: the authValue
BYTE *buffer // IN/OUT: parameter buffer to be decrypted
)
{
SESSION *session = SessionGet(handle); // encrypt session
// The HMAC key is going to be the concatenation of the session key and any
// additional key material (like the authValue). The size of both of these
// is the size of the buffer which can contain a TPMT_HA.
TPM2B_TYPE(HMAC_KEY, ( sizeof(extraKey->t.buffer)
+ sizeof(session->sessionKey.t.buffer)));
TPM2B_HMAC_KEY key; // decryption key
UINT32 cipherSize = 0; // size of cipher text
pAssert(session->sessionKey.t.size + extraKey->t.size <= sizeof(key.t.buffer));
// Retrieve encrypted data size.
if(leadingSizeInByte == 2)
{
// The first two bytes of the buffer are the size of the
// data to be decrypted
[1] cipherSize = (UINT32)BYTE_ARRAY_TO_UINT16(buffer);
[2] buffer = &buffer[2]; // advance the buffer
}
#ifdef TPM4B
else if(leadingSizeInByte == 4)
{
// the leading size is four bytes so get the four byte size field
cipherSize = BYTE_ARRAY_TO_UINT32(buffer);
buffer = &buffer[4]; //advance pointer
}
#endif
else
{
pAssert(FALSE);
}
[3] if(cipherSize > bufferSize)
return TPM_RC_SIZE;
// Compute decryption key by concatenating sessionAuth with extra input key
MemoryCopy2B(&key.b, &session->sessionKey.b, sizeof(key.t.buffer));
MemoryConcat2B(&key.b, &extraKey->b, sizeof(key.t.buffer));
if(session->symmetric.algorithm == TPM_ALG_XOR)
// XOR parameter decryption formulation:
// XOR(parameter, hash, sessionAuth, nonceNewer, nonceOlder)
// Call XOR obfuscation function
[4] CryptXORObfuscation(session->authHashAlg, &key.b, nonceCaller,
&(session->nonceTPM.b), cipherSize, buffer);
else
// Assume that it is one of the symmetric block ciphers.
[5] ParmDecryptSym(session->symmetric.algorithm, session->authHashAlg,
session->symmetric.keyBits.sym,
&key.b, nonceCaller, &session->nonceTPM.b,
cipherSize, buffer);
return TPM_RC_SUCCESS;
}
Two security issues arise in this function:
BYTE_ARRAY_TO_UINT16
macro to read a 16-bit field (cipherSize
) from the buffer pointed by parmBufferStart
without checking if there's any parameter data past the session area. The only length check was performed earlier in function ExecuteCommand
, but that check only verified that the sessionArea
of the command is at least 9 bytes in size. As a result, if a malformed command doesn't contain a parameterArea
past the sessionArea
, it will trigger an out-of-bounds memory read, making the TPM access memory past the end of the command.Note that the BYTE_ARRAY_TO_UINT16
macro doesn't perform any bounds check:
#define BYTE_ARRAY_TO_UINT16(b) (UINT16)( ((b)[0] << 8) \
+ (b)[1])
The UINT16_Unmarshal
function should have been used instead, which performs proper size checks before reading from a given buffer.
parameterArea
is provided (avoiding bug #1), the first two bytes of the parameterArea
are interpreted as the size of the data to be decrypted (cipherSize
variable at [1]). Right after reading cipherSize
, at [2], the buffer
pointer is advanced by 2. At [3] there's a sanity check (if the cipherSize
value is greater than the actual buffer size, then it bails out), but there's a problem here: after reading the cipherSize
16-bit field and advancing the buffer
pointer by 2, the function forgets to subtract 2 from bufferSize
, to account for the two bytes that were already processed. Therefore, it is possible to successfully pass the sanity check at [3] with a cipherSize
value that is, in fact, larger by 2 than the actual size of the remaining data. As a consequence, when either CryptXORObfuscation()
or ParmDecryptSym()
functions are called (at [4] and [5], respectively) to actually decrypt the data in the parameterArea
following the cipherSize
field, the TPM ends up writing 2 bytes past the end of the buffer, resulting in an out-of-bounds write.An OOB write of just 2 bytes may not seem like a very powerful primitive at first, but remember that last year our colleagues Damiano Melotti and Maxime Rossi Bellom managed to obtain code execution on Google's Titan M chip with an OOB write of a single byte with value 0x01
.
1) OOB read: function CryptParameterDecryption
in CryptUtil.c
can read 2 bytes past the end of the received TPM command. If an affected TPM doesn't zero out the command buffer between received commands, it can result in the affected function reading whatever 16-bit value was already there from the previous command. This is dependent on the implementation: for example, VMware doesn't clear out the command buffer between requests, so the OOB read can access whatever value is already there from the previous command; on the contrary, Hyper-V's virtual TPM pads the unused bytes in the command buffer with zeros every time it receives a request, so the OOB access ends up reading just zeros.
2) OOB write: functions CryptXORObfuscation
/ParmDecryptSym
in CryptUtil.c
(called from CryptParameterDecryption
) can write 2 bytes past the end of the command buffer, resulting in memory corruption.
This second bug is definitely the most interesting one. The chances of being able to overwrite something useful depend on how each implementation allocates the buffer that receives TPM commands. As an example:
malloc()
to allocate a command buffer of size 0x1008 (8 bytes for a send command prefix
that can be used to modify the locality
, plus 0x1000 bytes for the maximum TPM command size).Therefore, the chances of having something useful adjacent to the command buffer that we can overwrite with the OOB write are really implementation-dependent. All the three virtual TPMs mentioned above use a completely different approach for allocating the command buffer. In a similar way, the likeliness of having something useful to overwrite located right after the command buffer in the firmware of a given hardware TPM depends entirely on how that specific hardware vendor allocates the buffer that holds incoming commands.
In order to reproduce any of the 2 bugs described above, it is necessary to send 2 commands to the target TPM. In both cases, the first command must be a TPM2_StartAuthSession
command, to start an authorization session. For simplicity, we can specify TPM_ALG_XOR
as the symmetric algorithm to be used. As a result, we get a TPM response containing a session handle.
After that, we need to send a command that supports parameter encryption. We used TPM2_CreatePrimary
, although a few other commands should probably work as well. We pass the session handle obtained in the previous step in the sessionArea
of the TPM2_CreatePrimary
command, and we set the Decrypt
flag in the sessionAttributes
field. Then:
TPM2_CreatePrimary
command with a minimal valid sessionArea
, and no data after it, i.e. with a missing parameterArea
.TPM2_CreatePrimary
command with its total size equal to the maximum supported TPM command size (0x1000
bytes). In this case we do include a parameterArea
, with the cipherSize
field set to 0xfe5
(0x1000 - sizeof(command_base_header) - sizeof(handleArea) - sizeof(sessionArea)
), followed by 0xfe3
bytes with any value (filling the place of the encrypted parameter) to complete the 0x1000
bytes of the whole TPM2_CreatePrimary
command.You can download here a Proof-of-Concept to reproduce both vulnerabilities. The .zip
file contains a Python version of the PoC, meant to be run on Linux systems, and a C version in case you intend to run it from a Windows machine.
We discovered two security issues in the code of the TPM 2.0 reference implementation: an out-of-bounds read and an out-of-bounds write. As a result, every TPM (either software or hardware implementations) whose firmware is based on the reference code published by the Trusted Computing Group is expected to be affected.
Interestingly, although all affected TPMs share the exact same vulnerable function, which stems from the reference implementation code, the likeliness of successful exploitation depends on how the command buffer is implemented, and that part is left to each implementation. From what we saw, everyone seems to handle it in a different way: some clear out the command buffer between received requests, but others don't; some allocate the command buffer in the heap via malloc()
, while others use a global variable for it.
We were able to verify that these vulnerabilities are present in the software TPMs included in major desktop virtualization solutions such as VMware Workstation, Microsoft Hyper-V and Qemu. Virtual TPMs available in the biggest cloud computing providers were also likely affected. For instance, Google Cloud uses code published by IBM automatically extracted from the TCG reference implementation, and we verified that the bugs were present in the code provided by IBM. In the case of Microsoft Azure, we already mentioned that Hyper-V on Windows 10 is affected, and since the Azure hypervisor is based on Hyper-V, we expect these two vulnerabilities to be present on Microsoft's cloud platform as well.
Finally, we expect most TPM hardware vendors to be affected too. The lack of a debugging setup to get visibility on what's going on in the TPM firmware at runtime makes it harder to confirm the presence of the vulnerabilities in a physical chip. Static analysis could be an alternative to assess whether a hardware TPM is vulnerable or not, but in the few TPM firmware updates we managed to get our hands on were encrypted.
I'd like to thank Iván Arce, for the lot of valuable inputs and ideas he provided while discussing these bugs, as well as for taking care of handling such a complicated disclosure process with so many parties involved.
This timeline is not exhaustive and only lists events that we deemed relevant to the disclosure process.
/dev/tpm0
to communicate with the TPM.libtpms
as affected and asked if the maintainers were notified.libtpms
maintainers and deferred to CERT/CC for coordination with them.DeviceIOControl error 87 (Invalid Parameter)
of the Windows PoC indicates that the TPM is not vulnerable. Quarkslab replied that the error message revealed a bug in the PoC(!) that will be fixed shortly, and that determining if a given TPM chip is vulnerable or not without any log or debugging capabilities is difficult and without source code access it is only possible to do it by reverse engineering firmware which is a very time-consuming effort.ibmswtpm2
TPM2.0 reference implementation releases dating back to at least March 21st, 2017. Therefore any implementation of TPM2.0 based on them is likely affected. Quarkslab did not check any TPM1.2 implementation.