Et si on recodait un collecteur AD en pur Bash ?
BashHound & BashHound-CE : un collecteur Active Directory pour BloodHound en Bash pur. Protocole LDAP, encodage ASN.1, parsing des Security Descriptors et export JSON v5/v6.
La genèse du projet
J'ai toujours eu un attachement particulier pour Bash. Pas par obligation, par curiosité. Ce langage qu'on résume souvent à quelques ls et grep cache en réalité une profondeur que peu de gens explorent. Écrire des scripts qui font des choses qu'on pense impossibles en shell, c'est ce qui me donne envie de coder.
L'idée de BashHound est née en regardant une vidéo de la chaîne You Suck at Programming : "Pure bash — No commands, just bash". La vidéo montre jusqu'où on peut pousser Bash : implémenter des fonctionnalités réseau, manipuler du binaire, encoder des données, sans invoquer un seul outil externe. C'était exactement le genre de défi technique un peu fou que j'adore.
Je me suis alors posé la question : est-ce qu'on peut écrire un collecteur BloodHound fonctionnel en Bash pur ? Pas avec ldapsearch, pas avec python-ldap, pas avec un binaire compilé. Juste Bash, les descripteurs de fichiers, et xxd pour convertir du binaire.
La toute première ligne de code écrite a été celle-ci :
bashhound — commit initial
# Voici la première ligne de code de BashHound mother fuck**Et c'est là que le projet s'est arrêté. Parce que comme souvent avant de me lancer dans quelque chose, j'avais demandé à Claude ce qu'il en pensait : faisabilité, complexité, ressources nécessaires. La réponse avait été claire : ce n'était pas vraiment possible. Les arguments avancés :
- Bash ne sait pas manipuler du binaire : le protocole LDAP repose sur ASN.1 BER, un format binaire que les outils shell ne peuvent pas encoder/décoder nativement.
- Pas de gestion TLS native : LDAPS nécessite une couche TLS que Bash est incapable de négocier sans appeler OpenSSL, rendant l'approche "pure bash" impossible.
- Les sockets réseau en Bash sont trop limités :
/dev/tcpn'est pas fiable pour des protocoles aussi complexes qu'LDAP, avec ses réponses multi-paquets et ses longueurs variables. - La complexité des Security Descriptors Windows : parser des ACLs binaires en Bash serait ingérable, trop de structures imbriquées, trop d'edge cases.
- Les performances seraient rédhibitoires : chaque opération de parsing lancerait des dizaines de sous-processus, rendant l'outil inutilisable sur un domaine réel.
Bref, j'ai laissé tomber. Puis la vidéo est passée. Et j'ai recommencé. Cette fois sans demander la permission.
Ce n'est pas l'outil le plus rapide ni le plus complet, et c'est assumé. C'est avant tout un projet pour apprendre, pour comprendre en profondeur le protocole LDAP, l'encodage ASN.1 et les structures internes d'Active Directory. Et pour prouver, à Claude autant qu'à moi-même, que Bash peut aller beaucoup plus loin qu'on ne le pense.
Sommaire
- Contexte — BloodHound et ses collecteurs
- Les collecteurs existants
- Architecture globale du projet
- Contraintes et choix de design
- lib/asn1.sh — Encodage ASN.1 en Bash
- lib/ldap.sh — Protocole LDAP en Bash pur
- lib/ldap_parser.sh — Parsing des réponses LDAP
- lib/acl_parser.sh — Security Descriptors et ACEs
- lib/collectors.sh — Collecte des objets AD
- lib/export.sh / export_ce.sh — Export BloodHound JSON
- BashHound vs BashHound-CE — Format v5 vs v6, AD CS
- Benchmark — RustHound-CE vs BashHound-CE
- Limites connues
- Ce que ça m'a appris
- Conclusion
1. Contexte — BloodHound et ses collecteurs
Si t'as déjà fait de la pentest AD, tu connais BloodHound. L'outil qui transforme un annuaire LDAP en graphe de chemins d'attaque. Tu lui donnes des données, il te dit comment passer d'un compte lambda jusqu'aux Domain Admins. C'est devenu un standard dans les audits AD.
Mais BloodHound ne collecte rien tout seul. Il a besoin d'un collecteur qui va interroger le LDAP, extraire les objets, parser les ACLs, et tout sérialiser dans un format JSON précis. C'est ce bout-là qu'on réimplémente ici.
Petite précision : il existe deux versions de BloodHound, et elles ne parlent pas le même format.
- BloodHound legacy (v4/v5) : la version originale, basée sur Electron. Son format JSON est dit v5. Maintenu par la communauté mais plus activement développé par SpecterOps.
- BloodHound Community Edition (CE) : la réécriture complète par SpecterOps, avec une API REST, un backend Go et un frontend React. Son format JSON est v6, avec des schémas enrichis (nodes, edges, AD CS…).
BashHound cible le format v5 (BloodHound legacy), BashHound-CE cible le format v6 (BloodHound CE).
2. Les collecteurs existants
Avant de se lancer, un tour d'horizon de ce qui existe :
| Outil | Langage | Auteur(s) |
|---|---|---|
| SharpHound | C# (.NET) | SpecterOps |
| RustHound | Rust | OPENCYBER-FR |
| BloodHound.py | Python 3 | fox-it |
| RustHound-CE | Rust | g0h4n |
| BloodHound-CE-Python | Python 3 | dirkjanm |
3. Architecture globale du projet
L'architecture est classique pour un projet bash un peu sérieux : chaque lib a sa responsabilité, le script principal orchestre le tout. Ça ressemble à ça :
Structure BashHound-CE
BashHound-CE/
├── bashhound-ce # Point d'entrée principal (567 lignes)
└── lib/
├── asn1.sh # Encodage/décodage ASN.1 (330 lignes)
├── ldap.sh # Protocole LDAP en Bash pur (629 lignes)
├── ldap_parser.sh # Parsing des réponses LDAP (938 lignes)
├── acl_parser.sh # Parsing des Security Descriptors (558 lignes)
├── collectors.sh # Collecte des objets AD (1023 lignes)
└── export_ce.sh # Export JSON BloodHound CE v6 (2423 lignes)Structure BashHound (legacy)
BashHound/
├── bashhound # Point d'entrée principal (380 lignes)
└── lib/
├── asn1.sh # Encodage/décodage ASN.1 (247 lignes)
├── ldap.sh # Protocole LDAP en Bash pur (521 lignes)
├── ldap_parser.sh # Parsing des réponses LDAP (521 lignes)
├── acl_parser.sh # Parsing des Security Descriptors (388 lignes)
├── collectors.sh # Collecte des objets AD (516 lignes)
└── export.sh # Export JSON BloodHound v5 (1207 lignes)Le flux de données suit un pipeline précis :
Pipeline de traitement
LDAP Query (ASN.1 encodé)
↓
Réponse binaire LDAP (hex via xxd)
↓
ldap_parser.sh → Extraction des attributs (DN, SID, UAC, ACLs…)
↓
acl_parser.sh → Parsing Security Descriptors → ACEs
↓
collectors.sh → Écriture dans fichiers temporaires /tmp/bashhound_*_$$
↓
export.sh / export_ce.sh → Génération JSON BloodHound
↓
ZIP → Import dans BloodHoundLes données sont stockées temporairement dans des fichiers /tmp/bashhound_*_$$ (le $$ étant le PID du processus, pour éviter les collisions en cas d'exécutions parallèles). La phase d'export lit ces fichiers et génère les JSON finaux.
4. Contraintes et choix de design
Avant de plonger dans le code, quelques règles que je me suis fixées dès le départ.
Ce qui est autorisé
- /dev/tcp : c'est un built-in Bash, pas une commande externe. Ouvrir un socket TCP via
/dev/tcp/host/portne lance aucun processus fils. - xxd : une commande externe, mais utilisée uniquement pour convertir du binaire brut en hex et inversement. Bash ne sait pas manipuler des octets nuls dans des variables, donc
xxd -pest le seul moyen réaliste de travailler avec des réponses LDAP binaires. - openssl s_client : nécessaire uniquement pour LDAPS. Bash ne peut pas négocier TLS tout seul,
/dev/tcpne gère que du TCP brut. LDAP plain (port 389) reste en/dev/tcppur. - jq : utilisé uniquement en phase d'export pour sérialiser proprement le JSON final. Pas dans le parsing LDAP.
Pourquoi tout en hex
Bash traite les variables comme des chaînes de caractères. Un octet nul 0x00 dans une variable Bash la tronque. Les réponses LDAP étant du binaire pur, la seule représentation manipulable en Bash c'est la chaîne hex. Toute la pipeline interne travaille donc en hex, le binaire n'apparaissant qu'au moment d'envoyer sur le socket.
Ce qui est hors périmètre
- Authentification Kerberos (GSSAPI) : non implémentée, uniquement LDAP simple bind
- Paging LDAP (contrôle 1.2.840.113556.1.4.319) : pas de pagination, la limite de taille est désactivée côté serveur
- LDAP referrals : ignorés
- Forêts multi-domaines : collecte d'un seul domaine à la fois
5. lib/asn1.sh — Encodage ASN.1 en Bash
C'est là que ça commence à être vraiment fun. LDAP parle en binaire, et ce binaire c'est de l'ASN.1 BER. Chaque message LDAP est une structure sérialisée en octets, format TLV, spécifié dans la RFC 4511. Pour envoyer quoi que ce soit au DC depuis Bash, faut encoder ça à la main, sans lib externe, sans aide. Juste des chaînes hex et des opérations arithmétiques.
L'encodage BER
En LDAP, les messages sont encodés en BER (Basic Encoding Rules) d'ASN.1, sous une forme de type TLV : Tag, Length, Value. Chaque valeur est précédée de son type et de sa taille. La longueur peut s'encoder sur plusieurs octets dès qu'elle dépasse 127. En pratique les tags LDAP courants tiennent souvent sur un octet, mais BER permet aussi des tags multi-octets. LDAP utilise également des tags context-specific ASN.1, dont la signification dépend de la structure du message définie par le protocole.
Encodage BER — TLV
[ Tag (souvent 1 octet) ] [ Length (1 à N octets) ] [ Value (N octets) ]
Exemple : INTEGER 3
02 ← Tag INTEGER
01 ← Length : 1 octet
03 ← Value : 3
Exemple : OCTET STRING "CN=admin"
04 ← Tag OCTET_STRING
08 ← Length : 8 octets
434e3d61646d696e ← Value : "CN=admin" en hex ASCIILes tags définis dans asn1.sh
On commence par déclarer toutes les constantes : types ASN.1 universels et tags spécifiques aux messages LDAP. C'est la base sur laquelle tout le reste s'appuie.
lib/asn1.sh — Constantes
# Types ASN.1 universels
readonly ASN1_BOOLEAN=0x01
readonly ASN1_INTEGER=0x02
readonly ASN1_OCTET_STRING=0x04
readonly ASN1_NULL=0x05
readonly ASN1_ENUMERATED=0x0a
readonly ASN1_SEQUENCE=0x30 # SEQUENCE (même tag BER que SEQUENCE OF)
readonly ASN1_SET=0x31
# Messages LDAP (Application class, constructed)
readonly LDAP_BIND_REQUEST=0x60
readonly LDAP_BIND_RESPONSE=0x61
readonly LDAP_UNBIND_REQUEST=0x42
readonly LDAP_SEARCH_REQUEST=0x63
readonly LDAP_SEARCH_RESULT_ENTRY=0x64
readonly LDAP_SEARCH_RESULT_DONE=0x65
readonly LDAP_MODIFY_REQUEST=0x66
readonly LDAP_MODIFY_RESPONSE=0x67
# Tags contextuels LDAP (Context-specific)
readonly LDAP_CONTEXT_0=0x80 # simpleAuth (mot de passe en clair)
readonly LDAP_CONTEXT_7=0x87 # present filter (attribut=*)Encodage de la longueur
L'encodage de la longueur m'a coûté un bon moment. Moins de 128 octets ? Un seul byte, direct. Au-delà, on passe en forme longue : un premier octet avec le bit 7 à 1 qui indique combien d'octets suivent pour encoder la vraie longueur. Simple en théorie, piège en pratique quand on manipule tout en hex.
lib/asn1.sh — asn1_encode_length()
asn1_encode_length() {
local length=$1
if [ "$length" -lt 128 ]; then
# Forme courte : longueur directe sur 1 octet
printf '%02x' "$length"
else
# Forme longue : 0x80|n_octets + n octets de longueur
local hex_length=$(printf '%x' "$length")
local num_octets=$((${#hex_length} / 2))
if [ $((${#hex_length} % 2)) -ne 0 ]; then
hex_length="0$hex_length"
((num_octets++))
fi
printf '%02x%s' $((0x80 | num_octets)) "$hex_length"
fi
}Exemple : pour encoder une longueur de 300 octets (0x12C) :
Exemple longueur 300
300 = 0x12C → nécessite 2 octets
Résultat : 82 012c
82 = 0x80 | 0x02 (forme longue, 2 octets suivent)
01 = octet de poids fort
2c = octet de poids faibleEncodage des types principaux
lib/asn1.sh — Encodage INTEGER
asn1_encode_integer() {
local value=$1
local hex_value=$(printf '%x' "$value")
# Padding à nombre pair d'octets
if [ $((${#hex_value} % 2)) -ne 0 ]; then
hex_value="0$hex_value"
fi
# Si le bit de poids fort est à 1, ajouter 0x00 (éviter interprétation signed)
local first_byte=$((0x${hex_value:0:2}))
if [ "$first_byte" -ge 128 ]; then
hex_value="00$hex_value"
fi
local length=$((${#hex_value} / 2))
printf '%02x' "$ASN1_INTEGER" # Tag 0x02
asn1_encode_length "$length"
printf '%s' "$hex_value"
}lib/asn1.sh — Encodage OCTET STRING
asn1_encode_octet_string() {
local string="$1"
# Conversion ASCII → hex avec xxd
local hex_string=$(printf '%s' "$string" | xxd -p | tr -d '
')
local length=$((${#hex_string} / 2))
printf '%02x' "$ASN1_OCTET_STRING" # Tag 0x04
asn1_encode_length "$length"
printf '%s' "$hex_string"
}Toutes les fonctions d'encodage retournent une string hex. C'est la représentation interne choisie pour tout BashHound. Le binaire brut n'apparaît qu'au dernier moment, quand on envoie sur le socket via xxd -r -p >&3. Ce choix simplifie énormément le debugging : on peut logger n'importe quel message LDAP en clair.
Encodage OID (Object Identifier)
Les OIDs servent notamment pour les contrôles LDAP, par exemple 1.2.840.113556.1.4.801 qui permet de lire les Security Descriptors complets. Leur encodage ASN.1 est un peu particulier : les deux premiers arcs sont fusionnés en un seul octet (40*arc0 + arc1), et les suivants passent en base 128 avec des continuation bits. Ça m'a pris un moment à comprendre. La doc RFC est pas franchement accueillante.
lib/asn1.sh — Encodage OID
asn1_encode_oid() {
local oid="$1"
IFS='.' read -ra parts <<< "$oid"
# Premier octet = 40 * arc0 + arc1
local first_byte=$(( 40 * ${parts[0]} + ${parts[1]} ))
local hex_result=$(printf '%02x' "$first_byte")
# Arcs suivants : encodage base-128 (MSB first, bit 7 = continuation)
for ((i=2; i<${#parts[@]}; i++)); do
local num=${parts[i]}
if [ $num -lt 128 ]; then
hex_result+=$(printf '%02x' "$num")
else
# Encodage multi-octets avec continuation bits
local bytes=()
while [ $num -gt 0 ]; do
bytes=($((num & 0x7f)) "${bytes[@]}")
num=$((num >> 7))
done
for ((j=0; j<${#bytes[@]}; j++)); do
local byte=${bytes[j]}
[ $j -lt $((${#bytes[@]} - 1)) ] && byte=$((byte | 0x80))
hex_result+=$(printf '%02x' "$byte")
done
fi
done
local length=$((${#hex_result} / 2))
printf '06' # Tag OID
asn1_encode_length "$length"
printf '%s' "$hex_result"
}6. lib/ldap.sh — Protocole LDAP en Bash pur
Une fois l'encodage ASN.1 en place, il faut mettre les mains dans le protocole réseau lui-même. LDAP c'est du TCP, du binaire, et des messages structurés selon la RFC 4511. En Bash, zéro lib disponible. On réimplémente tout : connexion, encodage des requêtes, lecture des réponses octet par octet.
Connexion TCP avec /dev/tcp
Bash a une feature que pas grand monde connaît : /dev/tcp/host/port. Ouvrir ce pseudo-fichier crée une connexion TCP vers le serveur, et on peut lire/écrire dessus comme n'importe quel fichier. On utilise le descripteur 3 comme socket bidirectionnel :
lib/ldap.sh — Connexion LDAP plain TCP
ldap_connect_plain() {
local host="$1"
local port="$2"
# Ouverture du socket TCP via /dev/tcp (Bash built-in)
exec 3<>"/dev/tcp/$host/$port"
LDAP_FD=3 # FD de lecture/écriture
LDAP_USE_TLS=false
echo "INFO: Connexion LDAP établie à $host:$port" >&2
return 0
}Pour écrire sur le socket : printf '%s' "$hex" | xxd -r -p >&3 convertit la chaîne hex en binaire et l'envoie. Pour lire : dd bs=1 count=N <&3 lit exactement N octets depuis le socket.
Connexion LDAPS (TLS) avec OpenSSL
LDAPS c'est LDAP sur TLS, port 636. Et là, problème : Bash ne sait pas négocier du TLS tout seul. La solution que j'ai trouvée c'est d'utiliser openssl s_client comme tunnel TLS, en lui passant les données via deux named pipes (FIFOs). C'est un peu tordu, mais ça marche.
lib/ldap.sh — Connexion LDAPS via OpenSSL
ldap_connect_tls() {
local host="$1"
local port="$2"
# Création de deux FIFOs pour le tunnel TLS
local fifo_in="/tmp/bashhound_ldaps_in_$$"
local fifo_out="/tmp/bashhound_ldaps_out_$$"
mkfifo "$fifo_in" "$fifo_out"
# openssl s_client en arrière-plan : lit fifo_in, écrit fifo_out
openssl s_client -quiet -connect "$host:$port" -ign_eof < "$fifo_in" > "$fifo_out" 2>/dev/null &
LDAP_OPENSSL_PID=$!
sleep 1 # Attendre l'établissement TLS
# FD 3 → écriture vers fifo_in (→ openssl → serveur)
# FD 4 → lecture depuis fifo_out (← openssl ← serveur)
exec 3>"$fifo_in"
exec 4<"$fifo_out"
LDAP_FD=3
LDAP_USE_TLS=true
}Le pattern est assez propre une fois qu'on l'a compris : openssl s_client gère tout le TLS de façon transparente. On écrit dans FD 3 → openssl chiffre → envoie au DC. Le DC répond → openssl déchiffre → on lit depuis FD 4. Côté BashHound, on voit même plus le TLS.
LDAP Bind — Authentification
Le Bind c'est l'authentification, le premier message qu'on envoie au DC pour dire qui on est. En mode simple (mot de passe en clair sur LDAP, encodé mais pas chiffré), la structure ASN.1 ressemble à ça :
Structure ASN.1 du BindRequest
LDAPMessage ::= SEQUENCE {
messageID INTEGER,
protocolOp BindRequest
}
BindRequest ::= [APPLICATION 0] SEQUENCE { -- tag 0x60
version INTEGER (3),
name OCTET STRING (DN),
authentication [0] OCTET STRING (password) -- tag 0x80
}lib/ldap.sh — ldap_bind()
ldap_bind() {
local dn="$1"
local password="$2"
local version="${3:-3}"
# Encodage de chaque champ
local version_encoded=$(asn1_encode_integer "$version") # 02 01 03
local dn_encoded=$(asn1_encode_octet_string "$dn") # 04 len DN
local pwd_encoded=$(asn1_encode_octet_string_with_tag 0x80 "$password") # 80 len pwd
# BindRequest = APPLICATION 0 SEQUENCE {version, dn, auth}
local bind_request="${version_encoded}${dn_encoded}${pwd_encoded}"
local bind_request_msg=$(asn1_encode_sequence_with_tag 0x60 "$bind_request")
# LDAPMessage = SEQUENCE {messageID, protocolOp}
local ldap_message=$(ldap_create_message "$LDAP_MESSAGE_ID" "$bind_request_msg")
((LDAP_MESSAGE_ID++))
ldap_send_message "$ldap_message"
local response=$(ldap_receive_message)
# Vérification du resultCode (0 = success)
if [[ "$response" =~ 0a0100 ]]; then
echo "INFO: Bind réussi (resultCode=0)" >&2
return 0
fi
}Pour le confort d'utilisation, BashHound accepte plusieurs formats : username simple (converti automatiquement en CN=user,CN=Users,DC=...), UPN (user@domain.local), ou DN complet si tu veux être précis.
LDAP Search
La Search c'est le cœur de tout. C'est ce qu'on envoie pour récupérer les objets AD. Son format ASN.1 est nettement plus complexe que le Bind, notamment à cause du filtre LDAP qui a son propre encodage :
Structure SearchRequest ASN.1
SearchRequest ::= [APPLICATION 3] SEQUENCE { -- tag 0x63
baseObject LDAP_DN,
scope ENUMERATED { baseObject(0), singleLevel(1), wholeSubtree(2) },
derefAliases ENUMERATED,
sizeLimit INTEGER (0 = illimité),
timeLimit INTEGER (0 = illimité),
typesOnly BOOLEAN (false),
filter Filter,
attributes AttributeDescriptionList
}lib/ldap.sh — ldap_search()
ldap_search() {
local base_dn="$1"
local scope="${2:-2}" # 2 = wholeSubtree
local filter="${3:-(&(objectClass=*))}"
local attributes="${4:-*}"
local use_sd_flags="${5:-false}"
# Encodage du SearchRequest
local base_encoded=$(asn1_encode_octet_string "$base_dn")
local scope_encoded=$(asn1_encode_enumerated "$scope")
local deref_encoded=$(asn1_encode_enumerated 0)
local size_limit_encoded=$(asn1_encode_integer 0)
local time_limit_encoded=$(asn1_encode_integer 0)
local types_only_encoded=$(asn1_encode_boolean false)
local filter_encoded=$(ldap_encode_filter "$filter")
local attrs_encoded=$(ldap_encode_attributes "$attributes")
# Contrôle SD_FLAGS si nTSecurityDescriptor demandé
# OID 1.2.840.113556.1.4.801 avec valeur 7 (OWNER|GROUP|DACL)
if [[ "$attributes" == *"nTSecurityDescriptor"* ]]; then
local oid_encoded=$(asn1_encode_octet_string "1.2.840.113556.1.4.801")
local flags_int=$(asn1_encode_integer 7)
local value_seq=$(asn1_encode_sequence "$flags_int")
local value_encoded=$(asn1_encode_octet_string_hex "$value_seq")
local control=$(asn1_encode_sequence "${oid_encoded}${value_encoded}")
controls=$(asn1_encode_sequence_with_tag 0xa0 "$control")
fi
local search_request="${base_encoded}${scope_encoded}${deref_encoded}${size_limit_encoded}${time_limit_encoded}${types_only_encoded}${filter_encoded}${attrs_encoded}"
local search_request_msg=$(asn1_encode_sequence_with_tag 0x63 "$search_request")
local ldap_message=$(ldap_create_message "$LDAP_MESSAGE_ID" "$search_request_msg" "$controls")
ldap_send_message "$ldap_message"
# Lecture des résultats jusqu'au SearchResultDone (tag 0x65)
local results=()
local done=false
while [ "$done" = false ]; do
local response=$(ldap_receive_message)
if [[ "$response" =~ 64 ]]; then # SearchResultEntry
results+=("$response")
elif [[ "$response" =~ 65 ]]; then # SearchResultDone
done=true
fi
done
printf '%s
' "${results[@]}"
}Le contrôle SD_FLAGS (OID 1.2.840.113556.1.4.801) est indispensable pour récupérer les ACLs. Sans lui, on ne récupère généralement pas toutes les composantes utiles du security descriptor. Avec la valeur 7 (OWNER + GROUP + DACL), on a ce qu'il faut pour le parsing complet.
Lecture des réponses LDAP
Lire une réponse LDAP c'est plus délicat que d'en envoyer une. On sait pas à l'avance combien d'octets vont arriver. Faut lire le header d'abord, déterminer la longueur, puis lire exactement le bon nombre d'octets. Rien de plus, rien de moins :
lib/ldap.sh — ldap_receive_message()
ldap_receive_message() {
local read_fd=3
[ "$LDAP_USE_TLS" = "true" ] && read_fd=4
# Lecture du header (tag + premier octet de longueur)
local header=$(dd bs=1 count=2 <&$read_fd 2>/dev/null | xxd -p | tr -d '
')
local length_byte=$((0x${header:2:2}))
local total_length=0
if [ "$length_byte" -lt 128 ]; then
# Forme courte : longueur directe
total_length=$length_byte
else
# Forme longue : lire N octets supplémentaires
local num_octets=$((length_byte & 0x7f))
local length_hex=$(dd bs=1 count=$num_octets <&$read_fd 2>/dev/null | xxd -p | tr -d '
')
total_length=$((16#$length_hex))
fi
# Lecture du contenu complet
local content=$(dd bs=1 count=$total_length <&$read_fd 2>/dev/null | xxd -p | tr -d '
')
echo "${header}${content}"
}Tout est manipulé en hex. C'est un choix de design qui tient la route. Bash gère pas bien le binaire brut (les octets nuls cassent les variables), mais les strings hex c'est juste du texte. On peut faire des regex dessus, extraire des substrings avec ${var:offset:len}, comparer. C'est verbeux mais ça marche.
Auto-détection du DC et reconnexion automatique
Si tu passes pas de DC explicitement, BashHound le trouve tout seul via le DNS, enregistrement SRV _ldap._tcp.domain.local :
Auto-détection du DC via DNS SRV
LDAP_SERVER=$(host -t SRV "_ldap._tcp.$DOMAIN" 2>/dev/null | grep "has SRV record" | head -1 | awk '{print $NF}' | sed 's/.$//')
# Fallback : utiliser le nom de domaine directement
[ -z "$LDAP_SERVER" ] && LDAP_SERVER="$DOMAIN"BashHound-CE implémente également une reconnexion automatique : si la connexion LDAPS est perdue en milieu de collecte (timeout, DC qui recoupe la connexion), le client se reconnecte et re-bind automatiquement avant de réessayer l'envoi du message.
7. lib/ldap_parser.sh — Parsing des réponses LDAP
On reçoit les objets AD sous forme d'un gros blob hex. Une SearchResultEntry contenant tous les attributs de l'objet encodés en ASN.1. Maintenant faut en extraire quelque chose d'utile. Le parseur travaille entièrement avec des regex Bash ([[ =~ ]]) et des opérations de substring (${str:offset:len}). Pas de parsing ASN.1 générique. Une fonction par attribut.
Extraction des attributs simples
Le principe est toujours le même : on cherche le nom de l'attribut encodé en hex (par exemple sAMAccountName devient 73414d4163636f756e744e616d65) dans la réponse, et on lit les octets qui suivent pour récupérer la valeur :
lib/ldap_parser.sh — extract_sam_from_response()
extract_sam_from_response() {
local hex="$1"
# "sAMAccountName" en hex
local sam_attr_hex="73414d4163636f756e744e616d65"
# Pattern : attr_hex + SET (31) + length + OCTET_STRING (04) + len + value
if [[ "$hex" =~ ${sam_attr_hex}3184[0-9a-f]{8}04([0-9a-f]{2})([0-9a-f]+) ]]; then
local val_len_hex="${BASH_REMATCH[1]}"
local val_len=$((16#$val_len_hex))
local remaining="${BASH_REMATCH[2]}"
local val_hex="${remaining:0:$((val_len * 2))}"
echo "$val_hex" | xxd -r -p
fi
}Extraction et conversion des SID
L'attribut objectSid c'est du binaire Windows pur. Un SID S-1-5-21-X-Y-Z-RID a un format bien précis qu'il faut parser octet par octet, en faisant attention au little-endian pour les sub-authorities :
Structure binaire d'un SID Windows
Offset Taille Champ
0 1 Revision (toujours 1)
1 1 SubAuthorityCount (nombre de sub-authorities)
2 6 IdentifierAuthority (big-endian, ex: 000000000005 = NT Authority)
8 4*N SubAuthorities (little-endian) : domaine + RID
Exemple S-1-5-21-1234-5678-9012-500 :
01 ← Revision 1
05 ← 5 SubAuthorities
000000000005 ← Authority = 5 (NT)
15000000 ← 21 (little-endian) : DOMAIN_IDENTIFIER
d2040000 ← 1234 (little-endian)
2e160000 ← 5678 (little-endian)
34230000 ← 9012 (little-endian)
f4010000 ← 500 (little-endian) = Administrator RIDlib/ldap_parser.sh — Conversion SID hex → string
extract_sid_from_hex() {
local hex="$1"
local revision=$((16#${hex:0:2}))
local sub_count=$((16#${hex:2:2}))
# Authority (6 octets big-endian)
local auth_hex="${hex:4:12}"
local authority=$((16#${auth_hex}))
local sid="S-${revision}-${authority}"
# SubAuthorities (4 octets little-endian chacune)
for ((i=0; i<sub_count; i++)); do
local offset=$((16 + i * 8))
local sub_hex="${hex:$offset:8}"
# Inversion little-endian → big-endian
local sub_le="${sub_hex:6:2}${sub_hex:4:2}${sub_hex:2:2}${sub_hex:0:2}"
local sub_val=$((16#$sub_le))
sid+="-${sub_val}"
done
echo "$sid"
}Conversion des timestamps Windows FILETIME
Microsoft a eu l'idée géniale de représenter les dates en nombre d'intervalles de 100 nanosecondes depuis le 1er janvier 1601. Donc lastLogon, lastLogonTimestamp et pwdLastSet sont des entiers 64 bits en Windows FILETIME à convertir en Unix timestamp pour BloodHound. whenCreated en revanche est renvoyé en GeneralizedTime LDAP (ex : 20250318123045.0Z) et se parse différemment :
lib/ldap_parser.sh — Conversion FILETIME → Unix timestamp
extract_filetime_timestamp() {
local hex_response="$1"
local attr_name="$2"
local raw_value=$(extract_attribute_value "$hex_response" "$attr_name")
[ -z "$raw_value" ] && echo "-1" && return
# Cas spéciaux : 0 = jamais, 9223372036854775807 = jamais (max int64)
[ "$raw_value" = "0" ] || [ "$raw_value" = "9223372036854775807" ] && echo "-1" && return
# Conversion : filetime → unix_timestamp
# Unix epoch = 1970-01-01 = 11644473600 secondes après 1601-01-01
# FILETIME est en intervalles de 100ns → diviser par 10^7 pour obtenir des secondes
local unix_timestamp=$(( (raw_value / 10000000) - 11644473600 ))
echo "$unix_timestamp"
}Extraction des flags UserAccountControl (UAC)
UAC c'est un bitmask 32 bits qui encode toutes les propriétés d'un compte. C'est de là qu'on tire "compte désactivé ?", "Kerberoastable ?", "AS-REP Roasting ?", "délégation non contrainte ?"… Voici les flags que BashHound utilise :
Flags UserAccountControl
0x0001 SCRIPT Script de logon actif
0x0002 ACCOUNTDISABLE Compte désactivé
0x0010 LOCKOUT Compte verrouillé
0x0020 PASSWD_NOTREQD Mot de passe non requis
0x0040 PASSWD_CANT_CHANGE Historiquement associé, mais en pratique géré via ACLs sur l'objet
0x0200 NORMAL_ACCOUNT Compte utilisateur normal
0x0800 INTERDOMAIN_TRUST_ACCOUNT Compte trust inter-domaine
0x1000 WORKSTATION_TRUST_ACCOUNT Compte machine
0x2000 SERVER_TRUST_ACCOUNT Compte DC
0x10000 DONT_EXPIRE_PASSWD Mot de passe n'expire jamais
0x40000 SMARTCARD_REQUIRED Authentification smartcard requise
0x80000 TRUSTED_FOR_DELEGATION Kerberos unconstrained delegation
0x100000 NOT_DELEGATED Compte sensible, pas de délégation
0x200000 USE_DES_KEY_ONLY DES uniquement (vieux, vulnérable)
0x400000 DONT_REQ_PREAUTH AS-REP Roasting possible (pas de préauth)Le flag 0x1000 (WORKSTATION_TRUST_ACCOUNT) est important : il sert à exclure les comptes machines de collect_users(), qui ne doit retourner que des comptes humains. Les machines passent par collect_computers()avec leurs propres attributs.
8. lib/acl_parser.sh — Security Descriptors et ACEs
C'est probablement la partie la plus chiante du projet, et celle que Claude avait citée comme "ingérable en Bash". Le nTSecurityDescriptor c'est une structure binaire Windows qui contient les ACLs d'un objet AD. Il faut parser ça bit par bit pour en extraire les permissions offensives utilisables par BloodHound.
Structure d'un Security Descriptor
Structure SECURITY_DESCRIPTOR Windows
Offset Taille Champ
0 1 Revision (1)
1 1 Sbz1 (réservé)
2 2 Control flags (little-endian)
4 4 OffsetOwner → pointeur vers SID propriétaire
8 4 OffsetGroup → pointeur vers SID groupe primaire
12 4 OffsetSacl → pointeur vers SACL (System ACL)
16 4 OffsetDacl → pointeur vers DACL (Discretionary ACL)
20+ N Données variables (SIDs + ACLs)
Control flags importants :
0x0004 SE_DACL_PRESENT Le SD contient un DACL
0x0010 SE_DACL_PROTECTED DACL protégé de l'héritage
0x8000 SE_SELF_RELATIVE Offsets relatifs au début du SDStructure d'une ACL et d'une ACE
Structure ACL + ACE
ACL Header:
Offset Taille Champ
0 1 AclRevision
1 1 Sbz1
2 2 AclSize (little-endian)
4 2 AceCount
6 2 Sbz2
ACE (Access Control Entry) :
0 1 AceType
1 1 AceFlags
2 2 AceSize
4 4 AccessMask (les droits accordés/refusés)
8 N SID du principal (+ optionnellement un ObjectType GUID)
Types d'ACE :
0x00 ACCESS_ALLOWED_ACE_TYPE Autorisation standard
0x01 ACCESS_DENIED_ACE_TYPE Refus standard
0x05 ACCESS_ALLOWED_OBJECT_ACE_TYPE Autorisation sur objet spécifique (GUID)
0x06 ACCESS_DENIED_OBJECT_ACE_TYPE Refus sur objet spécifique (GUID)Les Access Masks
L'AccessMask c'est 32 bits qui définissent ce que le principal peut faire. BashHound mappe les valeurs hex vers les noms que BloodHound comprend :
lib/acl_parser.sh — Access Masks
declare -gA ACCESS_MASK_TO_RIGHT=(
# Droits génériques (32 bits de poids fort)
[10000000]="GenericAll"
[20000000]="GenericWrite"
[40000000]="GenericRead"
[80000000]="GenericExecute"
# Droits standard (bits 16-23)
[00010000]="Delete"
[00020000]="ReadControl"
[00040000]="WriteDacl"
[00080000]="WriteOwner"
# Droits spécifiques AD (bits 0-7)
[00000001]="CreateChild"
[00000002]="DeleteChild"
[00000004]="ListChildren"
[00000008]="Self"
[00000010]="ReadProperty"
[00000020]="WriteProperty"
[00000100]="ExtendedRight" # Extended Right (identifié par un GUID)
)Extended Rights et leur importance offensive
Les Extended Rights sont là où ça devient intéressant offensivement. Quand l'AccessMask vaut 0x100 (ExtendedRight) sur une ACE de type ACCESS_ALLOWED_OBJECT, un GUID dans l'ACE précise de quel droit il s'agit. Et certains de ces GUIDs valent de l'or en pentest :
lib/acl_parser.sh — Extended Rights critiques
declare -gA EXTENDED_RIGHTS=(
# ForceChangePassword : changer le mdp sans connaître l'ancien
["00299570-246d-11d0-a768-00aa006e0529"]="ForceChangePassword"
# DCSync : répliquer les secrets du domaine (récupérer les hashes NTLM)
# Trois GUIDs car DCSync requiert les 3 droits de réplication
["1131f6aa-9c07-11d1-f79f-00c04fc2dcd2"]="DCSync" # DS-Replication-Get-Changes
["1131f6ad-9c07-11d1-f79f-00c04fc2dcd2"]="DCSync" # DS-Replication-Get-Changes-All
["89e95b76-444d-4c62-991a-0facbeda640c"]="DCSync" # DS-Replication-Get-Changes-In-Filtered-Set
# ValidatedSPN : écrire des SPNs validés (Kerberoasting ciblé)
["f3a64788-5306-11d1-a9c5-0000f80367c1"]="ValidatedSPN"
# AddAllowedToAct : ajouter msDS-AllowedToActOnBehalfOfOtherIdentity
# (RBCD - Resource-Based Constrained Delegation)
["3f78c3e5-f79a-46bd-a0b8-9d18116ddc79"]="AddAllowedToAct"
# AddMember : ajouter des membres à un groupe
["bf9679c0-0de6-11d0-a285-00aa003049e2"]="AddMember"
)Ce sont les droits que BloodHound trace pour construire les chemins d'attaque. Et pour cause :
- ForceChangePassword sur un compte → peut réinitialiser son mot de passe → compromission directe
- DCSync sur le domaine → peut extraire tous les hashes NTLM via
secretsdump.py - WriteDacl / WriteOwner sur un objet → peut modifier ses ACLs pour s'octroyer d'autres droits
- AddMember sur un groupe admin → peut s'ajouter au groupe
- AddAllowedToAct sur une machine → RBCD attack
Flags d'héritage des ACEs
lib/acl_parser.sh — ACE Flags
declare -gA ACE_FLAGS=(
[01]="OBJECT_INHERIT_ACE" # Hérité par les objets enfants
[02]="CONTAINER_INHERIT_ACE" # Hérité par les conteneurs enfants
[04]="NO_PROPAGATE_INHERIT_ACE" # Ne se propage pas plus loin
[08]="INHERIT_ONLY_ACE" # S'applique uniquement aux héritiers
[10]="INHERITED_ACE" # Cette ACE est héritée (pas appliquée directement)
)Le flag INHERITED_ACE (0x10) est particulièrement important : il dit si l'ACE vient directement de l'objet ou si elle est héritée d'un conteneur parent. BloodHound distingue les deux dans son interface. Un droit hérité c'est souvent moins "intentionnel" qu'un droit direct.
9. lib/collectors.sh — Collecte des objets AD
collectors.sh c'est le chef d'orchestre. Il fait les requêtes LDAP, appelle le parseur, et balance les données dans des fichiers temporaires en format pipe-separated. Un fichier par type d'objet, une ligne par entrée.
Collecte des utilisateurs
lib/collectors.sh — collect_users()
collect_users() {
local filter="(objectClass=user)"
local attributes="distinguishedName,sAMAccountName,objectSid,primaryGroupID,
userAccountControl,servicePrincipalName,lastLogon,
lastLogonTimestamp,pwdLastSet,whenCreated,description,
adminCount,nTSecurityDescriptor"
local results=$(ldap_search "$DOMAIN_DN" 2 "$filter" "$attributes")
while IFS= read -r line; do
if [[ "$line" =~ ^308 ]]; then # SearchResultEntry commence par 30 (SEQUENCE)
local dn=$(extract_dn_from_response "$line")
local sam=$(extract_sam_from_response "$line")
local sid=$(extract_sid_from_response "$line")
local uac=$(extract_uac_flags "$line")
# Exclure les comptes machines (UAC bit 0x1000)
if (( uac & 0x1000 )); then continue; fi
# Exclure les objets dans OU=Domain Controllers
if [[ "$dn" =~ OU=Domain Controllers, ]]; then continue; fi
# Collecte des ACLs pour cet utilisateur
local aces=$(extract_aces_from_ldap_response "$line")
while IFS='|' read -r principal_sid right_name is_inherited; do
echo "$sid|User|$principal_sid|$right_name|$is_inherited" >> "$COLLECTED_ACES"
done <<< "$aces"
# Stockage format pipe-separated
echo "$dn|$sam|$sid|$primary_gid|$description|$when_created|$last_logon|$last_logon_ts|$pwd_last_set|$uac|$admin_count|$spns" >> "$COLLECTED_USERS"
fi
done <<< "$results"
}Collecte des GPOs
Les GPOs vivent dans CN=Policies,CN=System,DC=.... Chaque GPO est un objet groupPolicyContainer avec son GUID dans name, son chemin SYSVOL dans gPCFileSysPath, et un display name lisible :
lib/collectors.sh — collect_gpos()
collect_gpos() {
local gpo_container="CN=Policies,CN=System,$DOMAIN_DN"
local filter="(objectClass=groupPolicyContainer)"
local attributes="distinguishedName,name,displayName,gPCFileSysPath,
whenCreated,description,nTSecurityDescriptor"
local results=$(ldap_search "$gpo_container" 2 "$filter" "$attributes")
# ...
# Le name est le GUID : {6AC1786C-016F-11D2-945F-00C04FB984F9}
# gPCFileSysPath : \domain.localSYSVOLdomain.localPolicies{GUID}
}Collecte des Trusts de domaine
Les trusts inter-domaines sont des objets trustedDomain dans CN=System,DC=.... Utiles pour détecter les forêts, les trusts bidirectionnels, et les configurations à risque (SID filtering désactivé par exemple) :
lib/collectors.sh — collect_trusts()
collect_trusts() {
local system_dn="CN=System,$DOMAIN_DN"
local filter="(objectClass=trustedDomain)"
local attributes="distinguishedName,name,trustDirection,trustType,
trustAttributes,securityIdentifier,flatName"
# trustDirection :
# 1 = INBOUND (le domaine distant fait confiance à nous)
# 2 = OUTBOUND (nous faisons confiance au domaine distant)
# 3 = BIDIRECTIONAL
# trustType :
# 1 = DOWNLEVEL (NT 4.0)
# 2 = UPLEVEL (Active Directory)
# 3 = MIT (Kerberos realm non-Windows)
# 4 = DCE
# trustAttributes (bitmask) :
# 0x01 NON_TRANSITIVE
# 0x02 UPLEVEL_ONLY
# 0x04 QUARANTINED_DOMAIN (SID Filtering)
# 0x08 FOREST_TRANSITIVE
# 0x20 CROSS_ORGANIZATION (SID Filtering actif)
# 0x40 WITHIN_FOREST
}TrustDirection et TrustType comme des strings ("Bidirectional", "ParentChild"…) en me basant sur la doc de RustHound. Or BloodHound CE attend des entiers (3, 2…), cohérent avec bloodhound-python mais pas avec RustHound. J'ai ouvert une issue sur le repo SpecterOps qui a permis d'établir officiellement que les entiers sont le standard. La réponse de rvazarkar (SpecterOps) : "So using the integers will be the format going forward for sure, and we will generally recommend this for opengraph integrations as well." Il a ajouté que la question avait déclenché un petit débat interne chez SpecterOps.Collecte AD CS (BashHound-CE uniquement)
C'est ce qui différencie vraiment BashHound-CE du legacy. BloodHound CE permet de visualiser les attaques AD CS (ESC1 à ESC8). Il faut collecter les Certificate Templates, les CAs, et les NTAuth stores. BashHound-CE le fait :
lib/collectors.sh (CE) — Composants AD CS collectés
# Certificate Templates
# DN : CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,...
# Attributs : msPKI-Certificate-Name-Flag, msPKI-Enrollment-Flag,
# msPKI-Private-Key-Flag, pKIExtendedKeyUsage,
# msPKI-RA-Signature, msPKI-Template-Schema-Version
collect_cert_templates()
# Enterprise Certificate Authorities
# DN : CN=Enrollment Services,CN=Public Key Services,...
# Attributs : dNSHostName, certificateTemplates (templates activés)
collect_enterprise_cas()
# NTAuth Store — certificats de confiance pour auth client
# DN : CN=NTAuthCertificates,CN=Public Key Services,...
collect_ntauthstores()
# AIA (Authority Information Access) CAs
collect_aiacas()
# Root CAs
collect_rootcas()
# Issuance Policies
collect_issuancepolicies()Les Certificate Templates c'est là que ça se passe pour AD CS. Les mauvaises configurations sont légion dans les domaines réels, et elles ouvrent des portes sérieuses :
- ESC1 : enrollment disponible pour tout le monde, SAN arbitraire → demander un certificat au nom d'un admin
- ESC3 : Certificate Request Agent → enrollment on behalf of another user
- ESC4 : droits d'écriture sur le template → modifier les flags pour créer ESC1
- ESC6/7 : droits sur la CA elle-même
10. lib/export.sh / export_ce.sh — Export BloodHound JSON
Une fois les fichiers temporaires remplis, c'est la phase d'export qui transforme tout ça en JSON BloodHound. C'est de loin le module le plus volumineux du projet : 1207 lignes pour BashHound, 2423 lignes pour BashHound-CE. Beaucoup de code répétitif mais inévitable. Chaque type d'objet a son propre schéma JSON.
Format BloodHound v5 (legacy)
Le format v5 c'est un fichier JSON par type d'objet, avec une clé meta pour les métadonnées et une clé data avec la liste des objets. Simple :
Format JSON BloodHound v5 — Utilisateurs
{
"meta": {
"methods": 0,
"type": "users",
"count": 42,
"version": 5
},
"data": [
{
"Properties": {
"name": "JOHN.DOE@DOMAIN.LOCAL",
"domain": "DOMAIN.LOCAL",
"domainsid": "S-1-5-21-...",
"distinguishedname": "CN=John Doe,CN=Users,DC=domain,DC=local",
"samaccountname": "john.doe",
"enabled": true,
"admincount": false,
"passwordnotreqd": false,
"dontreqpreauth": false, // AS-REP Roasting
"sensitive": false,
"unconstraineddelegation": false,
"lastlogon": 1704067200,
"lastlogontimestamp": 1704067200,
"pwdlastset": 1703980800,
"whencreated": 1672531200,
"serviceprincipalnames": [], // Kerberoasting
"hasspn": false
},
"PrimaryGroupSID": "S-1-5-21-...-513", // Domain Users
"Aces": [
{
"PrincipalSID": "S-1-5-21-...-512",
"PrincipalType": "Group",
"RightName": "GenericAll",
"IsInherited": false
}
],
"ObjectIdentifier": "S-1-5-21-...-1105",
"IsDeleted": false,
"IsACLProtected": false
}
]
}Format BloodHound CE v6 (Community Edition)
Le format v6 de BloodHound CE est plus riche : nouveaux champs, nouveaux types d'objets, et notamment les liens ContainedBy qui permettent de visualiser la hiérarchie OU/conteneurs :
Format JSON BloodHound CE v6 — Structure
{
"meta": {
"methods": 0,
"type": "users",
"count": 42,
"version": 6 // ← v6 pour BloodHound CE
},
"data": [
{
"Properties": {
// Mêmes champs que v5 + nouveaux champs CE
"name": "JOHN.DOE@DOMAIN.LOCAL",
"objectid": "S-1-5-21-...-1105",
// ...
},
"Aces": [ ... ],
"ObjectIdentifier": "S-1-5-21-...-1105",
// Nouveau en v6 :
"AllowedToDelegate": [],
"AllowedToAct": [],
"HasSIDHistory": [],
"SPNTargets": [],
"ContainedBy": { // ← lien vers l'OU/conteneur parent
"ObjectIdentifier": "...",
"ObjectType": "OU"
}
}
]
}Export des objets AD CS (CE uniquement)
Types de fichiers JSON produits par BashHound-CE
bloodhound_users_TIMESTAMP.json # Utilisateurs
bloodhound_groups_TIMESTAMP.json # Groupes
bloodhound_computers_TIMESTAMP.json # Ordinateurs
bloodhound_domains_TIMESTAMP.json # Domaines + Trusts
bloodhound_gpos_TIMESTAMP.json # Group Policy Objects
bloodhound_ous_TIMESTAMP.json # Organizational Units
bloodhound_containers_TIMESTAMP.json # Containers AD
# AD CS (BashHound-CE uniquement) :
bloodhound_certtemplates_TIMESTAMP.json # Certificate Templates
bloodhound_enterprisecas_TIMESTAMP.json # Enterprise CAs
bloodhound_ntauthstores_TIMESTAMP.json # NTAuth Stores
bloodhound_aiacas_TIMESTAMP.json # AIA CAs
bloodhound_rootcas_TIMESTAMP.json # Root CAs
bloodhound_issuancepolicies_TIMESTAMP.json # Issuance PoliciesÀ la fin, tous les JSON sont zippés dans une archive horodatée prête à importer dans BloodHound :
Nommage du ZIP de sortie
20260318_143022_domain-local_bashhound.zip
# Format : YYYYMMDD_HHMMSS_domain-name_bashhound.zipGénération des ObjectIdentifiers pour les objets sans SID
Petit problème : certains objets AD comme les GPOs ou les Certificate Templates n'ont pas de SID. BloodHound CE a besoin d'un identifiant unique pour chaque nœud. Solution : générer un UUID-like déterministe à partir du DN via MD5 :
lib/export_ce.sh — Génération ObjectIdentifier
# Pour un Certificate Template dont le DN est connu mais pas le SID :
local dn_upper=$(echo "$dn" | tr '[:lower:]' '[:upper:]')
local object_id=$(echo -n "$dn_upper" | md5sum | awk '{print toupper($1)}' | sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/')
# Exemple : A3F8D2C1-4B7E-11D2-F0A9-00C04FB9842211. BashHound vs BashHound-CE — Format v5 vs v6, AD CS
BashHound et BashHound-CE partagent la même architecture et les mêmes libs de base, mais BashHound-CE est une réécriture bien plus poussée, pas un simple fork avec quelques lignes changées. Voici ce qui les distingue :
| Fonctionnalité | BashHound | BashHound-CE |
|---|---|---|
| Format JSON | v5 (legacy) | v6 (CE) |
| Cible | BloodHound legacy | BloodHound Community Edition |
| AD CS (ADCS) | Non | Oui (ESC1-8) |
| Taille du code | ~3 800 lignes | ~6 500 lignes |
| Logging | Simple (echo) | Structuré (log_info/warn/error) |
| Version management | Codée en dur | Auto-détection via pacman/dpkg/rpm |
| ldap_parser.sh | 521 lignes | 938 lignes (multivalué étendu) |
| export | export.sh (1 207 lignes) | export_ce.sh (2 423 lignes) |
Un détail que j'aime bien : le logging de BashHound-CE imite volontairement le format de RustHound. Ça donne quelque chose d'assez propre pour un script shell :
BashHound-CE — Format de log structuré
[2026-03-18T14:30:22Z INFO bashhound_ce::ldap] Connected to DOMAIN.LOCAL Active Directory!
[2026-03-18T14:30:22Z INFO bashhound_ce::ldap] Starting data collection...
[2026-03-18T14:30:23Z INFO bashhound_ce::export] 42 users parsed!
[2026-03-18T14:30:23Z INFO bashhound_ce::export] 18 groups parsed!
[2026-03-18T14:30:23Z INFO bashhound_ce::export] 12 computers parsed!
[2026-03-18T14:30:23Z INFO bashhound_ce::export] 20260318_143023_domain-local_bashhound.zip created!12. Benchmark — RustHound-CE vs BashHound-CE
Soyons honnêtes : Bash n'est pas Rust. BashHound-CE lance des dizaines de sous-processus ( xxd, dd, printf…) pour chaque attribut LDAP parsé, là où RustHound-CE gère tout nativement en mémoire. Le tableau ci-dessous présente une comparaison réaliste sur les environnements de test utilisés (machines HTB et labs AD).
Petit domaine (~100 objets)
| Métrique | RustHound-CE | BashHound-CE |
|---|---|---|
| Temps total (collecte + export) | ~3–5 s | ~40–90 s |
| Utilisateurs parsés | 100 % | 100 % |
| Groupes parsés | 100 % | 100 % |
| ACLs parsées | 100 % | ~80–90 %* |
| AD CS (cert templates) | Oui | Oui |
| Dépendances requises | Binaire compilé | bash, xxd, jq, zip |
| Taille du binaire / script | ~5 MB (Rust binary) | ~100 KB (scripts) |
* Certaines ACEs sur des objets complexes (attributs multivalués imbriqués, security descriptors non standards) peuvent être manquées selon la version. Des correctifs sont apportés régulièrement.
Pourquoi BashHound-CE est plus lent
Le goulot d'étranglement principal est la manipulation du binaire LDAP. Chaque réponse est convertie en hexadécimal via xxd (un fork par opération), puis parcourue en Bash pur avec des regex. Pour une réponse LDAP contenant 100 utilisateurs avec leurs ACLs, cela représente plusieurs centaines de sous-processus.
Profil d'exécution — ce qui prend du temps
# Pour chaque objet LDAP retourné :
# 1. ldap_receive_message() → dd (1 fork pour lire le header, 1+ pour le contenu)
# 2. Conversion hex → xxd -p (1 fork)
# 3. extract_*_from_response() → regex bash (rapide, inline)
# 4. extract_aces_from_ldap_response() → parse Security Descriptor
# → plusieurs appels parse_sid_from_hex() (inline bash)
# 5. Écriture dans /tmp/bashhound_*_$$ → I/O disque
# 6. Phase export : jq pour sérialiser en JSON
# RustHound-CE fait tout ça en mémoire avec des types natifs Rust,
# sans fork, sans I/O intermédiaire.scp suffit à le déployer sur n'importe quel Linux.13. Limites connues
BashHound(-CE) fonctionne. Mais il y a des cas où tu ferais mieux d'utiliser autre chose.
- Domaines volumineux (>500 objets) : chaque attribut LDAP parsé lance plusieurs sous-processus. Sur un domaine réel avec des milliers d'objets, la collecte peut prendre plusieurs minutes là où RustHound-CE met quelques secondes.
- Réponses LDAP atypiques : le parsing repose sur des regex et des offsets calculés manuellement. Des attributs multivalués imbriqués ou des Security Descriptors non standards peuvent être partiellement ratés selon la version.
- Dépendance à l'environnement shell : nécessite Bash 4.x minimum (tableaux associatifs), plus
xxd,dd,jq. Sur des systèmes ultra-minimalistes ces dépendances peuvent manquer. - Pas d'authentification Kerberos : uniquement LDAP simple bind. Sur des environnements qui forcent GSSAPI, BashHound ne peut pas s'authentifier.
- Pas de paging LDAP : si le DC impose une limite de résultats côté serveur et que tu ne peux pas la désactiver, tu n'obtiendras pas tous les objets.
- Quand utiliser autre chose : pentest sur grand domaine → RustHound-CE ou BloodHound-CE-Python. Lab rapide avec contrainte de déploiement → BashHound-CE.
14. Ce que ça m'a appris
Partir de zéro sur un collecteur, ça oblige à se confronter à des détails qu'une lib t'épargne d'habitude.
- LDAP se sérialise vraiment en ASN.1 BER. Avant ce projet je savais que LDAP était "du binaire". Maintenant je sais exactement comment un BindRequest de 47 octets est construit, tag par tag.
- Les Security Descriptors sont vraiment pénibles. Pas parce que c'est compliqué conceptuellement, mais parce que c'est du binaire little-endian avec des offsets relatifs, des GUIDs sur 16 octets, et des flags sur des bits individuels. BloodHound.py et SharpHound te cachent ça complètement.
- Ce que BloodHound attend réellement. Lire la spec du format JSON v5/v6 en essayant de le reproduire t'oblige à comprendre le modèle de données : pourquoi un SID est l'identifiant canonique, comment les ACEs sont normalisées, ce que signifient
IsInheritedouIsACLProtected. - Ce que Bash oblige à voir. En Python tu appelles
ldap3.Connection.search()et tu récupères un dict. En Bash tu reçois des octets, tu les convertis en hex, tu cherches un pattern à un offset précis, tu recalcules la longueur. C'est verbeux mais ça donne une compréhension concrète du protocole qu'on n'a pas autrement.
15. Conclusion
Alors, est-ce que c'était faisable en Bash ? Oui. Est-ce que c'est l'outil que tu vas utiliser sur un pentest avec 5 000 objets AD ? Probablement pas. Mais ce projet m'a forcé à aller vraiment loin dans la compréhension de chaque couche :
- Le protocole LDAP (RFC 4511) : structure des messages, opérations Bind/Search, contrôles étendus
- L'encodage ASN.1 BER/DER : format TLV, longueurs variables, types universels et applicatifs
- Les Security Descriptors Windows : structure binaire des ACLs, ACEs, access masks, extended rights et leur signification offensive
- Le format interne d'Active Directory : SIDs binaires, FILETIME, UserAccountControl, attributs multivalués
- L'écosystème BloodHound : format JSON v5/v6, structure des nœuds, arêtes, ACLs, AD CS
Pour de la pentest sérieuse sur de grands domaines, utilise BloodHound.py ou RustHound. Ils sont plus stables, plus rapides, plus complets. BashHound c'est avant tout un projet pour apprendre et pour montrer que Bash peut aller là où on ne l'attend pas.
Les deux repos sont sur GitHub si tu veux fouiller le code ou contribuer : 0xbbuddha/BashHound et 0xbbuddha/BashHound-CE.
Récap technique
- LDAP via
/dev/tcp: socket TCP natif Bash - LDAPS via
openssl s_client+ FIFOs - ASN.1 BER encodé/décodé en Bash pur (hex string manipulation)
- Security Descriptors Windows parsés bit par bit
- Extended Rights mappés (DCSync, ForceChangePassword, RBCD…)
- AD CS : Certificate Templates, Enterprise CAs, NTAuth Stores
- Export JSON v5 (BloodHound legacy) et v6 (BloodHound CE)
- ~3 800 lignes (BashHound) / ~6 500 lignes (BashHound-CE)