Escaping Adobe Sandbox: Exploiting an Integer Overflow in Microsoft Windows Crypto Provider

By Michele Campa

Overview

We describe a method to exploit a Windows Nday vulnerability to escape the Adobe sandbox. This vulnerability is assigned CVE-2021-31199 and it is present in multiple Windows 10 versions. The vulnerability is an out-of-bounds write due to an integer overflow in a Microsoft Cryptographic Provider library, rsaenh.dll.

Microsoft Cryptographic Provider is a set of libraries that implement common cryptographic algorithms. It contains the following libraries:
 
  • dssenh.dll  – Algorithms to exchange keys using Diffie-Hellman or to sign/verify data using DSA.
  • rsaenh.dll  – Algorithms to work with RSA.
  • basecsp.dll – Algorithms to work with smart cards.

These providers are abstracted away by API in the CryptSP.dll library, which acts as an interface that developers are expected to use. Each call to the API expects an HCRYPTPROV object as argument. Depending on certains fields in this object, CryptSP.dll redirects the code flow to the right provider. We will describe the HCRYPTPROV object in more detail when describing the exploitation of the vulnerability.

Cryptographic Provider Dispatch

Adobe Sandbox Broker Communication

 
Both Adobe Acrobat and Acrobat Reader run in Protected Mode by default. The protected mode is a feature that allows opening and displaying PDF files in a restricted process, a sandbox. The restricted process cannot access resources directly. Restrictions are imposed upon actions such as accessing the file system and spawning processes. A sandbox makes achieving arbitrary code execution on a compromised system harder.
 
Adobe Acrobat and Acrobat Reader use two processes when running in Protected Mode:
 
  • The Broker process, which provides limited and safe access to the sandboxed code.
  • The Sandboxed process, which processes and displays PDF files.
When the sandbox needs to execute actions that cannot be directly executed, it emits a request to the broker through a well defined IPC protocol. The broker services such requests only after ensuring that they satisfy a configured policy.
Sandbox and Broker

Sandbox Broker Communication Design

 
The communication between broker and sandbox happens via a shared memory that acts like a message channel. It informs the other side that a message is ready to be processed or that a message has been processed and the response is ready to be read.
 
On startup the broker initializes a shared memory of size 2MB and initializes the event handlers. Both, the event handlers and the shared memory are duplicated and written into the sandbox process via WriteProcessMemory().
 
When the sandbox needs to access a resource, it prepares the message in the shared memory and emits a signal to inform the broker. On the other side, once the broker receives the signal it starts processing the message and emits a signal to the sandbox when the message processing is complete.
Communication between Sandbox and Broker
The elements involved in the Sandbox-Broker communication are as follows:
 
  • Shared memory with RW permissions of size 2MB is created when the broker starts. It is mapped into the child process, i.e. the sandbox.
  • Signals and atomic instructions are used to synchronize access to the shared memory.
  • Multiple channels in the shared memory allow bi-directional communication by multiple threads simultaneously.
In summary, when the sandbox process cross-calls a broker-exposed resource, it locks the channel, serializes the request and pings the broker. Finally it waits for broker and reads the result.
 

Vulnerability

The vulnerability occurs in the rsaenh.dll:ImportOpaqueBlob() function when a crafted opaque key blob is imported. This routine is reached from the Crypto Provider interface by calling CryptSP:CryptImportKey() that leads to a call to the CPImportKey() function, which is exposed by the Crypto Provider.
				
					// rsaenh.dll

