← Back to Feed CACHED · 2026-05-17 09:42:19 · cache_key CVE-2025-29912
CVE-2025-22390 · CWE-521 · Disclosed 2025-01-04

Optimizely EPiServer

ASSESSED — NOISGATE V0.5
Vendor
Reassessed
Verdict:
01 · The Real Story

This is a door shipped with a short key, not a lock that opens itself

CVE-2025-22390 is a weak password policy issue: affected Optimizely CMS builds allowed CMS users to set passwords as short as six characters. The CVE text says EPiServer.CMS.Core before 12.32.0, but Optimizely's own advisory and release notes tie the fix to EPiServer.CMS.UI before 12.32.0, where version 12.32.0` changed the default minimum password length from six to eight characters.

The 7.5 / HIGH CVSS attached by CISA-ADP overstates reality for enterprise patch planning. This is not an authentication bypass, memory disclosure, or code execution bug; an attacker still needs a reachable CMS login, valid usernames, locally managed CMS credentials, weak user-chosen passwords, and usually the absence of MFA, lockout, or rate limiting. Optimizely's own advisory scored it Medium 5.5, and in the field it behaves closer to LOW patch urgency unless you knowingly expose local CMS auth on the internet.

"This is weak default hygiene, not a magic unauth compromise button."
02 · The Attack Path

4 steps from start to impact.

STEP 01

Find the CMS login surface

The attacker first identifies an Optimizely CMS admin/editor entry point, typically /util/login or /episerver/cms, using standard recon such as httpx, browser probing, or search-engine indexing of exposed admin paths. This is ordinary web recon, not vulnerability exploitation by itself.
Conditions required:
  • The CMS management interface is reachable from the attacker's network position
  • The deployment uses default or discoverable backend login paths
Where this breaks in practice:
  • Many enterprises keep edit/admin paths behind VPN, reverse proxy ACLs, or private network exposure only
  • Optimizely CMS exposure is materially smaller than mass-market platforms, so random spraying has a thinner target pool
Detection/coverage: External attack-surface tools can find the URLs, but vuln scanners usually cannot prove this CVE remotely because password policy is a configuration/runtime behavior rather than a bannered exploit primitive.
STEP 02

Determine whether local auth is in play

The bug matters most when the CMS relies on local ASP.NET Identity-style credentials rather than a federated IdP. An attacker or tester will check whether the site uses native username/password login, mixed mode, or SSO-only auth before attempting password attacks.
Conditions required:
  • The organization allows local CMS accounts for editors/admins
  • The attacker can reach the login workflow and observe auth behavior
Where this breaks in practice:
  • Optimizely explicitly supports third-party providers, single sign-on, and federated claims-based authentication, which can sidestep this weak local default
  • If SSO, MFA, or password controls are enforced upstream, the vulnerable local minimum length is much less relevant
Detection/coverage: This is mostly a manual assessment step; asset inventory and app-owner knowledge beat scanner output here.
STEP 03

Spray or guess weak passwords

With usernames and local auth in place, the attacker can use tools like Burp Intruder, hydra, or a custom script to attempt credential spraying against the CMS login. The CVE only reduces password search space; it does not remove the need to guess a real password or bypass authentication.
Conditions required:
  • At least one active CMS account uses a short, guessable password
  • No effective MFA, account lockout, CAPTCHA, IP throttling, or rate limiting blocks the spray
Where this breaks in practice:
  • MFA and lockout policies break the chain hard
  • Even without MFA, six-character minimum does not guarantee trivial passwords; many users still choose stronger ones
  • Password spraying generates noisy auth telemetry in modern reverse proxies, IdPs, and SIEMs
Detection/coverage: Detectable through repeated failed logons, source IP concentration, unusual login velocity, WAF/rate-limit counters, and app authentication logs.
STEP 04

Abuse the compromised CMS role

If a weak local CMS account is compromised, the attacker gains whatever that account can see or modify inside the CMS: unpublished content, editorial workflows, assets, and possibly admin functions depending on role. Impact depends entirely on the compromised account's privileges and whether the CMS is integrated with broader business systems.
Conditions required:
  • A valid CMS credential was successfully compromised
  • The account has meaningful edit or admin permissions
Where this breaks in practice:
  • Least-privilege editor roles can sharply limit blast radius
  • Compromise often stays inside one CMS tenant/site unless the org has over-broad admin assignment
Detection/coverage: Post-login behavior is visible in CMS audit logs, content change logs, session records, and downstream change-monitoring systems, but attribution to this CVE versus generic credential theft is weak.
03 · Intelligence Metadata

The supporting signals.

