This is a smuggled crate that only matters if you already have a badge to the loading dock
CVE-2026-39405 is a ZIP-slip style path traversal in Frappe LMS SCORM package handling. In Frappe LMS 2.50.0 and below, a user who already has the course editing role can upload a crafted SCORM ZIP whose file names contain traversal sequences, causing extraction outside the intended public/scorm/... directory; the fix landed in 2.50.1.
In a vacuum, arbitrary file write by a web app is scary enough to tempt a CRITICAL label. In real enterprise conditions, the biggest truth is the friction: this is not unauthenticated internet RCE; it requires a valid account, the right role, and use of a specific LMS feature. That pushes it down to = ASSESSED AT HIGH even though the downstream impact can still become code execution or full site compromise on badly isolated deployments.
4 steps from start to impact.
Get a course-editor foothold
- Reachability to the Frappe LMS instance
- A valid user account
- That account has the course editing role
- Requires prior access or insider status
- MFA and IdP controls can stop the account-takeover path
- Many LMS deployments keep course-authoring privileges limited to a small subset of users
Build a traversal SCORM package
zip, Python zipfile, or Burp-assisted content generation, the attacker crafts a SCORM archive containing file names like ../../../target. The patch in PR #2274 shows the root bug directly: the original code called extractall() without validating each archive member path.- Ability to create or modify a ZIP archive
- Knowledge of likely writable target paths
- Attacker still needs a destination that is both writable by the app user and useful for follow-on impact
- Blind arbitrary write is much less useful without filesystem/path knowledge
Trigger server-side extraction
- SCORM import feature is enabled and reachable to the attacker
- The target is running Frappe LMS 2.50.0 or below
- Organizations not using SCORM imports are effectively not exposed to this path
- Containerization and restrictive filesystem permissions can sharply limit where writes can land
bench, gunicorn, or worker processes outside /sites/*/public/scorm/. Web logs should show SCORM upload/import activity immediately before suspicious file creation.Convert file write into business impact
- Useful writable path outside the SCORM directory
- A deployment where overwritten files are served, trusted, or executed
- Arbitrary file write does not automatically equal instant RCE
- Least-privilege app users, immutable containers, and separated code/upload mounts can contain blast radius
The supporting signals.
| In-the-wild status | No confirmed in-the-wild exploitation surfaced in the reviewed sources, and it is not KEV-listed per provided intel. That materially lowers urgency versus a similar unauthenticated file-write bug under active abuse. |
|---|---|
| Public PoC availability | No polished public exploit repo stood out, but the fix is transparent: GHSA-mxh7-g3r7-g96h plus PR #2274 are enough for a copycat to build a ZIP-slip PoC quickly. |
| EPSS | Provided intel says 0.00052. A secondary aggregation at Tenable shows 0.00047; either way, this is extremely low threat-likelihood telemetry. Percentile was not authoritatively retrieved during review. |
| KEV status | No. No CISA KEV deadline pressure based on the intel supplied with the case. |
| CVSS / impact model | NVD shows a GitHub CNA CVSS v4 base 9.4 / CRITICAL with CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H at NVD. I think that overstates reality because PR:L here is doing a lot of work: it implies a logged-in, authorized editor on a feature-specific path. |
| Affected versions | Authoritative description says Frappe LMS 2.50.0 and below are affected, per NVD and the GitHub advisory. |
| Fixed version | Fixed in 2.50.1 via path validation during SCORM extraction, per 2.50.1" target="_blank" rel="noopener">release v2.50.1. I found no distro backport evidence; Debian marks it NOT-FOR-US. |
| Exposure reality | This is a niche, self-hosted LMS product, not a broadly exposed edge appliance. I found no dependable internet-scale fingerprint/count data in public GreyNoise/Shodan/Censys-style telemetry during review, so exposure should be treated as limited but not zero. *That is an inference from weak public telemetry, not proof of safety.* |
| Disclosure timeline | Disclosed 2026-05-20; the patch work itself was merged earlier on 2026-03-30 in PR #2274, then shipped in v2.50.1. |
| Reporter / discoverer | The advisory credits @nickhefty as reporter in the GitHub security advisory. |
noisgate verdict.
The decisive factor is the prerequisite chain: an attacker needs an authenticated course-editor account and access to the SCORM upload workflow before this bug matters. That sharply narrows the reachable population versus internet-facing pre-auth file-write bugs, but the remaining impact is still serious because arbitrary file write as the app user can become defacement, persistence, credential theft, or sometimes RCE.
Why this verdict
- Authenticated and role-bound: exploitation requires a valid account with the course editing role, which implies insider abuse or a prior compromise stage; that is the main reason this is not CRITICAL.
- Feature-gated exposure: only deployments that actually use SCORM imports and delegate content-authoring rights to semi-trusted users are meaningfully exposed, which narrows the reachable population well below generic web-app exposure.
- Impact remains heavyweight: once reached, this is arbitrary file write by the application process, which can absolutely lead to high-consequence outcomes on weakly isolated hosts.
Why not higher?
This is not an unauthenticated edge exploit and not a wormable path. The attacker must already be inside the trust boundary with the right role, and that prerequisite should be stopped by MFA, SSO hygiene, PAM discipline, and least-privilege role assignment in many enterprises.
Why not lower?
Arbitrary file write is not a cosmetic bug. Even with the prerequisite friction, a compromised editor account on a vulnerable host can still tamper with served content, plant persistence, or escalate to code execution depending on filesystem layout and restart behavior.
What to do — in priority order.
- Restrict or disable SCORM uploads — If SCORM import is unused, turn it off; if it is needed, limit it to a tiny trusted author group. This directly removes the vulnerable code path and should be deployed within 30 days for a HIGH finding.
- Shrink course-editor privileges — Audit who actually has course editing rights and remove the role from broad instructor or contractor populations. Because this bug is role-gated, privilege cleanup is a first-order risk reducer and should be completed within 30 days.
- Harden filesystem boundaries — Run the Frappe process with least privilege and keep upload paths separate from code, configs, service definitions, and executable content; use container immutability or mount options where possible. This limits arbitrary write blast radius and should be enforced within 30 days.
- Alert on out-of-path writes — Create FIM/EDR detections for Frappe web or worker processes writing outside expected SCORM directories, especially under app code,
sites/, templates, or served static paths. Detection engineering should be in place within 30 days. - Tighten account assurance — Require MFA for course-authoring accounts and review recent sign-ins, especially for shared training/admin identities. Since the exploit begins with a trusted editor account, identity controls materially reduce reachable risk and should be enforced within 30 days.
- A WAF alone does not solve this well; the dangerous payload is inside an authenticated ZIP upload and the failure happens during server-side extraction.
- A network perimeter blocklist is weak compensation because the attacker can be a legitimate internal or federated user.
- Malware scanning of uploads is not enough; this is a path-validation failure, not a signature-based malicious-file problem.
- Unauthenticated external scanning will miss most of the real exposure because the vulnerable path is role-gated behind login.
Crowdsourced verification payload.
Run this on the Linux host or container that runs Frappe LMS, ideally from the bench root or with the bench root passed as an argument. Invoke it as bash verify_cve_2026_39405.sh /opt/frappe-bench or simply bash verify_cve_2026_39405.sh from the bench directory; it needs only read access to app metadata and, if available, the bench command.
#!/usr/bin/env bash
# verify_cve_2026_39405.sh
# Checks whether a Frappe LMS installation is vulnerable to CVE-2026-39405
# Output: VULNERABLE / PATCHED / UNKNOWN
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN
set -u
TARGET_VERSION="2.50.1"
BENCH_ROOT="${1:-$(pwd)}"
vercmp() {
# returns: 0 equal, 1 greater, 2 less
local a="$1" b="$2"
if [[ "$a" == "$b" ]]; then
return 0
fi
local first
first=$(printf '%s\n%s\n' "$a" "$b" | sort -V | head -n1)
if [[ "$first" == "$a" ]]; then
return 2
else
return 1
fi
}
normalize_version() {
local v="$1"
v="${v#v}"
v="${v%%[-+~]*}"
printf '%s' "$v"
}
extract_from_bench_version() {
local out line v
if ! command -v bench >/dev/null 2>&1; then
return 1
fi
out=$(cd "$BENCH_ROOT" 2>/dev/null && bench version 2>/dev/null) || return 1
while IFS= read -r line; do
case "$line" in
lms*|*" lms "*|*"lms "*)
v=$(printf '%s' "$line" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
if [[ -n "$v" ]]; then
printf '%s' "$v"
return 0
fi
;;
esac
done <<< "$out"
return 1
}
extract_from_pyproject() {
local file line v
for file in \
"$BENCH_ROOT/apps/lms/pyproject.toml" \
"$BENCH_ROOT/frappe-bench/apps/lms/pyproject.toml"; do
[[ -f "$file" ]] || continue
line=$(grep -E '^version\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"' "$file" 2>/dev/null | head -n1 || true)
v=$(printf '%s' "$line" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
if [[ -n "$v" ]]; then
printf '%s' "$v"
return 0
fi
done
return 1
}
extract_from_git_tag() {
local dir v
for dir in \
"$BENCH_ROOT/apps/lms" \
"$BENCH_ROOT/frappe-bench/apps/lms"; do
[[ -d "$dir/.git" ]] || continue
v=$(git -C "$dir" describe --tags --abbrev=0 2>/dev/null | grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)
if [[ -n "$v" ]]; then
normalize_version "$v"
return 0
fi
done
return 1
}
VERSION=""
METHOD=""
if VERSION=$(extract_from_bench_version); then
METHOD="bench version"
elif VERSION=$(extract_from_pyproject); then
METHOD="pyproject.toml"
elif VERSION=$(extract_from_git_tag); then
METHOD="git tag"
else
echo "UNKNOWN - could not determine installed Frappe LMS version from bench, pyproject, or git metadata under: $BENCH_ROOT"
exit 2
fi
VERSION=$(normalize_version "$VERSION")
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "UNKNOWN - parsed version '$VERSION' via $METHOD but it is not comparable"
exit 2
fi
vercmp "$VERSION" "$TARGET_VERSION"
case $? in
0)
echo "PATCHED - detected Frappe LMS $VERSION via $METHOD (fixed version is $TARGET_VERSION)"
exit 0
;;
1)
echo "PATCHED - detected Frappe LMS $VERSION via $METHOD (newer than fixed version $TARGET_VERSION)"
exit 0
;;
2)
echo "VULNERABLE - detected Frappe LMS $VERSION via $METHOD (affected: $TARGET_VERSION and below per advisory wording)"
exit 1
;;
esac
echo "UNKNOWN - unexpected version comparison failure"
exit 2
If you remember one thing.
Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.