Tomcat Example and Documentation Applications Deployed
Checks whether the examples or docs web applications that ship with Tomcat are deployed.
- Why It Matters
- The sample applications are written to demonstrate features, not to be safe. Some (such as the session example) let visitors change server-side session data, and all of them reveal the exact Tomcat version.
- How It Detects
- Looks for examples/ and docs/ application folders (WEB-INF/web.xml or index.html) in each Tomcat installation's webapps folders, including the Debian and Ubuntu tomcatN-examples and tomcatN-docs packages.
- How to Fix
- Delete the examples and docs folders from webapps (or apt remove tomcatN-examples tomcatN-docs) and restart Tomcat.
- How to Verify
ls <webapps> lists only your own applications (and ROOT).- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N- Control Category
- Information Exposure: CIS Controls v8 3, 16; NIST CSF 2.0 PR.DS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 16.7
- NIST CSF 2.0 Subcategory
- PR.PS-01
- OWASP WSTG v4.2
- WSTG-CONF-02
- CWE
- CWE-1188
- OWASP Top 10
- A05:2021
- NIST SP 800-53 Rev. 5
- CM-7
- References
- Apache Tomcat 10.1, Security Considerations
OWASP WSTG-CONF-02, Test Application Platform Configuration
Source Code
The complete file that defines this check. Run it with openvapt scan --only WEB-TOM-005, or read it offline with openvapt explain WEB-TOM-005.
internal/checks/web/tomcat.go · Lines 1-217View on GitHub at This Commit
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/openvapt/openvapt/internal/check" |
| 11 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 12 | "github.com/openvapt/openvapt/internal/sys" |
| 13 | ) |
| 14 | |
| 15 | // Apache Tomcat checks. They read server.xml, tomcat-users.xml and the |
| 16 | // deployed application folders; no request is sent to Tomcat, and passwords |
| 17 | // read from tomcat-users.xml are compared in memory and never reported. |
| 18 | |
| 19 | var refTomcatSec = check.Ref{Title: "Apache Tomcat 10.1, Security Considerations", URL: "https://tomcat.apache.org/tomcat-10.1-doc/security-howto.html"} |
| 20 | |
| 21 | type tomcatRule struct { |
| 22 | id, title, summary, msg, rationale, detection, fix, verify, cvss string |
| 23 | sev check.Severity |
| 24 | cat check.Category |
| 25 | refs []check.Ref |
| 26 | std check.Standards |
| 27 | eval func(context.Context, sys.System, collect.TomcatInstance) []string |
| 28 | } |
| 29 | |
| 30 | func registerTomcat(r tomcatRule) { |
| 31 | check.Register(&check.Check{ID: r.id, Title: r.title, Domain: check.DomainWeb, Severity: r.sev, Category: r.cat, Effort: check.EffortLow, |
| 32 | CVSS: r.cvss, Summary: r.summary, Rationale: r.rationale, Detection: r.detection, Fix: r.fix, Verify: r.verify, Refs: r.refs, Standards: r.std, |
| 33 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 34 | ts := collect.Tomcats(s) |
| 35 | if len(ts) == 0 { |
| 36 | return []check.Result{check.SkipR("tomcat", "No Apache Tomcat installation found")} |
| 37 | } |
| 38 | var out []check.Result |
| 39 | for _, t := range ts { |
| 40 | if bad := r.eval(ctx, s, t); len(bad) > 0 { |
| 41 | out = append(out, check.FailR(t.Conf, r.msg, bad...)) |
| 42 | } else { |
| 43 | out = append(out, check.PassR(t.Conf, "No issue found")) |
| 44 | } |
| 45 | } |
| 46 | return out |
| 47 | }, |
| 48 | }) |
| 49 | } |
| 50 | |
| 51 | func readText(ctx context.Context, s sys.System, path string) string { |
| 52 | r, err := s.ReadFile(ctx, path) |
| 53 | if err != nil { |
| 54 | return "" |
| 55 | } |
| 56 | return string(r.Data) |
| 57 | } |
| 58 | |
| 59 | func loopbackAddr(a string) bool { |
| 60 | if a == "localhost" { |
| 61 | return true |
| 62 | } |
| 63 | ip := net.ParseIP(a) |
| 64 | return ip != nil && ip.IsLoopback() |
| 65 | } |
| 66 | |
| 67 | // Passwords Tomcat documentation, tutorials and scanners use as examples. |
| 68 | var tomcatDefaults = map[string]bool{"": true, "tomcat": true, "admin": true, "password": true, "s3cret": true, "secret": true, |
| 69 | "changeit": true, "manager": true, "root": true, "123456": true, "role1": true, "both": true, "<must-be-changed>": true} |
| 70 | |
| 71 | func init() { |
| 72 | registerTomcat(tomcatRule{ |
| 73 | id: "WEB-TOM-001", title: "Tomcat Manager Reachable Without Address Restriction", sev: check.High, cat: check.CatWebApp, msg: "Tomcat Manager is deployed without an address restriction", |
| 74 | cvss: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| 75 | summary: "Checks whether the Tomcat Manager or Host Manager application is deployed without an address filter limiting it to localhost.", |
| 76 | rationale: "The Manager deploys WAR files, which means it runs any code it is given. Exposed to the network it is one stolen or guessed password away from full compromise, and it is a standard target of automated attacks.", |
| 77 | detection: "Looks for the manager and host-manager applications in each Tomcat installation's webapps folder (and /usr/share/tomcatN-admin on Debian and Ubuntu), then reads their META-INF/context.xml and conf/Catalina/localhost/<app>.xml. Fails when no active RemoteAddrValve or RemoteCIDRValve restricts access.", |
| 78 | fix: "If you do not use them, delete the manager and host-manager folders (or apt remove tomcatN-admin). Otherwise keep the default valve in META-INF/context.xml: <Valve className=\"org.apache.catalina.valves.RemoteAddrValve\" allow=\"127\\.\\d+\\.\\d+\\.\\d+|::1|0:0:0:0:0:0:0:1\" /> and reach the Manager through an SSH tunnel.", |
| 79 | verify: "grep -A2 RemoteAddrValve <webapps>/manager/META-INF/context.xml", |
| 80 | refs: []check.Ref{refTomcatSec, {Title: "Apache Tomcat 10.1, Manager App How-To", URL: "https://tomcat.apache.org/tomcat-10.1-doc/manager-howto.html"}}, |
| 81 | std: check.Standards{CWE: []string{"CWE-1188", "CWE-923"}, OWASP: []string{"A01:2021", "A05:2021"}, WSTG: []string{"WSTG-CONF-05"}, NIST: []string{"AC-3", "CM-7"}}, |
| 82 | eval: func(ctx context.Context, s sys.System, t collect.TomcatInstance) []string { |
| 83 | var bad []string |
| 84 | for _, app := range []string{"manager", "host-manager"} { |
| 85 | var dirs []string |
| 86 | for _, w := range t.Webapps { |
| 87 | if collect.Exists(s, w+"/"+app+"/META-INF/context.xml") || collect.Exists(s, w+"/"+app+"/WEB-INF/web.xml") { |
| 88 | dirs = append(dirs, w+"/"+app) |
| 89 | } |
| 90 | } |
| 91 | if len(dirs) == 0 { |
| 92 | continue |
| 93 | } |
| 94 | restricted := false |
| 95 | for _, ctxFile := range []string{dirs[0] + "/META-INF/context.xml", t.Conf + "/Catalina/localhost/" + app + ".xml"} { |
| 96 | for _, v := range collect.XMLElements(readText(ctx, s, ctxFile), "Valve") { |
| 97 | cls := v["classname"] |
| 98 | if (strings.HasSuffix(cls, "RemoteAddrValve") || strings.HasSuffix(cls, "RemoteCIDRValve")) && v["allow"] != "" && v["allow"] != ".*" { |
| 99 | restricted = true |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | if !restricted { |
| 104 | bad = append(bad, dirs[0]+" is deployed with no RemoteAddrValve or RemoteCIDRValve restriction") |
| 105 | } |
| 106 | } |
| 107 | return bad |
| 108 | }, |
| 109 | }) |
| 110 | |
| 111 | registerTomcat(tomcatRule{ |
| 112 | id: "WEB-TOM-002", title: "Tomcat Administrative Users With Default or Weak Passwords", sev: check.Critical, cat: check.CatIdentity, msg: "Tomcat administrative users have weak credentials", |
| 113 | cvss: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| 114 | summary: "Checks tomcat-users.xml for manager or admin accounts with well-known default passwords, passwords equal to the user name, or short passwords.", |
| 115 | rationale: "Default Tomcat credentials (tomcat/tomcat, admin/admin, tomcat/s3cret) are in every attack tool's word list. A Manager account with such a password lets anyone deploy code on the server.", |
| 116 | detection: "Parses the <user> elements of conf/tomcat-users.xml (comments ignored). For users with manager-gui, manager-script, manager-jmx, admin-gui or admin-script roles, compares the password in memory against a short list of documented defaults, the user name and a 12-character minimum. Passwords stored as digests are not judged. No password is ever written to the report.", |
| 117 | fix: "Remove accounts you do not need. Give the remaining ones long random passwords and store them as digests: configure a CredentialHandler (for example SecretKeyCredentialHandler with PBKDF2) in the Realm and generate hashes with bin/digest.sh. Restart Tomcat.", |
| 118 | verify: "Re-run this check; no administrative user should be listed.", |
| 119 | refs: []check.Ref{refTomcatSec, {Title: "Apache Tomcat 10.1, Realm Configuration How-To (digested passwords)", URL: "https://tomcat.apache.org/tomcat-10.1-doc/realm-howto.html"}}, |
| 120 | std: check.Standards{CWE: []string{"CWE-1392", "CWE-521", "CWE-256"}, OWASP: []string{"A07:2021"}, ASVS: []string{"2.1.1", "2.5.4"}, WSTG: []string{"WSTG-ATHN-02"}, NIST: []string{"IA-5"}}, |
| 121 | eval: func(ctx context.Context, s sys.System, t collect.TomcatInstance) []string { |
| 122 | doc := readText(ctx, s, t.Conf+"/tomcat-users.xml") |
| 123 | digest := regexp.MustCompile(`(?i)CredentialHandler`).MatchString(readText(ctx, s, t.Conf+"/server.xml")) |
| 124 | var bad []string |
| 125 | for _, u := range collect.XMLElements(doc, "user") { |
| 126 | roles := strings.ToLower(u["roles"]) |
| 127 | if !strings.Contains(roles, "manager-") && !strings.Contains(roles, "admin-") { |
| 128 | continue |
| 129 | } |
| 130 | pw, name := u["password"], u["username"] |
| 131 | if digest && len(pw) >= 32 { |
| 132 | continue |
| 133 | } |
| 134 | switch { |
| 135 | case tomcatDefaults[strings.ToLower(pw)]: |
| 136 | bad = append(bad, fmt.Sprintf("user %q (roles %s) uses a documented default password", name, u["roles"])) |
| 137 | case strings.EqualFold(pw, name): |
| 138 | bad = append(bad, fmt.Sprintf("user %q (roles %s) uses the user name as password", name, u["roles"])) |
| 139 | case len(pw) < 12: |
| 140 | bad = append(bad, fmt.Sprintf("user %q (roles %s): fewer than 12 characters in the password", name, u["roles"])) |
| 141 | } |
| 142 | } |
| 143 | return bad |
| 144 | }, |
| 145 | }) |
| 146 | |
| 147 | registerTomcat(tomcatRule{ |
| 148 | id: "WEB-TOM-003", title: "Tomcat Shutdown Port Uses the Default Command", sev: check.Medium, cat: check.CatConfig, msg: "Tomcat shutdown port uses the default command", |
| 149 | cvss: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H", |
| 150 | summary: "Checks whether Tomcat's shutdown port is enabled with the well-known default command SHUTDOWN.", |
| 151 | rationale: "Anyone who can connect to the shutdown port (8005 by default) and send the shutdown string stops Tomcat. With the default string, every local user and every compromised process on the machine can take the applications offline.", |
| 152 | detection: "Reads the <Server> element of conf/server.xml and fails when port is not -1 and the shutdown attribute is SHUTDOWN.", |
| 153 | fix: "Disable the port with <Server port=\"-1\" ...> and stop Tomcat through systemd, or set shutdown to a long random string. Restart Tomcat.", |
| 154 | verify: "grep '<Server' <conf>/server.xml shows port=\"-1\".", |
| 155 | refs: []check.Ref{refTomcatSec, {Title: "Apache Tomcat 10.1 Configuration Reference, The Server Component", URL: "https://tomcat.apache.org/tomcat-10.1-doc/config/server.html"}}, |
| 156 | std: check.Standards{CWE: []string{"CWE-1188"}, OWASP: []string{"A05:2021"}, NIST: []string{"CM-6", "CM-7"}}, |
| 157 | eval: func(ctx context.Context, s sys.System, t collect.TomcatInstance) []string { |
| 158 | for _, srv := range collect.XMLElements(readText(ctx, s, t.Conf+"/server.xml"), "Server") { |
| 159 | if srv["port"] != "-1" && srv["shutdown"] == "SHUTDOWN" { |
| 160 | return []string{fmt.Sprintf("<Server port=%q shutdown=\"SHUTDOWN\">", srv["port"])} |
| 161 | } |
| 162 | } |
| 163 | return nil |
| 164 | }, |
| 165 | }) |
| 166 | |
| 167 | registerTomcat(tomcatRule{ |
| 168 | id: "WEB-TOM-004", title: "Tomcat AJP Connector Unprotected (Ghostcat)", sev: check.Critical, cat: check.CatWebApp, msg: "AJP connector is reachable without protection", |
| 169 | cvss: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| 170 | summary: "Checks for an active AJP connector that has no shared secret and is not bound to loopback, or that disables the secret requirement.", |
| 171 | rationale: "AJP is a trusted, unauthenticated protocol between a web server and Tomcat. CVE-2020-1938 (Ghostcat) showed that a reachable AJP port lets an attacker read any file in the web applications and, where uploads exist, execute code. It is listed in the CISA Known Exploited Vulnerabilities catalogue.", |
| 172 | detection: "Reads the <Connector> elements of conf/server.xml (comments ignored) and, for those with protocol AJP/1.3 or an AJP protocol class, fails when secretRequired=\"false\" or when there is no secret (or requiredSecret) and the address is not a loopback address.", |
| 173 | fix: "If you do not use AJP (most sites proxy over HTTP), delete or comment out the AJP <Connector>. Otherwise set address=\"127.0.0.1\", a long random secret=\"...\" with secretRequired=\"true\", and the same secret in the proxy (mod_proxy_ajp: ProxyPass ... secret=...). Upgrade Tomcat to a supported release.", |
| 174 | verify: "grep -i ajp <conf>/server.xml and ss -tlnp | grep 8009 shows nothing or 127.0.0.1 only.", |
| 175 | refs: []check.Ref{refTomcatSec, {Title: "Apache Tomcat 10.1 Configuration Reference, The AJP Connector", URL: "https://tomcat.apache.org/tomcat-10.1-doc/config/ajp.html"}, {Title: "NVD, CVE-2020-1938", URL: "https://nvd.nist.gov/vuln/detail/cve-2020-1938"}}, |
| 176 | std: check.Standards{CWE: []string{"CWE-306", "CWE-1327"}, OWASP: []string{"A05:2021", "A06:2021"}, NIST: []string{"SC-7", "AC-3", "CM-7"}}, |
| 177 | eval: func(ctx context.Context, s sys.System, t collect.TomcatInstance) []string { |
| 178 | var bad []string |
| 179 | for _, c := range collect.XMLElements(readText(ctx, s, t.Conf+"/server.xml"), "Connector") { |
| 180 | if !strings.Contains(strings.ToUpper(c["protocol"]), "AJP") { |
| 181 | continue |
| 182 | } |
| 183 | port := c["port"] |
| 184 | switch { |
| 185 | case strings.EqualFold(c["secretrequired"], "false") && !loopbackAddr(c["address"]): |
| 186 | bad = append(bad, "AJP connector on port "+port+" is not bound to loopback and does not require a secret") |
| 187 | case c["secret"] == "" && c["requiredsecret"] == "" && !loopbackAddr(c["address"]): |
| 188 | bad = append(bad, "AJP connector on port "+port+" is not bound to loopback and has no secret") |
| 189 | } |
| 190 | } |
| 191 | return bad |
| 192 | }, |
| 193 | }) |
| 194 | |
| 195 | registerTomcat(tomcatRule{ |
| 196 | id: "WEB-TOM-005", title: "Tomcat Example and Documentation Applications Deployed", sev: check.Medium, cat: check.CatExposure, msg: "Tomcat sample applications are deployed", |
| 197 | cvss: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N", |
| 198 | summary: "Checks whether the examples or docs web applications that ship with Tomcat are deployed.", |
| 199 | rationale: "The sample applications are written to demonstrate features, not to be safe. Some (such as the session example) let visitors change server-side session data, and all of them reveal the exact Tomcat version.", |
| 200 | detection: "Looks for examples/ and docs/ application folders (WEB-INF/web.xml or index.html) in each Tomcat installation's webapps folders, including the Debian and Ubuntu tomcatN-examples and tomcatN-docs packages.", |
| 201 | fix: "Delete the examples and docs folders from webapps (or apt remove tomcatN-examples tomcatN-docs) and restart Tomcat.", |
| 202 | verify: "ls <webapps> lists only your own applications (and ROOT).", |
| 203 | refs: []check.Ref{refTomcatSec, {Title: "OWASP WSTG-CONF-02, Test Application Platform Configuration", URL: "https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/02-Test_Application_Platform_Configuration/"}}, |
| 204 | std: check.Standards{CWE: []string{"CWE-1188"}, OWASP: []string{"A05:2021"}, WSTG: []string{"WSTG-CONF-02"}, NIST: []string{"CM-7"}}, |
| 205 | eval: func(ctx context.Context, s sys.System, t collect.TomcatInstance) []string { |
| 206 | var bad []string |
| 207 | for _, w := range t.Webapps { |
| 208 | for _, app := range []string{"examples", "docs"} { |
| 209 | if collect.Exists(s, w+"/"+app+"/WEB-INF/web.xml") || collect.Exists(s, w+"/"+app+"/index.html") { |
| 210 | bad = append(bad, w+"/"+app+" is deployed") |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | return bad |
| 215 | }, |
| 216 | }) |
| 217 | } |
Also available as plain Markdown for notes, scripts and AI tools. Same facts, same source, no styling.
Cite This Page
If this page helped your work, please cite it. The version and web address are filled in; the access date is today's date in your browser. Every other style, and when credit is required, is on How to Cite OpenVAPT.
OpenVAPT. (2026). WEB-TOM-005 Tomcat Example and Documentation Applications Deployed (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/WEB-TOM-005.html
In text: (OpenVAPT, 2026). Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
@misc{openvapt_web_tom_005,
author = {{OpenVAPT}},
title = {{WEB-TOM-005 Tomcat Example and Documentation Applications Deployed}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/WEB-TOM-005.html}},
note = {OpenVAPT Check Catalogue, version 0.1.0-alpha.6. Accessed 16 October 2026}
}
Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
Citing OpenVAPT does not mean OpenVAPT endorses, approves or has reviewed your work.