In-the-wild statusNo public active exploitation evidence found in the sources reviewed, and not KEV-listed. CISA did include the CVE in its weekly bulletin for vulnerabilities published the week of January 6, 2025, but that is *not* a KEV signal.
PoC availabilityNo public exploit repo or Metasploit-style PoC located. That's expected: the practical weapon is ordinary password spraying, not a code-level exploit chain.
EPSS0.00327 from the user-provided intel, which is very low predicted exploitation probability. FIRST notes EPSS estimates threat likelihood, not business impact.
Vendor vs. ADP scoringImportant mismatch: Optimizely's advisory says Medium 5.5, while CISA-ADP/NVD enrichment shows 7.5 HIGH with AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N. For patch triage, the vendor's narrative fits reality better than the generic ADP vector.
CVSS vector reality checkAV:N/AC:L/PR:N/UI:N implies a direct remote exploit path, but in practice exploitation requires discoverable login, local auth, valid usernames, weak chosen passwords, and usually missing MFA/rate limits. That is far more constrained than the vector suggests.
Affected versionsVendor advisory: EPiServer.CMS.UI before 12.32.0. The CVE/NVD description says Core, so defenders should inventory both naming conventions in SBOM/package data and treat the vendor advisory as the authoritative fix statement.
Fixed versionsEPiServer.CMS.UI 12.32.0+ is fixed; later builds such as 12.32.1 and 12.32.2 are also safe. Release notes explicitly say 12.32.0 changed the default password length from six to eight characters.
Exposure and scanner realityPublic fingerprinting is noisy here. Default backend login URLs are /util/login and /episerver/cms, but whether they are internet-reachable is deployment-specific; spot checks did not surface a stable public Shodan/Censys-style exposure count in the reviewed sources.
Disclosure timingOptimizely published the advisory on January 3, 2025; the CVE/NVD publication trail shows January 3-4, 2025 depending on source timezone/UTC handling.
Researcher / reporterNo public researcher attribution found in the vendor advisory, NVD entry, or release notes reviewed.
04 · The Call

noisgate verdict.

Final Verdict
DOWNGRADED to LOW (3.8/10)

The decisive factor is that this CVE does not give an attacker new access; it only makes already-exposed local CMS password authentication weaker. Real exploitation needs a narrow combination of conditions — exposed login, local auth instead of federated IdP, weak user-chosen passwords, and weak login defenses — which sharply reduces the reachable population.

HIGH This is **not** a direct unauthenticated exploit primitive
MEDIUM Most real-world enterprise exposure is narrowed by SSO/MFA/rate-limiting posture
MEDIUM Affected package naming discrepancy (**Core** vs **UI**) in public records

Why this verdict

  • Start from the real baseline: Optimizely's own advisory scored this Medium 5.5, not HIGH; the 7.5 comes from later CISA-ADP enrichment, and that vector treats a password-policy weakness like a direct remote data exposure bug.
  • Attacker position is worse than CVSS says: the chain requires unauthenticated remote access to the login page, but that immediately implies the admin surface must be exposed. In many enterprises the CMS edit/admin interface is private, VPN-gated, or reverse-proxy restricted, which cuts the exposed population well below a generic AV:N assumption.
  • The vuln implies prior credential success, not software compromise: exploitation still needs valid usernames plus a weak local password. That means the CVE only amplifies password-spraying or guessing campaigns; it does not replace them.
  • Modern controls should stop the chain: MFA, IdP federation, lockout, rate limiting, CAPTCHA, and WAF/NGFW auth protection all break the most practical attack step. Each of those controls adds compounding downward pressure on severity.
  • Blast radius is role-bound: even after compromise, impact is constrained by the CMS account's role. An editor account is bad, but it is not equivalent to server compromise or tenant-wide admin by default.
  • Threat telemetry is quiet: the supplied EPSS 0.00327 is very low, there is no KEV listing, and no public exploit kit or campaign evidence surfaced in review.

Why not higher?

It is not higher because there is no direct exploit primitive here: no auth bypass, no RCE, no deserialization, no SQLi, no data leak triggered by a single request. The attacker must chain exposure, user enumeration, weak human password choice, and missing auth controls, which is exactly the kind of multi-prerequisite path that should push severity down for enterprise patch scheduling.

Why not lower?

It is not IGNORE because this still weakens a credential boundary on a business application, and some Optimizely deployments do expose CMS login surfaces to the internet with local accounts. If you have public CMS admin access and no MFA on editor/admin identities, this can become an easy win for commodity password spraying.

05 · Compensating Control

