Peut-on faire des programmes impossible `a pirater
Transcription
Peut-on faire des programmes impossible `a pirater
Peut-on faire des programmes impossible à pirater ? 29 mars 2016 Le « piratage » informatique, c’est-à-dire la duplication non-autorisée de données numériques, ne se limite pas à la musique, aux livres ou au film. Elle concerne aussi les programmes informatiques. Ce qui fait des programmes informatiques un cas un peu différent des autres, c’est qu’il ne s’agit pas de données inertes. Les programmes peuvent, dans une certaine mesure, « se défendre » d’eux-mêmes contre la duplication incontrôlée. Nous avons tous, à un moment où à un autre, été confrontés à de tels mécanismes de protection. On nous a demandé un numéro de série, ou bien il a fallu autoriser la connection à un web-service spécialisé pour que notre copie du logiciel soit accréditée, etc. Avec le développement des applications en lignes, ce genre de contrôle n’a fait que se répandre. Cependant, une partie des n3rdz utilisateurs trouve le moyen de contourner ces mécanismes. En effet, on peut parfois trouver sur internet des versions crackées de logiciels protégés, c’est-à-dire des exécutables modifiés pour... ne plus effectuer la modification. Sous sa forme la plus simple, un mécanisme de contrôle d’accès se présenterait comme ça : int main(int argc, char **argv) { // verify licence if (!access_granted()) { printf("Pirate version detected !\\n"); exit(1); } // run main program ... } Il suffit de modifier la partie de l’exécutable qui effectue la vérification pour contourner le mécanisme. Ceci peut être aussi simple que transformer une instruction CPU de saut conditionnel en instruction de saut inconditionnel, ou bien en NOOP. En plus dans cet exemple, repérer l’endroit où effectuer la modification est particulièrement simple, car c’est la première chose qui a lieu. Les éditeurs de logiciels essayent souvent de compliquer un peu la vie des pirates, par exemple en retardant la vérification, et en essayant de la dissimuler. Elle est plus difficile à identifier si elle est imbriquée avec une fonctionnalité utile de l’application. En plus, un programme pourrait bien essayer de s’examiner lui-même pour voir s’il n’a pas été modifié. Las ! Tout ceci n’empêche pas les hackers suffisamment patients de faire sauter toutes les protections. En effet, ils ont tout le loisir de suivre à la trace l’exécution du programme (par exemple, dans une machine virtuelle), d’examiner quand ils veulent la mémoire qu’il utilise, etc. Dans ces conditions, mêmes les programmes les plus tordus finissent par révéler leurs secrets ! Il semble que le seul moyen d’empêcher les gens de modifier leurs programmes consiste à les rendre incompréhensible. Les pirates ne pourront pas les modifier dans le seul qu’ils veulent s’ils ne peuvent pas les comprendre. Les méthodes qui servent à rendre incompréhensible le code d’une application sont des techniques « d’obfuscation » (ce mot n’a pas de traduction très satisfaisante : « obscurcissement « ? « obscuration » ?). Leur intérêt est que les développeurs de logiciels, eux, ont besoin d’un code compréhensible sur lequel travailler, mais avant de produire un exécutable, on pourrait essayer de le rendre incompréhensible. 1 1 Résultats généraux sur l’obfuscation Le programme suivant : include <stdio.h> #include <math.h> #define E return #define S for char*J="LJFFF%7544x^H^XXHZZXHZ]]2#( #@@DA#(.@@%(0CAaIqDCI$IDEH%P@T@qL%PEaIpBJCA\ I%KBPBEP%CBPEaIqBAI%CAaIqBqDAI%U@PE%AAaIqBcDAI%ACaIaCqDCI%(aHCcIpBBH%E@aIqBAI%A\ AaIqB%AAaIqBEH%AAPBaIqB%PCDHxL%H@hIcBBI%E@qJBH#C@@D%aIBI@D%E@QB2P#E@’C@qJBHqJBH\ %C@qJBH%AAaIqBAI%C@cJ%" "cJ" "CH%C@qJ%aIqB1I%PCDI‘I%BAaICH%KH+@’JH+@KP*@%S@\ 3P%H@ABhIaBBI%P@S@PC#", ,*e;typedef float x;x U(x a){E a<0?0:a>1?1:a; } *j typedef struct{x c,a,t; } y;y W={1,1,1},Z={0,0,0},B[99],P,C,M,N,K,p,s,d,h ;y G(x t,x a,x c){K.c=t ; K.t=c; K.a=a;E K;}int T=-1,b=0,r,F=-111,(*m)(i\ nt)=putchar,X=40,z=5,o, a, c,t=0 ,n,R;y A(y a,y b,x c){E G(a.c+b.c*c,a.a +c*b.a,b.t*c+a.t);}x H= .5,Y =.66 ,I,l=0,q,w,u,i,g;x O(y a,y b){E q=a.t* b.t+b.c*a.c+a.a*b.a;}x Q(){E A(P,M,T ),O(K,K)<I?C=M,I=q:0;}y V(y a){E A(Z, a,pow(O(a,a),-H));}x D(y p){S(I=X,P =p,b=T; M=B[++b],p=B[M.c+=8.8-l*.45, ++b],b<=r;Q())M=p.t?q =M_PI*H,w=atan2( P.a-M.a,P.c-M.c) /q,o=p.c-2,a=p.a+1,t= o+a,w=q*(w>t+H*a?o: w>t?t:w<o-H*a?t :w<o?o:w),A( M,G(cos(w),sin(w),0), 1):A(M,p,U(O(A(P,M,T) ,p)/O(p,p))); M=P;M.a=.9;o=P.c/8+8;o^=a=P.t /8+8; M=Q ()?o&1 ?G(Y,0,0):W :G(Y,Y,1);E sqrt (I)-.45;} int main( int L,char k){ S(e =L>1?1[z= 0, k]:J ; e &&l<24 ; ** * ++e)S(o=a =0,j =J+9;(c= !(o&&c< X&&(q=l+=w) );o ?o=*j++/ *++j)&& 32,b++[B] =G(q +=*j/8&3,* j&7,0 ),B[r =b++]=G((c/8& 3)*( o<2? T:1), (c& 7)+ 1e-4,o>2),1: (o =(a =(c-=X)<0?w=c+6 ,t= a+1:c?(t ?0:m(c),a ):*++j)==((*e|32 ) ^z)&&1[j]-X));S(z =3*( L<3);++ F<110;)S(L=-301;p=Z,++L<300;m( p.c),m(p.a),m(p.t))S(c=T;++c<=z;)S(h =G(-4,4.6,29),d=V(A(A(A(Z,V(G(5,0 ,2)),L+L+c/2),V(G(2,-73,0)),F+F+c%2),G (30.75,-6,-75),20)),g=R=255-(n=z)*64; R*n+R;g*=H){S(u=i=R=0;!R&&94>(u+=i=D(h= A(h,d,i)));R=i<.01);S(N=V(A(P,C, T)),q=d.t*d.t,s=M,u=1;++i<6*R;u-= U(i/3-D(A(h,N,i/3)))/pow( 2,i));s=R?i=pow(U(O(N,V(A( M=V(G(T,1,2)),d,T)))) ,X),p=A(p,W,g*i),u*=U( O(N,M))*H*Y+Y,g*= n--?Y-Y*i:1-i,s:G( q,q,1); p=A(p,s ,g*u);h=A(h,N,.1 );d=A(d,N,-2*O (d,N));}E 0;} lorsqu’on l’exécute, calcule pendant environ une minute, pendant laquelle il produit cette image (au format PBM) : Vous êtes officiellement mis au défi de réussir à modifier le texte produit ! Ce programme est obfusqué. Ici, les trucs utilisés sont en partie de nature syntaxique (noms de variables incompréhensibles, boucles bizarres, etc.). Pour ne pas s’arrêter à la surface des choses, il faut se dire que ceci n’est pas suffisant pour arrêter un hacker. En plus, on n’a pas de garanties que ce qui a l’air dur en apparence n’est pas facile en réalité. Enfin, les pirates n’ont généralement pas accès au code source des programmes qu’ils veulent modifier. On va donc essayer de placer les choses dans un cadre plus abstrait et plus général. Tout d’abord, on va considérer qu’avoir un exécutable d’un programme et son code source sont deux choses équivalentes. En effet, étant donné un exécutable, on peut le « décompiler » et reproduire du code source qui lui correspond. On perd les noms des fonctions, les noms des variables, le découpage en « modules », mais on reconstitue assez bien le reste. 2 1.1 Fonctions « apprenables » Dans le fond, on voudrait qu’on ne puisse rien faire du code source d’un programme obfusqué. Mais il y a toujours une chose qu’on peut faire, c’est l’exécuter. Du coup, essayer de définir précisément ce qu’est l’obfuscation n’est pas très facile. Par exemple, une des difficultés avec l’obfuscation est qu’il existe des fonctions qui sont impossibles à obfusquer. En effet, si on est capable de produire le code source « clair » d’une fonction juste en étant capable de l’évaluer, alors on peut le faire à partir de la version obfusquée. Une telle fonction est dite « apprenable ». Plus précisément, il s’agit d’une fonction dont on peut déterminer entièrement la spécification juste en étant capable de l’évaluer un petit nombre de fois (un nombre de fois polynomial en sa taille). Un petit exemple valant mieux qu’un grand discours, penchons-nous sur la fonction F suivante : F : Zn ~x → Z 7→ ~x · ~a = n X xi ai , i=1 où ~a ∈ Zn est un vecteur d’entiers qu’on ne connaı̂t pas. En gros, F prend un vecteur en argument, et calcule son produit scalaire avec un vecteur ~a inconnu. Cette fonction n’est pas obfuscable, car il est très facile d’apprendre le vecteur ~a, et ainsi d’écrire une version « claire ». En effet, il n’est pas compliqué de voir que : F (1, 0, 0, . . . , 0) = a1 F (0, 1, 0, . . . , 0) = a2 .. . F (0, 0, 0, . . . , 1) = an Du coup, on peut « faire fuir » l’information contenue dans F dès qu’on a sous la main un moyen de l’évaluer n fois. Aucune technique d’obfuscation ne peut l’empêcher... 1.2 Définition Il nous faut donc une définition de l’obfuscation qui prenne en compte ce genre de phénomènes pénibles. Un obfuscateur est un programme O qui prend du code source en entrée et produit du code source en sortie (c’est un compilateur, en gros). Il doit avoir les caractéristiques suivantes : 1. O(P ) peut être calculé en temps polynomial en la taille de P . 2. O(P ) et P décrivent la même fonction. 3. la complexité en temps (resp. en espace) de O(P ) est une fonction polynomiale de celle de P . 4. O(P ) a la propriété de « boite noire virtuelle » C’est la dernière propriété qui donne un sens à la notion d’obfuscation. Étant donné une fonction P , on dit qu’on « a accès à P en boite noire » si on dispose d’un moyen de calculer P (x) pour n’importe quel x, mais qu’on ne peut pas observer le fonctionnement du mécanisme qui calcule x (il est dans une « boite noire »). On dit parfois qu’on « a accès à un oracle qui calcule P ». Par exemple, on peut imaginer qu’on a accès à un web-service auquel on envoie x, et qui renvoie P (x), mais qui est hébergé sur une machine qui n’est pas sous notre contrôle. On note MP lorsque M est une machine qui a accès à P en boite noire. L’idée générale de l’obfuscation peut se résumer de la façon suivante. Si on a accès à P en boite noire, alors on ne peut rien faire d’autre que calculer P sur des entrées de notre choix. On souhaite qu’on ne puisse rien faire de plus en ayant accès au code source de O(P ). Lorsque c’est le cas, on dit que O(P ) possède la propriété de « boite noire virtuelle ». Par exemple, si P est une fonction qui vérifie un mot de passe ou un numéro de série, alors on ne peut pas extraire d’information utile (pas le mot de passe ni de serial number valide). 3 Tout ceci est encore assez informel. Pour être plus précis, il faut faire entrer en jeu des « simulateurs ». Considérons un programme H (comme Hacker) qui essaye de briser l’obfuscation. Il prend en entrée le code source de O(P ) et essaye d’en extraire un bit d’information (il renvoie 0 ou 1). On voudrait qu’il existe un programme S (comme Simulateur) qui a accès à P en boite noire, qui a la même complexité que H et qui calcule le même résultat avec forte probabilité. S’il y arrive, alors cela signifie que H ne dispose d’aucun avantage en possédant le code source de O(P ) par rapport à S qui est juste capable d’évaluer P à travers un web-service. On peut donc, finalement, dire : « O(P ) possède la propriété de boite noire virtuelle si pour toute machine de Turing (randomisée) H qui s’arrête en temps polynomial, il existe une autre machine de Turing randomisée S, qui s’arrête en temps polynomial et telle que : P P H(O(P )) = 1 − P S (|P |) = 1 est négligeable en la taille de P . » Cette définition est compatible avec l’existence des fonctions apprenables : en effet le simulateur peut les apprendre lui-aussi, et fournir ainsi les mêmes réponses que le « Hacker ». 1.3 Exemples concrets Maintenant qu’on a définit ce qu’est l’obfuscation, il reste à se demander si c’est réalisable. Existe-til des fonctions non-apprenables ? Est-ce que toutes les fonctions (non-apprenables) sont obfuscables ? Existe-t-il des fonctions (non-apprenables) obfuscables ? Tout d’abord, on va voir qu’il existe des fonctions non-apprenables, qui en plus sont utiles. Considérons la fonction suivante, écrite en pseudo-python : def F(x): if x = 0xfd57207c81e0152d28d4cc87345a490d: return True return False C’est, en gros, une fonction qui vérifie un mot de passe. Elle compare son argument à une donnée secrète et renvoie True en cas d’égalité. Cette fonction est impossible à apprendre, et tout le monde ou presque en a déjà fait l’expérience : vous ne connaissez pas mon mot de passe dans les salles de TP, et vous ne l’apprendrez pas même en faisant des plusieurs essais. Si on a accès à une boite noire qui permet d’évaluer F (et qu’on ne connaı̂t pas le mot de passe, qui a été choisi aléatoirement), alors on a essentiellement sous la main une boite noire qui répond False. Ce n’est pas très utile ! En effet, la probabilité qu’on obtienne autre chose que False de la fonction est extrêmement faible. En effet, si le mot de passe a n bits, et qu’on a le droit à k essais, la probabilité qu’on arrive à le deviner est plus petite que k/2n . Si k est polynomial en n, cette probabilité est négligeable. Voici donc un exemple intéressant : est-il possible de transformer le code source de F de telle sorte qu’on ne puisse pas extraire le mot de passe de O(F ) ? Il se trouve que la réponse est « oui », à condition de disposer d’une fonction à sens unique, c’est-à-dire d’une fonction G qui peut se calculer en temps polynomial, mais pas s’inverser en temps polynomial. Ceci est plutôt du ressort de la cryptographie, mais on peut se contenter de dire que la plupart des fonctions de hachage cryptographiques, comme MD5, SHA-1, etc. sont à sens unique. Voici comment la fonction F peut être obfusquée : def F_obfuscated(x): if G(x) = 0x7175fa0aefc4fbc0e1cb701de847d110: return True return False Evidemment, G(x) est comparé avec l’image par G du mot de passe. Comme G est à sens unique, il n’est pas possible de récupérer le mot de passe x à partir de G(x) en temps polynomial. Ce mécanisme est utilisé dans tous les systèmes d’exploitations raisonnables. 4 1.4 Impossibilité générique Considérons maintenant une autre fonction : def F(x): if x = 0x48e216b42f6373c0813aa08b9756d260: return 0x8badf00d23deadbeef71cafe39defaced return 0 Cette fonction, quand on lui fournit le bon mot de passe, révèle une information secrète. Elle est aussi dure à apprendre que la précédente : si on y a accès en boite noire, on a quasiment aucune chance d’en faire sortir autre chose que zéro. Cependant, cette fonction F est beaucoup plus difficile à obfusquer que la précédente. En effet, comment faire pour dissimuler la valeur de retour secrète ? On va maintenant voir le résultat suivant, qui constitue une « mauvaise nouvelle 1 ». Théorème 1 (Boaz Barak, 2001). Il existe des fonctions difficiles à apprendre qui ne sont pourtant pas obfuscables. Voyons pourquoi. Considérons la fonction suivante : def Tester(f): y = f(0x48e216b42f6373c0813aa08b9756d260) if y = 0x8badf00d23deadbeef71cafe39defaced: return True return False C’est une fonction qui prend en argument une autre fonction f , et qui renvoie True quand il s’agit de la fonction F ci-dessus. Le même argument que tout à l’heure démontre que Tester est difficile à apprendre. On va voir que la paire de fonction (F, Tester) n’est pas obfuscable. Pour cela, on va construire un programme « Hacker » H qui sera capable d’extraire plus d’information de O(F) et O(Tester) que n’importe quel simulateur ne pourra le faire. Considérons le programme H(x, f ) = f (x). Il exécute son deuxième argument en lui fournissant le premier. Ceci n’est bien sûr possible qu’à condition de posséder le code source de f . Forcément, si on lui donne la paire O(F), O(Tester) , le programme H va répondre True. Par contre, un simulateur qui aurait un accès boite-noire à deux fonctions n’aurait aucun moyen de savoir s’il s’agit de F et Tester, ou bien s’il s’agit des deux fonctions : def foo(x): return 0 def bar(f): return False Du coup, le simulateur n’a pas la possibilité de calculer la même chose que le Hacker, et donc l’obfuscateur a échoué à fournir la propriété de boite-boire virtuelle. Les esprits chagrins feront remarquer qu’on a un peu triché, car on a montré qu’il existe deux fonctions qui ne sont pas obfuscables simultanément. Cet argument ne tient pas, car on peut regrouper les deux fonctions en une seule, qui prend un argument indiquant laquelle des deux il faut évaluer. 1.5 Autres notions On ne peut donc pas tout obfusquer. Le problème de fond, c’est que la notion de boite noire virtuelle est trop forte. On vient de voir qu’avoir du code qui calcule une fonction, même si on ne peut pas le comprendre, permet de faire des choses qui ne sont pas possibles si on ne dispose que d’un web-service qui calcule la fonction. Mais peut-être y a -t-il d’autres notions intéressantes d’obfuscation qui, elles, seraient atteignables ? 1. enfin, c’est une question de point de vue... 5 La réponse est oui, et l’une de ces notions est la « indifferentiability obfuscation ». L’idée est la suivante : si F et G sont deux codes sources (différents) qui calculent la même chose, alors il n’est pas possible de distinguer O(F ) et O(G). L’idée « il n’est pas possible de distinguer X et Y » signifie que si on nous donne l’un des deux, on ne peut pas déterminer duquel il s’agit en temps polynomial et avec probabilité de succès non-négligeable. Ceci est intéressant, par exemple pour la raison suivante : on a un programme dont souhaite distribuer une version de démonstration, qui ne contiendrait pas toutes les fonctionnalités (par exemple, on ne veut pas qu’il soit possible d’imprimer ni d’enregistrer). Ceci pourrait facilement se réaliser de la façon suivante demo_version = True def _actual_save(x): ... ... def Save(x): if demo_version: raise Exception("Demo version. Saving is disabled. " "Please buy the full version !") _actual_save(x) On voudrait en distribuer une version dans laquelle il soit difficile de ré-activer la fonctionnalité de sauvegarde. Pour cela, on peut utiliser de l’obfuscation indifférenciable. En effet, lorsque demo_version = True, ce code est fonctionnellement équivalent code dans lequel on aurait retiré la fonction _actual_save. Cela signifie qu’il est impossible de faire la différence entre l’obfuscation indifférentiable du code dans lequel demo_version = True et l’obfuscation indifférentiable du code dans lequel on a purement et simplement retiré la fonction. Si on ne peut pas détecter la présence du code de sauvegarde, a plus forte raison on ne peut pas le réactiver. La bonne nouvelle, c’est que l’obfuscation indifférentiable est toujours possible. On va le voir, au moins pour les circuits (c’est plus facile que pour les machines de Turing). Un circuit est un graphe orienté acyclique dont les noeuds sont des portes logiques. Cela représente une fonction qui a une entrée de taille fixée, une sortie de taille fixée, et un nombre total d’opération fixé. Étant donné un circuit C, il suffit d’énumérer, dans l’ordre, tous les circuits qui ont le même nombre d’entrées et de sorties. Pour chacun d’entre eux, on vérifie s’il calcule la même chose que C. Si oui, c’est l’obfuscation de C. L’avantage c’est que les obfuscations de deux circuits sont inconditionnellement 2 indifférentiables, car elles sont... égales. Le « petit problème » c’est que l’obfuscateur est très largementq exponentiel en la taille du circuit à obfusquer, et qu’on a pas de garantie très claire sur la complexité du circuit résultant. Cependant, en 2013, un groupe de chercheurs a démontré qu’il était possible de faire de l’obfuscation indifférentiable en temps polynomial, sous certaines hypothèses de complexité. Autrement dit, si certains problèmes algorithmiques issus de la cryptographie sont durs, alors il est difficile de distinguer les obfuscations. Cependant, cette technique ne va pas être utilisée en pratique avant un bon moment, car elle ralentit considérablement les programmes auxquels elle est appliquée. 2 En « pratique » 2.1 Taille Une idée un peu stupide mais efficace peut parfois être utilisée. Au lieu d’obfusquer le code d’une fonction, on va chercher à en produire une représentation de grande taille. Du coup, si des pirates veulent la distribuer largement, ils devront en distribuer une version très grosse (disons quelques dizaines de Go), ce qui les pénalisera. Il existera par contre une version secrète du code, qui elle sera compacte. 2. c’est-à-dire même avec une puissance de calcul illimitée 6 Considérons par exemple la fonction : def F(x): return G("85c24a0a95478d0afb7958dbec2fef2a" + x) où G est une fonction à sens unique, et où x est un entier de 32 bits. Imaginons que G ait une sortie de 128 bits. Alors on peut représenter cette fonction par un gros tableau A de 232 entrées de 128 bits chacune, tel que A[i] = F (i). Ce tableau occupe 64Go, et il est incompressible. En effet, pour réussir à représenter le tableau, il faudrait réussir à mettre la main sur la chaine de bits secrète planquée dans F , et pour cela il faudrait réussir à inverser la fonction à sens unique. Ceci peut servir de la façon suivante : dans une application ou un jeu en-ligne, le serveur envoie toute les minutes au client un paquet « challenge » qui contient un entier i. Le client doit répondre en renvoyant A[i]. Le serveur vérifie qu’il a bien récupéré F(i). En cas d’erreur ou d’expiration des délais, le client est déconnecté. 2.2 Obfuscation du graphe de contrôle de flot Imaginons un langage de programmation impératif simple (mais Turing-complet !). On se restreint à un langage simple, formé d’affectations, d’opérations arithmétiques, de GOTO, de saut conditionnel de la forme IF b1 THEN GOTO l1 ELSE GOTO l2 , de STOP, et d’instructions d’I/O style READ(x) et WRITE(y). Par exemple : 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: start : f ←1 READ(k) test : b ← (k <= 1) IF b THEN GOTO end ELSE GOTO work work : f ←f ×k k ←k−1 GOTO test end : WRITE(f ) STOP On découpe le code en “blocs”. Un bloc se termine par GOTO, IF ou STOP, en ne peut pas en contenir au milieu. Chaque bloc porte une étiquette. Ici les blocs sont : 1: 2: 3: 4: start : f ←1 READ(k) GOTO test : 1: 2: 3: test : b ← (k <= 1) IF b THEN GOTO end ELSE GOTO work 1: 2: 3: 4: work : f ←f ×k k ←k−1 GOTO test 1: 2: 3: end : WRITE(f ) STOP Les liens qui existent entre ces blocs forment le graphe de flot de contrôle du programme : Start Test Work End En gros, pour modifier un programme, il faut « comprendre » son graphe de flot de contrôle. C’est comme ça qu’on peut identifier les parties à désactiver, par exemple. L’idée de l’obfuscation de flot de contrôle consiste justement à rendre ceci compliqué. 7 Remise à plat. D’abord, on applatit (“flatten”) le graphe de flot de controle du programme. Pour cela on retire de chaque bloc son instruction de contrôle de flot, et on laisse un « dispatcher » assurer ça. Le programme devient : dispatcher = Dispatcher() while True: todo = dispatcher->next(b); if todo == 1: f, k = start() if todo == 2: b = test(k) if todo == 3: f, k = work(f, k) if todo == 4: end(f) Ici, le dispatcher est un objet qui possède un état interne, qui est mis à jour à chaque appel. Il prend ses décisions en fonction de son propre état ainsi que des valeurs des variables booléennes. Bien sûr, pour masquer le graphe de flot de contrôle, on peut ajouter des blocs bidons, des variables bidons, renomer les variables, mettre des blocs de jonction qui renoment les variables, mélanger les blocs entre eux, etc. Mais le truc de fond, c’est qu’on peut rendre le le problème de détecter le code mort et les variables inutiles arbitrairement compliqué, en modifiant le dispatcher. Voici comment procéder. Obfuscation. Pour ça, on prend un programme P dans lequel il y a une seule instruction WRITE — disons, WRITE(x) — et dans lequel la variable y n’apparaı̂t pas. On modifie P de la façon suivante : 1. Au tout début de l’exécution du programme, dans le premier bloc, on ajoute l’instruction “y ← 0”. 2. Juste avant le WRITE(x), on ajoute l’instruction “x ← x + y”. 3. On ajoute un nouveau bloc spécial qui ne contient que “y ← 1” Très clairement, si le bloc spécial n’est pas exécuté, la variable y ne sert à rien et on peut l’enlever. Par contre, s’il est exécuté, ça modifie le résultat visible du programme. Le plan, c’est qu’on modifie le dispatcher de la façon suivante : 1. En plus de son propre état, le dispatcher gère aussi l’état d’une machine de Turing ( !), ainsi qu’un bit Turing qui est initialement 1. 2. Si Turing = 0, le dispatcher ignore complètement la machine de Turing. 3. Chaque fois qu’il met à jour son état, si Turing = 1 le dispatcher fait faire une transition à la machine de Turing. 4. Si l’état actuel de la machine de Turing est acceptant, alors le dispatcher ne fait rien d’autre que définir Turing = 0 5. La fonction qui détermine quel est le prochain bloc a exécuter n’est pas modifiée, SAUF : si l’état actuel de la machine de turing est acceptant et Turing = 1, alors on court-circuite la fonction normale et on exécuter le bloc spécial “y ← 1” (ceci ne peut avoir lieu qu’une seule fois, car ensuite le bit Turing passe à 0). Ainsi, tester si le bloc spécial est mort est aussi dur que tester si une machine de Turing arbitraire accepte une entrée arbitraire. Ce serait donc indécidable. Cependant, on souhaite que la complexité en mémoire du dispatcher reste polynomiale. Cela signifie que tester si le bloc spécial est mort est aussi dur que tester si une machine de Turing en espace borné par un polynôme accepte une entrée arbitraire. Ce problème est PSPACE-complet par définition. En pratique, l’état de la machine de Turing peut être représenté par un paquet de variables booléeennes. Par exemple : — des variables qi qui sont vraies lorsque la machine est dans l’état i — des variables si qui sont vraies lorsque la tête de lecture est au-dessus de la i-ème case du ruban — des variables ti,j qui sont vraies lorsque la i-ème case du ruban contient le j-ème symbole de l’alphabet. 8 Ces variables peuvent être « mélangées » à celles du programme à obfusquer. Quand à la réalisation des transitions, elle revient à évaluer des expressions booleennes : en effet, lorsque la tête de lecture est au-dessus de la n-ème case du ruban, effectuer la transition (i, j) → (k, `, .) revient à écrire : if q[i] and s[n] and t[n, j]: q[i] = False q[k] = True s[n] = False s[n+1] = True t[n, j] = False t[n, l] = True Ceci rappelle furieusement l’argument utilisé par Turing pour montrer que la logique du premier ordre est indécidable, en encodant les transitions d’une machine de Turing dans une grosse formule logique qui est satisfaisable seulement si la machine s’arrête... Bref, toujours est-il que la réalisation des transitions peut elle aussi être « mélangée » au code de l’application à obfusquer. Et après, bonjour l’effort pour y comprendre quelque chose... 9