Bloquer la publicité grâce au DNS


Menteur, menteur

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 :

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 :

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 :

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).