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
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. |
noisgate verdict.
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.
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.
What to do — in priority order.
- 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.
- 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.
- 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/loginand/episerver/cms. This directly addresses the practical exploit path without waiting for a full package upgrade. - 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.
- 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.
- 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.
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()
If you remember one thing.
/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
- 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
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.