Escape Game

Durée60 minutes

Phase 1 : La piste du hacker

Mission

Une série d’alertes de sécurité a été déclenchée sur la plateforme de commerce en ligne BreizhDiscount. Les logs système indiquent une potentielle attaque coordonnée impliquant des tentatives de force brute sur les connexions, une création suspecte de comptes et des transactions frauduleuses.

En tant qu’expert sécurité, vous devez analyser les logs système pour identifier le point d’entrée de l’attaquant, comprendre sa méthode d’exploitation et évaluer l’étendue de la compromission.

Mise en place

Accéder à la base de données ← Cliquez sur le bouton pour accéder à la base de données.

Ensuite créez un notebook, copiez/collez le code suivant dans la première cellule et exécutez-la :

%reload_ext sql
%sql postgresql://user_tp:ue_info_reseaux_db@localhost/ue_info_reseaux_db
%config SqlMagic.displaylimit = None

Ensuite pour exécuter une requête vous devrez toujours précéder votre code de :

%%sql
votre requête ici

C’est tout ! Vous êtes prêt ! Basez-vous sur le schéma logique ci-dessous pour préparer vos requêtes tout au long de cet Escape Game.

Énigme 1 : Les tentatives de connexion

Objectif 1 : Identification des attaques par force brute

Situation : Le système de monitoring a déclenché une alerte suite à deux pics inhabituels d’échecs de connexion le 15 mars 2024. Ces pics contrastent fortement avec le pattern habituel d’échecs de connexion observé les jours précédents.

Mission : Pour confirmer cette hypothèse, vous devez analyser les logs de connexion à partir du 15 mars 2024 et identifier les adresses IP ayant effectué plus de 4 tentatives de connexion échouées, signature typique d’une attaque par force brute.

Information

Dans le monde réel, les attaques par force brute sont une menace courante pour les e-commerces. Les attaquants utilisent des outils automatisés qui tentent des milliers de combinaisons de mots de passe en quelques minutes. La détection rapide de ces tentatives est cruciale pour protéger les comptes clients et prévenir les fraudes.

  • Utilisez la table login_attempts.
  • Concentrez-vous sur les connexions où success = false.
  • N’oubliez pas de grouper les résultats par IP.

Pour valider cet objectif, notez le plus grand nombre de tentatives de connexion échouées pour une même adresse IP depuis le 15 mars.

Objectif 2 : Analyse de la distribution temporelle

Situation : Les premières analyses suggèrent que l’attaquant utilise un script automatisé avec un timing précis pour éviter la détection. Cette régularité dans les tentatives peut nous aider à différencier le trafic malveillant du trafic légitime.

Mission : Analysez la distribution temporelle des tentatives de connexion échouées pour identifier les patterns suspects. Vous devez identifier les pics d’activité minute par minute et visualiser la distribution pour repérer les séquences trop régulières pour être humaines.

Information

Les outils d’attaque automatisés laissent souvent des signatures temporelles distinctives. Alors que les utilisateurs légitimes ont des intervalles irréguliers entre leurs tentatives, les bots maintiennent souvent un rythme constant. Cette différence est un indicateur clé utilisé par les systèmes de détection d’intrusion modernes.

Commençons par analyser cette requête qui compte le nombre de tentatives de connexion par heure pour chaque adresse IP. Cette requête servira de base pour notre analyse mais elle manque des éléments importants pour détecter les comportements suspects :

SELECT 
    ip_address,
    attempt_date::timestamp::date as date,
    EXTRACT(HOUR FROM attempt_date) as heure,
    COUNT(*) as nb_tentatives
FROM login_attempts
GROUP BY 
    ip_address,
    attempt_date::timestamp::date,
    EXTRACT(HOUR FROM attempt_date)
ORDER BY nb_tentatives DESC;

Vous avez à votre disposition un script permettant de visualiser le nombre de connexions au cours du temps. Celui-ci nécessite cependant les champs suivants (order à respecter) : date, heure, minute, nb_tentatives.

Modifiez cette requête pour produire les données nécessaire au script. Pour cela :

  1. Supprimez les champs inutiles
  2. Ajoutez le comptage par minute
  3. Gardez uniquement les échecs de connexion entre le 14 et le 16 mars

  • Utilisez EXTRACT(MINUTE FROM attempt_date).
  • Adaptez le GROUP BY.

  • Filtrez les lignes avec un WHERE et utilisez BETWEEN pour fixer la fenêtre de temps.

