Bloquer la publicité grâce au DNS
Menteur, menteur
Note : la dernière version de ce script est désormais disponible sur Framagit. La technique de base restant inchangée, ce billet est toujours valable sur ce point.
Je râlais précédemment contre Pi-hole™ qui, partant d’une bonne idée, oubliait en chemin quelques considérations liées à la vie privée, notamment en essayant d’une part d’envoyer par défaut toutes vos requêtes DNS vers des silos étasuniens et d’autre part en laissant fuiter pas mal de requêtes DNS pour des domaines liés à la publicité et/ou à votre traque en ligne. Voyons aujourd’hui comment faire un blocage efficace grâce à Unbound et un peu d’huile de coude (un script rustique en bash en l’occurrence).
Principe
La première chose à faire est de trouver des listes de domaines à bloquer. Pour ce faire, voyons les listes qu’utilise un logiciel bien connu (enfin j’espère) des utilisateurs d’Android : AdAway. Elles sont au nombre de 4 et totalisent, une fois dédoublonnée, environ 59000 domaines :
- https://winhelp2002.mvps.org/hosts.txt
- https://adaway.org/hosts.txt
- https://hosts-file.net/ad_servers.txt
- https://pgl.yoyo.org/adservers/serverlist.php
Les trois premières ont la forme d’un fichier hosts classique associant une IPv4 (en général celle du localhost
) au nom de domaine à bloquer. C’est la forme la plus simple de blocage (c’est celle que pratique AdAway par exemple), mais c’est aussi celle qui va laisser fuiter le plus de données : si un domaine à bloquer est y listé, le client DNS fera tout de même une requête au résolveur pour connaître l’adresse, le système se contentant de remplacer la réponse par 127.0.0.1 (ou rien du tout si on définit 0.0.0.0 comme adresse) et... rien du tout dans le cas d’IPv6 (la bonne adresse sera utilisée). Mais avant d’en arriver à l’utilisation d’une quelconque adresse IP, la fuite des requêtes vers le résolveur élimine de facto la solution. J’avais déjà mentionné la liste disponible sur yoyo.org, notamment car le site en propose une version sous la forme d’un fichier de configuration pour Unbound. Si on y jette un œil :
...
local-zone: "goldstats.com" redirect
local-data: "goldstats.com A 127.0.0.1"
local-zone: "google-analytics.com" redirect
local-data: "google-analytics.com A 127.0.0.1"
...
local-zone
définit très logiquement une zone locale. Bien entendu, nous parlons ici de DNS, cela prend donc en compte tout ce qui est en dessous de ce domaine. Le type redirect
indique que, pour une requête portant sur le domaine (et les sous domaines donc) définis, il y sera répondu avec les données locales définies via local-data
. S'il n’y a rien, une réponse vide sera apportée :
# dig A google-analytics.com ... ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 45522 ;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 ... ;; QUESTION SECTION: ;google-analytics.com. IN A ;; ANSWER SECTION: google-analytics.com. 3600 IN A 127.0.0.1 ;; Query time: 0 msec ;; SERVER: ::1#53(::1)
# dig AAAA google-analytics.com ... ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 570 ;; flags: qr aa rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1 ... ;; QUESTION SECTION: ;google-analytics.com. IN AAAA ;; Query time: 0 msec ;; SERVER: ::1#53(::1)
Plusieurs choses intéressantes avec cette configuration : premièrement, les réponses reçues portent le bit AA
(Authoritative Answer) indiquant que le serveur interrogé (en l'occurence le résolveur) fait autorité pour la zone. Il n’est donc pas étonnant de voir les requêtes se voir retourner un status NOERROR
. Deuxièmement, la requête pour l’enregistrement AAAA
se voit retourner une réponse vide (ANSWER: 0
), ce qui correspond bien au comportement attendu du type redirect
. Par conséquent, on remarque que le fichier de configuration fourni par yoyo.org peut être allégé de toutes les lignes local-data
. On peut également regarder quels autres type le paramètre local-zone
accepte. En jetant un œil à la documentation d’Unbound, on trouve notamment types suivants :
deny
: n’envoie pas de réponse et jette la requête, sauf si un enregistrementlocal-data
est trouvé, auquel cas ce dernier est servi.refuse
: renvoie un code de réponseREFUSED
à la requête, sauf si un enregistrement local est trouvé, auquel cas ce dernier est servi.static
: Si une donnéelocal-data
est trouvée, celle-ci est servie. Sinon, la requête se voit répondreNODATA
ouNXDOMAIN
. Dans ce cas là, si un enregistrement localSOA
est défini, ce dernier est également servi.always_deny
: commedeny
mais ignore tout enregistrement local.always_nxdomain
: commestatic
mais ignore tout enregistrement local et répondsNXDOMAIN
.
Après ce petit tour d’horizon (d’autres types sont possible mais n’entrent pas en compte ici), le type static
semble parfaitement convenir.
On voit donc poindre le script mentionné en début de billet : on récupère les 4 listes, on les nettoie pour ne garder que les domaines (dans le cas de yoyo.org, nous la récupérerons sous une autre forme), puis on la dédoublonne pour finalement construire un fichier de configuration Unbound qui aura cette allure :
local-zone: "publicite.example" static
local-zone: "mouchard.domaine.example" static
...
Un peu d'huile de coude
Par sécurité, on peut fabriquer un petit fichier de configuration Unbound avec un ou deux domaines, on le place dans /etc/unbound/unbound.conf.d/
, on redémarre le serveur et on peut s'assurer qu'aucune requête portant sur ces domaines ne part vers l'extérieur en faisant de la capture de paquets avec tcpdump
ou Wireshark. Une fois que l'on a vérifié que tout va bien, on peut se mettre au travail. En pratique donc, pas de grosses difficultés, le script est en lui même assez simple. Le seul piège vient du fait que les fichiers récupérés sont pensés pour Windows et possèdent donc les retours-chariots de ce dernier. On résoud le problème avec tr
sort -bdfu $tmpListe | tr -d "\r" > $tmpListeClean
Le reste est essentiellement un enchaînement répétitif pour récupérer les listes, gérer les erreurs, logguer les actions. Petite spécificité tout de même, on stocke la liste des domaines brute
avant fabrication du fichier de configuration Unbound. On pourrait ne garder qu'un condensat de cette dernière, mais la conserver en entier permet de l’avoir sous la main pour chercher si un domaine y figure sans les fioritures de configuration du résolveur ainsi que de lister les différences entre chaque mise à jour. Si différences il y a, il suffit donc de faire un diff
entre les deux listes. Ce fichier sert aussi de témoin au script. S'il n'est pas présent, on considère qu'il s'agit d'une première éxécution et on fabrique le .conf d'Unbound sans se poser de questions.
Une fois le fichier de configuration fabriqué, on s'assure qu'il ne présente pas de problèmes grâce à l'utilitaire unbound-checkconf
qui est fourni avec Unbound. Si des erreurs sont trouvés, on ne prend pas de risque, on arrête là et on loggue le problème. Il s'agit de ne pas donner au résolveur un fichier qui pourrait l'empêcher de démarrer :
unbound-checkconf $tmpConf &> $tmpErr
if [ "$?" -ne 0 ]; then
echo -e "$(date +'%b %d %X %z') ERROR: La vérification de la configuration d'Unbound a échoué. Erreurs trouvées :" >> $logFile
...
exit 1
Si unbound-checkconf
ne remonte pas d'erreur, on met en place le fichier et on redémarre Unbound. Une fois le script en place et Unbound relancé, on peut éventuellement vérifier que tout fonctionne en piochant au hasard quelques sites dans la liste des sites bloqués :
# dig doubleclick.net ... ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 64227 ;; flags: qr aa rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, , ADDITIONAL: 1 ... ;; SERVER: ::1#53(::1)
L’absence de local-data
ne pose pas de problèmes et permet surtout de n’avoir à charger en RAM que le minimum nécessaire afin de pouvoir faire fonctionner cette solution sur un maximum de machines :
# top PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND ... 2626 unbound 20 0 295148 40852 6088 S 0,0 0,5 0:00.15 unbound
Unbound consomme donc environ 41 Mo de RAM après son démarrage, ce qui est très correct (en temps normal, au même stade sa consommation est de l’ordre de 20 Mo (sur un système 64 bits). Pour la petite histoire, avec la première version du script, qui pour chaque domaine ajoutait des local-data
pour signaler que le NS
de chaque domaine était le localhost
ainsi qu'un SOA
bidon, le tout calqué sur la méthode utilisée pour bloquer les requêtes vers les domaines .onion pour les versions d'Unbound inférieure à la 1.5.8. Dans ce cas le résolveur consommait 400 Mo de RAM au démarrage, ce qui est très limite pour une machine avec 1 Go de RAM comme le Raspberry Pi 2).
Au final, le script fabrique un fichier de configuration Unbound et un fichier ne contenant que la liste des domaines bloqués. Ne vous reste plus qu’à décider de la fréquence de mise à jour de la liste en trouvant le cron qui vous convient le mieux.
Attention toutefois, les quelques dizaines de milliers de domaines qui seront bloqués ne sont pas l’exhaustivité des domaines utilisés pour vous servir de la publicité ou des mouchards. Par ailleurs, si vous vous rendez à l’étranger, il est possible que les domaines locaux ne soient pas bloqués. La méthode que je détaille n’empêche donc pas l’hygiène numérique habituelle, notamment pour son navigateur Web (uMatrix, uBlock Origin et compagnie restent vos amis). Elle a néanmoins plusieurs avantages :
- Elle limite les requêtes DNS sortant de votre machine.
- Elle est sous votre contrôle : il est possible d’enlever ou d’ajouter des listes facilement au gré de ce que vous trouvez sur le Net (si vous connaissez de bonnes listes, n’hésitez d’ailleurs pas à les partager). Il est également possible d’arrêter totalement le blocage (en supprimant le fichier de configuration en question et en retirant le script de la
crontab
). - Si le résolveur sert tout un réseau local, ce sont toutes les machines connectées à ce dernier qui profiteront d’un minimum de protection. Les appareils aux logiciels non paramétrables (télé, console, frigo, sex-toys...) seront avec un peu de chances beaucoup moins bavards avec leurs fabricants (je devrais dire propriétaires à ce stade).
Avant d'oublier, vous pouvez télécharger le script librement et le modifier à volonté (il est sous Licence Publique IV 🍺).
Bonus
Service systemd s'exécutant au démarrage
Sur une machine qui redémarre assez régulièrement (toutes les quinze jours par exemple), lancer le script au démarrage permet éventuellement de s'affranchir de passer par la crontab
. Idem, sur une machine qui sert peu (c'est le cas de mon ordinateur portable par exemple), cela permet d'être à jour tout de suite en contrepartie d'un démarrage de la machine forcément un peu plus lent. Pour ce faire, sous un OS intégrant systemd, le plus simple est de créer un service. Cela permet notamment de s'assurer que les éléments dont nous avons besoin (le réseau et la résolution des noms) sont bien chargés par le système. Pour ce faire, on crée un fichier d'unité (Unit file) :
sudo nano /etc/systemd/system/adblock.service
On le renseigne correctement :
[Unit]
Description=Unbound AdBlock List Making
After=nss-lookup.target network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/unbound-adblock
RemainAfterExit=yes
User=root
[Install]
WantedBy=multi-user.target
La description est libre, After
indique que le service doit s'éxécuter une fois que les directives listées sont démarrées. À noter que l'on pourrait ajouter une directive Requires
listant les mêmes cibles et qui permettrait de ne pas démarrer le service si l'un des services attendus échoue. De ce que j'ai pu observer sur ma Debian, c'est peu utile, la mise à jour des listes n'est pas un élément critique du système et une erreur dans les logs n'est pas bien grave. Le RemainAfterExit
permet au service de garder un statut active (exited)
après exécution au lieu de inactive (dead)
. Finalement on enregistre le service auprès de systemd pour qu'il l'éxécute au boot :
sudo systemctl enable adblock.service
À noter que le script détecte les erreurs sur les récupérations de liste et stoppe si les 4 échouent. Cela est nécessaire pour une machine itinérante qui n'aura pas nécessairement accès au réseau lors de son boot. Dernière précision, les paramètres d'une connexion Wi-Fi ne sont pas nécessairement partagés entre tous les utilisateurs d'une machine. C'est le cas notamment sous KDE. Par conséquent, avec une connexion sans-fil, l'éxécution du script via cette méthode à toutes les chances d'échouer. Aucuns probmèmes constatés avec une connexion filaire via Ethernet ou le tethering d'un ordiphone (activé au démarrage). N'étant pas – loin de là – un expert de systemd, si vous connaissez une méthode pour éviter cet éceuil, je suis preneur 😉. Précision importante, si vous modifiez le fichier de service, n'oubliez pas un systemctl daemon-reload
afin que systemd vérifie qu'il ne contient pas d'erreurs (il ne faudrait pas que ça plante au prochain boot).
Merci à Troupier pour ces remarques sur systemd (RemainAfterExit
et daemon-reload
).