Implémentation des Forward DNS sous ElasticSearch

La phase d’énumération DNS, que ce soit pour un pentest externe ou dans le cadre d’un programme de bug bounty, est extrêmement importante puisqu’elle va nous permettre de découvrir de nouveaux sous domaines et donc d’élargir notre surface d’attaque.

Généralement pour énumérer des sous domaines on va utiliser des outils tels que Sublist3r qui va requêter plusieurs applications/API et utiliser quelques Google Dorks. Ensuite on pourra tenter de bruteforce des entrées DNS à l’aide d’un dictionnaire et espérer récolter quelques sous domaines supplémentaires. Ça c’est ce que je faisais jusqu’à ce que je découvre les FDNS !

Les FDNS sont en fait une énorme base de données contenant l’ensemble des entrées DNS détectées par le Projet Sonar (projet mené par la team Rapid7). Voici un extrait de ces FDNS :

fdns1.png

Tous les mois plusieurs archives (une par type d’entrées DNS) sont publiées sur leur page web. Les plus intéressantes pour nous sont bien évidemment les archives A, AAAA et CNAME et pèsent, en moyenne, entre 2Go et 25Go (l’archive consacrée aux entrées de type A contient généralement un peu plus de 3 milliards de lignes).

Une fois les archives décompressées on pourra les parcourir ligne par ligne tout en greppant notre nom de domaine afin de récupérer des sous domaines.

Le souci c’est que l’opération va durer très longtemps (vraiment très longtemps). Du coup pour réduire le temps de traitement on va utiliser un outil vraiment sympas : ElasticSearch ! Tout au long de cet article nous verrons sommairement comment fonctionne ElasticSearch et surtout comment stocker nos entrées de manière à pouvoir effectuer des recherches de sous domaines extrêmement rapidement !

NOTE : pour cet article j’ai utilisé un disque dur SSD de 500Go de la marque Crucial que vous pourrez acheter ici. Je n’ai pas effectué de benchmark sur des disques durs classiques donc je vous conseille vivement d’utiliser un SSD.

I/ ElasticSearch ?

ElasticSearch c’est tout simplement un moteur de recherche qui repose sur une base de données NoSQL ainsi qu’une API RESTful. Cet outil est utilisé lorsque l’on doit stocker une grosse quantité de données au format texte (ce qui est notre cas).

Souvent lorsque l’on parle d’Elasticearch on entend aussi parler d’outils tels que Kibana ou encore Logstach. Ces trois outils forment la suite ELK (ElasticSearch, Logstash, Kibana) qui permet de collecter les logs (via Logstash), stocker les logs (ElasticSearch) et les analyser visuellement (Kibana).

Pour cet article je vais utiliser la version 6.8 d’ElasticSearch que vous pourrez installer en entrant ces commandes :

wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.8.3.deb
sudo dpkg -i elasticsearch-6.8.3.deb

Une fois l’installation faites il faudra activer le service elasticsearch en entrant cette commande :

sudo systemctl start elasticsearch

Si tout s’est bien déroulé en exécutant un curl sur le port 9200 de votre machine vous obtiendrez ceci :

fdns2.png

Si c’est le cas ggwp, on va pouvoir commencer à jouer avec   !

II/ Introduction au fonctionnement d’ElasticSearch

ElasticSearch repose sur le principe d’index (l’équivalent d’une base en SQL classique). Chaque index est composé de plusieurs types (l’équivalent des tables) et contient des documents (l’équivalent des lignes stockées dans une base de données classiques) qui respectent un schéma prédéfini que l’on appelle le mapping.

Avant de traiter des données il faut d’abord les importer dans ElasticSearch. L’import de données se fera toujours pas le biais d’un fichier Json et ça tombe bien puisque comme nous l’avons vu plus haut, les FDNS sont stockées au format Json :

fdns3.png

Sur l’extract des FDNS ci-dessus on peut voir que nous avons 10 documents (10 lignes contenant des données) qui respectent toujours le même mapping puisque chaque document est composé de quatre types de valeurs : les « timestamp », les « name » (les domaines), les « type » (type d’entrées DNS) et les « value » l’adresse IP liée au nom de domaine.

Par défaut sous ElasticSearch il n’y a aucun index. Pour créer un index c’est simple, il suffit d’importer des données et pour cela nous allons nous servir de l’API Bulk qui va nous permettre d’ajouter/supprimer de grosses quantités de données en très peu de temps ! L’API Bulk est requêtable sur le endpoint suivant :

http://localhost:9200/_bulk

Tout ce qu’il faut faire c’est lui fournir un fichier Json qui respecte le format suivant :

# coding: utf-8

import gc
import json
import time
import argparse
from elasticsearch import helpers
from elasticsearch import Elasticsearch

