I hacked into UofT to change my grades
For the past two years, every computer science assignment I submitted went through MarkUs. Its white and blue interface with the MarkUs logo in the top-left corner had become so familiar that I barely thought about the application itself.
Out of curiosity, one night I opened a new submission and reached the same prompt I had clicked past for two years: Choose files. I selected a small text file called hello.txt containing one line:
<script>alert("hello world")</script>
I uploaded it to MarkUs and opened a specially constructed URL for the file in my browser. The alert "hello world" appeared on the MarkUs origin. The following night, the same URL sat at the front of a chain that returned a shell.
This writeup comes from research I did with Amin and Jacky, who have written about the vulnerabilities from their perspectives.
Disclaimer: On the university deployment, we only accessed files belonging to consenting users and ran the XSS alert in my browser. Grade changes, denial of service payloads and file overwrites were tested with artificial accounts and courses in local deployments.
Breaking Access Control
The first clue came when Amin noticed that a preview request selected the submitted file with one numeric parameter:
/courses/<course_id>/assignments/<assignment_id>/
submissions/download_file?select_file_id=5299
Following that led us to a second route that accepted the same ID:
/courses/<course_id>/assignments/<assignment_id>/
submissions/html_content?select_file_id=<file_id>
I kept my own authenticated session and changed select_file_id to a known file ID. MarkUs returned the file even though it belonged to a course in which my account was not enrolled. Changing the course_id and assignment_id components of the URL did not change which file was returned.
The controller loaded the record directly from the client-supplied ID:
file = SubmissionFile.find(params[:select_file_id])
The request was authenticated, but the requested file was never tied to a course, assignment, or group the requester was allowed to access. If an authenticated user knew or guessed another submission's ID, MarkUs returned its contents. This became CVE-2026-24900.
That exposed another student's file. More importantly for our investigation, it led us to the route that turned submitted text into running code.
Turning a Submission Into Stored XSS
I used the ID assigned to my earlier hello.txt upload and opened the direct html_content URL mentioned above.
The view placed the submitted file contents into an HTML response and explicitly marked them as safe without any sanitization:
<%= @html_content.html_safe %>
After opening the URL in my browser, an alert appeared on the MarkUs origin. The .txt file extension made no difference because the response Content-Type header determined how the browser interpreted the file. MarkUs had stored the payload with the submission and executed it when the URL was opened. This was stored XSS, later assigned CVE-2026-28405.
We discovered that the normal interface also used html_content for Jupyter notebook and RMarkdown rendering. A student therefore did not need an instructor to open some crafted URL if they went through the normal grading flow.
Acting Through an Instructor Session
Suppose an instructor opened the crafted URL or went through the normal grading flow with a notebook containing a malicious script. Because it ran on the MarkUs origin, the browser attached the instructor's authenticated session to same-origin requests. The script could then extract the CSRF authenticity token required for state-changing requests, as CSRF did not protect the application from JavaScript that MarkUs itself was serving.
We used that access to build a grade-changing proof of concept. The script loaded an artificial grading result, found its eight rubric criteria and submitted the same updates as the grading interface. After the page refreshed, all eight showed their maximum values. The grade change showed us that the submitted file could perform privileged actions available to the instructor, which led us to the configuration importer.
Escaping the Assignment Directory
MarkUs could import an assignment configuration as a ZIP archive through this instructor-only endpoint:
/courses/<course_id>/assignments/upload_config_files
During import, MarkUs built output paths directly from archive entry names:
filename = Pathname(entry.name)
.relative_path_from(CONFIG_FILES[:automated_tests_dir_entry])
file_path = assignment.autotest_files_dir.join(filename)
FileUtils.mkdir_p(File.dirname(file_path))
File.write(file_path, entry.get_input_stream.read)
Nothing checked that the final file_path remained inside assignment.autotest_files_dir. We added one traversal entry to an otherwise valid configuration archive and MarkUs wrote it outside the intended directory.
This was ZIP Slip, later assigned CVE-2026-25057. We could now write outside the assignment directory, but only to locations available to the MarkUs process. That was not yet a shell. We still needed a writable file that MarkUs would later execute.
Turning File Write Into RCE
We found the file we needed at lib/repo/markus-git-shell.sh. MarkUs used this wrapper for course-repository access over SSH. A repository operation such as cloning a course repository executed the script before handing the connection to Git.
We then completed the full proof of concept. The browser payload obtained a valid CSRF authenticity token, uploaded the crafted archive, replaced the wrapper and triggered it through normal repository access.
Chaining the Vulnerabilities Together
The IDOR was what first led us to the vulnerable html_content route, but it was not required for the final RCE path. A student could begin with their own submitted file and use the XSS to execute JavaScript in an instructor's authenticated MarkUs session.
That browser access provided the next capability. The script could make authenticated requests as the instructor and reach functionality that was normally restricted to instructors, including the assignment configuration importer. The ZIP Slip in that importer then moved the attack out of the browser and onto the server by allowing an archive entry to write outside its intended directory.
Arbitrary file write still was not enough for code execution on its own. The MarkUs process could overwrite markus-git-shell.sh, a wrapper that SSH repository access would later execute. Replacing that file and triggering a normal repository operation completed the final step from browser-based XSS to server-side code execution.
Disclosure & Remediation
We contacted the MarkUs maintainers through Microsoft Teams and then moved the reports into GitHub's private Security Advisory workflow. MarkUs 2.9.1 scoped the submitted-file lookup, removed the exposed html_content route and addressed the configuration-import path traversal.
During disclosure, I reviewed the IDOR patch and confirmed that its replacement lookup restored object scoping. I did not rerun every proof of concept against the final patched release and we no longer have the exact build identifiers from the university or local deployments, so the other patch claims and version ranges here come from the public advisories.
Separately, the project published advisories for unbounded ZIP extraction and YAML alias expansion. They were not part of the RCE chain and were fixed in MarkUs 2.9.4, making it the first release containing the fixes listed in the advisories for all five vulnerabilities.