Ok, so that title is a little dramatic, but this was a real vulnerability chain in MarkUs, a grading platform used by UofT and Waterloo. This was not a CTF, and we did not use it to change grades or mess with anyone’s data. Everything was responsibly disclosed through GitHub Security Advisories and Teams, and we also helped patch the bugs.
This post is going to focus mostly on my side of the story: getting MarkUs running locally, stealing CSRF tokens with XSS, building the zip slip payload, and turning file write into RCE.
Dependency Hell
Before we could do any cool hacker stuff, I had to get MarkUs running locally.
Honestly, setup can sometimes be the hardest part of vulnerability research and I think anyone who has tried testing real software locally knows what I mean. Sometimes you end up patching license checks, hunting for the one VM image an old app still runs on, waiting hours for dependencies to install on bad internet, or trying to track down vulnerable versions that are not even listed publicly anymore.
For MarkUs, I had to fix the Docker build and production-ish compose setup enough to run the full stack locally: Rails, Postgres, Redis, Resque workers, SSH, and Apache/httpd. The main Dockerfile fixes were patching /app/.bundle/config ownership after root ran bundle config, forcing npm dev dependencies during asset precompile so sass existed, and installing openssh-server in the prod image because compose-prod.yaml starts /usr/sbin/sshd.
After that, Rails and the Apache proxy both returned 200, which was the point where I could finally stop fighting Docker and start testing the actual bugs. Very fun. Very normal.
This mattered a lot because once my local setup worked, I could stream it in call and we could test ideas quickly without touching any real school instance. My computer’s name is Carl, so if you see that in the demo, that is why.
From XSS to CSRF
The chain started with a submission preview bug that could lead to XSS in an instructor’s session. I am not going to spend too much time on that here, because the part I worked on more was what came after the JavaScript was already running.
An alert() is cool for proving XSS, but it does not do much by itself. The real question is:
Can we use this to make authenticated requests as the instructor?
MarkUs, like most Rails apps, uses CSRF tokens for state-changing requests. So even if we had JavaScript running, we still needed a valid token before we could POST to interesting endpoints.
The trick was pretty classic CTF stuff. Since the XSS was same-origin, the payload could fetch another page from MarkUs, read the HTML, pull out a CSRF token, and then reuse that token in a request.
So instead of thinking about the XSS as just “run JavaScript”, we started thinking about it as “make the instructor’s browser do instructor things.”
The Interesting Endpoint
The endpoint that became really interesting was assignment configuration upload.
Instructors can upload a zip file to create an assignment from an exported MarkUs config. That means MarkUs receives a zip, reads files out of it, and writes parts of it back to disk.
Whenever I see “upload a zip and extract it”, my brain immediately goes to zip slip.
The vulnerable pattern was basically:
zip_file.glob(test_file_glob_pattern) do |entry| 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")endThe problem is that entry.name comes from the zip. If the zip entry contains ../, then the final path can escape the intended directory.
So if MarkUs thinks it is writing:
automated-test-config-files/automated-test-files/tests.pywe can instead make it write something like:
automated-test-config-files/automated-test-files/../../../../../some/other/fileThat gives arbitrary file write as the MarkUs app user.
The Zip Slip PoC
The PoC zip still needed to look like a valid assignment config. We could not just upload a zip with one random file and expect MarkUs to accept it.
So the script copied the normal fixture files MarkUs expected, then added one extra zip entry with path traversal.
Here is the shortened version:
import zipfilefrom pathlib import Path
payload_name = ( "automated-test-config-files/automated-test-files/" "../../../../../lib/repo/markus-git-shell.sh")
payload_content = """#!/bin/bash# reverse shell payload redacted"""
fixture_dir = Path("Markus/spec/fixtures/files/assignments/sample-timed-assessment-good")
entries = [ ("properties.yml", fixture_dir / "properties.yml"), ("tags.yml", fixture_dir / "tags.yml"), ("criteria.yml", fixture_dir / "criteria.yml"), ("annotations.yml", fixture_dir / "annotations.yml"), ( "automated-test-config-files/automated-test-specs.json", fixture_dir / "automated-test-config-files/automated-test-specs.json", ), ( "automated-test-config-files/automated-test-files/tests.py", fixture_dir / "automated-test-config-files/automated-test-files/tests.py", ),]
with zipfile.ZipFile("zip-slip-check.zip", "w") as zf: for arcname, src in entries: zf.write(src, arcname)
zf.writestr(payload_name, payload_content)The important part is zf.writestr(payload_name, payload_content).
That writes our payload into the zip under a path that escapes the automated test files folder and lands on lib/repo/markus-git-shell.sh.
File Write is Not RCE
Arbitrary file write is great, but it is not automatically RCE. We still needed to find a file we could overwrite that would actually get executed.
This is where Hack The Box helped a lot. On HTB boxes, after you get some access, you usually enumerate like crazy and look for anything weird: writable files, scripts, services, cron jobs, SSH config, whatever.
So I ran linpeas in the local MarkUs environment.
Eventually, linpeas highlighted markus-git-shell.sh in red.

That was the moment where things started to click. MarkUs uses that script for repository access over SSH. If we could overwrite it with the zip slip, then trigger a normal repo action, the server would execute our script.
So the chain became:
- XSS runs in an instructor session.
- The payload fetches a MarkUs page and steals a CSRF token.
- The payload uploads our crafted config zip.
- Zip slip overwrites
markus-git-shell.sh. - A normal SSH/git action triggers the overwritten script.
- RCE.
Popping the Shell
This took multiple days and hours upon hours of sitting in call trying random ideas, reading code, fixing local setup problems, and testing the chain over and over.
When the shell finally popped, we were screaming.
The video is muted, but trust me, we were freaking the hell out at 4 AM seeing the fruits of our labour.
Reporting
After confirming the chain locally, we wrote everything up through GitHub Security Advisories and talked with the professors through Teams.
We also helped patch the issues, which was honestly a really valuable part of the whole project. Finding bugs is fun, but fixing them teaches you a different side of security.
The relevant CVEs were:
CVE-2026-24900- submission preview IDOR.CVE-2026-28405- stored XSS in submitted-file previews.CVE-2026-25057- zip slip arbitrary file write leading to RCE.
Afterthoughts
Honestly, this was one of the coolest security projects I have worked on so far.
It was really satisfying seeing CTF and HTB skills transfer into a real vulnerability chain. The biggest lesson for me was probably perseverance. The first bug was small, but by digging deeper and not giving up, it turned into something much more serious.
Also, I learned that sometimes the real exploit is getting the Docker setup to work.
If you want more details on the full chain, Amin also wrote about it here: https://xtra.sh/blog/markus/
Anyways, thanks for reading! 😊