Déploiement d'un serveur de licence à adresse MAC fixe sur Kubernetes
Prolégomènes
Cet article décrit des moyens de contournement de protections techniques mises en œuvre dans une implémentation de serveur de licence relativement répandue. Les techniques utilisées pour analyser cette protection ont été appliquées de façon conforme à la legislation française (et européenne) en vigueur qui dispose que :
[…]
III. La personne ayant le droit d’utiliser le logiciel peut sans l’autorisation de l’auteur observer, étudier ou tester le fonctionnement ou la sécurité de ce logiciel afin de déterminer les idées et principes qui sont à la base de n’importe quel élément du logiciel lorsqu’elle effectue toute opération de chargement, d’affichage, d’exécution, de transmission ou de stockage du logiciel qu’elle est en droit d’effectuer.
[…]
Afin de nous éviter toute mise en cause, nous avons fait le choix de ne pas préciser les noms des logiciels concernés dans cet article. Les techniques de contournement précitées ne sont mises en œuvre que dans le but de permettre leur interoperabilité avec les technologies utilisées par le CRI et leur utilisation par les étudiants de l’école, dans la limite des licences qui nous sont attribuées et en respectant scrupuleusement les obligations contractuelles qui nous lient à l’éditeur.
Introduction
Il y a dans la culture de l’EPITA un très fort tropisme vers les logiciels libres : l’utilisation de nombre d’entre eux fait partie intégrante des enseignements de l’école depuis sa création. De manière générale, quand il est possible de choisir entre deux logiciels, l’un libre et l’autre non, nous avons tendance à privilégier les premiers.
Ce choix peut s’apprécier sous un angle purement budgétaire : les logiciels
non-libres sont souvent payants, mais c’est loin d’être la seule explication.
La disponibilité du code source des logiciels libres les rend auditables : il
est possible d’aller chercher directement dans le code source la cause de bugs
ou tout simplement de préciser des comportements qui ne sont pas toujours
décrits exhaustivement par la documentation. La liberté de modifier ces
logiciels nous permet d’en fournir des versions altérées, plus adaptées à nos
besoins (c’est le cas du programme bison
utilisé dans le cadre du projet
d’ING1 Tiger Compiler
). Enfin, la possibilité de les redistribuer nous permet
de fournir des images installables sur les machines des étudiants de l’école,
via la VM CRI notamment. Auditabilité, redistribuabilité, altérabilité sont les
trois raisons principales qui nous poussent à préférer les logiciels libres aux
logiciels non-libres, même quand ils sont gratuits.
Il existe cependant certains besoins pour lesquels l’école a fait le choix d’utiliser des logiciels non-libres, parmis eux figurent notamment M***** et S*****1 qui ne disposent pas d’alternatives libres viables. Ces logiciels ne sont utilisables qu’à la condition d’avoir fait l’acquisition de licences payantes. L’école dispose de 50 licences de type « concurrent », c’est-à-dire que ces logiciels peuvent être installés sur n’importe quelle machine appartenant à l’école mais que seules 50 instances peuvent être lancées en simultané.
Afin de garantir le respect de la licence, un license manager doit être installé sur un serveur et être accessible depuis les machines de l’école qui sont configurées pour obtenir et rendre des licences au grès de l’utilisation de M***** et S*****.
Afin d’éviter que plusieurs instances de ce license manager ne s’executent en parallèle, le fichier de licence obtenu auprès de l’éditeur du logiciel et permettant le lancement du serveur de licence comporte la mention d’un HostID, c’est-à-dire d’un identifiant qui identifie de façon unique la machine qui fera tourner le license manager. Le license manager refusera de se lancer s’il détermine qu’il s’exécute sur une machine différente de celle dont l’identifiant est indiqué dans le fichier de licence. Le fichier de licence comporte également une signature cryptographique pour éviter que l'HostID ne soit simplement modifié.
HostID
Ce fonctionnement semble être relativement robuste mais il se heurte à un problème relativement insoluble : sur quels éléments se baser pour déterminer la valeur de cet HostID. Un HostID doit effectivement répondre à trois caractéristiques essentielles :
- Être unique pour chaque machine, comme décrit précédement.
- Être stable, il ne doit pas changer à chaque redémarrage par exemple.
- Être infalsifiable, il ne doit pas pouvoir être modifié par l’opérateur de la machine.
Si les deux premiers critères sont assez simple à remplir, le troisième est autrement plus compliqué. Il n’existe en réalité pas de dispositif permettant de garantir l’infalsifiabilité d’un identifiant sur un ordinateur2.
Faute de mieux, la stratégie la plus souvent utilisée consiste à utiliser divers identifiants matériels (numéros de série des disque-durs, des périphériques PCIe, etc.) ou, dans le cas qui nous intéresse, l’adresse MAC de la machine. L’adresse MAC à l’avantage d’être unique3 et d’être à peu près stable (elle ne change pas spontanément).
En réalité parler d’adresse MAC de la machine est impropre, une machine n’a pas une adresse MAC mais une adresse MAC par interface réseau. On voit là un premier problème de cette démarche : quelle adresse utiliser sur une machine qui dipose de plusieurs interfaces réseaux, ce qui est souvent le cas sur des serveur ? Et ce n’est pas la seule difficulté : que se passe-t-il si on remplace l’interface réseau dont l’adresse MAC est utilisée comme HostID, suite à un dysfonctionnement par exemple ? Il reste également un problème critique : l’adresse MAC est très loin d’être infalsifiable, il est trivial de modifier l’adresse MAC d’une interface réseau.
# ip link set dev eno1 address 42:42:42:42:42:42
En une simple commande, j’ai modifié l’adresse MAC de mon interface réseau
eno1
en 42:42:42:42:42:42
. En pratique il serait possible d’aller inspecter
le contenu de l’EEPROM de la carte réseau pour retrouver l’adresse MAC réelle
de l’interface, mais on pourrait toujours contourner cette stratégie en
exécutant le license manager dans une machine virtuelle.
Conteneurisation
En pratique ce type de « protection » est plus une nuisance qu’autre chose, en
particulier quand on souhaite exécuter un serveur de licence dans un
conteneur4. Un conteneur est par nature une entité ephémère, à chaque fois
qu’un conteneur est recréé, une nouvelle interface réseau virtuelle lui est
attribuée et l’adresse MAC associée change. Il est possible, avec Docker, de
passer des options pour que l’interface soit créée avec une adresse MAC
spécifique, le fichier docker-compose.yml
que nous utilisions pour lancer le
serveur de licence ressemblait à ça :
services:
license-manager:
image: license-manager:v42
restart: unless-stopped
build: .
hostname: lic.pie.cri.epita.fr
mac_address: "42:42:42:42:42:42"
ports:
- "27000:27000"
- "1711:1711"
Ce type de configuration fonctionne parfaitement mais pose problème à plus long
terme puisqu’elle nous oblige à utiliser docker-compose pour gérer ce conteneur
alors même que cela fait deux ans que nous avons entrepris de migrer la majeure
partie de nos services sur Kubernetes ; il se trouve que Kubernetes ne nous
permet pas de choisir les adresses MAC des interfaces réseau des conteneurs
simplement. Il serait possible d’utiliser ip link
depuis l’entrypoint du
conteneur mais ça implique deux choses :
- Que le conteneur soit privilégié (ça signifie qu’être root dans le conteneur implique d’être root sur l’hôte).
- Que l’entrypoint s’exécute avec les permissions de root.
Avoir des conteneurs privilégiés est généralement une mauvaise idée, surtout dans une telle situation où rien ne justifie d’attribuer de telles possibilités au serveur de licence. Nous nous sommes donc résolus à adopter une autre stratégie : ne pas toucher à l’adresse MAC de l’interface virtuelle mais faire en sorte que le système mente au serveur de licence.
macspoofer.so
Récupération de l’adresse MAC
Il existe trois façons sous Linux de demander au système d’exploitation l’adresse MAC d’une interface réseau :
- Utiliser l’appel système
ioctl(2)
avecrequest=SIOCGIFHWADDR
. - Utiliser une socket
netlink
pour récupérer l’attributIFLA_ADDRESS
de l’interface réseau. - Lire le contenu du fichier
/sys/class/net/$IFNAME/address
.
On utilise donc strace(1)
pour savoir quelle méthode est utilisée par le
license manager.
$ strace -f license-manager # The real program name has been redacted
[...]
[pid 248] ioctl(9, SIOCGIFHWADDR, {ifr_name="eth0", ifr_hwaddr={sa_family=ARPHRD_ETHER, sa_data=00:53:00:01:02:03}}) = 0
[pid 248] ioctl(9, SIOCGIFHWADDR, {ifr_name="eth1"}) = -1 ENODEV (No such device)
[pid 248] ioctl(9, SIOCGIFHWADDR, {ifr_name="eth2"}) = -1 ENODEV (No such device)
[...]
C’est donc la première méthode qui est utilisée, en testant visiblement des
noms usuels d’interface réseau. Cette façon de faire est particulièrement peu
robuste puisqu’elle n’a aucune chance de fonctionner avec des systèmes récents
qui attribuent des nom déterministes pour les interfaces réseaux (eno1,
enp3s0f1, etc.). Cet appel système est également utilisé par ifconfig(1)
la
commande utilisée historiquement pour manipuler les interfaces réseau et qui a
été remplacé par la commande ip(1)
qui est, elle, basée sur netlink
.
Il existe plusieurs solutions pour faire mentir un appel système, on pourrait
utiliser ptrace(2)
pour mettre un hook sur l’appel système et lui faire
renvoyer la valeur que l’on veut, il est également possible d’obtenir des
résultats similaires avec eBPF. Une dernière technique consiste à ne pas faire
mentir l’appel système directement mais de remplacer le wrapper fourni par la
lib C. Ce wrapper est en réalité une fonction d’une bibliothèque dynamique, il
est donc possible de la surcharger au moment de l’execution du programme à
l’aide de LD_PRELOAD
5.
Surcharger une fonction de la lib C
Surcharger le wrapper d'ioctl(2)
va être un peu plus technique que prévu car
c’est une fonction variadique, on va donc dans un premier temps voir comment ça
peut fonctionner dans un cas plus simple. Nous allons surcharger getuid(2)
pour faire croire à la commande id(1)
que notre uid est 42, sauf si nous
sommes root.
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define ROOT_UID 0
#define SPOOFED_UID 42
static uid_t (*real_getuid)(void);
uid_t getuid(void)
{
if (real_getuid() == ROOT_UID)
return ROOT_UID;
return SPOOFED_UID;
}
__attribute__((constructor)) static void _init(void)
{
if (!(real_getuid = dlsym(RTLD_NEXT, "getuid")))
{
fprintf(stderr, "%s\n", dlerror());
exit(EXIT_FAILURE);
}
}
la fonction dlsym(3)
va nous permettre de récupérer l’adresse de la « vraie »
fonction getuid(2)
et on va la mettre dans une variable globale pour pouvoir
la réutiliser par la suite.
L’attribut __attribute__((constructor))
permet de demander à ce que la
fonction _init()
soit exécutée avant le main()
du programme dans laquelle
elle va être chargée. Cet attribut est généralement utilisé pour effectuer ce
type de tâche d’initialisation.
On compile ce programme en temps que bibliothèque dynamique et on utilise la
variable d’environnement LD_PRELOAD
pour rendre effective la surcharge :
$ gcc -shared -fPIC -D_GNU_SOURCE -ldl getuid.c -o getuid.so
$ id -ru
1000
$ LD_PRELOAD=./getuid.so id -ru
42
$ sudo LD_PRELOAD=./getuid.so id -ru
0
Surcharger ioctl(2)
Nous avons vu un peu plus tôt que le wrapper d'ioctl(2)
était une fonction
variadique, il y a deux raisons qui expliquent ce choix :
- Le troisième paramètre d'
ioctl(2)
est optionnel. - Le troisième paramètre d'
ioctl(2)
est soit un entier, soit un pointeur.
Il n’existe pas de moyen en C, contrairement au C++, de déclarer des paramètres
de fonctions comme optionnels et il n’est pas possible d’interchanger des
entiers et des pointeurs sans introduire d'undefined behavior. La seule façon
de déclarer ioctl(2)
d’une façon qui permette d’écrire des programmes valides
compte-tenu de ces contraintes est d’en faire une fonction variadique.
Il n’est pas possible en C standard d’écrire une fonction variadique capable
d’appeller une autre fonction variadique avec exactement les même paramètres
que les siens. Heureusement pour nous, GCC dispose de builtins qui sont
prévues spécifiquement pour ce cas de figure, apply_args
, apply
et
return
. La fonction __builtin_apply_args()
renvoie un pointeur vers une
zone de la pile allouée par le compilateur contenant le contexte d’appel de la
fonction6. La fonction __builtin_apply()
va prendre en paramètres un
pointeur vers la fonction qu’on veut appeller, le pointeur renvoyé par
__builtin_apply_args()
et la taille totale prise par les paramètres, elle
renvoie un pointeur, alloué sur la pile par le compilateur, qui contient la
valeur de retour de la fonction appelée7. __builtin_return()
va permettre
de renvoyer le résultat de la fonction à l’appelant initial.
On va juste se contenter d’y intercaler un test pour vérifier si on est dans le
cas qui nous intéresse (request == SIOCGIFHWADDR
) et remplacer l’adresse MAC
par la valeur souhaitée qu’on va récupérer dans la variable d’environnement
MACSPOOFER
. On fait bien attention à ne remplacer que les adresses MAC de
type ARPHRD_ETHER
, qui sont les adresses MAC usuelles à 6 octets :
#include <stdarg.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>
#include <sys/ioctl.h>
#include <net/if.h>
#include <sys/socket.h>
#include <linux/if_packet.h>
#include <net/if_arp.h>
/* We must compute the maximum size taken by the ioctl(2) arguments, to do so,
* we construct a struct with the proper types and let sizeof do its job.
*/
struct ioctl_args {
int fd;
int request;
union {
void *ptr;
unsigned long integer;
} argp;
};
#define IOCTL_ARGS_SIZE (sizeof (struct ioctl_args))
typedef int (*ioctl_decl_t)(int fd, int request, ...);
static ioctl_decl_t real_ioctl;
/* This variable will hold the MAC address set in the env */
static struct sockaddr_ll spoofed_addr;
void spoof_hwaddr(struct sockaddr *sockaddr)
{
/* We do not replace the address if the addresses family do not
* match.
*/
if (spoofed_addr.sll_family != sockaddr->sa_family)
return;
memcpy(sockaddr->sa_data, spoofed_addr.sll_addr,
sizeof (spoofed_addr.sll_addr));
}
int ioctl(int fd, unsigned long request, ...)
{
void *args = __builtin_apply_args();
void *result = __builtin_apply((void *) real_ioctl, args,
IOCTL_ARGS_SIZE);
(void) fd;
if (request == SIOCGIFHWADDR)
{
/* The caller want to retrieve a MAC address, and passed a
* pointer to an ifreq structure in ioctl third parameter.
* Since ioctl is variadic, we need to use the va_* macros to
* get it.
*/
va_list vargs;
va_start(vargs, request);
struct ifreq *ifreq = va_arg(vargs, struct ifreq *);
spoof_hwaddr(&ifreq->ifr_hwaddr);
va_end(vargs);
}
__builtin_return(result);
}
__attribute__((constructor)) static void _init(void)
{
const char *hwaddr;
if ((hwaddr = getenv("MACSPOOFER")))
{
int r = sscanf(hwaddr, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx%*c",
spoofed_addr.sll_addr,
spoofed_addr.sll_addr + 1,
spoofed_addr.sll_addr + 2,
spoofed_addr.sll_addr + 3,
spoofed_addr.sll_addr + 4,
spoofed_addr.sll_addr + 5);
if (r == 6)
/* If sscanf matched the 6 directives. we parsed a
* valid EUI-48 MAC Adresse (address family
* ARPHRD_ETHER).
*/
spoofed_addr.sll_family = ARPHRD_ETHER;
}
if (!(real_ioctl = dlsym(RTLD_NEXT, "ioctl")))
{
fprintf(stderr, "%s\n", dlerror());
exit(EXIT_FAILURE);
}
}
Vérifions le fonctionnement de notre bibliothèque avec ifconfig(1)
:
$ ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.2 netmask 255.255.0.0 broadcast 172.17.255.255
ether 00:53:00:01:02:03 txqueuelen 0 (Ethernet)
RX packets 3 bytes 450 (450.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
loop txqueuelen 1000 (Local Loopback)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
$ export MACSPOOFER=42:42:42:42:42:42
$ LD_PRELOAD=./macspoofer.so ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.2 netmask 255.255.0.0 broadcast 172.17.255.255
ether 42:42:42:42:42:42 txqueuelen 0 (Ethernet)
RX packets 4 bytes 560 (560.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
loop txqueuelen 1000 (Local Loopback)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
Utilisation dans une image Docker
Maintenant que nous disposons d’une bibliothèque capable de tromper le serveur de licence, il ne nous reste plus qu’à l’inclure dans notre image Docker.
Nous avons
publié une image Docker dans notre
GitLab qui contient cette
bibliothèque, il ne reste plus qu’à copier cette dernière dans l’image finale
et à l’ajouter dans le fichier /etc/ld.so.preload
pour qu’elle soit
automatiquement chargée pour chaque programme lancé depuis le conteneur.
FROM license-manager:v42
COPY --from=registry.cri.epita.fr/cri/docker/macspoofer /macspoofer/macspoofer.so /usr/local/lib/
RUN echo "/usr/local/lib/macspoofer.so" >> /etc/ld.so.preload
Il ne nous reste plus qu’à déployer ce service dans notre cluster Kubernetes en passant la valeur voulue pour l’addresse MAC via l’environnement.
Conclusion
Ce serveur de licence a été mis en production dans notre cluster Kubernetes le dimanche 30 mai avec une liveness probe pour le relancer automatiquement s’il venait à dysfonctionner.
Une connaissance fine des mécanismes bas niveau qui sont à l’œuvre dans nos systèmes nous permettent bien souvent de trouver des solutions créatives à des problèmes qui paraissent insolubles de prime abord.
Grâce à cette astuce nous avons pu migrer un service de plus vers notre cluster Kubernetes et la liste des VM historiques qu’il nous reste à décomissioner se réduit, après cet été elles se compteront sur les doigts d’une main.
-
Les noms des logiciels concernés ont été censurés. ↩︎
-
En réalité on pourrait faire des choses avec le TPM, mais ça impliquerait que l’éditeur intervienne physiquement sur la machine utilisée pour héberger le serveur de licence et ce n’est évidemment pas quelque chose d’envisageable hors cas très spécifiques. ↩︎
-
Sauf quand elle ne l’est pas, le CRI s’est déjà retrouvé avec une carte réseau entre les mains dont deux ports partageaient la même adresse MAC. ↩︎
-
On parlera surtout de Docker dans cet article, mais ce n’est pas la seule technologie de conteneurisation qui souffre de ce problème. ↩︎
-
Il n’est pas possible de surcharger un appel système avec
LD_PRELOAD
car un appel système n’est pas un appel de fonction classique mais une suite d’instructions en assembleur, la lib C fournit des wrappers pour simplifier la vie des développeurs et se charge de tout le travail compliqué. Ce sont ces fonctions que nous pouvons surcharger. ↩︎ -
Une partie de ces paramètres sont potentiellement passés via des registres en fonction de la convention d’appel,
apply_args
va se charger de gérer tout ces cas de façon uniforme. ↩︎ -
En pratique ce ne sera pas directement la valeur de retour mais des informations permettant de la retrouver. ↩︎