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 :

Article L122-6-1 du CPI.

[…]

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 :

  1. Être unique pour chaque machine, comme décrit précédement.
  2. Être stable, il ne doit pas changer à chaque redémarrage par exemple.
  3. Ê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 :

  1. Que le conteneur soit privilégié (ça signifie qu’être root dans le conteneur implique d’être root sur l’hôte).
  2. 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 :

  1. Utiliser l’appel système ioctl(2) avec request=SIOCGIFHWADDR.
  2. Utiliser une socket netlink pour récupérer l’attribut IFLA_ADDRESS de l’interface réseau.
  3. 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_PRELOAD5.

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 :

  1. Le troisième paramètre d'ioctl(2) est optionnel.
  2. 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.


  1. Les noms des logiciels concernés ont été censurés. ↩︎

  2. 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. ↩︎

  3. 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. ↩︎

  4. On parlera surtout de Docker dans cet article, mais ce n’est pas la seule technologie de conteneurisation qui souffre de ce problème. ↩︎

  5. 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. ↩︎

  6. 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. ↩︎

  7. En pratique ce ne sera pas directement la valeur de retour mais des informations permettant de la retrouver. ↩︎

mareo
mareo
Responsable du CRI
Suivant
Précédent