__int64 __fastcall CPImportKey(
        __int64 hcryptprov,
        char *key_to_imp,
        unsigned int key_len,
        __int64 HCRYPT_KEY_PUB,
        unsigned int flags,
        _QWORD *HCRYPT_KEY_OUT)
{

[Truncated]

  v7 = key_len;
  v9 = hcryptprov;
  *(_QWORD *)v116 = hcryptprov;
  NewKey = 1359;

[Truncated]

  v12 = 0i64;
  if ( key_len
    && key_len = key_len )
  {
    if ( (unsigned int)VerifyStackAvailable() )
    {

[1]

      v13 = (unsigned int)(v7 + 8) + 15i64;
      if ( v13 = (unsigned int)v7 )
      {
        v39 = (char *)((__int64 (__fastcall *)(_QWORD))g_pfnAllocate)((unsigned int)(v7 + 8));
        v12 = v39;

[Truncated]

      }

[Truncated]

      goto LABEL_14;
    }

[Truncated]

LABEL_14:

[2]

  memcpy_0(v12, key_to_imp, v7);
  v15 = 1;
  v107 = 1;
  v9 = *(_QWORD *)v116;

[Truncated]

[3]

  v18 = NTLCheckList(v9, 0);
  v113 = (const void **)v18;

[Truncated]

[4]

  if ( v12[1] != 2 )
  {

[Truncated]

  }
  if ( v16 == 6 )
  {

[Truncated]

  }
  switch ( v16 )
  {
    case 1:

[Truncated]

    case 7:

[Truncated]

    case 8:

[Truncated]

    case 9:

[5]

      NewKey = ImportOpaqueBlob(v19, (uint8_t *)v12, v7, HCRYPT_KEY_OUT);
      if ( !NewKey )
        goto LABEL_30;
      v40 = WPP_GLOBAL_Control;
      if ( WPP_GLOBAL_Control == &WPP_GLOBAL_Control || (*((_BYTE *)WPP_GLOBAL_Control + 28) & 1) == 0 )
        goto LABEL_64;
      v41 = 210;
      goto LABEL_78;
    case 11:

[Truncated]

    case 12:

[Truncated]

    default:

[Truncated]

  }
}
				
			

Before reaching ImportOpaqueBlob() at [5], the key to import is allocated on the stack or on the heap according to the available stack space at [1]. The key to import is copied, at [2], into the new memory allocated; the public key struct version member is expected to be 2. The HCRYPTPROV object pointer is decrypted at [3], and then at [4] the key version is checked to be equal to 2. Finally a switch case on the type field of the key to import leads to executing ImportOpaqueBlob() at [5]. This occurs if and only if the type member is equal to OPAQUEKEYBLOB (0x9).

The OPAQUEKEYBLOB indicates that the key is a session key (as opposed to public/private keys).

				
					__int64 __fastcall ImportOpaqueBlob(__int64 a1, uint8_t *key_, unsigned int len_, unsigned __int64 *out_phkey)
{

[Truncated]

    *out_phkey = 0i64;
    v8 = 0xC0;

[6]

    if ( len_ < 0x70 )
    {

[Truncated]

        return v10;
    }

[7]

    v13 = *((_DWORD *)key_ + 5); // read 4 bytes from (uint8_t*)key + 0x14
    if ( v13 )
        v8 = v13 + 0xC8;
    v14 = *((_DWORD *)key_ + 4); // read 4 bytes from (uint8_t*)key + 0x10
    if ( v14 )
        v8 += v14 + 8;
    v15 = (char *)ContAlloc(v8);
    v16 = v15;
    if ( v15 )
    {
        memset_0(v15, 0, v8);

[Truncated]

        v17 = *((unsigned int *)key_ + 4); // key + 0x10

[Truncated]

        v18 = v17 + 0x70;
        v19 = *((_DWORD *)key_ + 5);
        if ( v19 )
          v18 = v19 + v17 + 0x70;

[8]

        if ( len_ >= v18 )      // key + 0x10
        {
            if ( (_DWORD)v17 )
            {

[9]

                *((_QWORD *)v16 + 3) = v16 + 0xC8;
                memcpy_0(v16 + 0xC8, key_ + 0x70, v17);
            }

[Truncated]

    }
    else
    {

[Truncated]

    }
      if ( v16 )
        FreeNewKey(v16);
      return v10;
    }

[Truncated]

  return v10;
}
				
			

In order to reach the vulnerable code, it is required that the key to import has more than 0x70 bytes [6]. The vulnerability occurs due to an integer overflow that happens at [7] due to a lack of checking the values at addresses (unsigned int)((uint8_t*)key + 0x14) and (unsigned int)((uint8_t*)key + 0x10). For example if one of these members is set to 0xffffffff, an integer overflow occurs. The vulnerability is triggered when the memcpy() routine is called to copy (unsigned int)((uint8_t*)key + 0x10) bytes from key + 0x70 into v16 + 0xc8 at [9].

An example of an opaque blob that triggers the vulnerability is the following: if key + 0x10 is set to 0x120 and key + 0x14 equals 0xffffff00, then it leads to allocating 0x120 + 0xffffff00 + 0xc8 + 0x08 = 0xf0 bytes of buffer, into which 0x120 bytes are copied. The integer overflow allows bypassing a weak check, at [8], which requires the key length to be greater than: 0x120 + 0xffffff00 + 0x70 = 0x90.

Exploitation

The goal of exploiting this vulnerability is to escape the Adobe sandbox in order to execute arbitrary code on the system with the privileges of the broker process. It is assumed that code execution is already possible in the Adobe sandbox.

Exploit Strategy

The Adobe broker exposes cross-calls such as CryptImportKey() to the sandboxed process. The vulnerability can be triggered by importing a crafted key into the Crypto Provider Service, implemented in rsaenh.dll. The vulnerability yields an out-of-bounds write primitive in the broker, which can be easily used to corrupt function pointers. However, Adobe Reader enables a large number of security features including ASLR and Control Flow Guard (CFG), which effectively prevent ROP chains from being used directly to gain control of the execution flow.
 
The exploitation strategy described in this section involves bypassing CFG by abusing a certain design element of the Microsoft Crypto Provider. In particular, the interface that redirects code flow according to function pointers stored in the HCRYPTPROV object.
  

CryptSP – Context Object

HCRYPTPROV is the object instantiated and used by CryptSP.dll to dispatch calls to the right provider. It can be instantiated via the CryptAcquireContext() API, that returns an instantiated HCRYPTPROV object.

HCRYPTPROV is a basic C structure containing function pointers to the provider exposed routine. In this way, calling CryptSP.dll:CryptImportKey() executes HCRYPTPROV->FunctionPointer() that corresponds to provider.dll:CPImportKey().

The HCRYPTPROV data structure is shown below:

				
					Offset      Length (bytes)    Field                   Description
---------   --------------    --------------------    ----------------------------------------------
0x00        8                 CPAcquireContext          Function pointer exposed by Crypto Provider
0x08        8                 CPReleaseContext          Function pointer exposed by Crypto Provider
0x10        8                 CPGenKey                  Function pointer exposed by Crypto Provider
0x18        8                 CPDeriveKey               Function pointer exposed by Crypto Provider
0x20        8                 CPDestroyKey              Function pointer exposed by Crypto Provider
0x28        8                 CPSetKeyParam             Function pointer exposed by Crypto Provider
0x30        8                 CPGetKeyParam             Function pointer exposed by Crypto Provider
0x38        8                 CPExportKey               Function pointer exposed by Crypto Provider
0x40        8                 CPImportKey               Function pointer exposed by Crypto Provider
0x48        8                 CPEncrypt                 Function pointer exposed by Crypto Provider
0x50        8                 CPDecrypt                 Function pointer exposed by Crypto Provider
0x58        8                 CPCreateHash              Function pointer exposed by Crypto Provider
0x60        8                 CPHashData                Function pointer exposed by Crypto Provider
0x68        8                 CPHashSessionKey          Function pointer exposed by Crypto Provider
0x70        8                 CPDestroyHash             Function pointer exposed by Crypto Provider
0x78        8                 CPSignHash                Function pointer exposed by Crypto Provider
0x80        8                 CPVerifySignature         Function pointer exposed by Crypto Provider
0x88        8                 CPGenRandom               Function pointer exposed by Crypto Provider
0x90        8                 CPGetUserKey              Function pointer exposed by Crypto Provider
0x98        8                 CPSetProvParam            Function pointer exposed by Crypto Provider
0xa0        8                 CPGetProvParam            Function pointer exposed by Crypto Provider
0xa8        8                 CPSetHashParam            Function pointer exposed by Crypto Provider
0xb0        8                 CPGetHashParam            Function pointer exposed by Crypto Provider
0xb8        8                 Unknown                   Unknown
0xc0        8                 CPDuplicateKey            Function pointer exposed by Crypto Provider
0xc8        8                 CPDuplicateHash           Function pointer exposed by Crypto Provider
0xd0        8                 Unknown                   Unknown
0xd8        8                 CryptoProviderHANDLE      Crypto Provider library base address
0xe0        8                 EncryptedCryptoProvObj    Crypto Provider object's encrypted pointer
0xe8        4                 Const Val                 Constant value set to 0x11111111
0xec        4                 Const Val                 Constant value set to 0x1
0xf0        4                 Const Val                 Constant value set to 0x1
				
			
CryptSP dispatch using HCRYPTPROV

When a CryptSP.dll API is invoked the HCRYPTPROV object is used to dispatch the flow to the right provider routine. At offset 0xe0 the HCRYPTPROV object contains the real provider object that is used internally in the provider routines. When CryptSP.dll dispatches the call to the provider it passes the real provider object contained at HCRYPTOPROV + 0xe0 as the first argument.

				
					// CryptSP.dll

BOOL __stdcall CryptImportKey(
        HCRYPTPROV hProv,
        const BYTE *pbData,
        DWORD dwDataLen,
        HCRYPTKEY hPubKey,
        DWORD dwFlags,
        HCRYPTKEY *phKey)
{

[Truncated]

[1]

    if ( (*(__int64 (__fastcall **)(_QWORD, const BYTE *, _QWORD, __int64, DWORD, HCRYPTKEY *))(hProv + 0x40))(
           *(_QWORD *)(hProv + 0xE0),
           pbData,
           dwDataLen,
           v13,
           dwFlags,
           phKey) )
    {
        if ( (dwFlags &amp; 8) == 0 )
        {
            v9[11] = *phKey;
            *phKey = (HCRYPTKEY)v9;
            v9[10] = hProv;
            *((_DWORD *)v9 + 24) = 572662306;
        }
        v8 = 1;
    }

[Truncated]

}
				
			

At [1], we see an example how CryptSP.dll dispatches the code to the provider:CPImportKey() routine.

Crypto Provider Abuse

The Crypto Providers’ interface uses the HCRYPTPROV object to redirect the execution flow to the right Crypto Provider API. When the interface redirects the execution flow it sets the encrypted pointer located at HCRYPTPROV + 0xe0 as the first argument. Therefore, by overwriting the function pointer and the encrypted pointer, an attacker can redirect the execution flow while controlling the first argument.

Adobe Acrobat – CryptGenRandom abuse to identify corrupted objects

The Adobe Acrobat broker provides the CryptGenRandom() cross-call to the sandbox. If the CPGenRandom() function pointer has been overwritten with a function having a predictable return value different from the return value of the original CryptGenRandom() function, then it is possible to determine that a HCRYPTPROV object has been overwritten.

For example, if a pointer to the absolute value function, ntdll!abs, is used to override the CPGenRandom() function pointer, the broker executes abs(HCRYPTPROV + 0xe0) instead of CPGenRandom(). Therefore, by setting a known value at HCRYPTPROV + 0xe0, this cross-call can be abused by an attacker to identify whether the HCRYPTPROV object has been overwritten by checking if its return value is abs(<known value>).

Adobe Acrobat – CryptReleaseContext abuse to execute commands

The Adobe Acrobat broker provides the CryptReleaseContext() cross-call to the sandbox. This cross-call ends up calling CPReleaseContext(HCRYPTPROV + 0xe0, 0). By overwriting the CPReleaseContext() function pointer in HCRYPTPROV with WinExec() and by overwriting HCRYPTPROV + 0xe0 with a previously corrupted HCRYPTPROV object, one can execute WinExec with an arbitrary lpCmdLine argument, thereby executing arbitrary commands.

Shared Memory Structure – overwriting contiguous HCRYPTPROV objects.

In the following we describe the shared memory structure and more specifically how arguments for cross-calls are stored. The layout of the shared memory structure is relevant when the integer overflow is used to overwrite the function pointers in contiguous HCRYPTPROV objects.

The share memory structure is shown below:

				
					Field                   Description
--------------------    --------------------------------------------------------------
Shared Memory Header    Contains main shared memory information like channel numbers.
Channel 0 Header        Contains main channel information like Event handles.
Channel 1 Header        Contains main channel information like Event handles.
...
Channel N Header        Contains main channel information like Event handles.
Channel 0               Channel memory zone, where the request/response is written.
Channel 1               Channel memory zone, where the request/response is written.
...
Channel N               Channel memory zone, where the request/response is written.
				
			

The shared memory main header is shown below:

				
					Offset      Length (bytes)    Field                   Description
---------   --------------    --------------------    -------------------------------------------
0x00           0x04           Channel number          Contains the number of channels available.
0x04           0x04           Unknown                 Unknown
0x08           0x08           Mutant HANDLE           Unknown
				
			

The channel main header data structure is shown below. The offsets are relative to the channel main header.

				
					Offset      Length (bytes)    Field                   Description
---------   --------------    --------------------    -------------------------------------------
0x00           0x08           Channel offset          Offset to the channel memory region for
                                                      storing/reading request relative to share
                                                      memory base address.
0x08           0x08           Channel state           Value representing the state of the channel:
                                                      1 Free, 2 in use by sandbox, 3 in use by broker.
0x10           0x08           Event Ping Handle       Event used by sandbox to signal broker that
                                                      there is a request in the channel.
0x18           0x08           Event Pong Handle       Event used by broker to signal sandbox that
                                                      there is a response in the channel.
0x20           0x08           Event Handle            Unknown
				
			

Since the shared memory is 2MB and the header is 0x10 bytes long and every channel header is 0x28 bytes long, every channel takes (2MB - 0x10 - N*0x28) / N bytes.

The shared memory channels are used to store the serialization of the cross-call input parameters and return values. Every channel memory region, located at shared_memory_address + channel_main_header[i].channel_offset, is implemented as the following data structure:

				
					Offset      Length (bytes)    Field                   Description
---------   --------------    --------------------    -----------------------------------------------
0x00           0x04           Tag ID                  Tag ID is used by the broker to dispatch the
                                                      request to the exposed cross-call.
0x04           0x04           In Out                  Boolean, if set the broker copy-back in the
                                                      channel the content of the arguments after the
                                                      cross-call. It is used when parameters are
                                                      output parameters, e.g. GetCurrentDirectory().
0x08           0x08           Unknown                 Unknown
0x10           0x04           Error Code              Windows Last Error set by the broker after the
                                                      cross-call to inform sandbox about error status.
0x14           0x04           Status                  Broker sets to 1 if the cross-call has been
                                                      executed otherwise it sets to 0.
0x18           0x08           HANDLE                  Handle returned by the cross call
0x20           0x04           Return Code             Exposed cross-call return value.
0x24           0x3c           Unknown                 Unknown
0x60           0x04           Args Number             Number of argument present in the cross-call
                                                      emitted by the sandbox.
0x64           0x04           Unknown                 Unknown
0x68           Variable       Arguments               Data structure representing every argument
[ Truncated ]
				
			

At most 20 arguments can be set for a request but only the required arguments need to be specified. It means that if the cross-call requires two arguments then Args Number will be set to 2 and the Arguments data structure contains two elements of the Argument type. Every argument uses the following data structure:

				
					Offset     Length (bytes)    Field                   Description
---------  --------------    --------------------    --------------------------------------------------
0x0        4                 Argument type           Integer representing the argument type.
0x4        4                 Argument offset         Offset relative to the channel address, i.e. Tag
                                                     ID address, used to localize the argument value in the channel self
0x8        4                 Argument size           The argument's size.
				
			

Each of the argument data structures must be followed by another one that contains only the offset field filled with an offset greater than the last valid argument’s offset plus its own size, i.e argument[n].offset + argument[n].size + 1. Therefore, if a cross-call needs two arguments then three arguments must be set: two representing the valid arguments to pass to the cross-call and the third set to where the arguments end.

Shown below is an example of the arguments in a two-argument cross-call:

				
					Offset         Length (bytes)    Field
---------      --------------    --------------------
0x68           4                 Argument 0 type
0x6c           4                 Argument 0 offset
0x70           4                 Argument 0 size
0x74           4                 Argument 1 type
0x78           4                 Argument 1 offset
0x7c           4                 Argument 1 size
0x80           4                 Not Used
0x84           4                 Argument 1 offset + Argument 1 size + 1: 0x90 + N + M + 1
0x8c           4                 Not Used
0x90           N                 Argument 0 value
0x90 + N       M                 Argument 1 value
				
			
An Argument can be one of the following types:
				
					Argument type       Argument name         Description
--------------      ------------------    -------------------------------------------------------------
0x01                WCHAR String          Specify a wide string.
0x02                DWORD                 Specify an int 32 bits argument.
0x04                QWORD                 Specify an int 64 bits argument.
0x05                INPTR                 Specify an input pointer, already instantiated on the broker.
0x06                INOUTPTR              Specify an argument treated like a pointer in the cross-call
                                          handler. It is used as input or  output, i.e. return to the
                                          sandbox a broker valid memory pointer.
0x07                ASCII String          Specify an ascii string argument.
0x08                0x18 Bytes struct     Specify a structure long 0x18 bytes.
				
			

When an argument is of the INOUTPTR type (intended to be used for all non-primitive data types), then the cross-call handler treats it in the following way:

  1. Allocates 16 bytes where the first 8 bytes contain the argument size and the last 8 bytes the pointer received.
  2. If the argument is an input pointer for the final API then it is checked to be valid against a list of valid pointers before passing it as a parameter for the final API.
  3. If the argument is an output pointer for the final API then the pointer is allocated and filled by the final API.
  4. If the INOUT cross-call type is true then the pointer address is copied back to the sandbox.

Exploit Phases

The exploit consists of the following phases:

  1. Heap spraying – The sandbox process cross-calls CryptAcquireContext() N times in order to allocate multiple heap chunks of 0x100 bytes. The broker’s heap layout after the spray is shown below.
Broker heap layout after spray
  1. Abuse Adobe Acrobat design – Since the HCRYPTPTROV object is passed as a parameter to CryptAcquireContext() the pointer must be returned to the sandbox in order to allow using it for operations with Crypto Providers in the broker context. Because of this feature it is possible to find contiguous HCRYPTPROV objects.
  2. Holes creation – Releasing the contiguous chunks in an alternate way.
Creating holes in the broker process heap
  1. Import malicious key – The sandbox process cross-calls CryptImportKey() multiple times with a maliciously crafted key. It is expected that the key overflows into the next chunk, i.e. an HCRYPTPROV object. The overflow overwrites the initial bytes of the HCRYPTPROV object with a command string, CPGenRandom() with the address of ntdll!abs, and HCRYPTPROV + 0xe0 with a known value.
Overwriting the first HCRYPTPROV object
  1. Find overwritten object – The sandbox process cross-calls CryptGenRandom(). If it returns the known value then ntdll!abs() has been executed and the overwritten object has been found.
  2. Import malicious key – The sandbox process cross-calls CryptImportKey() multiple times with a maliciously crafted key. It is expected that the key overflows the next chunk, i.e. an HCRYPTPROV object. The overflow overwrites CPReleaseContext() with kernel32:WinExec(), CPGenRandom() with the address of ntdll!abs, and HCRYPTPROV + 0xe0 with the pointer to the object found in step 5.
Overwriting an HCRYPTPROV object a second time
  1. Find overwritten object – The sandbox process cross-calls CryptGenRandom(). If it returns the absolute value of the pointer found in step 5 then ntdll!abs() has been executed and the overwritten object has been found.
  2. Trigger – The sandbox process cross-calls CryptReleaseContext() on the HCRYPTPROV object found in step 7 to trigger WinExec().

Wrapping Up

We hope you enjoyed reading this. If you are hungry for more make sure to check our other blog posts.