What to do — in priority order.

  1. Move CMS auth behind your IdP — Prefer SSO/federated auth for CMS editors and admins so password policy is enforced upstream and MFA becomes mandatory. For a LOW verdict there is no noisgate mitigation SLA; treat this as backlog hygiene and implement in the next normal identity hardening window.
  2. Enforce MFA on every CMS editor/admin account — MFA is the single cleanest control because it breaks the password-spray path even if weak local passwords still exist. For LOW, there is no mitigation SLA; if the CMS login is internet-exposed, do this in your next routine change window rather than waiting for the patch alone.
  3. Tighten local password and lockout settings — If local CMS accounts must remain, require stronger length/complexity than the historical default and turn on account lockout, IP throttling, and rate limiting around /util/login and /episerver/cms. This directly addresses the practical exploit path without waiting for a full package upgrade.
  4. Restrict admin path exposure — Put CMS login endpoints behind VPN, IP allowlists, or an access proxy where possible. Reducing external reachability removes the unauthenticated remote starting point that the CVSS vector assumes.
  5. Cull dormant local CMS accounts — Short weak passwords matter much less if stale editor/admin accounts do not exist. Audit for disabled, unused, shared, and legacy local users as standard backlog hygiene.
What doesn't work
  • Changing the URL alone does not solve this; Optimizely's own security guidance says CMS does not rely on a secret URL for security.
  • HTTPS alone does not help against password spraying; it protects transport, not weak credential policy.
  • Endpoint antivirus/EDR on the app server does little for the primary exploit path because the attacker is authenticating through the normal web workflow, not dropping malware to win initial access.
06 · Verification

Crowdsourced verification payload.

Run this from an auditor workstation, CI job, or directly on the build/deploy host against the application source or deployment directory. Invoke it as python3 verify_cve_2025_22390.py /path/to/app or python verify_cve_2025_22390.py C:\inetpub\wwwroot\site; it needs read access only and looks for EPiServer.CMS.UI package references in .deps.json, packages.lock.json, project.assets.json, .csproj, and central package props files.

noisgate-verify.py
PYTHONREAD-ONLYSAFE
#!/usr/bin/env python3
# verify_cve_2025_22390.py
# Detects whether an Optimizely CMS application references EPiServer.CMS.UI < 12.32.0
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN, 3=usage/runtime error

import json
import os
import re
import sys
import xml.etree.ElementTree as ET

TARGET_PACKAGE = "episerver.cms.ui"
FIXED_VERSION = (12, 32, 0)
TEXT_FILE_NAMES = {
    "directory.packages.props",
    "packages.props",
}
JSON_FILE_SUFFIXES = (
    ".deps.json",
    "packages.lock.json",
    "project.assets.json",
)
XML_FILE_SUFFIXES = (
    ".csproj",
    ".props",
)


def normalize_version(v):
    if not v:
        return None
    v = v.strip()
    v = v.strip("[]()")
    if "," in v:
        v = v.split(",", 1)[0].strip()
    m = re.match(r"^(\d+)\.(\d+)\.(\d+)", v)
    if not m:
        return None
    return tuple(int(x) for x in m.groups())


def version_to_str(t):
    return ".".join(str(x) for x in t) if t else "unknown"


def compare_versions(a, b):
    return (a > b) - (a < b)


def record(found, source, version):
    nv = normalize_version(version)
    if nv:
        found.append((source, nv, version))


def parse_json_file(path, found):
    try:
        with open(path, "r", encoding="utf-8", errors="ignore") as f:
            data = json.load(f)
    except Exception:
        return

    lower_name = os.path.basename(path).lower()

    if lower_name.endswith("packages.lock.json"):
        deps = data.get("dependencies", {})
        for framework, packages in deps.items():
            if isinstance(packages, dict):
                for pkg, meta in packages.items():
                    if pkg.lower() == TARGET_PACKAGE and isinstance(meta, dict):
                        record(found, path, meta.get("resolved") or meta.get("requested"))

    elif lower_name.endswith("project.assets.json"):
        libs = data.get("libraries", {})
        for key in libs.keys():
            if "/" in key:
                pkg, ver = key.split("/", 1)
                if pkg.lower() == TARGET_PACKAGE:
                    record(found, path, ver)
        targets = data.get("targets", {})
        for framework, packages in targets.items():
            if isinstance(packages, dict):
                for key in packages.keys():
                    if "/" in key:
                        pkg, ver = key.split("/", 1)
                        if pkg.lower() == TARGET_PACKAGE:
                            record(found, path, ver)

    elif lower_name.endswith(".deps.json"):
        libs = data.get("libraries", {})
        for key in libs.keys():
            if "/" in key:
                pkg, ver = key.split("/", 1)
                if pkg.lower() == TARGET_PACKAGE:
                    record(found, path, ver)


def strip_ns(tag):
    return tag.split("}", 1)[-1]


