Logs Apache : reconstruire une injection SQL aveugle temporelle
Un journal d’accès Apache conserve chaque requête reçue avec son horodatage. Cette trace permet, après coup, d’identifier une attaque et parfois d’en reconstituer le résultat. Le cas traité ici : une injection SQL aveugle basée sur le temps, dont on peut retrouver la donnée exfiltrée en analysant les écarts de temps entre les requêtes enregistrées.
Reconnaître le motif dans les logs
Section intitulée « Reconnaître le motif dans les logs »Plusieurs signes trahissent une injection SQL aveugle temporelle dans un journal d’accès :
- une rafale de requêtes vers le même endpoint, ne variant que par un paramètre ;
- ce paramètre porte une valeur encodée (souvent URL-encode par-dessus du Base64) qui masque une charge SQL ;
- les horodatages de lignes successives présentent des écarts réguliers de quelques secondes, signature des appels à
sleep().
Un extrait, l’IP et la date étant neutralisées, ressemble à ceci :
10.0.0.66 - - [15/Jan/2025:12:12:54 +0100] "GET /admin/?action=membres&order=QVNDLChzZWxlY3Qg...aWQ9MSk%3D HTTP/1.1" 200 1005 "-" "-"10.0.0.66 - - [15/Jan/2025:12:13:00 +0100] "GET /admin/?action=membres&order=QVNDLChzZWxlY3Qg...aWQ9MSk%3D HTTP/1.1" 200 1005 "-" "-"Le paramètre order change à chaque ligne, mais reste illisible tant qu’il n’est pas décodé.
Décoder le payload
Section intitulée « Décoder le payload »La valeur est URL-décodée puis décodée depuis le Base64. On révèle alors la charge SQL injectée dans la clause de tri :
ASC,(select (case field( concat( substring(bin(ascii(substring(password,1,1))),5,1), substring(bin(ascii(substring(password,1,1))),6,1) ), concat(char(48),char(48)), -- "00" concat(char(48),char(49)), -- "01" concat(char(49),char(48)), -- "10" concat(char(49),char(49)) -- "11" ) when 1 then TRUE when 2 then sleep(2) when 3 then sleep(4) when 4 then sleep(6) end) from users where id=1)La lecture de ce payload donne toute la logique de l’attaque.
Le principe : extraire deux bits par requête
Section intitulée « Le principe : extraire deux bits par requête »Une injection SQL aveugle temporelle extrait une donnée sans jamais la voir dans la réponse : elle déduit chaque information du temps de réponse. La forme naïve teste un bit par requête (le serveur dort si le bit vaut 1), soit huit requêtes par caractère.
L’attaque observée ici est plus efficace. Elle lit deux bits à la fois grâce à field(concat(bit1, bit2), '00','01','10','11'), qui renvoie la position de la paire de bits dans la liste :
| Paire de bits | Position field | Action | Délai observé |
|---|---|---|---|
00 | 1 | TRUE | 0 s |
01 | 2 | sleep(2) | 2 s |
10 | 3 | sleep(4) | 4 s |
11 | 4 | sleep(6) | 6 s |
Chaque requête cible une paire de bits d’un caractère du mot de passe, via substring(bin(ascii(...)), position, 1). Le caractère à extraire et la position des bits changent d’une requête à l’autre.
Un caractère ASCII tenant sur 7 bits, il se reconstruit en quatre requêtes : trois paires (6 bits) et un dernier bit isolé, ce dernier n’utilisant que sleep(2) et sleep(4) pour coder 0 ou 1.
Reconstruire à partir des horodatages
Section intitulée « Reconstruire à partir des horodatages »Dans un journal Apache, l’horodatage inscrit correspond au moment où la réponse est renvoyée. Si le serveur exécute un sleep(6), la ligne suivante apparaît au moins six secondes plus tard. La différence de temps entre deux requêtes consécutives mesure donc la durée du blocage, et révèle la valeur des bits.
La méthode de reconstruction est directe :
- Extraire l’horodatage de chaque ligne.
- Calculer l’écart avec la ligne suivante.
- Traduire cet écart en bits (
0 s → 00,2 s → 01,4 s → 10,6 s → 11pour les paires). - Regrouper les bits par sept, puis convertir en caractère ASCII.
Automatiser le décodage
Section intitulée « Automatiser le décodage »Le décodage se prête à un court script. Cette version générique lit un journal, mesure les écarts et reconstruit la chaîne, sans dépendre d’un format de challenge particulier.
import sysfrom datetime import datetime
# Écart de temps -> bits, selon le barème d'encodage observéPAIR = {0: "00", 2: "01", 4: "10", 6: "11"} # trois premières requêtes du caractèreLAST = {2: "0", 4: "1"} # quatrième requête, bit isolé
def parse_times(path): times = [] for line in open(path): # Extraire l'heure du timestamp Apache [15/Jan/2025:12:12:54 +0100] start = line.index("[") + 1 stamp = line[start:start + 20] # 15/Jan/2025:12:12:54 times.append(datetime.strptime(stamp, "%d/%b/%Y:%H:%M:%S")) return times
def decode(times): flag, bits = "", "" for i in range(len(times) - 1): delta = int((times[i + 1] - times[i]).total_seconds()) if i % 4 in (0, 1, 2): bits += PAIR.get(delta, "") else: # i % 4 == 3, dernier bit du caractère bits += LAST.get(delta, "") flag += chr(int(bits, 2)) bits = "" return flag
if __name__ == "__main__": print(decode(parse_times(sys.argv[1])))Le script associe chaque plage de secondes à sa valeur binaire, assemble sept bits par caractère et reconstitue la chaîne que l’attaquant a exfiltrée.
Défense et détection
Section intitulée « Défense et détection »Empêcher l’injection à la source
Section intitulée « Empêcher l’injection à la source »Une clause de tri construite à partir d’une entrée utilisateur ne doit jamais accepter de valeur libre. Le champ et le sens de tri se valident contre une liste blanche.
ALLOWED = {"nom", "date", "id"}if order_field not in ALLOWED: abort(400)Repérer le motif côté supervision
Section intitulée « Repérer le motif côté supervision »Une injection temporelle laisse une empreinte reconnaissable dans les logs : de nombreuses requêtes vers un même endpoint, un paramètre encodé, et des temps de réponse anormalement longs et réguliers. Une alerte sur les requêtes dont la durée dépasse un seuil, ou sur les rafales depuis une même IP, détecte l’attaque en cours.
Limiter le débit
Section intitulée « Limiter le débit »Une extraction caractère par caractère demande des centaines de requêtes. Un contrôle de débit sur les endpoints sensibles ralentit fortement l’attaque et la rend visible.
À retenir
Section intitulée « À retenir »Un journal Apache ne conserve pas le corps des réponses, mais ses horodatages suffisent à trahir une injection SQL aveugle temporelle et à en reconstruire le résultat. L’attaquant encode sa charge et lit la donnée deux bits par requête via des durées de sleep distinctes ; l’analyste refait le chemin inverse en mesurant les écarts entre lignes. Côté défense, la parade tient dans la validation stricte des paramètres de tri, la détection des temps de réponse anormaux et la limitation du débit.