<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://malloc.pw/feed.xml" rel="self" type="application/atom+xml" /><link href="https://malloc.pw/" rel="alternate" type="text/html" /><updated>2026-07-11T19:49:10+00:00</updated><id>https://malloc.pw/feed.xml</id><title type="html">malloc.pw</title><subtitle>writeups, development updates, and notes on security and privacy.</subtitle><author><name>malloc</name></author><entry><title type="html">M5Burner is a mess</title><link href="https://malloc.pw/2026/07/11/m5burner-is-a-mess/" rel="alternate" type="text/html" title="M5Burner is a mess" /><published>2026-07-11T00:00:00+00:00</published><updated>2026-07-11T00:00:00+00:00</updated><id>https://malloc.pw/2026/07/11/m5burner-is-a-mess</id><content type="html" xml:base="https://malloc.pw/2026/07/11/m5burner-is-a-mess/"><![CDATA[<p>I write my own firmware for the Cardputer ADV - <a href="https://github.com/cryptspeak/csCardputer">csCardputer</a>. At some point I wanted to publish it through M5Burner, M5Stack’s official desktop tool for browsing, downloading, and flashing firmware onto their devices. Publishing means running the app, logging into my M5Stack account, and pointing it at my <code class="language-plaintext highlighter-rouge">.bin</code>.</p>

<p>M5Burner isn’t open source anymore. Older versions are still floating around on GitHub, but the current build ships as a closed Electron app. I don’t run closed-source software against my own accounts and my own files without at least a quick look under the hood, so before I did any of that, I unpacked it.</p>

<p>None of what follows needed the app to be closed source to happen - every mistake in this post is one an open project could make just as easily. Closed source is just why nobody outside M5Stack has had a chance to catch it first, and why unpacking it myself was the only way to check.</p>

<p>What started as a five-minute sanity check turned into a full read of the app’s main-process source, and by the end of it I had a list of issues that ranges from “this is sloppy” to “this is a one-shot, no-interaction remote code execution chain that fires on every app launch.” This post walks through what I found, how I found it, and what I did about it - I ended up writing my own client from scratch rather than touching the official one again.</p>

<h2 id="what-i-was-looking-at">What I was looking at</h2>

<p>M5Burner ships as a standard Electron app (<code class="language-plaintext highlighter-rouge">package.json</code> reports <code class="language-plaintext highlighter-rouge">"m5burner"</code>, version <code class="language-plaintext highlighter-rouge">3.0.0</code>). Electron apps are just Chromium plus Node.js glued together, and the entire security model of that combination hinges on how much access the “web page” part is given to the “Node.js” part. Get that wrong and you don’t have a desktop app with a bug - you have a script host that happens to render HTML.</p>

<p>Everything below comes from reading the app’s actual main-process source (<code class="language-plaintext highlighter-rouge">app.js</code> and the <code class="language-plaintext highlighter-rouge">packages/</code> directory) and, for a few specific points, the renderer bundle as well. I ran a handful of read-only, unauthenticated requests against the live API to confirm things like “does this host actually serve HTTPS” - no traffic was tampered with, no account other than my own test accounts was touched, and no device was flashed as part of this.</p>

<h2 id="the-renderer-runs-with-full-node-access-no-isolation-no-web-security">The renderer runs with full Node access, no isolation, no web security</h2>