def parse_xml_file(path, found):
    try:
        tree = ET.parse(path)
        root = tree.getroot()
    except Exception:
        return

    for elem in root.iter():
        tag = strip_ns(elem.tag)
        if tag == "PackageReference":
            include = (elem.attrib.get("Include") or elem.attrib.get("Update") or "").strip()
            if include.lower() == TARGET_PACKAGE:
                version = elem.attrib.get("Version")
                if not version:
                    for child in elem:
                        if strip_ns(child.tag) == "Version" and child.text:
                            version = child.text.strip()
                            break
                record(found, path, version)


def parse_text_fallback(path, found):
    try:
        with open(path, "r", encoding="utf-8", errors="ignore") as f:
            text = f.read()
    except Exception:
        return

    patterns = [
        re.compile(r"<PackageReference[^>]+(?:Include|Update)=\"EPiServer\.CMS\.UI\"[^>]+Version=\"([^\"]+)\"", re.I),
        re.compile(r"<PackageVersion[^>]+Include=\"EPiServer\.CMS\.UI\"[^>]+Version=\"([^\"]+)\"", re.I),
        re.compile(r"EPiServer\.CMS\.UI\s*["']?\s*[:=]\s*["']([^"'\s<]+)", re.I),
    ]
    for pat in patterns:
        for m in pat.finditer(text):
            record(found, path, m.group(1))


def scan(root_path):
    found = []
    for dirpath, dirnames, filenames in os.walk(root_path):
        # keep scan reasonable
        dirnames[:] = [d for d in dirnames if d.lower() not in {".git", ".vs", "node_modules", "packages"}]
        for name in filenames:
            lower = name.lower()
            full = os.path.join(dirpath, name)
            if lower.endswith(JSON_FILE_SUFFIXES):
                parse_json_file(full, found)
            elif lower.endswith(XML_FILE_SUFFIXES):
                parse_xml_file(full, found)
                if lower in TEXT_FILE_NAMES or lower.endswith(".props"):
                    parse_text_fallback(full, found)
            elif lower in TEXT_FILE_NAMES:
                parse_text_fallback(full, found)
    return found


def main():
    if len(sys.argv) != 2:
        print("UNKNOWN - usage: python3 verify_cve_2025_22390.py <app_or_repo_path>")
        sys.exit(3)

    root_path = sys.argv[1]
    if not os.path.exists(root_path):
        print(f"UNKNOWN - path does not exist: {root_path}")
        sys.exit(3)

    found = scan(root_path)
    if not found:
        print("UNKNOWN - EPiServer.CMS.UI reference not found in scanned files")
        sys.exit(2)

    vulnerable = []
    patched = []
    for source, normalized, raw in found:
        if compare_versions(normalized, FIXED_VERSION) < 0:
            vulnerable.append((source, normalized, raw))
        else:
            patched.append((source, normalized, raw))

    # Prefer worst-case if conflicting references are present.
    if vulnerable:
        src, norm, raw = sorted(vulnerable, key=lambda x: x[1])[0]
        print(f"VULNERABLE - found EPiServer.CMS.UI {raw} in {src}; fixed version is {version_to_str(FIXED_VERSION)}+")
        sys.exit(1)

    src, norm, raw = sorted(patched, key=lambda x: x[1])[0]
    print(f"PATCHED - found EPiServer.CMS.UI {raw} in {src}; fixed version is {version_to_str(FIXED_VERSION)}+")
    sys.exit(0)


if __name__ == "__main__":
    main()
07 · Bottom Line

If you remember one thing.

TL;DR
Monday morning, do not treat this like an emergency patch unless you know your Optimizely CMS admin login is public and still uses local passwords without MFA. For a LOW noisgate rating, the noisgate mitigation SLA gives you no SLA (backlog hygiene only), and the noisgate remediation SLA also treats this as backlog hygiene rather than a clock-driven fire drill: identify which Optimizely estates still expose /util/login or /episerver/cms, confirm whether they use local auth, turn on MFA/lockout/rate limiting where missing, and then roll EPiServer.CMS.UI to 12.32.0+ in the next normal CMS maintenance cycle.

Sources

  1. Optimizely CMS Security Advisory CMS-2025-02
  2. Optimizely CMS Security Announcements index
  3. Optimizely CMS 12 release notes
  4. Optimizely CMS 12 security documentation
  5. Optimizely developer forum: default CMS 12 backend login URLs
  6. NVD CVE-2025-22390
  7. CISA Vulnerability Summary for the Week of December 30, 2024
  8. WhatCMS Episerver usage statistics
Peer Review

What defenders are saying.

Submit a review attribution: handle + country only
0 flags selected · stored anonymously
Validation Results

Crowdsourced verification outputs.

Results submitted by users who ran the verification payload against their environment.