I write my own firmware for the Cardputer ADV - csCardputer. 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 .bin.

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.

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.

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.

What I was looking at

M5Burner ships as a standard Electron app (package.json reports "m5burner", version 3.0.0). 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.

Everything below comes from reading the app’s actual main-process source (app.js and the packages/ 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.

The renderer runs with full Node access, no isolation, no web security

This is the finding everything else builds on, so it goes first. Here’s the actual BrowserWindow M5Burner creates:

const mainWin = new BrowserWindow({
  minWidth: 1280,
  minHeight: 800,
  width: 1280,
  height: 760,
  center: true,
  frame: false,
  webPreferences: {
    nodeIntegration: true,
    contextIsolation: false,
    webSecurity: false,
    preload: path.resolve(__dirname, 'packages', 'preload.js')
  }
})
require('@electron/remote/main').enable(mainWin.webContents)

Take these one at a time:

  • nodeIntegration: true 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 require('child_process'), require('fs'), require('net'), directly. Not through some IPC bridge with a permission model. Directly.
  • contextIsolation: false 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.
  • webSecurity: false turns off the same-origin policy and mixed-content blocking for this window, so cross-origin restrictions just don’t apply.
  • On top of that, @electron/remote is enabled, which hands out references to main-process objects (the app object, capable of relaunching or quitting the app, reaching the filesystem through appRoot) to a window that already has raw Node access.

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

const appRoot = require('@electron/remote').getGlobal('appRoot');
window.appRoot = appRoot

Two lines. Not a contextBridge API surface, not restricting anything - just handing the app’s root path onto window, which it didn’t even need a preload script for given contextIsolation is off anyway. No attempt at least-privilege anywhere in this app.

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: whatever content ends up rendered in this window runs with the full privileges of the desktop user running the app. Not “a web page with some extra permissions” - arbitrary Node.js execution. The only remaining question is what content gets into that window.

The app loads a page over plain HTTP into that same window

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

if(updateInfo.needUpdate) {
  mainWin.webContents.on('dom-ready', () => {
    update(updateInfo.version)
  })
  await mainWin.loadURL('http://m5burner.m5stack.com/website/')
} else {
  await mainWin.loadFile(VIEW_HTML)
}

It loads http://m5burner.m5stack.com/website/ - plain HTTP, no TLS - directly into the same window I just described. nodeIntegration: true and all.

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 <script> tag calling require('child_process').exec() 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.

The auto-updater itself is the same problem, one level down

Even without the above, the updater is independently broken:

const ONLINE_VERSION_URL = 'http://m5burner-cdn.m5stack.com/appVersion.info'
const APP_PATCH_URL = 'http://m5burner-cdn.m5stack.com/patch'

function update(hashCode) {
  let filename = getFilename(hashCode)
  let url = APP_PATCH_URL + '/' + filename + '.zip?timestamp=' + Date.now()
  let req = http.get(url, response => {
    let patchFile = path.resolve(TMP_DIR, `patch_${Date.now().toString()}.zip`)
    let patchStream = fs.createWriteStream(patchFile)
    response.pipe(patchStream)
    response.on('end', () => {
      process.noAsar = true;
      let ws = unzip.Extract({path: ROOT_DIR})
      ws.on('close', () => { /* ... */ })
      fs.createReadStream(patchFile).pipe(ws)
    })
  })
  req.end()
}

Fetch a zip over plain HTTP, using Node’s raw http module (not even the app’s usual axios), and extract it straight into the app’s own install directory, with process.noAsar = true 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 Content-Length and an HTTP 200 is as much verification as this gets.

An on-path attacker who can intercept http://m5burner-cdn.m5stack.com/* 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.

The app also pins unzipper@^0.10.11 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 ../../../evil 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.

Command injection, if the machine ever joins the wrong network

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:

// wifiName/linux.js
module.exports = function getSSID() {
  return new Promise(resolve => {
    let ssid = ''
    let process = exec(`iwgetid --raw`)
    process.stdout.on('data', chunk => { ssid += chunk.toString() })
    process.on('exit', code => resolve(ssid))
  })
}
// wifiPassword/linux.js
module.exports = function getPassword(ssid) {
  return new Promise(resolve => {
    let output = ''
    let process = exec(`ls /etc/NetworkManager/system-connections/${ssid}.nmconnection`)
    // ...
  })
}

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

getPassword() repeats the same pattern one function down - the unsanitized SSID lands in a second exec() call, this time as part of an ls lookup for the matching .nmconnection 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 exec() call. Worth noting: on Linux, NetworkManager’s .nmconnection files are typically 0600 root:root, 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.

The Windows and macOS code paths have the same problem. On Windows:

// wifiPassword/win.js
exec(`chcp 437 && netsh wlan show profile name=${ssid} key=clear`)

On macOS:

// wifiPassword/osx.js
exec(`security find-generic-password -D "AirPort network password" -wa ${ssid}`)

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 - /bin/sh on Linux and macOS, cmd.exe on Windows - so the metacharacters that trigger it differ too (; and backticks on the Unix side, & and | on Windows), but the root cause is identical: attacker-controlled string, template-interpolated into exec(), no escaping.

An SSID is just a string an attacker gets to pick. A quote to close out the intended argument, a curl-into-sh 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 exec() the moment getSSID() runs.

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.

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 getSSID()/getPassword() and confirming how exec() handles shell metacharacters, not from a live capture.

HTTP where HTTPS was sitting right there

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:

$ 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

Every host serves valid HTTPS with no functional difference, except one - and that one’s worse. flow.m5stack.com 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:

$ curl -v https://flow.m5stack.com:5003/token
* TLS connect error: error:0A000126:SSL routines::unexpected eof while reading

So for every other host, this isn’t the backend lacking TLS - the app is choosing HTTP with an identical https:// endpoint available. flow.m5stack.com:5003 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.

File URL
packages/download.js http://m5burner-api.m5stack.com
packages/share.js http://m5burner-api.m5stack.com
packages/imageLoader.js http://m5burner.m5stack.com/cover
packages/mediaToken.js http://flow.m5stack.com:5003/token
packages/update.js both update URLs (see above)
app.js the update-notification page load (see above)

The mediaToken.js case is a compact example of how these compound. When the renderer sends IPC_GET_MEDIA_TOKEN, the main process reads the physically connected device’s MAC address via esptool read_mac (using spawn with an argument array - one of the few places in this codebase that actually handles a subprocess correctly), then POSTs that MAC to http://flow.m5stack.com:5003/token 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. auth.js and device.js, by contrast, do use https://uiflow2.m5stack.com 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.

I also dug into the app’s renderer bundle (packages/view/, a separate Angular app that doesn’t appear in any of the main-process snippets above) and found it hardcodes const wn_apiBaseUrl="http://m5burner-api.m5stack.com" 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:

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: <redacted>
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

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:

HTTP/1.1 200 OK
[...]
[{"_id":"<redacted>","uid":<redacted>,"fid":"<redacted>",
"name":"<redacted>","description":"<redacted>",
"cover":"<redacted>","category":"<redacted>", ...

I tested this against a second, throwaway account - /api/admin/firmware 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.

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.

A hardcoded credential, shipped to every install

// device.js
const REGISTRY_URL = 'https://m5stack-factory-tool.m5stack.com/record/old'

const registryOldDevice = ({mac, type}) => {
  return new Promise(resolve => {
    axios.default({
      url: `${REGISTRY_URL}`,
      method: 'POST',
      headers: {
        'Authorization': 'Basic bTVzdGFjay5tNWJ1cm5lcjpyZWdpc3RyeQ==',
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      data: `mac=${mac}&type=${type}`
    })

Decode that base64 blob and you get m5stack.m5burner:registry - 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.

Your password takes a detour it doesn’t need to

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

ipcMain.on(IPC_EVENT.IPC_LOGIN, async (event, args) => {
  let retry = 0
  let data = await login(args.email, args.password)
  while(data === false && retry < 3) {
    data = await login(args.email, args.password)
    retry++
  }
  if(data !== false) {
    event.sender.send(IPC_EVENT.IPC_LOGIN, {
      status: 0,
      data: {
        token: data.token,
        username: data.username,
        email: args.email,
        password: args.password   // sent right back to the renderer
      }
    })
  }
  // ...

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.

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 username and the session token (ssoToken) from this response and writes those to localStorage. 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.

What the renderer does persist: localStorage.setItem("ssoToken", token). In an Electron app, localStorage 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.

A few smaller things in the same area, from auth.js:

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

Backing that up, here’s the actual Set-Cookie from a real login response (token redacted, everything else as sent):

Set-Cookie: m5_auth_token=<redacted>; expires=Thu, 08 Oct 2026 16:56:52 GMT; path=/
Set-Cookie: token=<redacted>; expires=Thu, 08 Oct 2026 16:56:52 GMT; path=/

Same value, two different cookie names, issued at the same time, no Domain attribute, no HttpOnly, no Secure flag.

  • 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. 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.

WiFi credentials, one IPC message away from any content in the window

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

async function getCurrentWifi(send) {
  let ssid = await getSSID()
  let password = ''
  if(ssid) {
    password = await getPassword(ssid)
  }
  send({ ssid, password })
}

function bindAutoWifiEvents() {
  ipcMain.on(IPC_EVENT.IPC_GET_CURRENT_WIFI_INFO, async (ev, args) => {
    const send = data => ev.sender.send(IPC_EVENT.IPC_GET_CURRENT_WIFI_INFO_CALLBACK, data)
    await getCurrentWifi(send)
  })
}

Any content in that window can send IPC_GET_CURRENT_WIFI_INFO and get back the plaintext password for the current network - pulled from NetworkManager on Linux, the Keychain on macOS, and netsh on Windows, using the same getPassword() 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.

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.

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 localStorage on every step: localStorage.setItem("wifi__ssid", this.ssid), localStorage.setItem("wifi__password", this.password). If the auto-fill feature gets used, the current network’s plaintext password ends up sitting in the same on-disk, unencrypted localStorage store as the session token, indefinitely.

Path traversal, in more places than I expected

Several places join a filename that ultimately comes from IPC arguments or a remote JSON payload directly onto a base directory with path.resolve(), with no check that the result stays inside that directory:

// serialport/tool.js - the file that gets flashed to your device
let binFile = path.resolve(FIRMWARE_DIR, opts.file)
// firmware.js - arbitrary file delete
fs.unlink(path.resolve(FIRMWARE_DIR, args.file), /* ... */)
// imageLoader.js - writes an HTTP response body to disk
fs.writeFile(path.resolve(IMAGE_DIR, image), buf, /* ... */)

path.resolve(base, '../../etc/passwd') happily walks outside base - 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 fs 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 delete triggered by IPC_REMOVE_FIRMWARE_FILE.

There’s a more interesting variant in plugin/uiflow-config.js, which reads and writes UIFlow config data directly to and from a connected device’s flash:

function getConfig(payload, send) {
  let { com, file } = payload
  let binFile = path.resolve(FIRMWARE_DIR, `${file}`)
  let beginAddr = getBeginAddress(binFile)   // fs.statSync(binFile).size, turned into a flash offset
  // ... esptool read_flash at beginAddr, using the connected device on `com`

Same unchecked join, but getBeginAddress() does something stranger with the result: it takes the literal byte size of whatever path.resolve() landed on - fs.statSync(binFile).size, no lookup table, no partition scheme - and hands that number straight to esptool as a flash address. Two unrelated files of the same size produce the same offset. Point file outside FIRMWARE_DIR at an arbitrary file already on disk and its size becomes the offset read_flash pulls from - and through the symmetric setConfig() write path, the offset write_flash 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.

CSV injection into device config

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

// plugin/uiflow2-nvs-config.js
const fillCSV = ({server, ssid, pwd, sntp0, sntp1, sntp2, timezone, bootOpt}) =>
`key,type,encoding,value
uiflow,namespace,,
server,data,string,${server}
ssid0,data,string,${ssid}
pswd0,data,string,${pwd}
...

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 foo\nssid1,data,string,injected 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 (uiflow-wifi-packer.js, timercam-cfg-packer.js) build their blobs with explicit length-prefixed byte packing instead of string interpolation, and don’t have this problem.

So I wrote my own client

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.

I wasn’t going to keep running this against my own account to publish firmware, so I built m5uploader - 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.

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 (ttkbootstrap, if you’re curious) that talks to the API directly.

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.

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 renderer bundle instead, a separate Angular app under packages/view/ 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 app.js and packages/ was. Everything I attribute to the renderer in this post reflects what I could confirm from that; there may be more I missed.

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

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 not 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, m5_auth_token: <token>, not a cookie, not an Authorization: Bearer. Nothing in the source would have told me that on its own.

If you’re still running M5Burner

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 m5uploader for - same account, HTTPS only, none of the above. It doesn’t flash yet; for that, downloading your .bin by hand over https:// and running it through esptool.py 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.

To M5Stack

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.

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 security.txt, no SECURITY.md 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.

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.

None of this needs a redesign. nodeIntegration: false, contextIsolation: true, webSecurity: true 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 https:// 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 autoUpdater (or any updater that checks a signature before it extracts anything into your install directory) in front of the current hand-rolled http.get + unzip. Swap every exec() call that builds a shell command out of an SSID string - all three platforms have this - for execFile/spawn with an argument array. That one’s a rename, not a rewrite.

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 0x00001312 on GitHub.

  • malloc