parser = argparse.ArgumentParser() 
parser.add_argument("-i", help = "Json input file", dest = "fpath", required = "True")
parser.add_argument("-n", help = "Name of the index", nargs = "?", dest = "index_name", required = "True")
args = parser.parse_args()

print("Starting to fill in the index")
# Default value 
type_name = "_doc"

# Creating the ElasticSearch object
es = Elasticsearch()
# Deleting the index if it already exists
es.indices.delete(index = index_name, ignore = [400, 404])

# Creating the index
es.indices.create(index = index_name)
input_file = open(args.fpath, "r")

while("While we haven't read all lines of the FDNS Json file"):
	
	entries=[]
	MLines=[]

	# Treating 10 millions of documents at each iteration
	for x in range(10**6):
		try:
			MLines.append(next(input_file))
		except:
			# FDNS Json file was totaly parsed
			break

	# If Mlines is empty then the FDNS Json file was totaly parsed
	if(MLines == []):
		break

	for line in MLines:
		try:
			dictline = json.loads(line.split("\n")[0])
			sanitized = "{\"url\" :\"" + str(dictline["name"]) + "\"," + "\"ip\" : \"" + str(dictline["value"]) + "\"}"
			entry = {"_index": index_name,"_type": type_name,"_source": sanitized}
			print(entry)
			entries.append(entry)
		except Exception as e:
			# Debug purpose
			print(e)
		
	try:
		# Inserting the documents using the bulk endpoint
		s_bulk = helpers.streaming_bulk(es, actions = entries)
		for ok, response in s_bulk:
			if not ok:
				print(response)
	except Exception as e:
		# Debug purpose
		print(e)
	
	del entries
	del MLines
	gc.collect()

Pour le lancer il suffira de spécifier les options -i (nom du fichier Json à importer) et -n (nom de l’index à utiliser) :

python3 main.py -i fdns_extract.json -n dns

Et voici l’output :

Voilà ! Nous venons d’importer nos 10 documents. Nous n’aurons plus qu’à utiliser cette commande afin de lister le contenu de notre index (ici mon index s’appelle dns, à vous de modifier le champ en fonction du nom de votre index) :

curl -H 'Content-Type: application/json' -X GET http://localhost:9200/dns/_doc/_search\?pretty

Revenons un peu sur le endpoint _search. Comme vous l’avez vu plus haut c’est lui qui va nous permettre de requêter notre index afin d’en extraire les données. Ce endpoint, on peut l’utiliser de plusieurs manières et avec plusieurs paramètres. Par exemple si vous voulez afficher l’intégralité de votre index au format Json il faudra utiliser le mot clé ?pretty :

curl http://localhost:9200/dns/_doc/_search?pretty

Vous pourrez aussi faire des recherches via mots clés en utilisant le paramètre q :

curl http://localhost:9200/dns/_doc/_search?q=myvzw

Ce qui aura pour résultat de nous sortir tous les documents qui contiennent le mot « myvzw »:

Enfin vous pourrez bien évidemment filtrer en fonction des types de votre index. Par exemple on pourra demander à ElasticSearch de ne sortir que les documents qui contiennent la valeur 97.240.190.0 pour le type ip. Pour cela on se servira du mot clé « match » :

curl http://localhost:9200/dns/_search -d '{"query" : {"match" : { "ip" : "97.240.190.0" }}}' -H 'Content-Type: application/json'

Il y a littéralement une tonnnnnne de manière de requêter ElasticSearch mais je ne les présenterai pas d’une parce que suis loin de maîtriser cet outil et de deux parce que ça serait bien trop long (vous trouverez suffisamment de documentation un peu partout sur Internet  ! Le seul mot clé dont il faudra se souvenir c’est « match_phrase » (retenez le bien) !

Maintenant qu’on sait importer des données dans ElasticSearch et les requêter, il va être temps d’importer nos FDNS ! Pour ma part j’ai stocké mon index sur un SSD. Pour que cela soit possible il faut modifier le fichier de configuration d’elasticsearch :

sudo vim /etc/elasticsearch/elasticsearch.yml

Et modifier cette ligne :

path.data: /var/lib/elasticsearc

En :

path.data: /repertoire/de/votre/choix

Ensuite il faudra monter votre disque sur ce répertoire :

sudo mount /dev/sdxx /repertoire/de/votre/choix

Puis attribuer les droits à l’utilisateur elasticsearch :

sudo chmod -R elasticsearch:elasticsearch /repertoire/de/votre/choix

Enfin il faudra redémarrer le service elasticsearch :

sudo systemctl restart elasticsearch

Une fois tout cela fait nous allons pouvoir importer nos documents en utilisant ce script :

# coding: utf-8

import re
import gc
import json
import time
import gzip
import argparse
from elasticsearch import helpers
from elasticsearch import Elasticsearch

parser = argparse.ArgumentParser() 
parser.add_argument("-i", help = "Json input file", dest = "fpath", required = "True")
parser.add_argument("-n", help = "Name of the index", nargs = "?", dest = "index_name", required = "True")
args = parser.parse_args()

index_name = args.index_name
print("Starting to fill in the index")
type_name = "_doc"

# Creating the ElasticSearch object
es = Elasticsearch()
# Deleting the index if it already exists
es.indices.delete(index = index_name, ignore = [400, 404])

# Setting the mapping of the index
settings = {"settings" : {"number_of_shards" : 2,"number_of_replicas" : 0,"analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "url_tokenizer"
        }
      },
      "tokenizer": {
        "url_tokenizer": {
          "type": "simple_pattern_split",
          "pattern": "\\."
        }
      }
    }},"mappings" : {"_doc":{
        "properties" : {
            "ip" : { "type" : "ip"},"url":{"type":"text","analyzer":"my_analyzer"}
        }
    }}}

