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