Research/tomcat-rewrite-ns-loop
0-dayNo CVEHighPublic

Tomcat RewriteValve [NS] pins a worker

Apache Tomcat 11.0.27 RewriteValve prefix-matches [NS] as next. One unauthenticated GET that matches a copied httpd nosubreq rule pins the HTTP connector thread until the client drops. Labbed. Not RCE.

Name
Tomcat RewriteValve [NS] pins a worker
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Apache Software Foundation
Affected
Apache Tomcat 11.0.27 (52f3b364) RewriteValve. Valve is opt-in. Stock server.xml does not install it. Host rewrite.config is conf/Catalina/localhost/rewrite.config. Same-day main added a round cap.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, tomcat, rewritevalve, dos, cwe-835, unauthenticated

[NS] is nosubreq. Tomcat heard next.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/tomcat-rewrite-ns-loop (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, server.xml, rewrite.config, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is Apache Tomcat 11.0.27 (52f3b364e5882a5dbcfc42cb8313a7c2ae532c64). No CVE yet. CWE-835. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H). Remote unauthenticated DoS. RewriteValve enabled. Not RCE.

httpd has a flag [NS]. It means nosubreq: skip this rule when the request is an internal subrequest. Tomcat's RewriteValve.parseRuleFlag does not know that name. After nocase|NC and noescape|NE, any remaining token that startsWith("N") enables next. NS starts with N. So does NV. A rule copied from an Apache HTTP Server vhost, RewriteRule ^/loop$ - [NS], matches, substitution - returns the original URL, isNext() sets i = -1, and the worker never leaves the rule list. There is no round cap on this tag.

CVE-2026-65927 already taught this loop a lesson. Important, 11.0.25: [N] restarted at the second rule and skipped an ACL. The index is fixed. The cap is not. Same-day main after 11.0.27 added MAX_REWRITE_ROUNDS=1000 and exact flag matching. Those commits are robustness. They are also the leftover.

Why Tomcat after pfSense

I had just finished five High authenticated RCE labs on pfSense CE. Firewall PHP is a different kind of long day. Tomcat is the servlet container a lot of those boxes sit behind, or next to, or in front of as a reverse proxy that someone later pointed RewriteValve at because they already knew mod_rewrite. Jakarta EE 11. Servlet 6.1. Apache-2.0. The tree is large, the security page is honest, and the 2026 crop is dense enough that a leftover has to hide next to a named Important.

I only wanted Critical or High. Remote preferred. Medium stays on the floor unless it chains. CSRF against an administrative user is in-scope. Already-admin Manager, JMX, config, temp, work, and Attach API are out. Deployed webapps are trusted. Connector input is untrusted except allowed AJP attributes and RemoteIpValve / SSLValve headers. Clustering deserialisation without EncryptInterceptor is a known non-finding. Generic linear DoS is out. Non-linear resource use (one request pins a worker forever) is in.

Pinning 11.0.27, not 11.0.26

The day of the hunt, tomcat.apache.org listed 11.0.27, 10.1.61, and 9.0.123. Same calendar day. 11.0.x is the current Jakarta EE 11 line. 11.0.27 is not a security drop. Latest security drop is 11.0.26 (2026-09-15). I pinned the newest stable 11.x anyway, because a leftover that only exists on an older security drop is a CVE with a press release.

build.properties.default says version.major=11 version.minor=0 version.build=27. Tag commit 52f3b364e5882a5dbcfc42cb8313a7c2ae532c64. Native connector CVEs live in tomcat-native. Paid apps on top of Tomcat are other trees.

The clone is the tagged tree. Twenty parallel hunts: HTTP/1.1 framing, HTTP/2 HPACK, AJP, WebSocket, DefaultServlet / WebDAV, CGI / SSI, RewriteValve, security constraints, realms, TLS / OpenSSL-FFM, session persistence, Jasper / EL, Manager CSRF, RemoteIpValve / SSLValve, CSRF / CORS filters, Jakarta Authentication, cluster EncryptInterceptor, multipart, bundled examples, incomplete-fix siblings. Parent verified the KEEP leftover against RewriteValve.invoke, parseRuleFlag, and RewriteRule.evaluate. One independent lab on the official 11.0.27 tarball, loopback, witness-only.

