Chip Security Testing 
Binary Security Analysis 
Resources 
Blog
Contact us
Back to all articles
Time Travel Analysis
Binary Analysis

Exploiting a router vulnerability: Tenda AC15 | Part I

15 min read
Edit by Pierre Mondon • Sep 24, 2024
Share

Tenda-AC15-authors.gif

Let's document our journey for instrumenting a web-interface of a router firmware, namely Tenda AC15. Our goal is to emulate the firmware and reproduce the vulnerability CVE-2024-2815.

This vulnerability is reported as a stack based buffer overflow in the function R7WebsSecurityHandler of the router's web interface. A Proof of concept is associated to this CVE and is publicly available here.

In order to quickly analyse this vulnerability, we wanted to use the time travel analysis tool of esReverse. esReverse works on a full-system emulation. However, in some circumstances, we are only interested in a small part of that system. This is the case for userland applications for which only analysing that application can be handy. Indeed, only analysing the userland application reduices the size of the trace generated during recording, hence, speeding up the analysis process and scoping the trace to fined-tuned information specific to the analyst's task.

This was the motivation for interfacing Qiling and esReverse.

In the following, we will start by explaining how we extracted the firmware and go through the important components for this emulation. Second, we will present Qiling and Reven-unicorn. Third we will detail the thought process for interfacing the two tools, both explaining the solution and explaining the journey and issues we faced connecting them.

 

Tenda AC15 firmware

This is where we have the first discrepancy. Indeed, the reported CVE (on vulnDB) mentions Tenda AC15 15.03.20_multi.

tenda-ac20.png

 

However, the POC on Github mentions AC15 V15.03.05.18.

tenda-ac18.png

 

As the proof of concept is based on the firmware version AC15 V15.03.05.18 we chose to stick with it. The firmware is publicly available here.

The firmware extraction is straight forward as it is not encrypted:

1. Unzip the file

extraction1.png

2. Use binwalk to extract the filesystem

extraction2.png

3. Result of binwalk with the filesystem

extraction3.png

wget "https://static.tenda.com.cn/tdcweb/download/uploadfile/AC15/US_AC15V1.0BR_V15.03.05.18_multi_TD01.zip" unzip "US_AC15V1.0BR_V15.03.05.18_multi_TD01.zip" binwalk -e "US_AC15V1.0BR_V15.03.05.18_multi_TD01.bin" 2>/dev/null

Let's explore that filesystem, or at least the interesting files and folders for our webinterface emulation.

First, /etc_ro/init.d/ is a directory containing scripts launched at startup. In this firmware there is only one script: rcS.

Here are the interesting part of that script for us:

cp -rf /webroot_ro/* /webroot/ ... cfmd &

The directory webroot_ro/ is copied to webroot/. It contains the content for the web interface such as html, css, javascript files and images.

The script then starts a program called cfmd that will launch our webserver. As we know that the vulnerability lies in the function R7WebsSecurityHandler, we can search for that string in the filesystem and we got lucky:

# fgrep -r R7WebsSecurityHandler grep: bin/httpd: binary file matches # fgrep -r httpd grep: lib/libpal_vendor.so: binary file matches grep: lib/libcommon.so: binary file matches grep: bin/netctrl: binary file matches grep: bin/dhttpd: binary file matches grep: bin/httpd: binary file matches grep: bin/alibaba_update: binary file matches grep: bin/time_check: binary file matches grep: bin/business_proc: binary file matches grep: bin/cfmd: binary file matches grep: usr/sbin/smbd: binary file matches

As we can see, the vulnerable function is situated in the httpd binary. Now we try to understand how is launched that web interface. It turns out it is launched by cfmd (itself launched on startup via rcS in the init.d folder).

httpd.png

It turns out the binary is not launched with any parameters. We can now focus on emulating that binary as we have all the necessary information on the context in which this web interface runs.

We will get to that, but first let's contextualise the emulation tools that we will use.

 

What is Qiling?

Qiling is described by its authors as "A True Instrumentable Binary Emulation FRAMEWORK". This framework allows you to emulate a binary in the context of an operating system.

Basically, emulating a binary with Qiling requires four elements:

  • The targeted binary
  • A filesystem
  • A configuration file
  • An emulation script

The filesystem will be taken by Qiling as a basis for the system emulation. That is, if your binary requires libraries, they should be placed in the filesystem given to Qiling. Similarly, Qiling uses the interpreter from the given filesystem to load the needed libraries in the memory for the target binary. The configuration file contains meta-information for Qiling to perform the emulation.

Here is an example given by Qiling for Linux:

[CODE] # ram_size 0xa00000 is 10MB ram_size = 0xa00000 entry_point = 0x1000000 [OS64] stack_address = 0x7ffffffde000 stack_size = 0x30000 load_address = 0x555555554000 interp_address = 0x7ffff7dd5000 mmap_address = 0x7fffb7dd6000 vsyscall_address = 0xffffffffff600000 [OS32] stack_address = 0x7ff0d000 stack_size = 0x30000 load_address = 0x56555000 interp_address = 0x047ba000 mmap_address = 0x90000000 [KERNEL] #uid = 1000 #gid = 1000 uid=0 gid=0 pid = 1996 [MISC] current_path = / [NETWORK] # override the ifr_name field in ifreq structures to match the hosts network interface name. # that fixes certain socket ioctl errors where the requested interface name does not match the # one on the host. comment out to avoid override ifrname_override = eth0 # To use IPv6 or not, to avoid binary double bind. ipv6 and ipv4 bind the same port at the same time bindtolocalhost = True # Bind to localhost ipv6 = False

For most of your needs this configuration file will do the trick. The only needed modification will be the ifrname_override which is the network interface on which Qiling should work. Qiling will redirect the port 80 on the emulated binary to the port 8080 of the host by default.

 

Emulating httpd with Qiling

We will start by stating that the Qiling script for httpd and Tenda was already available on Qiling example list (here).

This script simply implements a server that accepts incoming connection on /var/cfm_socket and sends with connection.send(b'192.168.170.169') to each connected client. For our example, it turns out we do not need to investigate further the purpose of that server. Even when logging all the data received through that socket there is nothing. We know that if this server doesn't exist, httpd quits early during the initialisation phase as it tries to establish a connection.

The scripts also leverages the filesystem mapping feature of Qiling to map the /dev/urandom of the emulate filesystem to the host /dev/urandom. Practically, this allows the emulated system to have access to similar randomness generation through the host machine as it would have on the actual router.

Last, the script maps the syscall vfork to a function returning 0:

def __vfork(ql: Qiling): return 0 ql.os.set_syscall('vfork', __vfork)

This script, in combination with the filesystem presented here, is enough to emulate our httpd server. Note that in that extracted filesystem you should run that command to immitate the work of the init.d script:

cp -rf $(path to your extracted filesystem)/webroot_ro/* $(path to your extracted filesystem)/webroot/

Now we have a working emulation of httpd in Qiling with that Qiling script:

import os import socket import threading import pathlib import sys sys.path.append(".") from qiling import Qiling from qiling.const import QL_VERBOSE, QL_INTERCEPT def nvram_listener(): server_address = fr'{ROOTFS}/var/cfm_socket' if os.path.exists(server_address): os.unlink(server_address) # Create UDS socket sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.bind(server_address) sock.listen(1) data = bytearray() with open('cfm_socket.log', 'wb') as ofile: while True: connection, _ = sock.accept() try: while True: data += connection.recv(1024) if b'lan.webiplansslen' not in data: break connection.send(b'192.168.170.169') ofile.write(data) data.clear() finally: connection.close() def my_sandbox(path, rootfs): ql = Qiling(path, rootfs, verbose=QL_VERBOSE.DEBUG) ql.add_fs_mapper(r'/dev/urandom', r'/dev/urandom') if ql.debugger: def __vfork(ql: Qiling): return 0 ql.os.set_syscall('vfork', __vfork) try: ql.run() except Exception as e: print(e) nvram_listener_thread = threading.Thread(target=nvram_listener, daemon=True) nvram_listener_thread.start() my_sandbox([fr'{ROOTFS}/bin/httpd'], ROOTFS)

With that script we can reproduce the vulnerability CVE-2024-2815. Note that the Qiling script crashes when sending the vulnerable query, this is normal, bear in mind we are reproducing a crash.

 

What is Reven-unicorn?

Reven-unicorn is a tool developped by eShard that can generate esReven traces based on a Unicorn emulation. For those that are new here, esReverse is a time travel analysis solution with both a python API and a dedicated GUI. That GUI allows you to visualise code through time in contrast with tradionnal tools in which you have to imagine the time passing in the pictured code.

Giving a handle on the Unicorn object of Qiling to Reven-unicorn will automatically record the registers throughout the emulation. Reven-unicorn allows you to map memory, trace memory reads and write through an API. It is important to note that Reven-unicorn does not yet support memory re-mapping or mapping during the recording, that is, all memory mapping needs to be done before the recording starts.

 

Recording the emulation

Now we have a working emulation environment let's instrument our target for Time Travel Analysis. We will dive into our thought process when we first interfaced the two tools.

As Reven-unicorn and Qiling are both based on Unicorn. Our thought process was: "Both are using Unicorn must not be too hard to interface them"

After a bit of digging around, we came to the following logic:

  • We let Qiling do the entire emulation
  • We add hooks within Qiling and Unicorn engine. These hooks point the to our callback functions
  • These callbacks will be responsible for recoding everything that happens within the Unicorn engine of Qiling

The first step was straight forward:

import reven_unicorn ... def my_sandbox(path, rootfs): ql = Qiling(path, rootfs, verbose=QL_VERBOSE.DEBUG) ql.add_fs_mapper(r'/dev/urandom', r'/dev/urandom') #### Added by eshard rvn_handle = reven_unicorn.alloc(ql.uc) err = reven_unicorn.start_tracing(rvn_handle) #### if err != reven_unicorn.Error.OK: print(f"Error when starting the tracing: {err}") sys.exit(1) if ql.debugger: def __vfork(ql: Qiling): return 0 ql.os.set_syscall('vfork', __vfork) try: ql.run() except Exception as e: print(e) finally: #### Added by eShard err = reven_unicorn.free(rvn_handle) if err != reven_unicorn.Error.OK: print(f"Error when freeing the handle: {err}") sys.exit(1) ####

Simply following the guilines on how to use Reven-unicorn we were able to record the states of the registers throuhought the entire emulation. We were "only" missing the memory record.

It turns out, esReverse itself is pretty handy to debug our recording. Using the time travel GUI, we figured out the following: When a looking at a memory address in esReverse GUI, if it contains ??, that means that memory region is not mapped.

Remember that we cannot map memory during the tracing, so we needed to understand the memory layout of the Qiling emulation before the emulation happens. Also, remember that Qiling takes a configuration file as argument? Well, that describes the memory layout for the emulation (almost entirely). We leverage that to map the memory prior recording in reven_unicorn:

stack_addr = int(ql.profile.get("OS32", "stack_address"), 16) stack_size = int(ql.profile.get("OS32", "stack_size"), 16) load_addr = int(ql.profile.get("OS32", "load_address"), 16) interp_addr = int(ql.profile.get("OS32", "interp_address"), 16) # mmaps err = reven_unicorn.mem_map(rvn_handle, stack_addr, stack_size, True) err = reven_unicorn.mem_map(rvn_handle, load_addr, 0x1000000, True) err = reven_unicorn.mem_map(rvn_handle, interp_addr, 0x100000, True) err = reven_unicorn.mem_map(rvn_handle, mmap_addr, 0x1000000, True)

Because we don't know exactly how much memory will be used at each adress we simply map a large region, except for the stack size that is given to us by Qiling.

Now, when inspecting the recording in esReverse GUI, the memory is mapped and turns from ?? to 00. Which means that the memory simply was never written to (in the recording).

Because Qiling is an analysis framework, not only does it emulate the system, it also offers a wide range of analysis possibilities. Amongs which, mem_write

mem_write.png

This hook is exactly what we need to register all the writes during the emulation of httpd (or is it?). In order to leverage this capability, we simply register our callback to be called at every memory writes. In that callback we call reven_unicorn.mem_write(), which is the reven_unicorn function to trace a memory write.

def callback_reven_write(ql, access, offset: int, size: int, value: int): err = reven_unicorn.mem_write(rvn_handle, offset, struct.pack("<I", value)) def setup_reven(ql, binary): global rvn_handle global block_hook rvn_handle = reven_unicorn.alloc(ql.uc) ql.hook_mem_write(callback_reven_write)

Note that we pack the value to handle endianness as our emulation is in little endian (you may have to adapt that to suite your exact usecase).

 

That's it right?

Well... We have most of the emulation figured out but we are missing a small part: the code ????

nobin_load.png

As we can see on that view. The content of the stack on the memory view is correct, we have our input buffer thanks to our memory callback, we could even see the history of access of an address in the memory view. However, the disassembly isn't there.

 

Loading the libraries

We leveraged the debugging output of Qiling to understand what was going on. At this stage, we know we are missing some writes in the memory, in particular, the loading of the code in memory.

 

Qiling logs
[+] 0x047bc7c0: munmap(addr = 0x90001000, length = 0x1000) = 0x0 [+] Received interrupt: 0x2 [+] open("/lib/libvos_util.so", 0x0, 00) = 3 [+] 0x047bbd64: open(filename = 0x7ff3c308, flags = 0x0, mode = 0x0) = 0x3 [+] Received interrupt: 0x2 [+] 0x047bbda8: fstat(fd = 0x3, buf_ptr = 0x7ff3c2a0) = 0x0 [+] Received interrupt: 0x2 [+] 0x047bbe78: mmap2(addr = 0x0, length = 0x1000, prot = 0x3, flags = 0x4000022, fd = 0xffffffff, pgoffset = 0x0) = 0x90001000 [+] Received interrupt: 0x2 [+] read() CONTENT: b'\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00(\x00\x01\x00\x00\x00@\x0c\x00\x004\x00\x00\x00\xb0"\x00\x00\x02\x00\x00\x054\x00 ... \x00\x04\x00\x15\x00\x00\x08\x01\x00\x00\x12\x00\x08\x00\xaa\x01\x00\x00\x\x00\x00\x004\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00\x00\x00\xdd\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00\x00\x00\x00_init\x00_fini\x00__cxa_finalize\x00__deregister_frame_info\x00__register_frame_info\x00_Jv_RegisterClasses\x00vos_strcmp\x00vos_strcasecmp\x00vos_strncmp\x00vos_strncasecmp\x00vos_strstr\x00vos_strcpy\x00vos_strncpy\x00vos_strlen\x00vos_str_trim\x00vos_malloc\x00vos_free\x00vos_calloc\x00vos_memcmp\x00vos_memset\x00vos_memchr\x00vos_get_value_from_file\x00fopen\x00perror\x00strtok\x00fgets\x00fclose\x00vos_compare_with_wan_ip\x00inet_addr\x00ntohl\x00ipmask_to_numeric\x00ethaddr_aton\x00strtol\x00ethaddr_ntoa\x00sprintf\x00vos_get_mac_address_by_ipstr\x00sscanf\x00vos_get_mac_address_by_ip\x00vos_cale_time_active\x00localtime\x00atoi\x00vos_numbers_and_dot_ip_valid\x00puts\x00libc.so.0\x00_edata\x00__bss_start\x00__bss_start__\x00__bss_end__\x00__end__\x00_end\x00\x00\x00\x00\xa0\x00\x00\x17\x00\x00\x00\x04\xa0\x00\x00\x17\x00\x00\x00| ...\xa1\x00\x00\x17\x00\x00\x00l\xa1\x00\x00\x15\x10\x00\x00p\xa1\x00\x00\x15 \x00\xea' [+] 0x047bbef8: read(fd = 0x3, buf = 0x90001000, length = 0x1000) = 0x1000 [+] Received interrupt: 0x2 [+] 0x047bc150: mmap2(addr = 0x0, length = 0xb000, prot = 0x0, flags = 0x22, fd = 0xffffffff, pgoffset = 0x0) = 0x90075000 [+] Received interrupt: 0x2 [+] mmap2: unmapping memory between 0x90075000-0x90077000 to make room for fixed mapping [+] 0x047bc4a4: mmap2(addr = 0x90075000, length = 0x1fd4, prot = 0x5, flags = 0x12, fd = 0x3, pgoffset = 0x0) = 0x90075000 [+] Received interrupt: 0x2 [+] mmap2: unmapping memory between 0x9007f000-0x90080000 to make room for fixed mapping [+] 0x047bc324: mmap2(addr = 0x9007f000, length = 0x180, prot = 0x3, flags = 0x12, fd = 0x3, pgoffset = 0x2) = 0x9007f000 [+] Received interrupt: 0x2 [+] close(3) = 0 [+] 0x047bc680: close(fd = 0x3) = 0x0 [+] Received interrupt: 0x2 [+] 0x047bc7c0: munmap(addr = 0x90001000, length = 0x1000) = 0x0 [+] Received interrupt: 0x2 [+] open("/lib/libz.so", 0x0, 00) = 3 [+] 0x047bbd64: open(filename = 0x7ff3c2f8, flags = 0x0, mode = 0x0) = 0x3 [+] Received interrupt: 0x2 [+] 0x047bbda8: fstat(fd = 0x3, buf_ptr = 0x7ff3c290) = 0x0 [+] Received interrupt: 0x2 [+] 0x047bbe78: mmap2(addr = 0x0, length = 0x1000, prot = 0x3, flags = 0x4000022, fd = 0xffffffff, pgoffset = 0x0) = 0x90001000 [+] Received interrupt: 0x2

In these logs we can see multiple syscalls. Let us summarise this interaction:

  • open("/lib/libvos_util.so", 0x0, 00) = 3
  • maps the memory in Qiling emulation to load the binary. all the mmap2 with the parameter fd=0xffffffff
  • loads the library (fd)=0x3 using mmap2 at 0x90075000

We need to find a way to intercept these mmap2 calls. Not the ones mapping memory, but the ones that are writing in memory (with a fd != -1 or 0xffffffff you choose)

So we simply leverage Qiling API (once again) to hook every mmap2 syscalls.

def intercept_mmap2(ql: Qiling, addr, length, prot, flags, fd, offset, retval): global rvn_handle if fd != 0xffffffff and retval != -1 and flags != 0x22: value = ql.mem.read(addr, length) err = reven_unicorn.mem_write(rvn_handle, addr, bytes(value)) return retval def setup_reven(ql, binary): ... ql.os.set_syscall('mmap2', intercept_mmap2, QL_INTERCEPT.EXIT)

In our case this is the only syscall performing a write in memory that is not passed to the mem_write callback. In another example, you may encounter another syscall that perform a similar operation. This is why it is important to understand the thought process behind the interfacing of the two framework.

Remember, in the esReverse GUI : ?? means unmapped and 00 at a place of interest where data is supposed to be probably means you are missing a write in memory.

 

That's it right???

Well.... Not really!

We are actually now seeing this:

before_binsload.png

 

And that: noloader.png

In the first picture we can see that we are still missing our target binary disassembly. As a matter of fact, its memory adress is not even mapped yet! The second picture shows the lack of disassembly on the filesystem's interpreter, however, this time the memory adress is mapped in esReverse.

It turns out that Qiling will never give you a hook when loading these files because it is not part of the emulation from a Qiling point of view. Qiling will "manually" load these in memory, starting with the binary.

In order to load the binary, we leverage the file kernel.py chipped with reven-unicorn that contains a Binary class. This class contains a load_in_memory function, which is almost the same implementation as the binary loading function of Qiling based on the library elftools. This allows you to parse an ELF binary and load it in memory, in our case, we modified it so it does not load it in the actual unicorn memory but rather puts it in the trace using reven_unicorn.mem_map and reven_unicorn.mem_write.

If you followed correctly you may remember that we cannot mem_map once the tracing has started, this is why we need to map and write the binary as well as the interpreter before the recording starts.

file kernel.py class Binary def load_in_memory(self, uc: unicorn.Uc, rvn_handle: int, base_addr: int=0) -> None: for segment in self._binary.iter_segments(): if segment.header.p_type == "PT_LOAD": segment_addr = base_addr + segment.header.p_vaddr - (segment.header.p_vaddr % self._arch.page_size) segment_pages = math.ceil( (segment.header.p_vaddr - segment_addr + segment.header.p_memsz) / self._arch.page_size ) segment_size = segment_pages * self._arch.page_size print(f" - Segment - Address: 0x{segment_addr:x} - Size: 0x{segment_size:x}") err = reven_unicorn.mem_map(rvn_handle, segment_addr, segment_size, False) err = reven_unicorn.mem_write(rvn_handle, segment_addr, segment.data()) if err != reven_unicorn.Error.OK: print(f"Error when mapping a segment: {err}") sys.exit(1) file emu_script.py def setup_reven(ql, binary): global rvn_handle global block_hook rvn_handle = reven_unicorn.alloc(ql.uc) binary = kernel.Binary(arch, binary) loader = kernel.Binary(arch, './user_light_fs/lib/ld-uClibc.so.0') ... # print(f"Entry point: {binary.entrypoint}") binary.load_in_memory(ql.uc, rvn_handle) loader.load_in_memory(ql.uc, rvn_handle, base_addr=interp_addr)

fini.png

Now we have our instrumentation completed and we can analyse that vulnerability. Read the part 2 here.

esReverse Release-02.png

Share

Categories

All articles
(103)
Binary Analysis
(57)
Chip Security
(40)
Corporate News
(15)
Expert Review
(6)
Time Travel Analysis
(13)

you might also be interested in

Time Travel Analysis

Time Travel Analysis with QEMU on IoT Targets: Not Always That Hard - Part I

15 min read
Edit by Guillaume Vinet • Jul 8, 2025
CopyRights eShard 2025.
All rights reserved
Privacy policy | Legal Notice
CHIP SECURITY
esDynamicExpertise ModulesInfraestructureLab Equipments