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.
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. It contained 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 appeared on the MarkUs origin. The following night, the same URL sat at the front of a chain that returned a shell:
markus@3c53a21c6334:~$
This writeup comes from research I did with Amin and Jacky, who have written about the vulnerabilities from their perspectives.
We found three related bugs, but only the Cross-site Scripting (XSS) and ZIP Slip were required for an RCE chain. The insecure direct object reference (IDOR) led us to a vulnerable rendering route. The XSS let JavaScript act through an instructor's authenticated browser, and an instructor only ZIP import turned that access into arbitrary file write. That write reached a shell wrapper MarkUs later executed.
Disclaimer: On the university deployment, we accessed only files belonging to consenting users and ran the XSS alert only in my browser. Grade changes, malicious configuration imports, denial-of-service payloads, file overwrite, and the shell were tested only with artificial accounts and courses in local deployments.
An unscoped file ID crossed course boundaries
The first clue came when Amin noticed that a preview request selected the submitted file with one numeric parameter:
download_file?select_file_id=5299&show_in_browser=true&preview=true
Following that preview flow 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 code.
A text file became same-origin JavaScript
I used the ID assigned to my hello.txt upload and opened its direct html_content URL.
The view placed the submitted file contents into an HTML response and explicitly marked them as safe:
<%= @html_content.html_safe %>
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 later 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.
Jacky reproduced the vulnerable response and found that its Content Security Policy header was report-only, so the browser reported the inline script without blocking it.
Borrowing the instructor's session
An artificial 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.
Amin 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 any instructor-only actions, which led us to the configuration importer.
The configuration ZIP escaped its 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:
zip_file_path = Pathname.new(entry.name)
filename = zip_file_path.relative_path_from(CONFIG_FILES[:automated_tests_dir_entry])
file_path = File.join(assignment.autotest_files_dir, filename.to_s)
FileUtils.mkdir_p(File.dirname(file_path))
File.write(file_path, entry.get_input_stream.read, mode: "wb")
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.
A writable wrapper turned file write into code execution
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. Jacky describes that part of the investigation in more detail in his account.
The default setup permissions allowed the ZIP Slip to overwrite the wrapper. We assembled the full proof of concept: the browser payload obtained a CSRF authenticity token, imported the crafted archive, replaced the wrapper, and triggered it through repository access.
At last, when the shell connected, we ran ls:
csc108 log
Those were the actual course and log directories.
The result depended on our local layout: the wrapper existed at that path, the MarkUs process could overwrite it, SSH repository access was configured to execute it, and a later repository action reached that code path. We confirmed those conditions locally. Other installations may use different paths, permissions, or SSH configurations.
That final permission was decisive: if the application user had been unable to modify executable application files, the chain would have stopped at file write.
Three assumptions, one chain
The way we found the bugs was not the way the final exploit worked. The IDOR led us to html_content, but the RCE path began with a student's own submission. The XSS borrowed an instructor's authority, the ZIP Slip crossed into the filesystem, and our local permissions and SSH setup supplied the final step from file write to code execution.
The chain depended on three independent choices lining up: MarkUs rendered a submission on its own origin, trusted an archive entry as a filesystem path, and allowed the application user to overwrite code that SSH would execute.
Disclosure and fixes
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. The XSS advisory also says the patched Content Security Policy blocks embedded scripts in the remaining rendered-file flows.
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.