Dans votre notebook Jupyter, commencez par exécuter votre requête dans une première cellule en utilisant la syntaxe %%sql. Pour la visualisation, votre requête doit renvoyer ces colonnes dans cet ordre : ip_address (text), heure (numeric), minute (numeric), nb_tentatives (numeric).

Ensuite, dans une nouvelle cellule, exécutez le code de visualisation suivant qui récupérera automatiquement le résultat de la cellule précédemment exécutée avec la variable _ :

import seaborn as sns
import pandas as pd
import matplotlib.pyplot as plt
from datetime import datetime, timedelta

# Récupération des résultats de la requête SQL
result = _
df = result.DataFrame()

# Conversion des colonnes Decimal en int
df['heure'] = df['heure'].astype(int)
df['minute'] = df['minute'].astype(int)

# Création d'une colonne datetime en combinant date, heure et minute
df['datetime'] = pd.to_datetime(df['date']) + \
                pd.to_timedelta(df['heure'], unit='h') + \
                pd.to_timedelta(df['minute'], unit='m')

# Calcul des dates min et max
date_min = df['datetime'].min()
date_max = df['datetime'].max()
total_hours = (date_max - date_min).total_seconds() / 3600

# Détermination de la fréquence des ticks en fonction de la durée totale
if total_hours <= 1:  # Moins d'1h
    freq = '10min'
    date_format = '%Hh%M'
elif total_hours <= 3:  # Moins de 3h
    freq = '15min'
    date_format = '%Hh%M'
elif total_hours <= 6:  # Moins de 6h
    freq = '30min'
    date_format = '%Hh%M'
elif total_hours <= 12:  # Moins de 12h
    freq = '1H'
    date_format = '%Hh%M'
elif total_hours <= 24:  # Moins de 24h
    freq = '2H'
    date_format = '%Hh%M'
elif total_hours <= 48:  # Moins de 48h
    freq = '4H'
    date_format = '%d/%m %Hh'
else:  # Jusqu'à 72h
    freq = '6H'
    date_format = '%d/%m %Hh'

# Calcul du nombre de bins adaptatif (1 bin = 5 minutes)
nb_bins = int(total_hours * 12)  # 12 intervals de 5 minutes par heure

# Configuration du style
plt.figure(figsize=(15, 7))

# Création de l'histogramme
sns.histplot(data=df, 
            x='datetime',
            weights='nb_tentatives',
            bins=nb_bins,
            color='steelblue')

# Personnalisation du graphique
plt.title(f'Distribution temporelle des tentatives de connexion échouées\n'
          f'du {date_min.strftime("%d/%m/%Y %Hh%M")} au {date_max.strftime("%d/%m/%Y %Hh%M")}', 
          pad=20, 
          fontsize=14)
plt.xlabel('Date et Heure (intervalles de 5 minutes)', fontsize=12)
plt.ylabel('Nombre de tentatives de connexion échouées', fontsize=12)

# Configuration adaptative de l'axe X
dates = pd.date_range(start=date_min, end=date_max, freq=freq)
plt.xticks(dates, [d.strftime(date_format) for d in dates], rotation=45)

# Ajout d'une grille
plt.grid(True, alpha=0.3)

# Ajustement de la mise en page
plt.tight_layout()

Adaptez votre requête pour visualiser plus clairement la période de quelques heures avant et après l’attaque.

Pour valider cet objectif, notez le nombre de période de 30 minutes durant lesquelles les attaques ont eu lieu.

Validez vos réponses

Énigme 2 : Analyse des comptes corrompus

Objectif 1 : Détection des connexions simultanées

Situation : Suite à l’attaque par force brute, plusieurs comptes semblent avoir été compromis. Ces comptes montrent des connexions réussies depuis différentes localisations géographiques dans des intervalles de temps très courts.

Mission : Identifiez tous les comptes présentant des connexions réussies depuis des adresses IP différentes dans un intervalle de 30 minutes. Ce pattern indique généralement que plusieurs attaquants utilisent simultanément le même compte compromis.

Information