# Creating the index
es.indices.create(index = index_name, body = settings)
input_file = gzip.open(args.fpath, "rb")

nbr_iteration = 1
while("While we haven't read all the Json file"):
	# Treating 1 millions lines at the time
	entries = []
	MLines = []
	# Appending 1000000 lines
	for x in range(1000000):
		try:
			MLines.append(next(input_file))
		except:
			# do while break style
			break
	
	if(MLines==[]):
		break

	for line in MLines:
		try:
			dictline = json.loads(line)
			sanitized="{\"url\" :\"" + dictline["name"] + "\"," + "\"ip\" : \"" + dictline["value"] + "\"}"
			# Filtering out invalid domain name/IP's
			if(re.search("\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}",dictline["name"]) is None and re.search("^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$",dictline["value"]) is not None):
				entries.append(json.loads(sanitized))
		except:
			pass
	
	def gen():
		for elt in entries:
			yield {"_index": index_name,"_type": type_name,"_source": elt}
	
	try:
		s_bulk = helpers.streaming_bulk(es, actions = gen())
		for ok, response in s_bulk:
			if not ok:
				print(response)
	except:
		print("streaming_bulk error")
	
	print("Iteration {0}".format(nbr_iteration))
	nbr_iteration += 1
	del entries
	del MLines
	# Used to collect unused RAM
	gc.collect()

print("Parsing done !")

Je vous laisse lire le code pour comprendre ce qu’il fait. Globalement en plus d’importer les données il va aussi filtrer les entrées afin de supprimer celles qui ne sont pas intéressantes (les FQDN par exemple) et mieux gérer la mémoire en utilisant un generator (si vous ne savez pas ce que c’est je vous invite à lire ce superbe article).

Voilà ! Il ne reste plus qu’à attendre qu’ElasticSearch se remplisse ce qui devrait durer environ une journée et demi (eh oui je vous rappelle qu’il y a 3 milliards de lignes à traiter !)

Une fois l’import terminé il faudra modifier légérement le fonctionnement de notre cluster afin d’augmenter la taille des données à traiter via cette requête curl :

curl -X PUT "localhost:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
 {
     "transient" : {
         "indices.breaker.total.limit" : "80%"
     }
 }
 '

Pour finir je vous fournis aussi le script permettant de requêter ElasticSearch :

#!/usr/bin/python3
# -*- coding: utf-8 -*-

from elasticsearch import Elasticsearch
from elasticsearch import helpers
import argparse

parser = argparse.ArgumentParser(description = 'ElasticSearch_DNS_parser')
parser.add_argument('-d', type = str, help = 'Domain name to look for', dest = "domain")
parser.add_argument('-n', type = str, help = 'Name of the index', dest = "index")
args = parser.parse_args()

index_name = args.index
type_name = "_doc"
size = "10000"

es = Elasticsearch()

def match(field, value):
	return es.search(index = index_name, body = {"size" : size, "query" : {"match_phrase" : { field : value }}})["hits"]["hits"]

def domains(pattern):
	mhost = match("url", pattern)
	return list(set([(m["_source"]["url"],m["_source"]["ip"]) for m in mhost]))

results = domains(args.domain)
for r in results:
	print(r[0])

Comme vous pouvez le voir j’utilise bel et bien le mot clé match_phrase qui va nous permettre de lister seulement les entrées qui contiennent notre domaine de recherche. Pour l’utiliser il suffira d’entrer cette commande :

python3 query.py -n {INDEX} -k domain.tld

Et vous obtiendrez cet output (pour le nom de domaine whiteflag.blog) :

Voilà ! Vous pouvez dorénavant consulter les FDNS et ce extrêmement rapidement !