security-tools/tools/brute-login/brute-login.sh

130 lines
4.5 KiB
Bash
Raw Normal View History

2026-09-24 17:50:43 +01:00
#!/usr/bin/env bash
# brute-login.sh — teste la robustesse de l'authentification de la
# preprod par énumération de mots de passe sur un utilisateur connu.
#
# USAGE
# ./brute-login.sh [targets.env]
#
# Comportement :
# - lit la wordlist LOGIN_WORDLIST (un mot de passe par ligne) ;
# - pour chaque entrée, émet POST LOGIN_PATH avec {{USER}}/{{PASS}} ;
# - considère réussite si le code HTTP == LOGIN_SUCCESS_CODE ;
# - s'arrête à la première réussite (ou à LOGIN_MAX_ATTEMPTS) ;
# - journalise les tentatives dans $OUTPUT_DIR/brute-login.log.
#
# Le délai REQUEST_DELAY espace les requêtes (voir docs/scope.md).
# Voir targets.example.env pour tous les paramètres.
COMMON_SH="$(cd "$(dirname "$0")/../.." && pwd)/lib/common.sh"
# shellcheck disable=SC1091
source "$COMMON_SH"
load_target "${1:-}"
mkdir -p "${OUTPUT_DIR:-./output}"
LOG="${OUTPUT_DIR:-./output}/brute-login.log"
: > "$LOG"
[[ -f "${LOGIN_WORDLIST}" ]] \
|| die "Wordlist introuvable : ${LOGIN_WORDLIST} (réglez LOGIN_WORDLIST dans targets.env)"
# URL absolue de login (LOGIN_PATH peut être relatif ou absolu dans le périmètre).
login_url() {
case "$LOGIN_PATH" in
http*) printf '%s' "$LOGIN_PATH" ;;
/*) printf '%s%s' "$TARGET_BASE_URL" "$LOGIN_PATH" ;;
*) printf '%s/%s' "$TARGET_BASE_URL" "$LOGIN_PATH" ;;
esac
}
URL="$(login_url)"
assert_in_scope "$URL"
info "Cible : $URL"
info "Utilisateur : ${LOGIN_USERNAME}"
info "Wordlist : ${LOGIN_WORDLIST}"
info "Succès si code == ${LOGIN_SUCCESS_CODE}"
info "Délai : ${REQUEST_DELAY}s entre requêtes"
info "Journal : $LOG"
# Construit le corps de la requête pour un couple (user, pass).
# Deux formats selon LOGIN_CONTENT_TYPE : "json" ou "form".
build_body() {
local user="$1" pass="$2"
local ct="${LOGIN_CONTENT_TYPE:-json}"
case "$ct" in
form)
if [[ -n "${LOGIN_BODY_FORM:-}" ]]; then
printf '%s' "${LOGIN_BODY_FORM//\{\{USER\}\}/$user}" \
| sed "s/{{PASS}}/$(printf '%s' "$pass" | sed 's/[&/\]/\\&/g')/"
else
# form-urlencoded : grant_type=password + user + pass.
# Encodage minimal des caractères spéciaux pour URL.
local u p
u="$(printf '%s' "$user" | sed 's/[&+/@=%]/\\&/g')"
p="$(printf '%s' "$pass" | sed 's/[&+/@=%]/\\&/g')"
printf 'grant_type=password&%s=%s&%s=%s' \
"$LOGIN_USER_FIELD" "$u" "$LOGIN_PASS_FIELD" "$p"
fi
;;
json|*)
if [[ -n "${LOGIN_BODY_JSON:-}" ]]; then
printf '%s' "${LOGIN_BODY_JSON//\{\{USER\}\}/$user}" \
| sed "s/{{PASS}}/$(printf '%s' "$pass" | sed 's/[&/\]/\\&/g')/"
else
# Construction JSON simple, échappement basique.
local u p
u="$(printf '%s' "$user" | sed 's/[\\"]/\\&/g')"
p="$(printf '%s' "$pass" | sed 's/[\\"]/\\&/g')"
printf '{"%s":"%s","%s":"%s"}' \
"$LOGIN_USER_FIELD" "$u" "$LOGIN_PASS_FIELD" "$p"
fi
;;
esac
}
# En-tête Content-Type selon le format.
content_type_header() {
case "${LOGIN_CONTENT_TYPE:-json}" in
form) printf 'application/x-www-form-urlencoded' ;;
*) printf 'application/json' ;;
esac
}
count=0
found=0
while IFS= read -r pass || [[ -n "$pass" ]]; do
# ignore lignes vides et commentaires
[[ -z "$pass" || "$pass" == \#* ]] && continue
count=$((count + 1))
if [[ "${LOGIN_MAX_ATTEMPTS}" != "0" && "$count" -gt "${LOGIN_MAX_ATTEMPTS}" ]]; then
info "Limite LOGIN_MAX_ATTEMPTS=${LOGIN_MAX_ATTEMPTS} atteinte, arrêt."
break
fi
body="$(build_body "$LOGIN_USERNAME" "$pass")"
http_probe POST "$URL" \
-H "Content-Type: $(content_type_header)" \
--data "$body"
code="$LAST_CODE"
printf '%s\t%s\t%s\n' "$(date -Is)" "$code" "$pass" >> "$LOG"
drop_body
if [[ "$code" == "${LOGIN_SUCCESS_CODE}" ]]; then
ok "Connexion réussie avec : $pass (code $code après $count tentatives)"
found=1
break
fi
# Anti-lockout : si on voit un 429 (trop de requêtes) ou un 403
# persistant, on prévient plutôt que de marteler.
if [[ "$code" == "429" ]]; then
warn "Code 429 (rate limit) reçu — la cible limite les tentatives. Pause + longue."
sleep 5
fi
pace
done < "${LOGIN_WORDLIST}"
if [[ "$found" -eq 0 ]]; then
warn "Aucun mot de passe trouvé après $count tentatives. Voir $LOG."
exit 1
fi