Pwning pwn.college
This summer, I had the opportunity to work at Arizona State University’s SEFCOM Lab for a second year in a row under their cybersecurity internship program. I was tasked with working on their cybersecurity and computer science learning platform pwn.college. While working on the platform, I often did challenges to test my cybersecurity skills. One dojo (group of challenges) in particular was the Hanto Dojo. These challenges offered up complex problems like breaking the built-in lecture-watching system for pwn.college and various other unique challenges.
The Short Hunt
I originally was confused on how you could break the lecture challenge since it was built on the server and run by it– I thought it wasn’t possible at first. That was until I learned how the lecture actually worked, by sending a POST request with your progress and metadata every few seconds until you finish the video.
While in this context, it doesn’t seem anyway out of the ordinary, it was for me. Instead of displaying a common interface for all challenges like the VSCode workspace, Desktop, or terminal, this page displayed a html website that I could view from outside the container.
This simple page sparked my curiosity. If this container could display a (relatively) arbitrary web page, why couldn’t I? So, I dug into the code for the lecture challenge– it was a static HTML page run with a basic python http server.
.
├── Dockerfile
├── static
│ ├── css
│ │ └── style.css
│ └── js
│ └── script.js
├── templates
│ └── lecture.html
└── wsgi.py
Originally, I wanted to run a separate docker image (which likely would’ve worked), but the setup was a pain because of the docker authentication. I went with a DIY solution instead:
#!/bin/sh
export PYTHONDONTWRITEBYTECODE=1
mkdir -p /var/log/gunicorn
cd /opt/lecture
# Replace lecture server with evil one
cat /challenge/wsgi.py > /opt/lecture/wsgi.py
# Copy in our evil web page
cp /challenge/evil.html /opt/lecture/templates/evil.html
# Force bind to ports before they are taken by benign services
gunicorn --daemon
gunicorn --bind 0.0.0.0:8080 --daemon
gunicorn --bind 0.0.0.0:6080 --daemon
With that and a malicious test page which set your account’s country I had pwned pwn.college.
Hacker’s First CVE
After finding this issue, I notified my internship supervisor who had me create a GitHub security report to track the issue. I filled out the info with a basic description/POC you can find here (or under CVE-2026-25117). We decided to use a public fork, even though Github provides a private one, as there was little to no risk of someone understanding the PR to be fixing this issue and exploiting it. It was a simple report for a simple issue.
On Second Thought…
My internship responsibilities transitioned from mainly working on the website’s data system to working on this issue. What I thought would be a simple fix at first—a Content Security Policy Header— was anything but.
Due to how the dojo is set up, both dojo-author and system-made code are executed through a VM and passed through to the root domain. There is no way to specifically target dojo-author or user code without hitting infrastructure like VSCode and the Desktop environment. Whether it was CSP or iframe sandboxing, there was no way to stop this attack on the root domain.
Super subdomains
Instead, I had to switch my focus to using subdomains—their data was entirely isolated from the root host. I could use them to prevent user code from executing on the same permission level as the script to change your email or password. Subdomains, however, aren’t as simple as they seem theoretically. They require reverse proxies to correctly route them to the place that they need to be when multiple hosts (i.e. dojo.website and workspace.dojo.website) are running on the same IP.
Since pwn.college already uses nginx internally for their routing (ironically, one of the rules was what caused this issue), I was able to piggy-back on that. I created a workspace subdomain that routed users requests directly to containers.
Security when pwning
Direct routing creates a new class of issues– how do you determine whether a user is modifying their url to access another users workspace? We decided that without verification, it wasn’t really possible to tell as cookies didn’t carry over to the subdomains (the intended fix). The solution to that was to add verification, specifically a hash algorithm called HMAC. It allowed us to have a private key stored on all the servers that could verify a signature in the url. The url at this point became /workspace/containerID/signature/port/path?arg=something which allowed us to authenticate users even without their cookies.
There were a couple of issues that I had to solve when making this system. First, how can workspace nodes (multi-node setup) verify workspaces from the main node? The answer I decided on was to propagate the key between all of them by having the same environment variable stored on each. I decided to differentiate this one from the workspace key (used for authenticated nodes on the wireguard network) since they give 2 different levels of authentication---one could theoretically let you see someones workspace and the other would let you control all dojo servers.
Testing my patience
The URL signature system was the main component of the fix. After that, the only problems left were tests and multi-node deployments (what pwn.college actually runs on).
That was easier said than done. Most of the tests work correctly by default. They never had to touch any of the workspace nodes at all. However, for the small subset (4) tests where interacted occurred, failures were very common and hard to debug.
Github actions formats log files based on the testing library. Which for most projects, is a great solution. For this though, it meant parsing through a 2 million line raw log file since docker wasn’t in the pytest output. I had a hard time figuring out why these tests were failing. Sometimes, it would be just 1 test, and other times it would be all 4. I eventually figured out that the routing in the child containers did not work correctly. Due to the new workspace system plus a separate test container, the tests couldn’t find the workspace domain.
I fixed this by adding a single DNS entry— one of the simplest solutions to a problem that took upwards of a month to fix because the log file was bigger than most images.
TEST_CONTAINER_EXTRA_ARGS+=("--add-host" "${DOJO_HOST_CONFIG}:${CONTAINER_IP}")
Cyclomatic Complexity
Then I came to the issue of multi-node systems. These are far more complex since the main node needs to handle session signing and pass it off it to the correct workspace node while maintaining public routing (via nginx). Additionally, we decided to make the nodes have a personal subdomain to allow direct routing. The direct routing reduced load times since workspace content no longer had to go from node -> main node -> user. This unexpected performance improvement did have a couple of pain points— mainly, the aforementioned routing from the main node.
The method I decided on was to have the least setup and doubling of knowledge possible. When users requested a workspace, the main node would generate a url the same as before, but it would have an ip as well as a container id to go to.
/workspace/containerID:internalIP/signature/port/path?arg=something`
The NGINX would then transparently proxy the results of a internal web server running on the node. This server, running on port 8888, would use the workspace node’s environment variables to return a redirect to the correct workspace domain. It would look something like
Request -> (Workspace URL) URL to main node w/ routing to workspace internal ip
Workspace URL -> Main Node -> Workspace Node w/ routing info -> Redirect to workspace subdomain -> Content
It’s pretty complicated, but it reduces the amount of copy and pasting you have to do and reduces the risk of a compromise spreading easily.
await result;
After some (annoying) test failures, I was able to get my PR working locally on my multi-node setup along with the CI environment on github actions. I then went over the changes with my mentor and the PR was merged. There were a couple issues with how iframes were handled when testing it in production™—we had a set up a rule to prevent iframing and pwn.college url and the clipboard didn’t work due to permission sandboxing, but those were quickly fixed.
result.error((e) =>
The main issue that still exists now is the VSCode Server’s storage. VSCode’s server uses IndexedDB which is tied specifically to the subdomain and there is no way to override it. The true solution to this is to use a distributed remote proxy (like Cloudflare) to host all the nodes on the same subdomain ( ex: workspace.dojo.website/node1/blah). I decided that the most important thing was to get the fix out and not to worry about temporary settings. Theoretically, the fix is easy enough that we could
result.finally(() =>
I learned a lot from this vulnerability. Before this, I had not worked on offensive or defensive security, so this was truly a first in many ways. Patching my own vulnerability has made me appreciate the maintainers of open source projects that have to deal with this regularly. And with AI only making the process of finding vulnerabilities easier (or hallucinating and making more work for maintainers), maintainers’ work isn’t getting easier any time soon. This vulnerability was a challenge, but it was one that made me grow my skills for which I am immensely grateful.