"""RSFiles! component detection — standalone, reusable module. This module knows nothing about exploitation; it only answers the question "is RSFiles! (com_rsfiles) present on this host, and is it patched?" It is used by cve_2026_57827.py but can equally be dropped into a different tool. Usage (as a library): from rsfiles_detect import Detector det = Detector(cfg, http) d = det.detect("example.com") if d.detected: print(d.version, "patched" if det.is_patched(d.version) else "vulnerable") """ from __future__ import annotations import os import sys # Make sure this script's own directory is on the import path so that # `common` can be found no matter where / how we are launched. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import re from dataclasses import dataclass from typing import Optional import requests from common import ( HttpClient, Config, base_candidates, c, normalize_host, parse_version, GREEN, RED, YELLOW, TIMEOUT, ) # First release that closes the anonymous upload route. PATCHED_VERSION = "1.17.12" # Confirmed endpoints from the RSJoomla advisory (kept for reference / logs). CHECKUPLOAD_ENDPOINT = "/index.php?option=com_rsfiles&task=rsfiles.checkupload" # Files that indicate the component is present. DETECT_PATHS = [ "/components/com_rsfiles/rsfiles.php", "/components/com_rsfiles/rsfiles.xml", "/administrator/components/com_rsfiles/rsfiles.xml", "/media/com_rsfiles/css/rsfiles.css", ] # XML manifests that can reveal the exact version number. VERSION_MANIFESTS = [ "/administrator/components/com_rsfiles/rsfiles.xml", "/components/com_rsfiles/rsfiles.xml", ] @dataclass class Detection: """The outcome of probing a single host.""" host: str detected: bool = False version: Optional[str] = None base: Optional[str] = None # reachable base URL that confirmed it protocol: Optional[str] = None # "https" or "http" @property def vulnerable(self) -> bool: """True only when the version is known and below the patched one.""" if not self.detected or not self.version: return False return parse_version(self.version) < parse_version(PATCHED_VERSION) class Detector: """Probes one or many hosts for the RSFiles! component and its version. ``http`` must be an object with a ``request(base, method, url, **kw)`` method returning a ``requests.Response`` — our HttpClient satisfies this, which lets callers share one session pool across detection + exploitation. """ def __init__(self, cfg: Config, http: HttpClient): self.cfg = cfg self.http = http # -- logging ------------------------------------------------------------- def _log(self, host: str, msg: str) -> None: if self.cfg.debug: print(f" [detect:{host}] {msg}", flush=True) # -- public API ----------------------------------------------------------- def detect(self, host: str) -> Detection: """Return a Detection for ``host`` after trying both protocols.""" d = Detection(host=host) for base in base_candidates(host): for path in DETECT_PATHS: try: r = self.http.request(base, "GET", f"{base}{path}", timeout=self.cfg.timeout) except requests.RequestException: continue if r.status_code != 200: continue self._log(host, f"present via {path} on {base}") d.detected = True d.base = base d.protocol = base.split("://")[0] d.version = self._probe_version(base) if d.version: self._log(host, f"version={d.version}") return d return d def _probe_version(self, base: str) -> Optional[str]: for path in VERSION_MANIFESTS: try: r = self.http.request(base, "GET", f"{base}{path}", timeout=self.cfg.timeout) m = re.search(r"([0-9.]+)", r.text) if m: return m.group(1) except requests.RequestException: continue return None # -- version helpers ------------------------------------------------------- @staticmethod def is_patched(version: Optional[str]) -> bool: """True if the version is known and >= the patched release.""" if not version: return False return parse_version(version) >= parse_version(PATCHED_VERSION) def _print_single(d: Detection) -> None: Y = GREEN print() print(f" Host : {d.host}") print(f" RSFiles! : {c('YES', Y)}{' v' + d.version if d.version else ' (version unknown)'}" if d.detected else " RSFiles! : NO") print(f" Protocol : {d.protocol or '-'}") if d.detected: if d.vulnerable: print(f" Status : {c('VULNERABLE', RED)} (below {PATCHED_VERSION})") elif d.version: print(f" Status : {c('PATCHED / SAFE', YELLOW)} (>= {PATCHED_VERSION})") else: print(f" Status : {c('UNKNOWN — version not disclosed', YELLOW)}") def main(argv: Optional[list[str]] = None) -> int: import argparse p = argparse.ArgumentParser( prog="rsfiles_detect.py", description="RSFiles! (com_rsfiles) component/version detection — no exploit, scan only.", epilog="""examples: python rsfiles_detect.py -t example.com python rsfiles_detect.py -t example.com --debug python rsfiles_detect.py -f hosts.txt""", ) src = p.add_mutually_exclusive_group(required=True) src.add_argument("-t", "--target", help="single host to probe") src.add_argument("-f", "--file", help="file of hosts (one per line, # = comment)") p.add_argument("--timeout", type=float, default=TIMEOUT) p.add_argument("--debug", action="store_true", help="verbose request-level logging") a = p.parse_args(argv) hosts: list[str] = [] if a.target: hosts.append(a.target) if a.file: if not os.path.isfile(a.file): print(f" {c('[!] file not found: ' + a.file, RED)}", file=sys.stderr) return 1 with open(a.file) as f: hosts.extend(line.strip() for line in f if line.strip() and not line.startswith("#")) hosts = list(dict.fromkeys(normalize_host(h) for h in hosts)) if not hosts: p.print_help() return 1 cfg = Config(timeout=float(a.timeout), debug=a.debug) http = HttpClient(cfg) det = Detector(cfg, http) if len(hosts) == 1: _print_single(det.detect(hosts[0])) else: print(f"\n Probing {len(hosts)} host(s)...\n") for h in hosts: d = det.detect(h) state = ("VULN" if d.vulnerable else "SAFE" if d.detected and d.version else "PRESENT?" if d.detected else "absent") print(f" [{state:8s}] {h:30s} v{d.version if d.version else '-'}") http.close_all() print() return 0 if __name__ == "__main__": sys.exit(main())