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.
4 steps from start to impact.
Find the CMS login surface
/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.- The CMS management interface is reachable from the attacker's network position
- The deployment uses default or discoverable backend login paths
- 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
Determine whether local auth is in play
- The organization allows local CMS accounts for editors/admins
- The attacker can reach the login workflow and observe auth behavior
- 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
Spray or guess weak passwords
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.- 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
- 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
Abuse the compromised CMS role
- A valid CMS credential was successfully compromised
- The account has meaningful edit or admin permissions
- 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
/util/login and /episerver/cms. This directly addresses the practical exploit path without waiting for a full package upgrade.- 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.
The supporting signals.
| In-the-wild status | No 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 availability | No public exploit repo or Metasploit-style PoC located. That's expected: the practical weapon is ordinary password spraying, not a code-level exploit chain. |
| EPSS | 0.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 scoring | Important 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 check | AV: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 versions | Vendor 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 versions | EPiServer.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 reality | Public 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 timing | Optimizely published the advisory on January 3, 2025; the CVE/NVD publication trail shows January 3-4, 2025 depending on source timezone/UTC handling. |
| Researcher / reporter | No public researcher attribution found in the vendor advisory, NVD entry, or release notes reviewed. |
Sources.
- Optimizely CMS Security Advisory CMS-2025-02
- Optimizely CMS Security Announcements index
- Optimizely CMS 12 release notes
- Optimizely CMS 12 security documentation
- Optimizely developer forum: default CMS 12 backend login URLs
- NVD CVE-2025-22390
- CISA Vulnerability Summary for the Week of December 30, 2024
- WhatCMS Episerver usage statistics
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:Nassumption. - 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.
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.
#!/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()