This post kicks off a short series into reversing the Adobe Reader sandbox. I initially started this research early last year and have been working on it off and on since. This series will document the Reader sandbox internals, present a few tools for reversing/interacting with it, and a description of the results of this research. There may be quite a bit of content here, but I’ll be doing a lot of braindumping. I find posts that document process, failure, and attempt to be far more insightful as a researcher than pure technical result.
I’ve broken this research up into two posts. Maybe more, we’ll see. The first here will detail the internals of the sandbox and introduce a few tools developed, and the second will focus on fuzzing and the results of that effort.
This post focuses primarily on the IPC channel used to communicate between the sandboxed process and the broker. I do not delve into how the policy engine works or many of the restrictions enabled.
Introduction
This is by no means the first dive into the Adobe Reader sandbox. Here are a few prior examples of great work:
2011 – A Castle Made of Sand (Richard Johnson)
2011 – Playing in the Reader X Sandbox (Paul Sabanal and Mark Yason)
2012 – Breeding Sandworms (Zhenhua Liu and Guillaume Lovet)
2013 – When the Broker is Broken (Peter Vreugdenhil)
Breeding Sandworms
was a particularly useful introduction to the sandbox, as it describes in some detail the internals of transaction and how they approached fuzzing the sandbox. I’ll detail my approach and improvements in
part two of this series.
In addition, the ZDI crew of Abdul-Aziz Hariri, et al. have been hammering on the Javascript side of things for what seems like forever (Abusing Adobe Reader’s Javascript APIs) and have done some great work in this area.
After evaluating existing research, however, it seemed like there was more work to be done in a more open source fashion. Most sandbox escapes in Reader these days opt instead to target Windows itself via win32k/dxdiag/etc and not the sandbox broker. This makes some sense, but leaves a lot of attack surface unexplored.
Note that all research was done on Acrobat Reader DC 20.6.20034 on a Windows 10 machine. You can fetch installers for old versions of Adobe Reader here. I highly recommend bookmarking this. One of my favorite things to do on a new target is pull previous bugs and affected versions and run through root cause and exploitation.
Sandbox Internals Overview
Adobe Reader’s sandbox is known as protected mode and is on by default, but can be toggled on/off via preferences or the registry. Once Reader launches, a child process is spawned under low integrity and a shared memory section mapped in. Inter-process communication (IPC) takes place over this channel, with the parent process acting as the broker.
Adobe actually published some of the sandbox source code to Github over 7 years ago, but it does not contain any of their policies or modern tag interfaces. It’s useful for figuring out variables and function names during reversing, and the source code is well written and full of useful comments, so I recommend pulling it up.
Reader uses the Chromium sandbox (pre Mojo), and I recommend the following resources for the specifics here:
These days it’s known as the “legacy IPC” and has been replaced by Mojo in Chrome. Reader actually uses Mojo to communicate between its RdrCEF (Chromium Embedded Framework) processes which handle cloud connectivity, syncing, etc. It’s possible Adobe plans to replace the broker legacy API with Mojo at some point, but this has not been announced/released yet.
We’ll start by taking a brief look at how a target process is spawned, but the main focus of this post will be the guts of the IPC mechanisms in play. Execution of the child process first begins with BrokerServicesBase::SpawnTarget
.
This function crafts the target process and its restrictions. Some of these
are described here in greater detail, but they are as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
From here, the policy manager enforces interceptions, handled by the InterceptionManager, which handles hooking and rewiring various Win32 functions via the target process to the broker. According to documentation, this is not for security, but rather:
1
|
|
From here we can now take a look at how the IPC mechanisms between the target and broker process actually work.
The broker process is responsible for spawning the target process, creating a shared memory mapping, and initializing the requisite data structures. This shared memory mapping is the medium in which the broker and target communicate and exchange data. If the target wants to make an IPC call, the following happens at a high level:
- The target finds a channel in a free state
- The target serializes the IPC call parameters to the channel
- The target then signals an event object for the channel (ping event)
- The target waits until a pong event is signaled
At this point, the broker executes ThreadPingEventReady
, the IPC processor entry point, where the following occurs:
- The broker deserializes the call arguments in the channel
- Sanity checks the parameters and the call
- Executes the callback
- Writes the return structure back to the channel
- Signals that the call is completed (pong event)
There are 16 channels available for use, meaning that the broker can service up to 16 concurrent IPC requests at a time. The following diagram describes a high level view of this architecture:
From the broker’s perspective, a channel can be viewed like so:
In general, this describes what the IPC communication channel between the broker and target looks like. In the following sections we’ll take a look at these in more technical depth.
IPC Internals
The IPC facilities are established via TargetProcess::Init
, and is really what we’re most interested in. The following snippet describes how the shared memory mapping is created and established between the broker and target:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
The calculated shared_mem_size
in the source code here comes out to 65536 bytes, which isn’t right. The shared section is actually 0x20000 bytes in modern Reader binaries.
Once the mapping is established and policies copied in, the SharedMemIPCServer
is initialized, and this is where things finally get interesting. SharedMemIPCServer
initializes the ping/pong events for communication, creates channels, and registers callbacks.
The previous architecture diagram provides an overview of the structures and layout of the section at
runtime. In short, a ServerControl
is a broker-side view of an IPC channel. It contains the server side event handles, pointers to both the channel and its buffer, and general information about the connected IPC endpoint. This structure is not visible to the target process and exists only in the broker.
A ChannelControl
is the target process version of a ServerControl
; it contains the target’s event handles, the state of the channel, and information about where to find the channel buffer. This channel buffer is where the CrossCallParams
can be found as well as the call return information after a successful IPC dispatch.
Let’s walk through what an actual request looks like. Making an IPC request requires the target
to first prepare a CrossCallParams
structure. This is defined as a class, but we can model it as a struct:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
I’ve also gone ahead and defined a few other structures needed to complete the picture. Note that the return structure, CrossCallReturn
, is embedded within the body of the CrossCallParams
.
There’s a great ASCII diagram provided in the sandbox source code that’s highly instructive, and I’ve duplicated it below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
A tag is a dword indicating which function we’re invoking (just a number between 1 and approximately 255, depending on your version). This is handled server side dynamically, and we’ll explore that further later on.
Each parameter is then sequentially represented by a ParamInfo
structure:
1 2 3 4 5 |
|
The offset is the delta value to a region of memory somewhere below the CrossCallParams
structure. This is handled in the Chromium source code via the ptrdiff_t
type.
Let’s look at a call in memory from the target’s perspective. Assume the channel buffer is at 0x2a10134
:
1 2 3 4 5 6 7 8 9 |
|
0x2a10134
shows we’re invoking tag 3, which carries 7 parameters (0x2a10170
).
The first argument is type 0x1 (we’ll describe types later on), is at delta
offset 0xa0, and is 0x86 bytes in size. Thus:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
This shows the delta of the parameter data and, based on the parameter type, we know it’s a unicode string.
With this information, we can craft a buffer targeting IPC tag 3 and move onto
sending it. To do this, we require the
IPCControl
structure. This is a simple structure defined at the start of the IPC shared memory section:
1 2 3 4 5 |
|
And in the IPC shared memory section:
1 2 3 |
|
So we have 16 channels, a handle to server_alive
, and the start of our
ChannelControl
array.
The server_alive
handle is a mutex used to signal if the server has crashed.
It’s used during tag invocation in SharedmemIPCClient::DoCall
, which we’ll describe later on. For now, assume that if we WaitForSingleObject
on this and it returns WAIT_ABANDONED
, the server has crashed.
ChannelControl
is a structure that describes a channel, and is again defined as:
1 2 3 4 5 6 7 |
|
The channel_base
describes the channel’s buffer, ie. where the CrossCallParams
structure can be found. This is an offset from the base of the shared memory section.
state
is an enum that describes the state of the channel:
1 2 3 4 5 6 7 |
|
The ping and pong events are, as previously described, used to signal to the opposite endpoint that data is ready for consumption. For example, when the client has written out its CrossCallParams
and ready for the server, it signals:
1 2 3 4 |
|
When the server has completed processing the request, the pong_event
is signaled and the client reads back the call result.
A channel is fetched via SharedMemIPCClient::LockFreeChannel
and is invoked when GetBuffer
is called. This simply identifies a channel in the IPCControl
array wherein state == kFreeChannel
, and sets it to kBusyChannel
. With a
channel, we can now write out our CrossCallParams
structure to the shared memory buffer. Our target buffer begins at channel->channel_base
.
Writing out the CrossCallParams
has a few nuances. First, the number of
actual parameters is NUMBER_PARAMS+1. According to the source:
1 2 3 4 |
|
This can be observed in the CopyParamIn
function:
1 2 3 4 |
|
Note the offset written is the offset for index+1
. In addition, this offset is aligned. This is a pretty simple function that byte aligns the delta inside the channel buffer:
1 2 3 4 5 6 7 8 |
|
Because the Reader process is x86, the alignment is always 8.
The pseudo-code for writing out our CrossCallParams
can be distilled into the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
Once the CrossCallParams
structure has been written out, the sandboxed process signals the ping_event
and the broker is triggered.
Broker side handling is fairly straightforward. The server registers a ping_event
handler during SharedMemIPCServer::Init
:
1 2 |
|
RegisterWait
is just a thread pool wrapper around a call to RegisterWaitForSingleObject
.
The ThreadPingEventReady
function marks the channel as kAckChannel
, fetches a pointer to the provided buffer, and invokes InvokeCallback
. Once this
returns, it copies the CrossCallReturn
structure back to the channel and signals the pong_event
mutex.
InvokeCallback
parses out the buffer and handles validation of data, at a high level (ensures strings are strings, buffers and sizes match up, etc.). This is probably a good time to document the supported argument types. There are 10 types in total, two of which are placeholder:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
These are taken from internal_types
,
but you’ll notice there are two additional types: ASCII_TYPE
and MEM_TYPE
, and are unique to Reader. ASCII_TYPE
is, as expected, a simple 7bit ASCII string. MEM_TYPE
is a memory structure used by the broker to read
data out of the sandboxed process, ie. for more complex types that can’t be trivially passed via the API. It’s additionally used for data blobs, such as PNG images, enhanced-format datafiles, and more.
Some of these types should be self-explanatory; WCHAR_TYPE
is naturally a wide char, ASCII_TYPE
an ascii string, and ULONG_TYPE
a ulong. Let’s look at a few of the non-obvious types, however: VOIDPTR_TYPE
, INPTR_TYPE
, INOUTPTR_TYPE
, and MEM_TYPE
.
Starting with VOIDPTR_TYPE
, this is a standard type in the Chromium sandbox so we can just refer to the source code. SharedMemIPCServer::GetArgs
calls GetParameterVoidPtr
. Simply, once the value itself is extracted it’s cast to a void ptr:
1
|
|
This allows tags to reference objects and data within the broker process itself. An example might be NtOpenProcessToken
, whose first parameter is a handle to the target process. This would be retrieved first by a call to OpenProcess
, handed back to the child process, and then supplied in any future calls that may need to use the handle as a VOIDPTR_TYPE
.
In the Chromium source code, INPTR_TYPE
is extracted as a raw value via GetRawParameter
and no additional processing is performed. However, in Adobe Reader, it’s actually extracted in the same way INOUTPTR_TYPE
is.
INOUTPTR_TYPE
is wrapped as a CountedBuffer
and may be written to during the IPC call. For example, if CreateProcessW
is invoked, the PROCESS_INFORMATION
pointer will be of type INOUTPTR_TYPE
.
The final type is MEM_TYPE
, which is unique to Adobe Reader. We can define the structure as:
1 2 3 4 5 |
|
As mentioned, this type is primarily used to transfer data buffers to and from the broker process. It seems crazy. Each tag is responsible for performing its own validation of the provided values before they’re used in any ReadProcessMemory/WriteProcessMemory
call.
Once the broker has parsed out the passed arguments, it fetches the context dispatcher and identifies our tag handler:
1 2 3 |
|
The handler is fetched from
PolicyBase::OnMessageReady
,
which winds up calling
Dispatcher::OnMessageReady
.
This is a pretty simple function that crawls the registered IPC tag list for
the correct handler. We finally hit InvokeCallbackArgs
, unique to Reader,
which invokes the handler with the proper argument count:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
In total, Reader supports tag functions with up to 17 arguments. I have no idea why that would be necessary, but it is. Additionally note the first two arguments to each tag handler: context handler (dispatcher) and CrossCallParamsEx
. This last structure is actually the broker’s version of a CrossCallParams
with more paranoia.
A single function is used to register IPC tags, called from a single initialization function, making it relatively easy for us to scrape them all at runtime. Pulling out all of the IPC tags can be done both statically and dynamically; the former is far easier, the latter is more accurate. I’ve implemented a static generator using IDAPython, available in this project’s repository (ida_find_tags.py
), and can be used to pull all supported IPC tags out of Reader along with their parameters. This is not going to be wholly indicative of all possible calls, however. During initialization of the sandbox, many feature checks are performed to probe the availability of certain capabilities. If these fail, the tag is not registered.
Tags are given a handle to CrossCallParamsEx
, which gives them access to the CrossCallReturn
structure. This is defined here and, repeated from above, defined as:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
This 52 byte structure is embedded in the CrossCallParams
transferred by the sandboxed process. Once the tag has returned from execution, the following occurs:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
and the sandboxed process can finally read out its result. Note that this mechanism does not allow for the exchange of more complex types, hence the availability of MEM_TYPE
. The final step is signaling the pong_event
, completing the call and freeing the channel.
Tags
Now that we understand how the IPC mechanism itself works, let’s examine the implemented tags in the sandbox. Tags are registered during initialization by a function we’ll call InitializeSandboxCallback
. This is a large function that handles allocating sandbox tag objects and invoking their respective initalizers. Each initializer uses a function, RegisterTag
, to construct and register individual tags. A tag is defined by a SandTag
structure:
1 2 3 4 5 |
|
The Arguments
array is initialized to INVALID_TYPE
and ignored if the tag does not use all 17 slots. Here’s an example of a tag structure:
1 2 3 4 5 |
|
Here we see tag 3 with 7 arguments; the first is WCHAR_TYPE
and the remaining 6 are ULONG_TYPE
. This lines up with what know to be the NtCreateFile tag handler.
Each tag is part of a group that denotes its behavior. There are 20 groups in total:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
The names were extracted either from the Reader binary itself or through correlation with Chromium. Each dispatcher implements an initialization routine that invokes RegisterDispatchFunction
for each tag. The number of registered tags will differ depending on the installation, version, features, etc. of the Reader process. SandboxBrokerServerDispatcher
, for example, can have a sway of approximately 25 tags.
Instead of providing a description of each dispatcher in this post, I’ve instead put together a separate page, which can be found here. This page can be used as a tag reference and has some general information about each. Over time I’ll add my notes on the calls. I’ve additionally pushed the scripts used to extract tag information from the Reader binary and generate the table to the sander
repository detailed below.
libread
Over the course of this research, I developed a library and set of tools for examining and exercising the Reader sandbox. The library, libread
, was developed to programmatically interface with the broker in real time,
allowing for quickly exercising components of the broker and dynamically reversing various facilities. In addition, the library was critical during my fuzzing expeditions. All of the fuzzing tools and data will be available in the next post in this series.
libread
is fairly flexible and easy to use, but still pretty rudimentary and, of course, built off of my reverse engineering efforts. It won’t be feature complete nor even completely accurate. Pull requests are welcome.
The library implements all of the notable structures and provides a few helper functions for locating the ServerControl
from the broker process. As we’ve seen, a ServerControl
is a broker’s view of a channel and it is held by the broker alone. This means it’s not somewhere predictable in shared memory and we’ve got to scan the broker’s memory hunting it. From the sandbox side there is also a find_memory_map
helper for locating the base address of the shared memory map.
In addition to this library I’m releasing sander
. This is a command line tool that consumes libread
to provide some useful functionality for inspecting the sandbox:
1 2 3 4 5 6 7 |
|
The most useful functionality provided here is the -m
flag. This allows one to monitor the IPC calls and their arguments in real time:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
We’re also able to dump all IPC calls in the brokers’ channels (-d
), which can help debug threading issues when fuzzing, and trigger a test IPC call (-t
). This latter function demonstrates how to send your own IPC calls via libread
as well as allows you to test out additional tooling.
The last available feature is the -c
flag, which captures all IPC traffic and logs the channel buffer to a file on disk. I used this primarily to seed part of my corpus during fuzzing efforts, as well as aid during some reversing efforts. It’s extremely useful for replaying requests and gathering a baseline corpus of real traffic. We’ll discuss this further in forthcoming posts.
That about concludes this initial post. Next up I’ll discuss the various fuzzing strategies used on this unique interface, the frustrating amount of failure, and the bugs shooken out.