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.
Summary
The "HTML to Markdown" MISP expansion module fetches the content of a
url-typeattribute 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
ipaddressmodule treats the mapped literal as a distinctIPv6Addressobjectthat does not match any of the module's IPv4
ip_networkranges nor its singleIPv6 entry (
::1/128).Details
misp_modules/modules/expansion/html_to_markdown.py:ipaddress.ip_address("::ffff:127.0.0.1")parses as anIPv6Address. Membershiptests (
ip in net) between an IPv6 address and an IPv4ip_networkshort-circuitto
Falseon version mismatch, and the one IPv6 entry present,::1/128, isloopback-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()returnsFalseforevery IPv4-mapped IPv6 form of every address the module intends to block, and
fetchHTML()proceeds to fetch it:PoC
Confirmed on v3.0.8, using the module functions unmodified:
Live test against a local HTTP server standing in for an internal-only
service:
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 thatarrived 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.pymodule (ip_addr.is_private,.is_loopback,.is_reservedonthe 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 isconverted 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
requestscall (rather than re-resolving at fetch time) toclose the related DNS-rebinding TOCTOU class of bypass.