Les connexions simultanées depuis des locations géographiques distinctes sont un indicateur majeur de compromission de compte. Les cybercriminels partagent souvent les identifiants volés sur des forums, conduisant à des utilisations simultanées par différents acteurs malveillants.

  • Débutez par poser les bases de votre requête en affichant (sans doublon) customer_id, ip_address et attempt_date.
  • Demandez-vous comment récupérer les informations d’un même client pour 2 connexions différentes.
  • Il est impossible avec une seule table login_attempt de trouver une ligne avec différentes addresses IP !
  • Faites donc un JOIN entre 2 tables login_attempt (donnez leur un nom différent avec un alias) qui vont representer un même client à des moments différents. Quelle est le critère de jointure approprié ?

Dans la sélection avec WHERE :

  • Vérifiez que les informations clients venant de chacune des tables n’ont pas la même adresse IP.
  • Utiliser attempt_date + INTERVAL '30 minutes' pour sélectionner la fenêtre temporelle.
  • Gardez uniquement les connexions réussies (success = true).

Pour valider cet objectif, filtrez les connexions à partir 15 mars minuit '2024-03-15 00:00:00' et triez par attempt_date la plus ancienne et customer_idcroissant.

Notez les 3 chiffres du premier customer_id qui apparait.

Objectif 2 : Identification des comptes suspects

Situation : L’analyse des logs révèle que les attaquants créent des variantes de comptes légitimes en insérant des points supplémentaires dans les adresses email (ex: “j.ohn.smith@email.com” pour “john.smith@email.com”). Ces comptes sont ensuite utilisés pour des transactions frauduleuses.

Mission : Identifiez toutes les transactions dépassant 1000€ effectuées par des comptes dont l’email présente ce pattern suspect de points multiples, afin d’évaluer l’ampleur de la fraude.

Information

La technique du “dot fraud”, qui fonctionne avec certains fournisseurs de messagerie, est privilégiée par les fraudeurs car elle leur permet de gérer facilement de nombreux comptes frauduleux depuis une seule boîte mail, plutôt que de devoir surveiller des dizaines de boîtes différentes.

  • Le mot-clef LIKE permet d’utiliser des jokers % et _ pour écrire une expression régulière.

Pour valider cet objectif, notez le numéro de la transaction frauduleuse (transaction_id) ayant le plus haut montant.

Validez vos réponses

Énigme 3 : Les transactions suspectes

Objectif 1 : Détection des micro-transactions nocturnes

Situation : Après avoir compromis des comptes, les attaquants effectuent généralement des petites transactions test pour vérifier si les moyens de paiement associés sont toujours valides avant de procéder à des transactions plus importantes.

Mission : Identifiez les comptes ayant effectué exclusivement des transactions dont le montant est strictement inférieur à 50€ entre 1h et 5h du matin le 15 mars 2024, période où l’activité légitime est minimale.

Information

Les micro-transactions nocturnes sont une technique classique utilisée par les fraudeurs pour tester des cartes volées. En limitant les montants, ils évitent de déclencher les alertes basées sur les montants, tandis que le timing nocturne réduit les chances de détection immédiate par les propriétaires légitimes des cartes.

Le mot-clef exclusivement est important : on ne veut que les comptes pour lesquels il n’existe pas de transaction dont le montant est supérieur ou égal à 50€.

Il y a deux façons différentes de résoudre cette requête :

  • Une complexe avec des requêtes imbriquées avec double NOT IN ou NOT EXISTS.
  • Une plus simple avec … HAVING et une condition à bien spécifier.
  • Pensez à utiliser le mot-clef BETWEEN.

Pour valider cet objectif, notez le plus grand customer_id retourné par votre requête.

Objectif 2 : Distribution géographique des transactions suspectes

Situation : Les transactions frauduleuses présentent souvent des anomalies dans leur distribution géographique. Les attaquants utilisent un réseau d’adresses IP pour masquer leur activité, mais certains patterns géographiques peuvent les trahir.

Mission : Analysez la distribution géographique des transactions du 15 mars 2024 pour identifier les zones présentant des activités anormales de montant moyen, du volume de transaction par compte et de diversité d’adresses IP.

Information