<p>This is the finding everything else builds on, so it goes first. Here’s the actual <code class="language-plaintext highlighter-rouge">BrowserWindow</code> M5Burner creates:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">mainWin</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">BrowserWindow</span><span class="p">({</span>
  <span class="na">minWidth</span><span class="p">:</span> <span class="mi">1280</span><span class="p">,</span>
  <span class="na">minHeight</span><span class="p">:</span> <span class="mi">800</span><span class="p">,</span>
  <span class="na">width</span><span class="p">:</span> <span class="mi">1280</span><span class="p">,</span>
  <span class="na">height</span><span class="p">:</span> <span class="mi">760</span><span class="p">,</span>
  <span class="na">center</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">frame</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
  <span class="na">webPreferences</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">nodeIntegration</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
    <span class="na">contextIsolation</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
    <span class="na">webSecurity</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
    <span class="na">preload</span><span class="p">:</span> <span class="nx">path</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">__dirname</span><span class="p">,</span> <span class="dl">'</span><span class="s1">packages</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">preload.js</span><span class="dl">'</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">})</span>
<span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">@electron/remote/main</span><span class="dl">'</span><span class="p">).</span><span class="nx">enable</span><span class="p">(</span><span class="nx">mainWin</span><span class="p">.</span><span class="nx">webContents</span><span class="p">)</span>
</code></pre></div></div>

<p>Take these one at a time:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">nodeIntegration: true</code> means any JavaScript that ends up running in this window - the app’s own UI, but also anything else that ever gets loaded into it - can call <code class="language-plaintext highlighter-rouge">require('child_process')</code>, <code class="language-plaintext highlighter-rouge">require('fs')</code>, <code class="language-plaintext highlighter-rouge">require('net')</code>, directly. Not through some IPC bridge with a permission model. Directly.</li>
  <li><code class="language-plaintext highlighter-rouge">contextIsolation: false</code> means the preload script doesn’t get its own isolated JS world. It shares a global scope with the page. This is the setting that’s supposed to stop a compromised or malicious page from reaching into privileged APIs - here there’s nothing to reach into, because there’s no wall.</li>
  <li><code class="language-plaintext highlighter-rouge">webSecurity: false</code> turns off the same-origin policy and mixed-content blocking for this window, so cross-origin restrictions just don’t apply.</li>
  <li>On top of that, <code class="language-plaintext highlighter-rouge">@electron/remote</code> is enabled, which hands out references to main-process objects (the <code class="language-plaintext highlighter-rouge">app</code> object, capable of relaunching or quitting the app, reaching the filesystem through <code class="language-plaintext highlighter-rouge">appRoot</code>) to a window that already has raw Node access.</li>
</ul>

<p>I checked the preload script to see if it at least tries to do anything sane with the isolation boundary it doesn’t have:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">appRoot</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">@electron/remote</span><span class="dl">'</span><span class="p">).</span><span class="nx">getGlobal</span><span class="p">(</span><span class="dl">'</span><span class="s1">appRoot</span><span class="dl">'</span><span class="p">);</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">appRoot</span> <span class="o">=</span> <span class="nx">appRoot</span>
</code></pre></div></div>

<p>Two lines. Not a <code class="language-plaintext highlighter-rouge">contextBridge</code> API surface, not restricting anything - just handing the app’s root path onto <code class="language-plaintext highlighter-rouge">window</code>, which it didn’t even need a preload script for given <code class="language-plaintext highlighter-rouge">contextIsolation</code> is off anyway. No attempt at least-privilege anywhere in this app.</p>

<p>Individually, any one of these settings is a bad idea. Electron’s own security docs tell you not to use any of them. Together, they mean: <strong>whatever content ends up rendered in this window runs with the full privileges of the desktop user running the app.</strong> Not “a web page with some extra permissions” - arbitrary Node.js execution. The only remaining question is what content gets into that window.</p>

<h2 id="the-app-loads-a-page-over-plain-http-into-that-same-window">The app loads a page over plain HTTP into that same window</h2>

<p>M5Burner checks for updates on every launch. If it decides one’s available:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span><span class="p">(</span><span class="nx">updateInfo</span><span class="p">.</span><span class="nx">needUpdate</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">mainWin</span><span class="p">.</span><span class="nx">webContents</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">dom-ready</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">update</span><span class="p">(</span><span class="nx">updateInfo</span><span class="p">.</span><span class="nx">version</span><span class="p">)</span>
  <span class="p">})</span>
  <span class="k">await</span> <span class="nx">mainWin</span><span class="p">.</span><span class="nx">loadURL</span><span class="p">(</span><span class="dl">'</span><span class="s1">http://m5burner.m5stack.com/website/</span><span class="dl">'</span><span class="p">)</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
  <span class="k">await</span> <span class="nx">mainWin</span><span class="p">.</span><span class="nx">loadFile</span><span class="p">(</span><span class="nx">VIEW_HTML</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It loads <code class="language-plaintext highlighter-rouge">http://m5burner.m5stack.com/website/</code> - plain HTTP, no TLS - directly into the same window I just described. <code class="language-plaintext highlighter-rouge">nodeIntegration: true</code> and all.</p>

<p>Put those two findings together: anyone in a position to intercept that plaintext HTTP request - a rogue access point, ARP spoofing on a shared network, a compromised router, a malicious exit node - can substitute their own response. There’s no certificate to fake, no signature to forge, because there was never any integrity guarantee to begin with. A <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag calling <code class="language-plaintext highlighter-rouge">require('child_process').exec()</code> runs with the privileges of whoever’s running the app, no click required, the moment the update check fires - which happens on every launch. The app just trusts unauthenticated plaintext HTTP to decide what code to run.</p>

<h2 id="the-auto-updater-itself-is-the-same-problem-one-level-down">The auto-updater itself is the same problem, one level down</h2>

<p>Even without the above, the updater is independently broken:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">ONLINE_VERSION_URL</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">http://m5burner-cdn.m5stack.com/appVersion.info</span><span class="dl">'</span>
<span class="kd">const</span> <span class="nx">APP_PATCH_URL</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">http://m5burner-cdn.m5stack.com/patch</span><span class="dl">'</span>

<span class="kd">function</span> <span class="nx">update</span><span class="p">(</span><span class="nx">hashCode</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">filename</span> <span class="o">=</span> <span class="nx">getFilename</span><span class="p">(</span><span class="nx">hashCode</span><span class="p">)</span>
  <span class="kd">let</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">APP_PATCH_URL</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">/</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">filename</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">.zip?timestamp=</span><span class="dl">'</span> <span class="o">+</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span>
  <span class="kd">let</span> <span class="nx">req</span> <span class="o">=</span> <span class="nx">http</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">response</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">patchFile</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">TMP_DIR</span><span class="p">,</span> <span class="s2">`patch_</span><span class="p">${</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">().</span><span class="nx">toString</span><span class="p">()}</span><span class="s2">.zip`</span><span class="p">)</span>
    <span class="kd">let</span> <span class="nx">patchStream</span> <span class="o">=</span> <span class="nx">fs</span><span class="p">.</span><span class="nx">createWriteStream</span><span class="p">(</span><span class="nx">patchFile</span><span class="p">)</span>
    <span class="nx">response</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">patchStream</span><span class="p">)</span>
    <span class="nx">response</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">end</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">process</span><span class="p">.</span><span class="nx">noAsar</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
      <span class="kd">let</span> <span class="nx">ws</span> <span class="o">=</span> <span class="nx">unzip</span><span class="p">.</span><span class="nx">Extract</span><span class="p">({</span><span class="na">path</span><span class="p">:</span> <span class="nx">ROOT_DIR</span><span class="p">})</span>
      <span class="nx">ws</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">close</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">})</span>
      <span class="nx">fs</span><span class="p">.</span><span class="nx">createReadStream</span><span class="p">(</span><span class="nx">patchFile</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">ws</span><span class="p">)</span>
    <span class="p">})</span>
  <span class="p">})</span>
  <span class="nx">req</span><span class="p">.</span><span class="nx">end</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Fetch a zip over plain HTTP, using Node’s raw <code class="language-plaintext highlighter-rouge">http</code> module (not even the app’s usual <code class="language-plaintext highlighter-rouge">axios</code>), and extract it straight into the app’s own install directory, with <code class="language-plaintext highlighter-rouge">process.noAsar = true</code> set so the extracted files land as normal files rather than being blocked as an asar archive. No signature check, no checksum, no pinning - a <code class="language-plaintext highlighter-rouge">Content-Length</code> and an HTTP 200 is as much verification as this gets.</p>

<p>An on-path attacker who can intercept <code class="language-plaintext highlighter-rouge">http://m5burner-cdn.m5stack.com/*</code> can serve an arbitrary zip that overwrites the app’s own source files, which then run with full privileges the next time the app starts - or immediately, depending what gets overwritten. This is the textbook insecure-auto-update pattern, and it’s exploitable entirely on its own without the Electron flags or the update-page loader from the previous sections.</p>

<p>The app also pins <code class="language-plaintext highlighter-rouge">unzipper@^0.10.11</code> with a caret range and no lockfile in the copy I reviewed, so I can’t confirm the exact resolved version. That matters because this dependency is the one extracting an unauthenticated download into the app’s own directory - whether it guards against zip-slip (a zip entry named something like <code class="language-plaintext highlighter-rouge">../../../evil</code> that escapes the extraction directory) isn’t a hygiene checkbox here, it’s the only thing standing between a malicious zip and writes outside that directory.</p>

<h2 id="command-injection-if-the-machine-ever-joins-the-wrong-network">Command injection, if the machine ever joins the wrong network</h2>

<p>This one doesn’t need an on-path attacker at all - it’s triggered by association, not proximity. M5Burner has a convenience feature that auto-fills your current WiFi’s SSID and password into a device’s config. On Linux:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// wifiName/linux.js</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="kd">function</span> <span class="nx">getSSID</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">return</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">resolve</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">ssid</span> <span class="o">=</span> <span class="dl">''</span>
    <span class="kd">let</span> <span class="nx">process</span> <span class="o">=</span> <span class="nx">exec</span><span class="p">(</span><span class="s2">`iwgetid --raw`</span><span class="p">)</span>
    <span class="nx">process</span><span class="p">.</span><span class="nx">stdout</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">data</span><span class="dl">'</span><span class="p">,</span> <span class="nx">chunk</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="nx">ssid</span> <span class="o">+=</span> <span class="nx">chunk</span><span class="p">.</span><span class="nx">toString</span><span class="p">()</span> <span class="p">})</span>
    <span class="nx">process</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">exit</span><span class="dl">'</span><span class="p">,</span> <span class="nx">code</span> <span class="o">=&gt;</span> <span class="nx">resolve</span><span class="p">(</span><span class="nx">ssid</span><span class="p">))</span>
  <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// wifiPassword/linux.js</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="kd">function</span> <span class="nx">getPassword</span><span class="p">(</span><span class="nx">ssid</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">resolve</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">output</span> <span class="o">=</span> <span class="dl">''</span>
    <span class="kd">let</span> <span class="nx">process</span> <span class="o">=</span> <span class="nx">exec</span><span class="p">(</span><span class="s2">`ls /etc/NetworkManager/system-connections/</span><span class="p">${</span><span class="nx">ssid</span><span class="p">}</span><span class="s2">.nmconnection`</span><span class="p">)</span>
    <span class="c1">// ...</span>
  <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">getSSID()</code> returns whatever the currently-associated access point calls itself, completely unsanitized. That string then gets interpolated directly into a shell command string passed to <code class="language-plaintext highlighter-rouge">child_process.exec()</code>, which runs it through <code class="language-plaintext highlighter-rouge">/bin/sh -c</code> - nothing along that path escapes it, quotes it, or checks it first.</p>

<p><code class="language-plaintext highlighter-rouge">getPassword()</code> repeats the same pattern one function down - the unsanitized SSID lands in a second <code class="language-plaintext highlighter-rouge">exec()</code> call, this time as part of an <code class="language-plaintext highlighter-rouge">ls</code> lookup for the matching <code class="language-plaintext highlighter-rouge">.nmconnection</code> file. I didn’t trace how the actual password value gets pulled out once the file is located. Doesn’t matter for the injection point - the SSID is already unsanitized shell input by the time it reaches this <code class="language-plaintext highlighter-rouge">exec()</code> call. Worth noting: on Linux, NetworkManager’s <code class="language-plaintext highlighter-rouge">.nmconnection</code> files are typically <code class="language-plaintext highlighter-rouge">0600 root:root</code>, so the password may not actually be retrievable when the app runs as a normal user - but the command injection fires regardless, because the shell interprets the SSID’s metacharacters before any file access is attempted.</p>

<p>The Windows and macOS code paths have the same problem. On Windows:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// wifiPassword/win.js</span>
<span class="nx">exec</span><span class="p">(</span><span class="s2">`chcp 437 &amp;&amp; netsh wlan show profile name=</span><span class="p">${</span><span class="nx">ssid</span><span class="p">}</span><span class="s2"> key=clear`</span><span class="p">)</span>
</code></pre></div></div>

<p>On macOS:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// wifiPassword/osx.js</span>
<span class="nx">exec</span><span class="p">(</span><span class="s2">`security find-generic-password -D "AirPort network password" -wa </span><span class="p">${</span><span class="nx">ssid</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
</code></pre></div></div>

<p>Same pattern, all three platforms: the SSID comes back from whichever OS API reports the current network name, and gets interpolated unsanitized into a shell command. The shell differs - <code class="language-plaintext highlighter-rouge">/bin/sh</code> on Linux and macOS, <code class="language-plaintext highlighter-rouge">cmd.exe</code> on Windows - so the metacharacters that trigger it differ too (<code class="language-plaintext highlighter-rouge">;</code> and backticks on the Unix side, <code class="language-plaintext highlighter-rouge">&amp;</code> and <code class="language-plaintext highlighter-rouge">|</code> on Windows), but the root cause is identical: attacker-controlled string, template-interpolated into <code class="language-plaintext highlighter-rouge">exec()</code>, no escaping.</p>

<p>An SSID is just a string an attacker gets to pick. A quote to close out the intended argument, a <code class="language-plaintext highlighter-rouge">curl</code>-into-<code class="language-plaintext highlighter-rouge">sh</code> one-liner, and a trailing comment marker to swallow the rest of the command - that’s a complete shell command wearing an access point’s name. It doesn’t need to be sent to the machine; the machine’s own WiFi stack hands it straight to <code class="language-plaintext highlighter-rouge">exec()</code> the moment <code class="language-plaintext highlighter-rouge">getSSID()</code> runs.</p>

<p>The part worth being precise about: this isn’t passive RF exposure - the machine has to associate with the hostile network at the OS level first. That’s a lower bar than it sounds like, but it’s not zero: a cloned SSID matching a saved network the machine auto-connects to, or someone picking the wrong entry off a list by hand. Once that association happens and this feature runs, that string executes as a shell command with the desktop user’s privileges. No phishing involved, nothing to click - just the wrong network, joined once.</p>

<p>Unlike the network-facing findings above, I didn’t stand up a rogue AP to trigger this end-to-end - this one’s from reading <code class="language-plaintext highlighter-rouge">getSSID()</code>/<code class="language-plaintext highlighter-rouge">getPassword()</code> and confirming how <code class="language-plaintext highlighter-rouge">exec()</code> handles shell metacharacters, not from a live capture.</p>

<h2 id="http-where-https-was-sitting-right-there">HTTP where HTTPS was sitting right there</h2>

<p>I went through every first-party host the app talks to and checked whether HTTPS is actually available on the exact host and port the app uses:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ curl -sk -o /dev/null -w "HTTP:%{http_code}\n" https://m5burner-cdn.m5stack.com/appVersion.info
HTTP:200
$ curl -sk -o /dev/null -w "HTTP:%{http_code}\n" http://m5burner-cdn.m5stack.com/appVersion.info
HTTP:200
$ curl -sk -o /dev/null -w "HTTP:%{http_code}\n" https://m5burner-api.m5stack.com/api/firmware
HTTP:200
$ curl -sk -o /dev/null -w "HTTP:%{http_code}\n" https://m5burner.m5stack.com/website/
HTTP:200
</code></pre></div></div>

<p>Every host serves valid HTTPS with no functional difference, except one - and that one’s worse. <code class="language-plaintext highlighter-rouge">flow.m5stack.com</code> answers HTTPS fine on port 443, but the media-token endpoint the app actually calls lives on port 5003, and that port doesn’t speak TLS at all:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ curl -v https://flow.m5stack.com:5003/token
* TLS connect error: error:0A000126:SSL routines::unexpected eof while reading
</code></pre></div></div>

<p>So for every other host, this isn’t the backend lacking TLS - the app is choosing HTTP with an identical <code class="language-plaintext highlighter-rouge">https://</code> endpoint available. <code class="language-plaintext highlighter-rouge">flow.m5stack.com:5003</code> is worse: there isn’t even a same-host HTTPS fallback on that port. Fixing it means moving the media-token feature to a TLS-capable port, not just flipping a URL scheme.</p>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>URL</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">packages/download.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">http://m5burner-api.m5stack.com</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">packages/share.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">http://m5burner-api.m5stack.com</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">packages/imageLoader.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">http://m5burner.m5stack.com/cover</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">packages/mediaToken.js</code></td>
      <td><code class="language-plaintext highlighter-rouge">http://flow.m5stack.com:5003/token</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">packages/update.js</code></td>
      <td>both update URLs (see above)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">app.js</code></td>
      <td>the update-notification page load (see above)</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">mediaToken.js</code> case is a compact example of how these compound. When the renderer sends <code class="language-plaintext highlighter-rouge">IPC_GET_MEDIA_TOKEN</code>, the main process reads the physically connected device’s MAC address via <code class="language-plaintext highlighter-rouge">esptool read_mac</code> (using <code class="language-plaintext highlighter-rouge">spawn</code> with an argument array - one of the few places in this codebase that actually handles a subprocess correctly), then POSTs that MAC to <code class="language-plaintext highlighter-rouge">http://flow.m5stack.com:5003/token</code> and hands the response token back over IPC. The app doesn’t consume this token itself - the renderer just presents it to the user for copying, presumably for use with UIFlow or another M5Stack service outside of M5Burner. Either way, both the device identifier and the returned token travel in cleartext, over a port that doesn’t support TLS at all. <code class="language-plaintext highlighter-rouge">auth.js</code> and <code class="language-plaintext highlighter-rouge">device.js</code>, by contrast, <em>do</em> use <code class="language-plaintext highlighter-rouge">https://uiflow2.m5stack.com</code> for login and device-binding - so the codebase is capable of HTTPS, it’s just applied inconsistently, and inconsistently in exactly the places that decide what code and data end up on your machine.</p>

<p>I also dug into the app’s renderer bundle (<code class="language-plaintext highlighter-rouge">packages/view/</code>, a separate Angular app that doesn’t appear in any of the main-process snippets above) and found it hardcodes <code class="language-plaintext highlighter-rouge">const wn_apiBaseUrl="http://m5burner-api.m5stack.com"</code> for its firmware-management API calls - publishing, editing, deleting, visibility, share codes. All authenticated, all plain HTTP. My first capture of these calls went through a Burp listener with automatic TLS upgrade enabled, which showed HTTPS in the proxy history even though the app sent plain HTTP - enough to make me doubt my own source reading until I noticed the setting, turned it off, and captured it again clean. Here’s what the app actually sends, on port 80:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api/admin/firmware HTTP/1.1
Host: m5burner-api.m5stack.com
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) m5burner/3.0.0 Chrome/96.0.4664.110 Electron/16.0.7 Safari/537.36
m5_auth_token: &lt;redacted&gt;
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
</code></pre></div></div>

<p>That’s a full-control account credential - the same header value that lets you publish, edit, or delete firmware under an account - in a plaintext HTTP request. Not a hypothetical. The server answers it, same cleartext connection, with that account’s own data:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 200 OK
[...]
[{"_id":"&lt;redacted&gt;","uid":&lt;redacted&gt;,"fid":"&lt;redacted&gt;",
"name":"&lt;redacted&gt;","description":"&lt;redacted&gt;",
"cover":"&lt;redacted&gt;","category":"&lt;redacted&gt;", ...
</code></pre></div></div>

<p>I tested this against a second, throwaway account - <code class="language-plaintext highlighter-rouge">/api/admin/firmware</code> is scoped to whichever token calls it, not a global dump, which is expected and not a finding on its own. What matters is what the token is worth: it’s the entire auth story for this API. No cookie on top of it, no session check beyond it. Whichever account it belongs to, that one header gets the request through, and it went out in the clear.</p>

<p>This needs less from an attacker than the update-page or auto-updater findings: those require an on-path attacker able to substitute a response. This doesn’t - passive capture on a shared or unencrypted segment is enough to walk off with a full-control token. Anyone on the same WiFi AP, same network segment, same untrusted hop gets that header for free.</p>

<h2 id="a-hardcoded-credential-shipped-to-every-install">A hardcoded credential, shipped to every install</h2>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// device.js</span>
<span class="kd">const</span> <span class="nx">REGISTRY_URL</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">https://m5stack-factory-tool.m5stack.com/record/old</span><span class="dl">'</span>

<span class="kd">const</span> <span class="nx">registryOldDevice</span> <span class="o">=</span> <span class="p">({</span><span class="nx">mac</span><span class="p">,</span> <span class="nx">type</span><span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">return</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">resolve</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">axios</span><span class="p">.</span><span class="k">default</span><span class="p">({</span>
      <span class="na">url</span><span class="p">:</span> <span class="s2">`</span><span class="p">${</span><span class="nx">REGISTRY_URL</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span>
      <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
        <span class="dl">'</span><span class="s1">Authorization</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Basic bTVzdGFjay5tNWJ1cm5lcjpyZWdpc3RyeQ==</span><span class="dl">'</span><span class="p">,</span>
        <span class="dl">'</span><span class="s1">Content-Type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">application/x-www-form-urlencoded</span><span class="dl">'</span>
      <span class="p">},</span>
      <span class="na">data</span><span class="p">:</span> <span class="s2">`mac=</span><span class="p">${</span><span class="nx">mac</span><span class="p">}</span><span class="s2">&amp;type=</span><span class="p">${</span><span class="nx">type</span><span class="p">}</span><span class="s2">`</span>
    <span class="p">})</span>
</code></pre></div></div>

<p>Decode that base64 blob and you get <code class="language-plaintext highlighter-rouge">m5stack.m5burner:registry</code> - a static HTTP Basic Auth username and password, sitting in the client source. Anyone who unpacks the app gets the same credential every other install has. It’s presumably meant to say “this request came from the real app,” but it can’t actually do that, because it isn’t secret. It provides obscurity, not authentication.</p>

<h2 id="your-password-takes-a-detour-it-doesnt-need-to">Your password takes a detour it doesn’t need to</h2>

<p>Login happens over IPC between the renderer and the main process. Here’s the handler:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">ipcMain</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="nx">IPC_EVENT</span><span class="p">.</span><span class="nx">IPC_LOGIN</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="nx">args</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">retry</span> <span class="o">=</span> <span class="mi">0</span>
  <span class="kd">let</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">login</span><span class="p">(</span><span class="nx">args</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span> <span class="nx">args</span><span class="p">.</span><span class="nx">password</span><span class="p">)</span>
  <span class="k">while</span><span class="p">(</span><span class="nx">data</span> <span class="o">===</span> <span class="kc">false</span> <span class="o">&amp;&amp;</span> <span class="nx">retry</span> <span class="o">&lt;</span> <span class="mi">3</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">login</span><span class="p">(</span><span class="nx">args</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span> <span class="nx">args</span><span class="p">.</span><span class="nx">password</span><span class="p">)</span>
    <span class="nx">retry</span><span class="o">++</span>
  <span class="p">}</span>
  <span class="k">if</span><span class="p">(</span><span class="nx">data</span> <span class="o">!==</span> <span class="kc">false</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">event</span><span class="p">.</span><span class="nx">sender</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">IPC_EVENT</span><span class="p">.</span><span class="nx">IPC_LOGIN</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">status</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
      <span class="na">data</span><span class="p">:</span> <span class="p">{</span>
        <span class="na">token</span><span class="p">:</span> <span class="nx">data</span><span class="p">.</span><span class="nx">token</span><span class="p">,</span>
        <span class="na">username</span><span class="p">:</span> <span class="nx">data</span><span class="p">.</span><span class="nx">username</span><span class="p">,</span>
        <span class="na">email</span><span class="p">:</span> <span class="nx">args</span><span class="p">.</span><span class="nx">email</span><span class="p">,</span>
        <span class="na">password</span><span class="p">:</span> <span class="nx">args</span><span class="p">.</span><span class="nx">password</span>   <span class="c1">// sent right back to the renderer</span>
      <span class="p">}</span>
    <span class="p">})</span>
  <span class="p">}</span>
  <span class="c1">// ...</span>
</code></pre></div></div>

<p>After a successful login, the main process echoes your plaintext password straight back into the IPC response that carries the session token. There’s no reason for it - the renderer needs the token, not the password, once auth has succeeded.</p>

<p>I originally assumed the renderer would stash that password somewhere for a “remember me” feature. Having now read the renderer code: the login handler only pulls <code class="language-plaintext highlighter-rouge">username</code> and the session token (<code class="language-plaintext highlighter-rouge">ssoToken</code>) from this response and writes those to <code class="language-plaintext highlighter-rouge">localStorage</code>. It doesn’t appear to persist the password further. So the impact is smaller than I first thought - but the round trip itself is still unnecessary. The plaintext password exists, briefly, in a process boundary it didn’t need to cross, in a renderer that (per the first section) has no isolation from arbitrary content.</p>

<p>What the renderer <em>does</em> persist: <code class="language-plaintext highlighter-rouge">localStorage.setItem("ssoToken", token)</code>. In an Electron app, <code class="language-plaintext highlighter-rouge">localStorage</code> isn’t an ephemeral browser-tab thing - it’s backed by an on-disk LevelDB store in the app’s profile directory, in plaintext. Anyone with local filesystem access to a logged-in user’s machine - malware, a shared machine, a backup that leaks - can read a live session token straight off disk. That token is sufficient on its own to publish, edit, or delete firmware under that account.</p>

<p>A few smaller things in the same area, from <code class="language-plaintext highlighter-rouge">auth.js</code>:</p>

<ul>
  <li><strong>Logout is a no-op.</strong> Literally: <code class="language-plaintext highlighter-rouge">const logout = () =&gt; {}</code>. It doesn’t invalidate anything server-side, doesn’t clear a cookie, doesn’t tell the server the session should end.</li>
  <li><strong>The session token is threaded around as a manually-constructed <code class="language-plaintext highlighter-rouge">Cookie</code> header string</strong> rather than held in any real credential store - <code class="language-plaintext highlighter-rouge">Cookie: m5_auth_token=${ssoToken}</code> - and <code class="language-plaintext highlighter-rouge">device.js</code> has one code path (<code class="language-plaintext highlighter-rouge">checkOldDevice</code>) that sends a <em>different</em> cookie name (<code class="language-plaintext highlighter-rouge">token</code> instead of <code class="language-plaintext highlighter-rouge">m5_auth_token</code>) for what looks like the same purpose, which reads like leftover inconsistency rather than anything intentional.</li>
</ul>

<p>Backing that up, here’s the actual <code class="language-plaintext highlighter-rouge">Set-Cookie</code> from a real login response (token redacted, everything else as sent):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Set-Cookie: m5_auth_token=&lt;redacted&gt;; expires=Thu, 08 Oct 2026 16:56:52 GMT; path=/
Set-Cookie: token=&lt;redacted&gt;; expires=Thu, 08 Oct 2026 16:56:52 GMT; path=/
</code></pre></div></div>

<p>Same value, two different cookie names, issued at the same time, no <code class="language-plaintext highlighter-rouge">Domain</code> attribute, no <code class="language-plaintext highlighter-rouge">HttpOnly</code>, no <code class="language-plaintext highlighter-rouge">Secure</code> flag.</p>

<ul>
  <li><strong>Failed logins retry automatically - one initial attempt, then up to three more on any failure, four total, no backoff, even when the password is simply wrong.</strong> I didn’t test whether the server enforces its own rate limit independently, but the client-side pattern - retrying immediately regardless of why the previous attempt failed - is the shape you’d start from if you were building a credential-stuffing tool. That’s speculation, not something I tried, worth saying plainly.</li>
</ul>

<h2 id="wifi-credentials-one-ipc-message-away-from-any-content-in-the-window">WiFi credentials, one IPC message away from any content in the window</h2>

<p>Remember the “auto-fill current WiFi” feature from the command-injection section? Here’s the handler behind it:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">getCurrentWifi</span><span class="p">(</span><span class="nx">send</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">ssid</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getSSID</span><span class="p">()</span>
  <span class="kd">let</span> <span class="nx">password</span> <span class="o">=</span> <span class="dl">''</span>
  <span class="k">if</span><span class="p">(</span><span class="nx">ssid</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">password</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getPassword</span><span class="p">(</span><span class="nx">ssid</span><span class="p">)</span>
  <span class="p">}</span>
  <span class="nx">send</span><span class="p">({</span> <span class="nx">ssid</span><span class="p">,</span> <span class="nx">password</span> <span class="p">})</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">bindAutoWifiEvents</span><span class="p">()</span> <span class="p">{</span>
  <span class="nx">ipcMain</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="nx">IPC_EVENT</span><span class="p">.</span><span class="nx">IPC_GET_CURRENT_WIFI_INFO</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">ev</span><span class="p">,</span> <span class="nx">args</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">send</span> <span class="o">=</span> <span class="nx">data</span> <span class="o">=&gt;</span> <span class="nx">ev</span><span class="p">.</span><span class="nx">sender</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">IPC_EVENT</span><span class="p">.</span><span class="nx">IPC_GET_CURRENT_WIFI_INFO_CALLBACK</span><span class="p">,</span> <span class="nx">data</span><span class="p">)</span>
    <span class="k">await</span> <span class="nx">getCurrentWifi</span><span class="p">(</span><span class="nx">send</span><span class="p">)</span>
  <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Any content in that window can send <code class="language-plaintext highlighter-rouge">IPC_GET_CURRENT_WIFI_INFO</code> and get back the plaintext password for the current network - pulled from NetworkManager on Linux, the Keychain on macOS, and <code class="language-plaintext highlighter-rouge">netsh</code> on Windows, using the same <code class="language-plaintext highlighter-rouge">getPassword()</code> functions from the command-injection section. No confirmation dialog, no scoping to “only after the user clicked the specific button for this.” It just answers.</p>

<p>Given nodeIntegration is on and there’s no isolation, this is less a separate vulnerability than a restatement of the first finding with a specific payload attached: the current network’s password is one IPC round trip away from anything running in that window, whether the user put it there or not.</p>

<p>It doesn’t stop at that one round trip, either. The renderer component behind this feature writes whatever ends up in its SSID/password fields to <code class="language-plaintext highlighter-rouge">localStorage</code> on every step: <code class="language-plaintext highlighter-rouge">localStorage.setItem("wifi__ssid", this.ssid)</code>, <code class="language-plaintext highlighter-rouge">localStorage.setItem("wifi__password", this.password)</code>. If the auto-fill feature gets used, the current network’s plaintext password ends up sitting in the same on-disk, unencrypted <code class="language-plaintext highlighter-rouge">localStorage</code> store as the session token, indefinitely.</p>

<h2 id="path-traversal-in-more-places-than-i-expected">Path traversal, in more places than I expected</h2>

<p>Several places join a filename that ultimately comes from IPC arguments or a remote JSON payload directly onto a base directory with <code class="language-plaintext highlighter-rouge">path.resolve()</code>, with no check that the result stays inside that directory:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// serialport/tool.js - the file that gets flashed to your device</span>
<span class="kd">let</span> <span class="nx">binFile</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">FIRMWARE_DIR</span><span class="p">,</span> <span class="nx">opts</span><span class="p">.</span><span class="nx">file</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// firmware.js - arbitrary file delete</span>
<span class="nx">fs</span><span class="p">.</span><span class="nx">unlink</span><span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">FIRMWARE_DIR</span><span class="p">,</span> <span class="nx">args</span><span class="p">.</span><span class="nx">file</span><span class="p">),</span> <span class="cm">/* ... */</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// imageLoader.js - writes an HTTP response body to disk</span>
<span class="nx">fs</span><span class="p">.</span><span class="nx">writeFile</span><span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">IMAGE_DIR</span><span class="p">,</span> <span class="nx">image</span><span class="p">),</span> <span class="nx">buf</span><span class="p">,</span> <span class="cm">/* ... */</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">path.resolve(base, '../../etc/passwd')</code> happily walks outside <code class="language-plaintext highlighter-rouge">base</code> - that’s documented behavior, not a bug in the function. Given the first finding, this mostly matters as defense-in-depth rather than a novel way in - anything that could exploit this could just call <code class="language-plaintext highlighter-rouge">fs</code> directly - but it’s worth listing because some of these are reachable from IPC alone without full code execution, and one of them is an arbitrary file <em>delete</em> triggered by <code class="language-plaintext highlighter-rouge">IPC_REMOVE_FIRMWARE_FILE</code>.</p>

<p>There’s a more interesting variant in <code class="language-plaintext highlighter-rouge">plugin/uiflow-config.js</code>, which reads and writes UIFlow config data directly to and from a connected device’s flash:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">getConfig</span><span class="p">(</span><span class="nx">payload</span><span class="p">,</span> <span class="nx">send</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="p">{</span> <span class="nx">com</span><span class="p">,</span> <span class="nx">file</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">payload</span>
  <span class="kd">let</span> <span class="nx">binFile</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">FIRMWARE_DIR</span><span class="p">,</span> <span class="s2">`</span><span class="p">${</span><span class="nx">file</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
  <span class="kd">let</span> <span class="nx">beginAddr</span> <span class="o">=</span> <span class="nx">getBeginAddress</span><span class="p">(</span><span class="nx">binFile</span><span class="p">)</span>   <span class="c1">// fs.statSync(binFile).size, turned into a flash offset</span>
  <span class="c1">// ... esptool read_flash at beginAddr, using the connected device on `com`</span>
</code></pre></div></div>

<p>Same unchecked join, but <code class="language-plaintext highlighter-rouge">getBeginAddress()</code> does something stranger with the result: it takes the literal byte size of whatever <code class="language-plaintext highlighter-rouge">path.resolve()</code> landed on - <code class="language-plaintext highlighter-rouge">fs.statSync(binFile).size</code>, no lookup table, no partition scheme - and hands that number straight to <code class="language-plaintext highlighter-rouge">esptool</code> as a flash address. Two unrelated files of the same size produce the same offset. Point <code class="language-plaintext highlighter-rouge">file</code> outside <code class="language-plaintext highlighter-rouge">FIRMWARE_DIR</code> at an arbitrary file already on disk and its size becomes the offset <code class="language-plaintext highlighter-rouge">read_flash</code> pulls from - and through the symmetric <code class="language-plaintext highlighter-rouge">setConfig()</code> write path, the offset <code class="language-plaintext highlighter-rouge">write_flash</code> targets on the physically connected device. The write side is what matters: this isn’t just “read the wrong file on the host,” it’s a path traversal bug steering where the app writes on hardware sitting on your desk.</p>

<h2 id="csv-injection-into-device-config">CSV injection into device config</h2>

<p>Last one, and the lowest severity, but a tidy example of the same “user input, straight into a structured format, no escaping” pattern:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// plugin/uiflow2-nvs-config.js</span>
<span class="kd">const</span> <span class="nx">fillCSV</span> <span class="o">=</span> <span class="p">({</span><span class="nx">server</span><span class="p">,</span> <span class="nx">ssid</span><span class="p">,</span> <span class="nx">pwd</span><span class="p">,</span> <span class="nx">sntp0</span><span class="p">,</span> <span class="nx">sntp1</span><span class="p">,</span> <span class="nx">sntp2</span><span class="p">,</span> <span class="nx">timezone</span><span class="p">,</span> <span class="nx">bootOpt</span><span class="p">})</span> <span class="o">=&gt;</span>
<span class="s2">`key,type,encoding,value
uiflow,namespace,,
server,data,string,</span><span class="p">${</span><span class="nx">server</span><span class="p">}</span><span class="s2">
ssid0,data,string,</span><span class="p">${</span><span class="nx">ssid</span><span class="p">}</span><span class="s2">
pswd0,data,string,</span><span class="p">${</span><span class="nx">pwd</span><span class="p">}</span><span class="s2">
...
</span></code></pre></div></div>

<p>WiFi SSID, password, and other config fields go straight into a CSV template literal, which later gets compiled into an NVS binary blob and written to the device. A value like <code class="language-plaintext highlighter-rouge">foo\nssid1,data,string,injected</code> injects an extra row. Impact is limited in the normal case - it’s your own device, your own data - but it stops being limited the moment this config comes from anywhere you don’t fully control, like an imported or shared config file. Worth noting: the other two config packers in the same directory (<code class="language-plaintext highlighter-rouge">uiflow-wifi-packer.js</code>, <code class="language-plaintext highlighter-rouge">timercam-cfg-packer.js</code>) build their blobs with explicit length-prefixed byte packing instead of string interpolation, and don’t have this problem.</p>

<h2 id="so-i-wrote-my-own-client">So I wrote my own client</h2>

<p>None of this is a single missed edge case. It’s a pattern: transport security applied inconsistently, a renderer given every privilege Electron lets you hand out, an update mechanism with no integrity checks, and enough “just interpolate the string” moments that I stopped trusting any one part of it in isolation.</p>

<p>I wasn’t going to keep running this against my own account to publish firmware, so I built <a href="https://github.com/cryptspeak/m5uploader">m5uploader</a> - a from-scratch client covering account login, browsing/downloading the public firmware catalog, and firmware management (publish, edit, delete, visibility, share codes). Apache-2.0, source is up now.</p>

<p>The design choices map directly onto the list above: HTTPS only, with certificate verification on, no exceptions. No embedded browser, no Node integration exposed to anything resembling a web page - it’s a plain desktop GUI (<a href="https://ttkbootstrap.readthedocs.io/">ttkbootstrap</a>, if you’re curious) that talks to the API directly.</p>

<p>Two things I want to be upfront about. First, the auth token is currently stored in plaintext on disk. OS-level secure storage (keyring/keychain) is the right answer and it’s planned, but this project - the client, this writeup, and the blog infrastructure - all shipped on one person’s timeline, and that didn’t make the cut before publication. Second, flashing support isn’t there yet. The workflow for it is rougher than I’d like for the same reason: account management and publishing came first, flashing will get proper attention once the rest stabilizes. Both are temporary - the source is there, the issues are tracked.</p>

<p>Getting the API right took more than reading the client source, and this is the part I actually enjoyed. Some of it came straight from the main-process code - login, the public catalog, and share-code resolution are all plainly there. The firmware-management endpoints (publish, edit, delete, visibility, share codes) live in the <em>renderer</em> bundle instead, a separate Angular app under <code class="language-plaintext highlighter-rouge">packages/view/</code> that never shows up in any of the main-process snippets earlier in this post. I pulled it from the same install everything else came from, straight off M5Stack’s download page - I just spent the first pass entirely inside the asar and didn’t think to check the rest of what shipped alongside it. Unlike the main-process code, the renderer ships obfuscated - minified, mangled names, logic chains - so working through it is slower and less complete than reading <code class="language-plaintext highlighter-rouge">app.js</code> and <code class="language-plaintext highlighter-rouge">packages/</code> was. Everything I attribute to the renderer in this post reflects what I could confirm from that; there may be more I missed.</p>

<p>With the bundle in front of me, the hardcoded base URL was easy to find: <code class="language-plaintext highlighter-rouge">const wn_apiBaseUrl="http://m5burner-api.m5stack.com"</code>, wired into <code class="language-plaintext highlighter-rouge">publishFirmware()</code>, <code class="language-plaintext highlighter-rouge">updateFirmware()</code>, and the rest, plus a separate <code class="language-plaintext highlighter-rouge">getFirmwareList()</code> hitting <code class="language-plaintext highlighter-rouge">http://m5burner-api-fc-hk-cdn.m5stack.com/api/firmware</code> for the public catalog. Both plain HTTP.</p>

<p>What wasn’t in the code was the auth mechanism for those calls. I went looking for anything that attaches a header or a token - an interceptor, anything - and came up empty. The only HTTP interceptor in the bundle is Angular’s stock XSRF-cookie one, which doesn’t apply here. So on paper, the renderer’s own calls go out with no auth header at all, pointing at some ambient cookie rather than anything explicit. But source only tells you what’s <em>not</em> in the code, not what actually leaves the machine. For that I had to watch the traffic, which is the capture I showed earlier: a bare, non-standard header, <code class="language-plaintext highlighter-rouge">m5_auth_token: &lt;token&gt;</code>, not a cookie, not an <code class="language-plaintext highlighter-rouge">Authorization: Bearer</code>. Nothing in the source would have told me that on its own.</p>

<h2 id="if-youre-still-running-m5burner">If you’re still running M5Burner</h2>

<p>The update-page load is the one I’d worry about first - it fires on launch, whether or not you were looking for an update, the moment you’re on a network you don’t fully control. Don’t run it on WiFi you don’t trust; wired, or a hotspot that’s actually yours, is safer than a coffee shop or a conference floor. For logging in, browsing the catalog, or publishing firmware, that’s what I built <a href="https://github.com/cryptspeak/m5uploader">m5uploader</a> for - same account, HTTPS only, none of the above. It doesn’t flash yet; for that, downloading your <code class="language-plaintext highlighter-rouge">.bin</code> by hand over <code class="language-plaintext highlighter-rouge">https://</code> and running it through <code class="language-plaintext highlighter-rouge">esptool.py</code> directly skips the app’s update mechanism and its API calls entirely, at the cost of the GUI. None of that fixes M5Burner itself - it’s just a way to lower the odds until M5Stack does.</p>

<h2 id="to-m5stack">To M5Stack</h2>

<p>I like the hardware. The Cardputer ADV is a genuinely fun little device to build for, and I wouldn’t have bothered digging this deep into your desktop tool if I didn’t care whether it’s safe to run.</p>

<p>I also want to be straight about how this is being published, since it matters: I didn’t send you any of this before writing it up. I looked for a security contact first - no <code class="language-plaintext highlighter-rouge">security.txt</code>, no <code class="language-plaintext highlighter-rouge">SECURITY.md</code> on your GitHub org, nothing on your site pointing at a private channel to report something like this - and came up with nothing, so this post is the report, on the first attempt, in public. That means the update-page RCE chain above is live against the current shipping build as of publishing this, and I don’t have a better answer to that than the mitigation section above. I’d have preferred to send this privately first; I didn’t have anywhere to send it.</p>

<p>I’m not speculating about the transport issue either - I captured the app’s own traffic twice to be sure, and the second time, without anything upgrading it along the way, it was exactly what the source promised: a full-control account token, in plain HTTP, on port 80.</p>

<p>None of this needs a redesign. <code class="language-plaintext highlighter-rouge">nodeIntegration: false</code>, <code class="language-plaintext highlighter-rouge">contextIsolation: true</code>, <code class="language-plaintext highlighter-rouge">webSecurity: true</code> is a few lines and it’s the current Electron default - you’d be deleting code, not writing it. Point the update checker and the first-party API calls at the <code class="language-plaintext highlighter-rouge">https://</code> versions of hosts that already serve HTTPS correctly - the media-token endpoint is the one exception, and it’ll need a TLS-capable port stood up before there’s a scheme to switch to. Put Electron’s own <code class="language-plaintext highlighter-rouge">autoUpdater</code> (or any updater that checks a signature before it extracts anything into your install directory) in front of the current hand-rolled <code class="language-plaintext highlighter-rouge">http.get</code> + unzip. Swap every <code class="language-plaintext highlighter-rouge">exec()</code> call that builds a shell command out of an SSID string - all three platforms have this - for <code class="language-plaintext highlighter-rouge">execFile</code>/<code class="language-plaintext highlighter-rouge">spawn</code> with an argument array. That one’s a rename, not a rewrite.</p>

<p>I’m not asking for a bug bounty or credit here. I’m asking because I’d genuinely like to be able to recommend the official tool to people again instead of telling them to use a third-party client someone wrote in their spare time because they didn’t trust the real one. If it’d help to walk through any of this in more detail, or verify a fix once one’s out, I’m around - through the contact info on this blog, or as <a href="https://github.com/0x00001312">0x00001312</a> on GitHub.</p>

<ul>
  <li>malloc</li>
</ul>]]></content><author><name>malloc</name></author><category term="security" /><category term="electron" /><category term="reverse-engineering" /><category term="m5stack" /><category term="disclosure" /><summary type="html"><![CDATA[M5Burner is M5Stack's official desktop tool for flashing firmware. What it's doing behind the scenes isn't as safe as it should be.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://malloc.pw/assets/posts/m5burner-is-a-mess-og.jpg" /><media:content medium="image" url="https://malloc.pw/assets/posts/m5burner-is-a-mess-og.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ping</title><link href="https://malloc.pw/2026/07/08/ping/" rel="alternate" type="text/html" title="Ping" /><published>2026-07-08T00:00:00+00:00</published><updated>2026-07-08T00:00:00+00:00</updated><id>https://malloc.pw/2026/07/08/ping</id><content type="html" xml:base="https://malloc.pw/2026/07/08/ping/"><![CDATA[<p>Ping. Into the void. Off into the distance.
This blog will hold security writeups, devlogs and my thoughts on security and privacy related topics.</p>]]></content><author><name>malloc</name></author><summary type="html"><![CDATA[First post: what this blog is for — security writeups, devlogs, and notes on privacy.]]></summary></entry></feed>