The skip list is half the hunt

Before you look for leftovers, you write down what is already public so you do not "discover" a CVE with a press release. Tomcat's list lives at tomcat.apache.org/security-11.html. On 11.0.27 the interesting ones were already closed:

  • CVE-2026-86350 Important HTTP/2 header mix-up, regression of CVE-2026-41293. 11.0.27 even took the header-emitter lock around the remaining race. Fail-closed leftover of that lock is CVE-2026-77762 Low.
  • CVE-2026-78383 Important AJP missing-body thread pin. Connector commented out, loopback, secretRequired=true.
  • CVE-2026-77791 Important WebSocket close busy-wait. Named fix parks. Remaining occupancy is the designed blockingSendTimeout.
  • CVE-2026-76183 Important WebSocket constraint bypass, path parsed as an endpoint template.
  • CVE-2026-68763 Important HTTP/2 backlog leak. replaceStream subtracts.
  • CVE-2026-68569 Important DataSourceRealm principal lookup fail-open.
  • CVE-2026-65927 Important RewriteValve [N] restarts at the second rule. Index is fixed. Cap is this post.
  • CVE-2026-65182 Important security-constraint longest-path order.
  • CVE-2025-55752 Important RewriteValve PUT traversal / RCE. Decode then normalize. StandardContextValve 404s /WEB-INF.
  • CVE-2025-24813 / CVE-2024-50379 DefaultServlet write-enabled RCE. readonly still defaults true.

Native 2.0.16 (CVE-2026-86247 / 86246 / 86243) is a different repo. Rewrite Low siblings on this pin: CVE-2026-59083 (+ as space), CVE-2026-53404 (OR/next), CVE-2025-31651 (rewrite-rule bypass), CVE-2025-55668 (session fixation via rewrite). Vendor already called those.

Twenty surfaces, one leftover

HTTP/1.1 does not mix another user's request on a keep-alive connection. Chunk extensions fail closed. Transfer-Encoding on HTTP/1.0 is rejected. Trailers use a strict parser and an empty allow-list.

HTTP/2 on this tag already has the 11.0.26 mix-up lock plus the 11.0.27 emitter race lock. Remaining work is window leak, async IO, trailing-dot SNI. None of that grants one multiplexed stream another user's identity.

AJP stays commented out, binds loopback, and refuses to start without a secret. Arbitrary request attributes 403 unless they match the allow-list.

WebSocket constraint mapping uses the same Mapper result CoyoteAdapter already normalized. Close occupancy is the named CVE's bound.

DefaultServlet readonly is still true. PUT / DELETE / partial-PUT stay off. CGI and SSI stay commented out of conf/web.xml.

Constraints, FORM restore, and authenticators consume the same post-normalize path. JNDIRealm GSSAPI bind, DataSourceRealm fail-open, DIGEST "null", and LockOutRealm case are named. CombinedRealm add/remove is JMX.

RemoteIpValve and SSLValve are off. Manager RemoteCIDRValve is localhost. HTML Manager CSRF nonce is present. Text and JMX Manager have no CSRF filter by design, and combining those roles with manager-gui is documented as a footgun.

Jasper never feeds request URI, query, headers, or body into ExpressionFactory. Cluster without EncryptInterceptor is a known non-finding; with AES/GCM it is the documented default. Multipart temps are UUID names under work, not the client filename. Bundled examples XSS / chat DoS are Low or named.

That is Tomcat doing what Tomcat said it would do. The default connector is HTTP/1.1 on 8080. RewriteValve is not in stock conf/server.xml. Unauth GET of ROOT is a 404 or a welcome file. I am not filing that.