L’analyse géographique des transactions est un outil puissant dans la détection des fraudes. Des transactions importantes depuis des localisations inhabituelles ou des schémas de déplacement impossibles (comme des transactions simultanées dans des villes éloignées) sont des indicateurs fiables d’activité frauduleuse.

Pour démarrer cette analyse, vous disposez d’une requête qui calcule des statistiques clés par ville. Cette requête s’organise en deux temps : elle commence par agréger les données de transactions pour chaque ville (montants moyens, nombre de transactions par compte, nombre d’IPs par compte), puis elle classifie ces villes selon leur niveau de risque en se basant sur différents seuils d’alerte que vous allez devoir définir

WITH stats_par_ville AS (
    -- Cette première partie calcule toutes les statistiques par ville
    SELECT 
        l.city as ville,
        l.country as pays,
        COUNT(*) as nb_transactions,
        COUNT(DISTINCT t.customer_id) as nb_comptes,
        ROUND(AVG(t.amount)::numeric, 2) as montant_moyen,
        COUNT(DISTINCT t.ip_address) as nb_ips,
        -- Ratio : nombre de transactions par compte
        ROUND(COUNT(*)::numeric / NULLIF(COUNT(DISTINCT t.customer_id), 0), 1) as ratio_tx_compte,
        -- Ratio : nombre d'IPs différentes par compte
        ROUND(COUNT(DISTINCT t.ip_address)::numeric / NULLIF(COUNT(DISTINCT t.customer_id), 0), 1) as ratio_ip_compte
    FROM transactions t
    JOIN locations l ON t.ip_address LIKE l.ip_range || '%'
    WHERE t.transaction_date >= '2024-03-15'
    GROUP BY l.city, l.country
    HAVING COUNT(*) > 0
)
SELECT 
    ville,
    pays,
    nb_transactions as "nombre de transactions",
    nb_comptes as "nombre de comptes",
    montant_moyen as "montant moyen",
    nb_ips as "nombre d'IPs",
    ratio_tx_compte as "tx par compte",
    ratio_ip_compte as "ip par compte",
    -- À vous d'implémenter le calcul du score de risque ici !
    -- Il doit combiner trois CASE pour les différents critères
    [votre code ici] as score_risque
FROM stats_par_ville
ORDER BY score_risque DESC;

Cette requête doit être complétée pour calculer le score de risque et aider à détecter les activités frauduleuses.

Modifier cette rêquete pour que le score de risque soit la somme de ces trois composantes :

  • Score basé sur le montant moyen (max 40 points)
    • 40 points si montant_moyen > 2000€
    • 20 points si montant_moyen > 1000€
    • 0 points sinon
  • Score basé sur le ratio transactions/compte (max 30 points)
    • 30 points si ratio_tx_compte > 8 transactions/compte
    • 15 points si ratio_tx_compte > 3 transactions/compte
    • 0 points sinon
  • Score basé sur le ratio IPs/compte (max 30 points)
    • 30 points si ratio_ip_compte > 3 IPs/compte
    • 15 points si ratio_ip_compte > 1 IP/compte
    • 0 points sinon Score maximum possible : 100 points

Structure pour un des trois CASE :

CASE
    WHEN xxx > n1 THEN 1
    WHEN xxx > n2 THEN 2
    ELSE 0
END

Pour additionner les trois scores, utilisez des parenthèses :

(
    [premier CASE] +
    [deuxième CASE] +
    [troisième CASE]
)

Pour visualiser ces résultats sur une carte de France, exécutez d’abord votre requête SQL modifiée dans une cellule Jupyter avec la syntaxe %%sql, puis lancez dans une nouvelle cellule le code Python fourni ci-dessous qui créera une carte interactive où la taille des cercles représente le montant moyen des transactions et la couleur le niveau de risque.

import folium
import pandas as pd
import numpy as np

# Récupération des résultats de la requête SQL
result = _
df = result.DataFrame()

# Conversion des colonnes en types appropriés
df['montant moyen'] = df['montant moyen'].astype(float)
df['score_risque'] = df['score_risque'].astype(float)

