EDIT 08/08/19: Correction de la mise en page des blocs de code.
Comme le dit l’illustre Labo, “Pour faire un sniffer, rien de plus simple normalement en Python !”. Sauf … quand on développe sur windows.
En effet, la bibliothèque de référence pour le sniffing, j’ai nommé scappy, a plutot mal survécu à son portage pour python 3. Au moment où j’écris, le développement pour windows est encore marqué in-progress. C’est utilisable mais en plus de la dépendance à WinPcap (qui doit être installé si vous utilisez wireshark), cette version nécessite d'être lancée depuis un Powershell. Ca dénote un peu avec la facilité d'utilisation de python.
1. Les raw sockets
Il existe pourtant une autre façon d'écouter le réseau; grâce au raw sockets. Une raw socket est une socket qui permet d’envoyer ou de recevoir des données depuis la couche IP (sans couche transport ex: TCP, UDP). On a alors tout le loisir de parser ou forger nous même les entêtes des paquets. De plus lorsqu’on reçoit sur cette socket, on reçoit tout le traffic réseau.
Voilà ... Le boulot est fait, il ne reste alors plus qu'à parser les entêtes IP, TCP ou UDP pour reconstruire les paquets. C'était pas si dur ?
Les raw sockets existent sous linux mais aussi sous windows. Naturellement, windows n'a pas le même son de cloche et sa version est limité.
Selon la documentation:
- TCP data cannot be sent over raw sockets.
- UDP datagrams with an invalid source address cannot be sent over raw sockets. The IP source address for any outgoing UDP datagram must exist on a network interface or the datagram is dropped. This change was made to limit the ability of malicious code to create distributed denial-of-service attacks and limits the ability to send spoofed packets (TCP/IP packets with a forged source IP address).
- A call to the bind function with a raw socket for the IPPROTO_TCP protocol is not allowed.
En plus de ça, on doit lancer le script depuis une console administrateur (et donc avoir un compte administrateur).
2. Le sniffer
Dans suite du tutoriel, on va implémenter un petit sniffer de paquets TCP en python. Il sera compatible windows mais aussi linux. Il affichera dans le console les paquets qui transitent sur notre carte réseau.
On commence par créer un fichier "sniffer.py"
import io
import os
import socket
class Sniffer:
def __init__(self):
# Due to windows limitation, we sniff from IP
self.sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)
# We want the IP headers in the data received
self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
# We want all the data related to our IP address
host = socket.gethostbyname(socket.gethostname())
self.sock.bind((host, 0))
# In windows case, we have to confirm that we want to receive all.
if os.name == "nt":
self.sock.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON)
def __del__(self):
# Deactivate the "receive all".
if os.name == "nt":
self.sock.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF)
def recv(self):
buffer = io.BytesIO(self.sock.recv(2048))
return buffer.read()
def main():
sniffer = Sniffer()
while True:
packet = sniffer.recv()
print(packet)
if __name__ == "__main__":
main()
2.1 Lancer le script
Windows
Si vous êtes sous windows, il faut vous procurer un cmd admin.
La façon la plus connue est :
- Ouvrir cortana et taper "cmd" dans la barre de recherche.
- Clic droit sur "Invite de commandes".
- "Exécuter en tant qu'administrateur".
- Taper "cd /d".
- Drag and drop du dossier de votre projet.
Si comme moi vous trouvez cette méthode trop longue et que vous ne l'entendez pas de cette oreille, il suffit de créer un fichier "sniffer.bat" à côté de votre script python et d’y coller ceci:
@echo off REM Deactivate commands prompt
pushd %~dp0 REM Come back in the current directory
python sniffer.py REM Start the python script
pause REM To be able to read exceptions
popd REM Leave the current directory
Pour lancer le script :
- Clic droit sur "sniffer.bat".
- "Exécuter en tant qu'administrateur".
Linux
Les linuxiens n’ont qu’à taper dans leur terminal favori "sudo python sniffer.py".
2.2 Parser les paquets
Après avoir généré un peu de trafic réseau, une armée de bytes à dû venir brailler dans votre terminal. Pas de panique, on va parser tout ça et faire le tri.
2.2.1 Le header IP
Les paquets reçus contiennent un header IP. On va commencer par parser celui-ci grâce à cette documentation IP Reference (RFC791).
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version| IHL |Type of Service| Total Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Identification |Flags| Fragment Offset |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Time to Live | Protocol | Header Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options | Padding |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Example Internet Datagram Header
On crée alors la classe Packet.
class Packet:
def __init__(self):
self.src_address = ""
self.src_port = 0
self.protocol = 0
self.dst_address = ""
self.dst_port = 0
self.data = b''
def __parse_ip_header(self, buffer):
header = buffer.read(20)
values = struct.unpack("!BBHHHBBH4s4s", header)
ihl = values[0] & 0x0F
self.src_address = socket.inet_ntoa(values[8])
self.dst_address = socket.inet_ntoa(values[9])
# Read the option bytes
buffer.read((ihl * 4) - 20)
self.protocol = values[6]
N'oubliez pas d'ajouter au début du fichier.
import struct
On récupère ici uniquement les champs du header qui vont nous servir. On lit ensuite les options (qui ont une taille variable) pour ne pas se décaler.
def parse(self, buffer):
self.__parse_ip_header(buffer)
if self.protocol == 6:
self.__parse_tcp_header(buffer)
self.data = buffer.read()
Le protocole qui nous intéresse est TCP. Selon la documentation, son identifiant est 6.
2.2.1 Le header TCP
Pour parser le header TCP, on utilise cette documentation TCP Reference (RFC793).
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |U|A|P|R|S|F| |
| Offset| Reserved |R|C|S|S|Y|I| Window |
| | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options | Padding |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
TCP Header Format
Même combat que pour le header IP, on garde que ce qui nous intéresse et on lit les option bytes.
def __parse_tcp_header(self, buffer):
header = buffer.read(20)
values = struct.unpack("!HHIIHHHH", header)
self.src_port = values[0]
self.dst_port = values[1]
data_offset = values[4] >> 12
# Read the option bytes
buffer.read((data_offset * 4) - 20)
Et pour rendre notre sniffer un peu plus audible, on implémente une méthode spéciale.
def __repr__(self):
if self.protocol == 6:
return "<TCP {0}>{1} {2}>".format(self.src_port, self.dst_port, self.data)
return "<IP {0}>{1} {2}>".format(self.src_address, self.dst_address, self.data)
2.3 El famoso port 5555
On est maintenant capable de parser les paquets TCP mais on veut maintenant garder uniquement les paquets qui nous interresse.
On sait que :
- Les paquets utilisent le protocol TCP.
- Le port du serveur est toujours 5555.
- Les paquets ont une taille superieur à 0 (pour eviter de traiter les ACK ou SYN).
- On connait les adresses IP des serveurs d'authentification.
def is_interesting(packet):
if packet.protocol != 6:
return False
if packet.src_port != 5555 and packet.dst_port != 5555:
return False
if len(packet.data) == 0:
return False
return True
Voila, sans aucune dependance vous êtes maitenant capable d'écouter vos sockets chanter. Il reste encore a parser el famoso protocole 5555 mais ça sort du cadre du tuto.
2.4 Limitations
Ce sniffer est très basique et ne reconstruit pas les conversations TCP.
Pour aller plus loin, on peut envisager la suppression des doublons lors de ré-envois et tri de l'ordre d'arrivé des paquets.
Je vous laisse profiter du chant des sockets. Mais souvenez vous qu'à pleine puissance, l’écoute prolongée peut endommager l’oreille de l’utilisateur.
3. Code source
"sniffer.bat"
@echo off REM Deactivate commands prompt
pushd %~dp0 REM Come back in the current directory
python sniffer.py REM Start the python script
pause REM To be able to read exceptions
popd REM Leave the current directory
"sniffer.py"
import io
import os
import socket
import struct
class Packet:
def __init__(self):
self.src_address = ""
self.src_port = 0
self.protocol = 0
self.dst_address = ""
self.dst_port = 0
self.data = b''
def __parse_ip_header(self, buffer):
header = buffer.read(20)
values = struct.unpack("!BBHHHBBH4s4s", header)
ihl = values[0] & 0x0F
self.src_address = socket.inet_ntoa(values[8])
self.dst_address = socket.inet_ntoa(values[9])
# Read the option bytes
buffer.read((ihl * 4) - 20)
self.protocol = values[6]
def __parse_tcp_header(self, buffer):
header = buffer.read(20)
values = struct.unpack("!HHIIHHHH", header)
self.src_port = values[0]
self.dst_port = values[1]
data_offset = values[4] >> 12
# Read the option bytes
buffer.read((data_offset * 4) - 20)
def parse(self, buffer):
self.__parse_ip_header(buffer)
if self.protocol == 6:
self.__parse_tcp_header(buffer)
self.data = buffer.read()
def __repr__(self):
if self.protocol == 6:
return "<TCP {0}>{1} {2}>".format(self.src_port, self.dst_port, self.data)
return "<IP {0}>{1} {2}>".format(self.src_address, self.dst_address, self.data)
class Sniffer:
def __init__(self, packet_filter=lambda: False):
self.packet_filter = packet_filter
# Due to windows limitation, we sniff from IP
self.sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)
# We want the IP headers in the data received
self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
# We want all the data related to our IP address
host = socket.gethostbyname(socket.gethostname())
self.sock.bind((host, 0))
# In windows case, we have to confirm that we want to receive all.
if os.name == "nt":
self.sock.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON)
def __del__(self):
# Deactivate the "receive all".
if os.name == "nt":
self.sock.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF)
def recv(self):
while True:
buffer = io.BytesIO(self.sock.recv(2048))
packet = Packet()
packet.parse(buffer)
if self.packet_filter(packet):
return packet
def is_interesting(packet):
if packet.protocol != 6:
return False
if packet.src_port != 5555 and packet.dst_port != 5555:
return False
if len(packet.data) == 0:
return False
return True
def main():
sniffer = Sniffer(packet_filter=is_interesting)
while True:
packet = sniffer.recv()
print(packet)
if __name__ == "__main__":
main()
4. References
- Black Hack Python - Justin Seitz
- Code a network packet sniffer in python for Linux
- IP Reference (RFC791)
- TCP Reference (RFC793)
- Understanding TCP Sequence and Acknowledgment Numbers