Le pare-feu Netfilter permet de faire tout ce qu'un pare-feu au niveau noyau est capable de faire : filtrer des paquets en fonction de critères tels que l'adresse source, le port de destination, etc. Si cela semble suffisant la plupart du temps, il peut être utile d'avoir accès à des bibliothèques pour accéder, par exemple, à un annuaire où à une base de données. Nous allons voir comment fonctionne le weatherwall, le pare-feu next-generation et d'autres applications utiles (ou pas).
1. Netfilter, vie et mort des paquets
Le travail de Netfilter consiste à aiguiller, en fonction de critères de décision, les paquets vers des actions (cibles) terminales. C'est assez semblable à notre compagnie de transport nationale, avec un brin de fiabilité et de prédictibilité en plus (quoique, le module FUZZY permet d'obtenir le même comportement…). Les cibles les plus classiques sont DROP, REJECT et ACCEPT.
Cependant, la liste des critères de décision est assez limitée, et il est très difficile de l'étendre : la programmation se fait en mode noyau. Il n'y a donc pas accès à des bibliothèques externes. Il faut rebooter assez fréquemment (la moindre erreur ne pardonne pas), et, pire encore, il faut programmer proprement. Pour résoudre ces problèmes, Netfilter a introduit plusieurs cibles terminales : QUEUE et NFQUEUE. Ces cibles permettent d'envoyer le paquet en espace utilisateur pour qu'une application puisse effectuer sa propre analyse et décider du verdict. Des cibles similaires existent pour le log : LOG et NFLOG.
Ces cibles sont déjà utilisées par plusieurs applications majeures, telles que Snort-Inline (IDS/IPS), NuFW (pare-feu authentifiant), ulogd (journalisation Netfilter, voir l'article dans ce magazine).
Cependant, la programmation se fait toujours en langage C. Ce n'est pas forcément un problème, mais ça ne facilite pas le développement rapide. Donc, on a troqué le vilain kernel panic contre un vilain segmentation fault.
L'objectif de cet article est de montrer comment, en combinant les fonctionnalités de la cible NFQUEUE et de SWIG, on peut créer des modules de développement rapide d'applications de filtrage, et comment ces modules peuvent conduire à écrire des applications pas toujours très sérieuses.
2. Swig
2.1 Swig, c'est quoi ?
SWIG (Simplified Wrapper and Interface Generator [3]) est un générateur d'interfaces : il permet de générer automatiquement la couche d'abstraction permettant d'utiliser des modules en C et C++ depuis des langages de haut niveau. SWIG supporte la génération pour de nombreux langages tels que Python, Perl, Ruby, Lua, et même Java ou C# (comme ça, tout le monde trouvera de quoi troller).
Par rapport à l'utilisation directe de l'API de chaque langage (par exemple perlxs ou ctypes), SWIG présente l'inconvénient d'être légèrement plus lent (et encore, ça reste à prouver). Par contre, il a l'avantage d'être générique (un seul code pour tous les wrappers), et d'être assez flexible pour nos besoins : il supporte l'utilisation de fonctions de conversion, et permet de créer un résultat orienté objet.
Cette dernière fonctionnalité est d'autant plus intéressante que dans nfqueue-bindings, nous n'avons pas envie d'exposer les fonctions de la même manière dans le langage de haut niveau, mais plutôt de les encapsuler de manière à les simplifier.
Grâce à SWIG, on va donc pouvoir troquer notre vilain segmentation fault contre un joli traceback python de 20 lignes.
2.2 Exemple simple
Mais assez parlé, passons à l'utilisation de SWIG. Imaginons que nous ayons le fichier file.h suivant :
#ifndef __MY_FILE__
#define __MY_FILE__
int fact(int);
int my_mod(int n, int m);
#endif
Le fichier file.c correspondant :
#include "file.h"
/* Compute factorial of n */
int fact(int n)
{
if (n <= 1) return 1;
else return n*fact(n-1);
}
/* Compute n mod m */
int my_mod(int n, int m)
{
return(n % m);
}
Nous souhaitons pouvoir utiliser ces fonctions dans un langage de haut niveau (ici, on prend comme exemple Python). Nous allons donc créer un fichier d'interface de SWIG, example.i, pour pouvoir ensuite générer un module Python. Normalement, on devrait déclarer toutes les fonctions dans le fichier SWIG, mais il y a beaucoup plus simple : il suffit d'inclure le fichier header.
/* File : example.i */
%module example
%{
/* Put headers and other declarations here */
#include "file.h"
%}
%include "file.h"
%extend my_struct_t {
int my_func(int);
}
La directive %module permet de préciser le nom sous lequel le module pourra être importé dans Python. On invoque ensuite SWIG en utilisant la commande swig, et en précisant le langage de destination :
swig -python example.i
Cette commande va créer deux fichiers, example.py et example_wrap.c. Ce dernier doit être compilé avec nos autres fichiers (file.c) pour former une bibliothèque dynamique, qui sera chargé par le module Python. On compile donc le tout :
gcc -c -fpic file.c example_wrap.c -I/usr/include/python2.5/
gcc -shared -o _example.so file.o example_wrap.o -lpython2.5
Notez que le nom du fichier de sortie est celui du module, préfixé du caractère « _ » (souligné). Le module est prêt, on peut l'utiliser :
$ python
>>> import example
>>> example.fact(5)
120
2.3 Code orienté objet
Le code précédent aurait été suffisant pour pouvoir encapsuler l'intégralité des fonctions de libnetfilter_queue. Mais, nous ne souhaitons pas « juste » proposer les fonctions à Python. Ça n'aurait pas beaucoup d'intérêt (et c'est déjà possible en utilisant des choses comme ctypes). L'autre objectif est d'encapsuler les fonctions pour pouvoir faire de la programmation objet, et SWIG va nous aider.
SWIG est capable de convertir du code C++, en conservant la notion d'objet dans le langage de destination. Mais, cela nous aurait obligé à écrire une couche d'abstraction C++ à la main, donc le gain aurait été faible. Nous allons plutôt utiliser des structures C, parce que SWIG offre la possibilité assez sympathique d'étendre ces structures en leur ajoutant des fonctions, qui deviennent donc des fonctions membres.
On ajoute maintenant la structure suivante au fichier header :
struct my_struct_t {
int var;
};
SWIG va générer un objet Python opaque (de type proxy, pour transférer tous les appels sur l'objet Python vers l'objet C correspondant, en convertissant les arguments si besoin est).
>>> s = example.my_struct_t()
>>> print s
<example.my_struct_t; proxy of <Swig Object of type 'my_struct_t *' at 0x85cc440> >
Pour ajouter des fonctions membres, il faut utiliser l'instruction %extend de SWIG, dans le fichier interface :
%extend my_struct_t {
int my_func(int);
}
Cette instruction permet d'ajouter des fonctions membres à l'objet correspondant à la structure. La fonction C correspondante doit exister, et sa signature doit être du type :
code_retour <nom_structure>_<nom_fonction>(type_structure *self, autres_arguments);
Dans notre exemple, la signature sera la suivante :
int my_struct_t_my_func(struct my_struct_t *self, int n)
{
if (self == NULL)
return;
self->var += n;
return self->var;
}
C'est fini ! Après recompilation, nous voilà capable d'accéder à la fonction comme pour tout objet python :
>>> s = example.my_struct_t()
>>> s.var = 0
>>> s.my_func(42)
42
>>> print s.var
42
3. nfqueue-bindings
3.1 Cmake !
Après avoir utilisé (et bataillé avec) automake et autoconf, chacun se fera son opinion sur ces outils. De mon côté, le choix a été vite fait : Cmake !
CMake va nous servir pour détecter les bibliothèques nécessaires à la compilation, pour créer les fichiers Makefile, qui serviront à gérer la compilation et l'installation.
Pour les pré-requis, la détection sera extrêmement simple : le minimum est SWIG, et libnetfilter_queue :
FIND_PACKAGE(SWIG REQUIRED)
INCLUDE(${SWIG_USE_FILE})
INCLUDE(UsePkgConfig)
PKGCONFIG(libnetfilter_queue LIBNFQ_INCLUDE_DIR LIBNFQ_LINK_DIR LIBNFQ_LINK_FLAGS LIBNFQ_CFLAGS)
ADD_SUBDIRECTORY(python)
ADD_SUBDIRECTORY(perl)
Voilà, CMake se charge de trouver et d'exporter les variables requises. Pour les variables spécifiques, la recherche se fera dans le répertoire associé (perl ou python). Par exemple (pour Python) :
FIND_PACKAGE(PythonInterp)
FIND_PACKAGE(PythonLibs)
Le principal de la configuration se fait en précisant le nom du fichier interface SWIG, et le nom des fichiers C :
SET(SOURCES ../nfq.c ../nfq_common.c ../nfq_utils.c)
SWIG_ADD_MODULE(nfqueue python libnetfilter_queue.i ${SOURCES})
SWIG_LINK_LIBRARIES(nfqueue ${PYTHON_LIBRARIES} ${LIBNFQ_LINK_FLAGS})
3.2 Fonctionnement
nfqueue_bindings n'a pas pour vocation de copier l'API de libnetfilter_queue, donc au lieu d'exposer toutes les fonctions de l'API, seul un objet est directement visible : queue. Cette objet contient les méthodes nécessaires pour créer une file et s'enregistrer auprès du noyau.
La récupération des données des paquets se fera également sous forme de données brutes, ce qui permettra soit d'accéder directement aux données, soit d'utiliser un module standard (par exemple NetPacket::IP pour Perl ou dpkt et scapy pour Python).
Cependant, un problème apparaît assez vite : libnetfilter_queue fonctionne en utilisant une fonction de rappel (callback) en C, alors que nous ne pouvons que fournir une fonction de haut niveau (Python). Et là, c'est le drame : après avoir cherché un bon moment, la chose n'apparaît pas du tout simple avec SWIG. Il a donc été décidé d'utiliser directement l'API des langages de haut niveau.
La solution la plus simple (et aussi la moins propre) est de créer une fonction qui permettra de stocker une référence sur la fonction Python, dans un pointeur générique (void *). Ce n'est pas très élégant, mais cela simplifiera les choses pour la suite.
int set_callback(PyObject *pyfunc)
{
self->_cb = (void*)pyfunc;
Py_INCREF(pyfunc);
return 0;
}
La fonction de callback nfqueue a une signature imposée par nfqueue. Le contenu de cette fonction devra effectuer trois actions : extraire les données, construire un objet Python, appeler la fonction.
1. Extraction des données (code simplifié) :
int swig_nfq_callback(struct nfq_q_handle *qh, struct nfgenmsg *nfmsg,
struct nfq_data *nfad, void *data)
{
struct nfqnl_msg_packet_hdr *ph;
int id;
char *payload_data;
int payload_len;
ph = nfq_get_msg_packet_hdr(nfad);
id = ntohl(ph->packet_id);
payload_len = nfq_get_payload(nfad, &payload_data);
2. Construction de l'objet Python :
func = (PyObject *) data;
p = malloc(sizeof(struct payload));
p->data = payload_data;
p->len = payload_len;
p->id = id;
payload_obj = SWIG_NewPointerObj((void*) p, SWIGTYPE_p_payload, 1);
arglist = Py_BuildValue("(i,O)", 42, payload_obj);
La dernière ligne correspond à la conversion d'une structure C en son objet Python « proxifié » par SWIG. Pour comprendre le fonctionnement, il suffit de regarder le code source du fichier C généré par SWIG. Le chiffre 42, lui, ne sert à rien.
L'utilisation de l'API Python (en particulier l'appel de fonctions) n'est pas thread-safe. Il faut donc entourer le code des macros SWIG_PYTHON_THREAD_BEGIN_ALLOW et SWIG_PYTHON_THREAD_END_ALLOW.
3. Appel de la fonction
result = PyEval_CallObject(func,arglist);
result = PyErr_Occurred();
if (result) {
/* callback failure */
PyErr_Print();
}
Tout fonctionne. Enfin, presque : on a oublié d'appliquer le verdict ! Pas de panique, ce point est assez simple à régler, il suffit d'appeler la fonction nfq_set_verdict. Cette fonction doit être appelée pendant la fonction de callback. On l'encapsule donc dans SWIG comme toute autre fonction C. Dans le fichier d'interface SWIG, on ajoute :
/* taken from /usr/include/linux/netfilter.h */
#define NF_DROP 0
#define NF_ACCEPT 1
#define NF_STOLEN 2
#define NF_QUEUE 3
#define NF_REPEAT 4
#define NF_STOP 5
#define NF_MAX_VERDICT NF_STOP
%extend queue {
int set_verdict(int d) {
return nfq_set_verdict(self->qh, self->id, d, 0, NULL);
}
};
C'est tout pour le code C : le reste n'est que de la gestion d'erreurs, et de l'encapsulation de fonctions standards permettant de régler les paramètres nfqueue.
3.3 Compilation
nfqueue-bindings est disponible au téléchargement sur le site http://software.inl.fr. Seuls les paquets source sont disponibles. Les paquets binaires sont pris en charge directement par les distributions Linux.
À l'heure actuelle, la version stable est la version 0.1. Ce numéro est assez faible non pas par manque de stabilité, mais plus par manque de maturité (l'API pourrait encore changer), et par le fait que certaines fonctions C ne sont pas encore encapsulées.
Le dépôt git contient des corrections effectuées après la release. Il est donc parfois préférable de l'utiliser. C'est ce que nous allons faire ici.
$ git clone git://git.inl.fr/git/nfqueue-bindings.git
Cette commande récupère le dépôt git, en fait une copie locale, et extrait la dernière version dans la branche master. N'ayez crainte, tout cela est très rapide (merci git), et le suffixe .git est automatiquement supprimé.
Avant de pouvoir compiler, assurez-vous que les dépendances sont bien installées : CMake (version minimum 2.4), SWIG, pkg-config, libnfnetlink, libnetfilter_queue, python-dev et libperl-dev. Il est aussi fortement conseillé d'avoir un noyau pas trop ancien, minimum 2.6.18 (2.6.20 pour pouvoir régler la taille de la file de communication entre le noyau et l'espace utilisateur).
Pour ne pas polluer le répertoire source avec les fichiers issus de la configuration et de la compilation, nous allons créer un sous-répertoire build, et compiler dans ce répertoire. Si quelque chose ne fonctionne pas comme voulu, il suffira de l'effacer et de recommencer. Commençons par invoquer CMake, en précisant un répertoire d'installation.
$ cd nfqueue-bindings
$ mkdir build
$ cd build
$ cmake -DCMAKE_INSTALL_PREFIX=/usr/local ..
Si tout se passe bien, un message doit vous indiquer que la configuration est finie, et que les fichiers Makefile ont été générés. On continue ensuite par la très classique compilation :
$ make
$ [sudo] make install
3.4 Exemple simple
Nous disposons donc maintenant de deux modules (Perl ou Python). Si vous avez installé ces modules dans des répertoires non standards, ils ne sont pas utilisables directement : l'interpréteur se plaint qu'il ne peut pas charger le module.
Pour Python, la solution est soit de modifier la variable d'environnement PYTHONPATH, soit d'ajouter quelques lignes au début du programme :
from sys import path
path.append('/path/to/python/bindings')
import nfqueue
Pour Perl, le plus simple est d'ajouter le chemin vers le module dans la variable @INC, mais en faisant attention de le faire avant l'exécution du reste du code (donc dans la directive BEGIN) :
BEGIN {
push @INC,"/path/to/perl/bindings";
};
use nfqueue;
L'exemple qui suit va effectuer un décodage très basique des paquets, en utilisant le module Python dpkt ([4]). La fonction de callback doit extraire les données brutes du payload, puis effectuer une conversion vers un objet dpkt.ip.IP. Une connaissance du format des paquets IP peut être utile (sans pour autant avoir besoin de lire les RFC), et, dans tous les cas, il ne faut pas oublier que certaines informations sont dépendantes du protocole. Par exemple, la notion de port de destination n'a de sens que pour TCP ou UDP.
def cb(i,payload):
print "python callback called !", i
print "payload len ", payload.get_length()
data = payload.get_data()
pkt = ip.IP(data)
print "proto:", pkt.p
print "source: %s" % inet_ntoa(pkt.src)
print "dest: %s" % inet_ntoa(pkt.dst)
if pkt.p == ip.IP_PROTO_TCP:
print " sport: %s" % pkt.tcp.sport
print " dport: %s" % pkt.tcp.dport
payload.set_verdict(nfqueue.NF_DROP)
return 1
Dans le cas où la fonction de callback ne renvoie pas de décision (ACCEPT ou DROP), un verdict par défaut est appliqué (ACCEPT). Si la fonction de verdict est appelée plusieurs fois, c'est le premier appel qui gagne. Cela permet également de renvoyer la décision le plus rapidement possible, et de pouvoir continuer le traitement si besoin est.
La création de la file NFQUEUE permet de préciser à quelle file on souhaite se connecter. Cette file est identifiée par un numéro, et doit correspondre au paramètre --queue-num de la règle iptables (nous reviendrons sur ce point après). Il faut également positionner la fonction de callback avant d'appeler la boucle d'évènements, qui est une boucle infinie, jusqu'à une interruption (clavier par exemple) ou une erreur.
q = nfqueue.queue()
q.open()
q.bind();
q.set_callback(cb)
q.create_queue(0)
try:
q.try_run()
except KeyboardInterrupt, e:
print "interrupted"
q.unbind()
q.close()
La fonction fast_open a récemment été ajoutée pour effectuer toutes les opérations d'initialisation :
q = nfqueue.queue()
q.set_callback(cb)
q.fast_open(0)
Il reste à ajouter une règle avec iptables pour envoyer les paquets (suivant un critère, inutile de tout envoyer) vers la cible NFQUEUE.
# iptables -A OUTPUT -p tcp --destination 192.168.33.181 -m state --state ESTABLISHED -j ACCEPT
# iptables -A OUTPUT -p tcp --destination 192.168.33.181 --dport 80 -m state --state NEW --syn -j NFQUEUE --queue-num 0
On lance notre programme de test en utilisant la commande sudo. En effet, l'opération de connexion (bind) demande des privilèges administrateur, et on teste l'envoi d'un paquet (telnet ou nc feront l'affaire).
$ sudo ./examples/example.py
open
bind
setting callback
creating queue
trying to run
setting copy_packet mode
python callback called ! 42
payload len 60
proto: 6
source: 192.168.33.145
dest: 192.168.33.181
sport: 38247
dport: 80
python callback call: 0 sec 552 usec
D'autres exemples sont disponibles dans le répertoire examples des sources.
4. Applications
4.1 Weatherwall
Maintenant que nos bindings sont prêts, nous allons pouvoir les utiliser. La première application sera une illustration des possibilités qui s'offrent par le fait de ne pas être en mode noyau : nous allons créer un pare-feu dont le critère de filtrage sera non pas l'adresse source ou destination, mais plutôt la météo de la ville source ou destination.
Tout d'abord, nous allons utiliser les bindings pour récupérer l'adresse de destination des paquets. Grâce aux bindings (et aux modules Perl), ce point est réglé assez rapidement :
my $ip_obj = NetPacket::IP->decode($payload->get_data());
my $dst_ip = $ip_obj->{dest_ip};
L'étape suivante consiste à récupérer le nom de la ville, et le pays correspondant à l'adresse IP de destination. Nous allons utiliser pour cela l'extension Perl Geo::IP. Avant de pouvoir l'utiliser, il faut télécharger une base contenant des informations sur tous les blocs IP.
wget http://www.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz
gunzip GeoLiteCity.dat.gz
On utilise donc Geo::IP pour récupérer la ville et le pays. Cette récupération peut parfois échouer (toutes les adresses IP du monde ne sont pas enregistrées ou encore l'adresse est celle d'un réseau privé, etc.), donc il ne faut pas oublier la gestion des erreurs.
use Geo::IP;
my $record = get_city($dst_ip);
$record or return;
my $city = $record->city . ", " . $record->country_name;
Le nom du pays est mis dans un format "Ville, Pays" qui sera ensuite utilisé pour récupérer la météo.
La récupération de la météo est à peine plus compliquée. La difficulté se situe surtout dans le choix des modules : il en existe de nombreux (basés sur METAR ou encore weather.com). Certains de ces modules demandent de s'enregistrer (ce qui est parfois payant). Ils ont donc été écartés. Après une recherche sur le net, nous choisissons donc d'utiliser Weather::Underground ([5]), qui semble fonctionner assez bien.
En utilisant la ville et le pays récupéré juste avant, on récupère un objet contenant les paramètres météo : température, humidité, et conditions actuelle (pluie, etc.). Là encore, la récupération de la météo peut échouer, donc il faut faire attention.
use Weather::Underground;
my $weather = get_weather($city);
if (not $weather) {
print "unable to fetch weather for $city\n";
return;
}
La récupération de la météo se fait en ouvrant une connexion. Attention de ne pas renvoyer cette connexion à NFQUEUE, sinon votre programme ne sera qu'une nouvelle sorte de boucle infinie !
Nous y sommes presque ! L'objet contient toutes les données qui nous intéressent, il est facile de les lire :
print "Place: ", $weather->{place}, "\n";
print "Conditions: ", $weather->{conditions}, "\n";
print "Temperature: ", $weather->{temperature_celsius}, "\n";
print "Humidity: ", $weather->{humidity}, "\n";
Et c'est là où la syntaxe Perl est appréciable :
if ($weather->{conditions} =~ m/[Rr]ain/)
and $city neq "Rennes")
{
$payload->set_verdict($nfqueue::NF_DROP);
return;
}
$payload->set_verdict($nfqueue::NF_ACCEPT);
4.2 Personal Firewall
Vous en avez assez que Netfilter filtre des paquets sans rien vous dire ? Vous souhaitez pouvoir effectuer un contrôle plus poussé ? Pas de problème, nous allons créer une application permettant de demander à l'utilisateur de valider les paquets !
On utilise le même système que précédemment, en enregistrant une fonction de rappel sur la réception d'un paquet. La première étape consiste à extraire les valeurs qui nous intéressent du paquet.
text_dst = None
if pkt.p == ip.IP_PROTO_TCP:
print " sport: %s" % pkt.tcp.sport
print " dport: %s" % pkt.tcp.dport
text = "%s:%s => %s:%s" % (inet_ntoa(pkt.src),pkt.tcp.sport, inet_ntoa(pkt.dst), pkt.tcp.dport)
text_dst = "%s:%s" % (inet_ntoa(pkt.dst), pkt.tcp.dport)
Nous disposons maintenant d'une variable text qui contient le quadruplet TCP de la connexion, par exemple 192.168.1.12:59568 => 192.168.1.1:80.
Il ne reste plus qu'à présenter ce texte à l'utilisateur, en lui demandant de valider (ou pas) la connexion. Pour des raisons d'heure tardive et d'urgence sur les délais de réalisation, l'implémentation a été faite avec la solution la plus rapide, en Python-Qt :
reply = QMessageBox.question(None,'accept packet ?',text,QMessageBox.Yes, QMessageBox.No)
if reply == QMessageBox.Yes:
decision = nfqueue.NF_ACCEPT
payload.set_verdict(nfqueue.NF_ACCEPT)
else:
decision = nfqueue.NF_DROP
payload.set_verdict(decision)
Pour pouvoir tester, il faut ajouter une règle iptables qui envoie les paquets à la cible NFQUEUE (mais on ne veut que les paquets SYN). On ajoute donc deux règles, une pour les connexions établies, et une pour les paquets SYN.
# iptables -A OUTPUT -p tcp --destination 192.168.33.181 -m state --state ESTABLISHED -j ACCEPT
# iptables -A OUTPUT -p tcp --destination 192.168.33.181 --dport 80 -m state --state NEW --syn -j NFQUEUE
Voila ! On dispose maintenant d'une application presque aussi utile et professionnelle que ses équivalents sous un système propriétaire.
Pour accélérer un peu les performances (et ne pas trop ennuyer l'utilisateur), le programme a été amélioré pour ne filtrer que les paquets SYN d'un part, et pour mettre en cache les décisions d'autre part. Cette partie est extrêmement simple : avant de poser la question, on regarde si le quadruplet est déjà dans le cache (pour faire sale, on stocke directement le texte concernant l'adresse et le port de destination dans le cache), et on applique la décision si c'est le cas :
if text_dst and cache_decisions.has_key(text_dst):
print "shortcut: %s (%d)" % (text_dst,cache_decisions[text_dst])
return payload.set_verdict(cache_decisions[text_dst])
Il ne reste plus qu'à remplir le cache, en ajoutant après l'application de la décision :
cache_decisions[text_dst] = decision
4.3 Filtrage du contenu des paquets
Maintenant que nous savons décoder les paquets, il serait intéressant de pouvoir lire les données, par exemple le contenu des requêtes HTTP. Un peu de connaissance TCP/IP s'impose : une connexion TCP est composée de paquets de contrôle (qui servent à assurer que les données ont effectivement été transmises) et d'un seul paquet qui contiendra réellement les données. La différence entre ces paquets se fait grâce à l'utilisation de drapeaux (flags) : SYN, ACK, RST, FIN et PSH. Chaque paquet envoyé est marqué d'un ou plusieurs drapeaux, par exemple le premier paquet d'un connexion est forcément un SYN.
Le paquet contenant les données est marqué du flag PSH. Il est important de restreindre autant que possible le filtrage au niveau d'iptables pour n'envoyer en espace utilisateur que le minimum de données possibles : la copie coûte cher (en temps), et Linux n'est pas forcément idéal sur ce point (pas de zero-copy, donc avant d'arriver en espace utilisateur, un paquet est copié plusieurs fois).
L'exemple suivant est fait en Perl (pour changer). On procède, comme précédemment, mais cette fois on doit d'abord décoder le couche IP du paquet, puis la couche TCP pour accéder aux données. Nous allons utiliser nfqueue-bindings pour écrire un programme qui vérifie qu'une connexion sur le port 80 contient vraiment des données correspondant au protocole HTTP (de manière assez rudimentaire). On adapte la fonction de callback :
if($ip_obj->{proto} == IP_PROTO_TCP) {
# decode the TCP header
my $tcp_obj = NetPacket::TCP->decode($ip_obj->{data});
if ($tcp_obj->{flags} & NetPacket::TCP::PSH) {
print "TCP data:\n";
print "*" x 50 . "\n";
print $tcp_obj->{data};
print "*" x 50 . "\n";
if ($tcp_obj->{dest_port} == 80) {
_check_http($tcp_obj->{data}) or return $payload->set_verdict($nfqueue::NF_DROP);
}
}
}
Il reste à écrire la fonction _check_http. Cette fonction est censée s'assurer que les données vérifient bien le protocole http. Donc, il faudrait lire la RFC correspondante et effectuer un nombre assez grand de vérifications. À titre d'exemple, on va uniquement vérifier que les données contiennent deux paramètres classiques dans les requêtes HTTP : une ligne commençant par GET (le nom de l'objet demandé) et une autre ligne commençant par User-Agent (information contenant le nom du navigateur). Ces lignes peuvent apparaître dans le désordre. Donc, nous allons faire au plus simple : une expression rationnelle que l'on applique sur l'intégralité des données, en mode multi-ligne.
my @http_checks = (
"^GET ",
"^User-Agent",
);
sub _check_http
{
my $data = shift;
foreach my $check (@http_checks) {
return 0 unless ($data =~ /$check/moi);
}
return 1;
}
Si une au moins des vérifications échoue, la fonction renverra 0 et le paquet sera rejeté.
4.4 Modification de paquets
Vous en avez assez que vos utilisateurs passent leur temps à faire de la messagerie instantanée ou de l'IRC ? On va les aider à se faire des amis en modifiant leurs paquets dynamiquement !
La modification de paquets est une fonctionnalité moins connue de libnetfilter_queue. Puisque nous disposons du paquet, il serait bon de pouvoir le modifier, et le réinjecter. Eh bien, c'est possible ! Après avoir modifié les différents champs, il va falloir ré-assembler chaque couche du protocole en recalculant la somme de contrôle (checksum), en commençant par la couche TCP, puis la couche IP, et de le renvoyer au noyau via la fonction nfq_set_verdict. La fonction suivante est déclarée dans le fichier d'interface SWIG :
int set_verdict_modified(int d, char *new_payload, int new_len) {
return nfq_set_verdict(self->qh, self->id, d, new_len, new_payload);
}
Tout d'abord, on ne souhaite filtrer que les connexions TCP, et, dans ces connexions, uniquement les paquets qui contiennent des données (pas les acquittements). On identifie à nouveau ces paquets grâce au flag TCP PSH (Push). La partie ré-assemblage du paquet et calcul du checksum est entièrement gérée par le module NetPacket::IP. Il suffit donc d'appeler la fonction encode.
my $ip_obj = NetPacket::IP->decode($payload->get_data());
if($ip_obj->{proto} == IP_PROTO_TCP) {
# Desassemblage du paquet
my $tcp_obj = NetPacket::TCP->decode($ip_obj->{data});
if ($tcp_obj->{flags} & NetPacket::TCP::PSH &&
length($tcp_obj->{data})) {
# Modification
$tcp_obj->{data} =~ s/love/hate/m;
print "**********\n";
print $tcp_obj->{data};
print "**********\n";
# Re-assemblage
$ip_obj->{data} = $tcp_obj->encode($ip_obj);
my $modified_payload = $ip_obj->encode();
# Envoi
$payload->set_verdict_modified($nfqueue::NF_ACCEPT,$modified_payload,length($modified_payload));
}
La règle iptables doit être modifiée pour ne filtrer que les paquets PSH :
iptables -A OUTPUT -p tcp --destination 192.168.33.181 --tcp-flags psh psh -j NFQUEUE --queue-num 0
En regardant la sortie (assez verbeuse) du programme, on voit que la substitution a bien été faite :
TCP src_port: 33242
TCP dst_port: 6667
TCP flags : 24
TCP data : PRIVMSG regit :I wanted to say that in private, but I love my boss
**********
PRIVMSG regit :I wanted to say that in private, but I hate my boss
**********
192.168.xx.xxx => 213.219.xxx.xx 6
TCP src_port: 33242
TCP dst_port: 6667
data length: 120
ret: 157
perl callback call: 0 sec 1693 usec
Il faut faire attention cependant aux performances. Le fait de ré-assembler les paquets et de les envoyer prend environ trois fois plus de temps qu'un simple ACCEPT.
5. Conclusion
Voila, c'est tout pour cette introduction à nfqueue-bindings. De nombreuses autres applications sont envisageables. À vous d'inventer la vôtre ! Par exemple, nfqueue-bindings permet également de modifier la marque associée à une connexion, peut utiliser la cible NF_REPEAT pour renvoyer le paquet au début de la chaîne de filtrage, et bien d'autres choses encore.
nfqueue-bindings est disponible sur http://software.inl.fr/trac/wiki/nfqueue-bindings, et est packagé pour Debian par votre serviteur.
Les évolutions prévues sur nfqueue-bindings concernent surtout l'ajout de fonctions permettant d'accéder aux métadonnées d'un paquet (interface d'entrée et de sortie, etc.), à l'ajout de fonctions qui ne sont actuellement pas accessibles, à l'amélioration des performances, et à une meilleure gestion des erreurs. L'ajout du support d'autres langages est également envisagé.
Un projet similaire nflog-bindings [6] a été créé, pour la cible NFLOG (au lieu de NFQUEUE), ce qui permet d'avoir accès au mêmes fonctionnalités sauf les fonctions de modification et de verdict sur les paquets. L'intérêt est évidemment de ne pas bloquer le paquet pendant que la fonction de callback est appelée.
Enfin, je tiens à remercier Éric pour l'idée originale du pare-feu météo :)
Références
[1] nfqueue-bindings : http://software.inl.fr/trac/wiki/nfqueue-bindings
[2] La présentation faite au SSTIC : http://www.wzdftpd.net/downloads/rump-sstic-2008-nfqueue-bindings.pdf
[3] SWIG : http://www.swig.org
[4] dpkt : http://code.google.com/p/dpkt/
[5] Weather::Underground : http://search.cpan.org/~mnaguib/Weather-Underground-3.02/Underground.pm
[6] NFLog-bindings : http://software.inl.fr/trac/wiki/nflog-bindings