The rewrite hunt looked for RCE and walked past the hang

I gave RewriteValve its own hunt. The brief was file-read, constraint bypass, session steal, map RCE. prg: does not spawn a process. It Class.forNames an admin class. txt: and rnd: read admin paths at start. int: is toupper / tolower / escape / unescape. CVE-2025-55752's order is decode then normalize. Query-to-path cannot keep .. on requestPath. [N] restart index is fixed. QSD wins over QSA. Session leftover is still fixation, already CVE-2025-55668.

Verdict on that hunt: NONE.

The incomplete-fix hunt is where the hang lived. CVE-2026-65927 closed the ACL skip. The same i = -1 has no round cap. httpd bounds this (MaxRedirects default 10). Tomcat docs say "Be careful not to create an infinite loop." Flag parse is prefix. [S=-1] walks the index backward. Substitution - is the boring case everyone copies from httpd when they want "match, change nothing."

I almost dropped it. Opt-in valve. Documented infinite loop. Then I put it next to AJP. CVE-2026-78383 is Important thread-pin DoS on a connector that is also commented out of stock server.xml. RewriteValve is the same class of opt-in. [NS] becoming [N] is not an operator writing RewriteRule ^(.*)$ /$1 [N] on purpose. It is a parser that treats httpd nosubreq as next. And evaluate("-") returns the original URL, so every matching request is a pin, not only a pair of ping-pong rules.

Same-day main after the tag landed two commits: 2da9fddb (MAX_REWRITE_ROUNDS=1000, reject skip < 0) and 8992dbdd (exact-match flags so [NS] fails parse). Robustness. No security advisory yet. That is a leftover.

How the flag actually parses

Tokens are comma-split after stripping []. [NS] is one token "NS". After NC and NE:

Plain text
} else if (flag.startsWith("next") || flag.startsWith("N")) {
    rule.setNext(true);

Substitution - leaves substitution null. A matching rule returns the original URL:

Plain text
if (substitution != null) {
    return substitution.evaluate(matcher, lastMatcher, resolver);
} else {
    return url;
}

Next restarts at rule 0:

Plain text
if (rule.isNext() && newtest != null) {
    i = -1;
    continue;
}
if (newtest != null) {
    i += rule.getSkip();
}

isLast() is checked first, so [L,NS] breaks and does not pin. That is why a copied httpd [L,NS] is safe and a copied [NS] is not. Skip adds a possibly negative integer, then the for increment, so [S=-1] is the same rule forever.

Default Host path is Container.getConfigPath: conf/Catalina/localhost/rewrite.config. You add one Valve line to the Host and drop a file next to it. People who already have a working httpd rewrite map copy the flags.

The lab

Official tomcat:11.0.27-jre17 is not on Docker Hub. The lab downloads apache-tomcat-11.0.27.tar.gz from archive.apache.org onto eclipse-temurin:17-jre-jammy. SHA512 a617896307cada4884b24f80c4e977ddad8b3e0f88ce0e2a6eea29d5f332498e276ae5976c227a9c8daecf05c7c90def684a3766dd5a81f47a56d8b1adf43dc3. RELEASE-NOTES has to contain Apache Tomcat Version 11.0.27.

Host RewriteValve in server.xml. rewrite.config copied to /usr/local/tomcat/conf/Catalina/localhost/rewrite.config. Connector maxThreads="32". Loopback 127.0.0.1:18910. Compose project tomcat-rewrite-ns-loop.

Plain text
RewriteRule ^/loop$ - [NS]
RewriteRule ^/next$ - [N]
RewriteRule ^/skip$ - [S=-1]
RewriteRule ^/ok$ - [L]
RewriteRule ^/safe$ - [L,NS]

The PoC is HTTP GET with a short timeout. /ok must complete. /loop, /next, and /skip must hang. /safe must complete. While one /loop is still in flight, sibling /ok must still answer. Witness is the last-line token, not a payload.

Last run:

Plain text
control-ok path=/ok status=404 elapsed=0.013s timed_out=0
ns-hang path=/loop status=None elapsed=4.002s timed_out=1
L-ns-safe path=/safe status=404 elapsed=0.005s timed_out=0
n-hang path=/next status=None elapsed=4.003s timed_out=1
skip-hang path=/skip status=None elapsed=4.002s timed_out=1
sibling-ok path=/ok status=404 elapsed=0.013s timed_out=0
sibling-inflight-alive=1
SUCCESS TOMCAT-REWRITE-NS-LOOP control-ok=yes ns-hang=yes L-ns-safe=yes n-hang=yes skip-hang=yes sibling-alive=yes TOMCAT-REWRITE-NS-LOOP-WITNESS

404 on /ok is ROOT with no welcome file. That is the control. The hang is the leftover. Stack tears down on EXIT.

What an attacker can do

Method Sink Privilege
Unauth GET /loop matching Host rewrite.config RewriteRule ^/loop$ - [NS] RewriteValve.invoke i = -1 because startsWith("N") on token NS Availability (lab-proven): pin one HTTP connector thread until the client drops or the process is restarted. Lab: /loop timed out at 4.002s.
Unauth GET /next with explicit [N] same isNext() restart Availability (lab-proven): same pin. Lab: /next timed out at 4.003s.
Unauth GET /skip with [S=-1] i += -1 then the for increment Availability (lab-proven): same pin. Lab: /skip timed out at 4.002s.
One matching request per worker same sink, maxThreads concurrent matching URLs Availability (inferred): fill the connector pool (lab used 32; stock default is 200). New requests then queue or refuse. Sibling /ok still answered while one /loop was in flight.
Hang vs 13ms 404 on /ok timing of next/skip vs [L] Recon (lab-proven): tell that a next/skip rule matched. Lab: 4.002s hang vs 0.013s 404.

Lab-proven vs inferred: /loop [NS] hang, /next [N] hang, /skip [S=-1] hang, /ok [L] 404 in 13ms, /safe [L,NS] 404 in 5ms, and sibling /ok 404 while /loop is in flight are lab-proven. Pool exhaustion is inferred from one-thread pin plus maxThreads.

Requires: RewriteValve on the Host or Context and a matching rewrite.config rule. Valve is opt-in, same class as commented-out AJP. Copied httpd [NS] is a matching rule, not an intentional infinite [N] loop.

Adjacent surface that stays closed

Surface Lab
[L,NS] (httpd last+nosubreq) /safe 404 in 5ms (L-ns-safe=yes)
Other in-flight requests while one thread is pinned /ok 404 in 13ms during /loop (sibling-alive=yes)
Stock server.xml without RewriteValve valve absent; leftover does not fire
RCE / WEB-INF read / constraint bypass of CVE-2026-65927 [N] now restarts at rule 0; this leftover is availability only
Manager / JMX / AJP / DefaultServlet PUT unchanged; PUT still readonly

No code execution as the Tomcat user. No read of conf/ or WEB-INF. No session steal. No Manager deploy. The JVM stays up; other workers still answer until the pool is filled.

The fix

Cap rewrite rounds. main after this tag already has MAX_REWRITE_ROUNDS=1000 in 2da9fddb and rejects skip < 0. Exact-match flags so [NS] fails parse: 8992dbdd. Until those land in a release: avoid [NS] / [NV] / [N] / [S=-1] on matching rules, or drop RewriteValve.

Replay:

Plain text
cd lab
./run.sh

Loopback 127.0.0.1:18910. Image tag tomcat-rewrite-ns-loop:11.0.27. Witness TOMCAT-REWRITE-NS-LOOP-WITNESS.

References

Abraxas Labs https://abraxaslabs.tech https://github.com/abraxas https://x.com/abraxas_null (@abraxas_null) abraxas.null@proton.me