# Coordonnées des villes
coords = {
    # France
    'Paris': [48.8566, 2.3522],
    'Lyon': [45.7500, 4.8500],
    'Marseille': [43.2965, 5.3698],
    'Bordeaux': [44.8378, -0.5792],
    'Lille': [50.6292, 3.0573],
    'Toulouse': [43.6047, 1.4442],
    
    # International
    'New York': [40.7128, -74.0060],
    'Sao Paulo': [-23.5505, -46.6333],
    'Tokyo': [35.6762, 139.6503],
    'Moscow': [55.7558, 37.6173],
    'St. Petersburg': [59.9343, 30.3351],
    'Berlin': [52.5200, 13.4050],
    'Beijing': [39.9042, 116.4074],
    'Mumbai': [19.0760, 72.8777],
    'Singapore': [1.3521, 103.8198]
}

# Création de la carte centrée sur une vue mondiale
m = folium.Map(location=[30, 0], zoom_start=2)

def get_color(score):
    if score >= 70:
        return 'red'
    elif score >= 40:
        return 'orange'
    else:
        return 'yellow'

# Calcul du rayon min et max pour l'échelle des cercles
min_amount = float(df['montant moyen'].min())
max_amount = float(df['montant moyen'].max())

def get_radius(amount):
    min_radius = 3
    max_radius = 8
    if amount == min_amount:
        return min_radius
    return min_radius + (max_radius - min_radius) * (np.log(amount) - np.log(min_amount)) / (np.log(max_amount) - np.log(min_amount))

# Ajout des marqueurs
for _, row in df.iterrows():
    if row['ville'] in coords:
        popup_content = f"""
        <div style="width:200px">
            <b>{row['ville']} ({row['pays']})</b><br>
            Nombre de transactions: {int(row['nombre de transactions'])}<br>
            Nombre de comptes: {int(row['nombre de comptes'])}<br>
            Montant moyen: {float(row['montant moyen']):,.2f}€<br>
            Transactions par compte: {float(row['transactions par compte']):,.1f}<br>
            IPs par compte: {float(row['ip par compte']):,.1f}<br>
            Score de risque: {int(row['score_risque'])}
        </div>
        """
        
        folium.CircleMarker(
            coords[row['ville']],
            radius=get_radius(float(row['montant moyen'])),
            popup=folium.Popup(popup_content, max_width=300),
            color=get_color(row['score_risque']),
            fill=True,
            fillColor=get_color(row['score_risque']),
            fillOpacity=0.7,
            weight=2
        ).add_to(m)

# Légende des niveaux de risque
legend_html = '''
<div style="position: fixed; 
            bottom: 50px; 
            right: 50px; 
            z-index: 1000;
            padding: 10px;
            background-color: white;
            border-radius: 5px;
            box-shadow: 0 0 15px rgba(0,0,0,0.2);">
            <p><strong>Score de risque</strong></p>
            <p><i style="background: red; width: 15px; height: 15px; display: inline-block; border-radius: 50%;"></i> ≥ 70 (Élevé)</p>
            <p><i style="background: orange; width: 15px; height: 15px; display: inline-block; border-radius: 50%;"></i> 40-69 (Moyen)</p>
            <p><i style="background: yellow; width: 15px; height: 15px; display: inline-block; border-radius: 50%;"></i> < 40 (Faible)</p>
</div>
'''
m.get_root().html.add_child(folium.Element(legend_html))

# Légende des tailles
amounts_for_legend = [min_amount, 
                     np.exp((np.log(min_amount) + np.log(max_amount))/2), 
                     max_amount]

legend_html = '''
<div style="position: fixed; 
            bottom: 50px; 
            left: 50px; 
            z-index: 1000;
            padding: 10px;
            background-color: white;
            border-radius: 5px;
            box-shadow: 0 0 15px rgba(0,0,0,0.2);">
            <p><strong>Montant moyen</strong></p>
'''
for amount in amounts_for_legend:
    legend_html += f'<p><i style="background: #666; width: {get_radius(amount)}px; height: {get_radius(amount)}px; display: inline-block; border-radius: 50%;"></i> {amount:,.0f}€</p>'
legend_html += '</div>'
m.get_root().html.add_child(folium.Element(legend_html))

# Affichage de la carte
m

Pour valider cet objectif, notez le nombre de zones avec un score de risque Élevé (supérieur à 70).

Validez vos réponses

Prochaine étape (il devrait vous rester 1h15)

Continuez maintenant avec la Phase 2 pour analyser et maintenir la cohérence et l’intégrité des données du système après l’attaque.