STH Mini Web CTF 2025 ครั้งที่ 1
STH Mini Web CTF 2025 ครั้งที่ 1
Description
Author: Siam Thanat Hack Company Limited
เว็บโจทย์การแข่งขัน: https://web1.ctf.p7z.pw
เป้าหมายการเจาะระบบ:
- ทำการโจมตีเว็บโจทย์การแข่งขัน เพื่อหาข้อความลับ ที่เรียกว่า Flag โดย Flag จะมีรูปแบบ เช่น
STH1{cff940beed74db5e1c7c63007223a6e6}
- เข้าสู่ระบบเป็นสิทธิ์ผู้ดูแลระบบ (Flag 1)
- ทำการพิมพ์เงินออกจากระบบ (Flag 2)
Solution
Flag 1
เมื่อเปิดเว็บไซต์ที่โจทย์ให้มาจะเจอกับหน้า login โดยถ้าเรา inspect (Ctrl+Shift+I)
ดู source code หน้านี้ก็จะพบว่ามี credential ที่คอมเมนต์ไว้อยู่
1
2
username: test
password: test
นำ credential ที่ได้ มา login ระบบจะพามาหน้า userinfo ซึ่งมีการแสดง username, role และ status สังเกตว่าระบบมีการแสดงผล role เลยคิดว่าน่าจะมี role admin และ น่าจะเกี่ยวกับ token ที่เอาไว้กำหนด role
มาดูในส่วนของ Cookies จะพบว่า มี Cookie ชื่อว่า remember_me ซึ่งมีค่าที่อยู่ในรูปแบบของ JWT (JSON Web Tokens) ตอน login ต้องติ้กเลือก remember me ด้วย ระบบถึงจะมีการกำหนดค่า Cookie ตัวนี้
1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbiI6ImI4MTk0M2JhLWQxYzUtNDk1YS04NDI3LTQ3MTFjMzkyNTZiZiJ9.Rlk_a69lx16hNhwn4nBfRxhiMGmEDoPIcxfr1_7JdH8
JWT (JSON Web Tokens) ประกอบด้วยสามส่วนหลักที่แยกจากกัน โดยแต่ละส่วนจะถูก encode ให้อยู่ในรูปแบบ base64 และนำมาต่อกันโดยมี
.
คั่น
- Header - ใช้เพื่อระบุประเภทของ Token และอัลกอริธึมที่ใช้ในการเข้ารหัส เช่น HS256 หรือ RS256
- Payload - ใช้เก็บข้อมูลต่างๆ ที่จะถูกส่ง เช่น ข้อมูลผู้ใช้ ข้อความ หรือข้อมูลที่ต้องการแชร์
- Signature - ใช้เพื่อยืนยันความถูกต้อง (Integrity) ของ Token โดยจะถูกสร้างจากการเข้ารหัส Header และ Payload ด้วย Secret Key
ดังนั้นเราสามารถนำ JWT ที่ได้มาไป decode จาก base64 ให้อ่านรู้เรื่องได้โดยใช้เว็บไซต์ https://jwt.io/
Payload ด้านในมีการระบุ token ตรงนี้คิดว่าเป็น token ของ user test
เราต้องหา token ของ admin แล้วนำมาแก้ไขในส่วนนี้ซึ่งการที่จะแก้ไข JWT ได้จำเป็นต้องมี secret key ตามที่ได้อธิบายในส่วนของ signature ไปด้านบน
สามารถ bruteforce หา secret key ได้โดยการใช้ John The Ripper (เครื่องมือยอดนิยมสำหรับการ crack รหัสผ่านจาก hash หรือข้อมูลที่ถูกเข้ารหัสด้วยวิธีการ bruteforce, dictionary attack) โดยระบุ format เป็น HMAC-SHA256 และใช้ rockyou.txt เป็น wordlist ในการ bruteforce หา secret key
1
2
3
4
5
6
7
8
9
10
11
12
$ cat jwt.txt
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbiI6ImI4MTk0M2JhLWQxYzUtNDk1YS04NDI3LTQ3MTFjMzkyNTZiZiJ9.Rlk_a69lx16hNhwn4nBfRxhiMGmEDoPIcxfr1_7JdH8
$ john --format=HMAC-SHA256 --wordlist=rockyou.txt jwt.txt
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 256/256 AVX2 8x])
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
"bobcats" (?)
1g 0:00:00:01 DONE (2025-03-27 01:18) 0.5780g/s 8291Kp/s 8291Kc/s 8291KC/s "chinor23"..*7¡Vamos!
Use the "--show" option to display all of the cracked passwords reliably
Session completed.
จากผลลัพธ์ secret ket ของเราเป็น "bobcats"
(รวม double quote ลงไปใน key ด้วยนะครับ ตรงนี้ตอนแรกทำให้ผมสับสนนิดนึง5555 นึกว่า key คือ bobcats
) ตอนนี้มี secret key ไว้เปลี่ยน payload แล้ว แต่ยังขาด token ของ admin
กลับมา inspect ดู soucre code อีกรอบที่ไฟล์ script.js
ในหน้า userinfo.php
บรรทัดที่ 35 มี api endpoint api.php?action=get_alluser
ไว้ใช้สำหรับดู user ทั้งหมด เลยลองเข้าไปดู เจอ 2 users คือ test
ที่เรามี credential อยู่ตอนนี้ และ admin-uat
บรรทัดที่ 23 api.php?action=get_userinfo&user=test
น่าสนใจตรงที่มี parameter user
เพิ่มเข้ามา ถ้าลองเปลี่ยน user ตรงนี้เป็น admin-uat
จะได้ข้อมูลหรือป่าว ปรากฎว่าสามารถดูข้อมูลได้ และ ได้ remember_me_token มาใช้ login เป็น admin-uat
แล้ว เหลือแค่นำ token ตรงนี้ไปใส่ใน payload ของ JWT
นำ 73eb7063-f8c3-4e50-bea2-07c05681aa92
ใส่ใน payload ของ JWT และ ใส่ secret key "bobcats"
ให้เรียบร้อย ทีนี้ก็จะได้ JWT ของ admin-uat
มาแล้ว
1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbiI6IjczZWI3MDYzLWY4YzMtNGU1MC1iZWEyLTA3YzA1NjgxYWE5MiJ9.IFc2uZiX_3x1ihXgRaANOPvmySpQzFz_wMD0up8Ny0I
จากนั้นนำ JWT ของ admin-uat
ไปใส่ใน Cookie remember_me
และลบ PHPSESSID แลัว refresh ก็จะพบว่าตอนนี้เราเป็น admin-uat
เรียบร้อยแล้ว
ในไฟล์ script.js
ก่อนหน้านี้ บรรทัดที่ 34 มีคอมเมนท์ admin.php ซึ่งน่าจะหมายถึง path เว็บไซต์ของ admin ลองเข้าไปดู แล้ว inspect ดู source code ก็จะได้ Flag 1 มาแล้ว!
Flag 1: STH1{310052ba6883872435f7c5aafa850813}
Flag 2
ต่อจาก Flag 1 เลย อ่าน source code บรรทัดที่ 50-66
ฟังก์ชัน validateNumber($input)
- ใช้ preg_match()
เพื่อตรวจสอบว่า $input
ตรงกับ Regular Expression นี้หรือไม่ /^[0-9]+$/m
^
- เริ่มต้นบรรทัด[0-9]
- ตัวเลข 0 ถึง 9+
- 1 ตัวอักษรขึ้นไปของสิ่งที่มาก่อนหน้า กรณีนี้คือตัวเลข$
- สิ้นสุดบรรทัดm
- การใช้งานในหลายบรรทัด
สรุปคือ ตรวจสอบว่าเริ่มต้นด้วยตัวเลขและลงท้ายด้วยตัวเลขหรือไม่
บรรทัดที่ 60 นำ amount
มาเช็คกับฟังก์ชัน validateNumber
และ เช็คว่าใน amount
มี string STH
อยู่หรือไม่ โดยใช้ฟังชันก์ strpos()
ถ้าเช็คทั้งสองอย่างแล้วใช่ทั้งคู่ ก็จะมี output message ออกมาเป็น Flag 2 ที่เราต้องการ จึงได้ลองใช้ payload เป็น
1
2
ASTH
1234
บรรทัดแรก ASTH
ทำให้ strpos()
คืนค่าออกมาเป็น 1 ถ้าไม่ได้เพิ่ม A
ไปด้านหน้า ฟังชันก์จะคืนค่ามาเป็น 0 ซึ่งเป็น false
strpos()
- ค้นหาตำแหน่งแรกที่พบของ substring ใน string ถ้าค้นหาเจอจะคืนค่าเป็นตำแหน่ง (index) ของการเจอครั้งแรก ถ้าไม่จะคืนค่าเป็น false
บรรทัดสอง 1234
ทำให้ preg_match()
คืนค่าออกมาเป็น true เพราะ preg_match()
ใช้ m
ซึ่งมีอย่างน้อยแค่บรรทัดเดียวที่ match ก็จะคืนค่าเป็น true เลย
เราสามารถ input หลายบรรทัดได้โดยการเปลี่ยน code ของหน้านี้ ตรง tag input
ให้เป็น textarea
หรือ สามารถใช้ Burp Suite ดัก request แล้วเปลี่ยนก็ได้ (ใช้ %0D%0A
ในการคั่นแต่ล่ะบรรทัด) แต่ ผมขอใช้วิธีแรกเพราะว่าง่ายดีครับ :)
ได้ Flag 2 เรียบร้อย!
Flag 2: STH2{d9d2532fd8ad5419450b5ea34ed93f32}