THMag01/Coder une machine virtuelle

Un article de HackaWiki.

Sommaire

Coder une machine virtuelle

Bien que le principe d'une machine virtuelle soit facile à comprendre, en concevoir une se révèle plus ardu parce qu'il faut intervenir au plus bas niveau. La portabilité est l'application la plus connue d'un tel outil, mais nous nous intéressons davantage, dans cet article, à son utilité en reverse engineering et protections logicielles.

By Virtualabs

Une machine virtuelle permet de s'affranchir des contraintes matérielles, et donc de pouvoir développer un code portable. Une de ces machines est de nos jours très connue et répandue, j'ai nommé la Java Virtual Machine de Sun, qui sert de base à l'exécution d'applets Java. Leur conception est originale, quoique longue et pas souvent très claire. Je vous propose donc de voir à quoi peut servir un tel outil, autant en matière de protection logicielle que d'émulation (et donc de portabilité). Pour illustrer mon propos, je vais détailler une petite machine virtuelle qui permet de faire des opérations de base.

1 – La machine virtuelle : une solution pratique

La machine virtuelle fait office de couche intermédiaire entre le système et le matériel, c'est à dire qu'elle va permettre à ce qu'on appelle un code portable de s'exécuter normalement sur n'importe quelle machine, en s'affranchissant entièrement des caractéristiques techniques de celle-ci. Un avantage donc pour la portabilité, mais un gros inconvénient : l'exécution est plus lente (cela est dû au fait que la machine virtuelle est une sur-couche). Bien sûr, ce qui nous intéresse c'est ce qu'elle peut apporter du point de vue sécurité, et de ce côté, elle se rend très utile. Elle permet de protéger des algorithmes de programmes, de réaliser des fake servers ou encore de développer des OS en toute sécurité. Bref, c'est un outil polyvalent.

Elle reste tout de même complexe à développer, c'est pourquoi je présenterai plus loin dans cet article une petite machine virtuelle qui permet de réaliser par exemple un algorithme de décryptage personnalisé, ou encore du traitement de données.

a) Protection logicielle

L'emploi d'une machine virtuelle peut s'avérer utile dans le cas d'une protection d'algorithme. Elle se présente généralement sous la forme d'un module à intégrer au programme, module qui est généralement une DLL sous Windows ou une librairie dynamique sous Linux. Son utilisation permet de rendre une partie d'algorithme illisible par un désassembleur, ou encore de permettre un traitement de haut niveau sur des données, grâce à des fonctionnalités fournies par cette machine virtuelle. Le traitement de haut niveau est difficilement identifiable, du fait de sa complexité.

b) Un fake server pas cher

L'emploi d'une machine virtuelle permet d'émuler un système, voire même un système d'exploitation entier, tournant sur un processeur choisi ! Elle peut donc servir à créer un honeypot, ou encore à falsifier les résultats d'une recherche d'os par fingerprinting. VMWare est un exemple de ce qui existe en matière d'émulation de systèmes, et est très performant.

c) Développement d'OS

Pour les codeurs, il existe différents outils contenant une machine virtuelle, et permettant d'émuler par exemple le démarrage d'une machine physique afin de tester un code de système d'exploitation (Bochs), et donc de développer l'OS sans avoir à tout réinstaller à chaque fois. Ce côté-ci est moins axé sécurité informatique.

2 – Principe de fonctionnement d'une machine virtuelle

Une machine virtuelle fonctionne basiquement sur le même principe qu'un micro-processeur. Elle possède son propre jeu d'instructions, ses registres, sa ou ses piles, selon le cas. Les variantes plus évoluées gèrent différents types de variables, et possèdent des fonctionnalités étendues : réseau, gestion de processus et de threads, gestion des exceptions, etc.

La base même d'une machine virtuelle est ce qu'on appelle généralement le p-code, nommé ainsi pour "pseudo-code". Ce p-code n'est qu'un équivalent des codes opérations associés aux instructions, qui restent spécifiques au processeur, et dans notre cas à notre machine virtuelle. Pour vous faire une idée du fonctionnement interne d'une machine virtuelle, voici un schéma représentant une machine virtuelle de base (et les interactions entre les différents éléments tels que la pile, les registres et les différents gestionnaires).

fig1.png

Figure 1 - fonctionnement interne

Ce schéma est celui de fonctionnement de ma petite machine virtuelle, certes pas très poussée, mais fonctionnelle. L'ensemble reste très minimaliste, mais permet de faire à peu près ce que l'on veut (dans certaines limites). D'un point de vue généraliste, la création d'un code pseudo-compilé pour notre machine virtuelle va se dérouler en deux temps. Dans un premier temps, on écrira le code sous forme textuelle (le code source), que l'on compilera dans un second temps grâce à un compilateur réalisé en perl (merci les cours de perl). Ce compilateur va transformer le code textuel en une série de codes-opérations, qui seront interprétés par notre machine virtuelle (ce qui permettra d'aboutir au résultat voulu). La machine virtuelle sera donc juste un programme qui interprétera les codes-opérations, et qui fera donc ce qu'on lui demande.

Pour qu'une machine virtuelle soit attractive, elle doit fournir plusieurs supports :

  • une gestion des variables
  • une gestion des exceptions
  • une gestion des threads (processus légers)
  • une gestion des erreurs
  • et d'autres encore.

Le gestionnaire des variables gère toutes les manipulations relatives aux variables, dont notamment la gestion des différents types (qui peuvent être de plus haut niveau, par exemple des tables de hachage ou encore de grands entiers). C'est un gestionnaire important, car l'emploi de variables simplifie grandement le codage. Le gestionnaire d'exceptions quant à lui permet d'autoriser une gestion des erreurs par le programme (en pseudo-code) lui-même, au lieu d'afficher une erreur et de stopper l'interprétation. Certains gestionnaires, comme celui des threads, peuvent être très pratiques, notamment pour un exécution des tâches simultanée. Par contre, celui-ci reste toujours compliqué à implémenter, car deux choix sont possibles : soit leur gestion est effectuée par la machine virtuelle, dans quel cas l'exécution sera plus lente mais orchestrée par la machine virtuelle en elle-même, soit on peut employer les threads systèmes fournis par le système d'exploitation, ce qui est plus rapide, mais cela dépend encore de la machine. L'implémentation la plus courante (mais aussi la plus longue) est l'intégration d'un ordonnanceur dans la machine elle-même (solution pouvant être mise en œuvre en C++). Les machines virtuelles peuvent devenir très complexes, notamment lorsqu'elles emploient une programmation orientée objet, tel que le fait Java. Juste pour vous montrer la complexité de celle-ci, voici un diagramme des principaux gestionnaires et modules constituant la JVM, figure 2.

fig2.png

Figure 2 – la JVM

3 – Conception et emploi d'une petite machine virtuelle en protection logicielle

Je vais axer mon article sur la protection logicielle, bien que ce genre de machine virtuelle puisse servir à d'autres applications, car le développement d'une telle machine permettant l'émulation est longue et fastidieuse. Au contraire, celui d'une machine virtuelle spécifique à un programme est assez simple à mettre en œuvre, comme vous pourrez le constater.

Notre machine ne s'occupera que de la gestion des variables et des erreurs. Ce qui la rend quand même attractive, sans pour autant en faire une bête de course (et de plus, plus c'est gros et plus c'est long à coder ). Bref, on va implémenter notre petite machine en C++, cela va nous permettre de hiérarchiser les différents éléments, et donc de pouvoir avoir une vue claire et objective de l'ensemble. Le C++ permet aussi de définir pour chaque gestionnaire (de pile, d'erreur, etc.) une classe spécifique, c'est à dire un objet qui sera facile à manipuler et simplifiera ensuite la programmation, ce qui est d'ailleurs l'avantage d'employer des classes.

a) Le langage

Du côté du langage employé, nous n'allons pas réinventer la roue. C'est pourquoi j'ai opté pour le langage le plus simple qu'il soit, l'assembleur. Nous allons quand même en profiter pour y ajouter quelques macros de haut-niveau, comme une MessageBox et une commande de prompt (utile pour la saisie). Nous en profiterons pour implémenter de même plusieurs modes d'accès (comme ceux proposés par l'assembleur x86). Le langage est très minimaliste, mais permet de créer une petite routine de base. Voici l'ensemble des instructions que j'ai implémentées :

  • Instructions de manipulation de mémoire : MOV
  • Manipulation de pile : PUSH, POP
  • Opérateurs arithmétiques : ADD, SUB, DIV, MUL, INC, DEC (ces deux derniers n'agissant que sur des registres, car ils sont souvent employés dans des boucles, et cela permet de gagner en place).
  • Opérateurs logiques : XOR, AND, OR,SHL, SHR
  • Instructions de test : CMP, TEST
  • Instructions de saut conditionnel : JLE, JGE, JL, JG, JNE, JNZ, JNC, JC, JZ, JE
  • Instructions de sous-routines : CALL, RET

Rien qu'avec ce petit jeu d'instructions, on peut facilement écrire un petit code fonctionnel. Pour le moment, je n'ai pas implémenté d'interface "variables/programme appelant", car ce genre de possibilités requiert tout d'abord la création d'un format spécifique.

b)La réalisation de la VM

Les extraits (commentés) qui suivent montrent les grands points de la réalisation, le code entier étant disponible sur www.virtualabs.tk [ou sur packetstorm ?]. La première classe que j'ai implémentée est celle de la pile, qui fut simple à réaliser. Il s'agit d'une pile little-endian, c'est à dire à adresses décroissantes, pour ne pas dérouter les habitués de la programmation assembleur sur x86 .

#include <iostream>
#include <cstdlib>
#include "Stack.h"

using namespace std;

//Constructeur
//size : taille de la pile à créer
Stack::Stack(int size)
{
    tab_stack=new int[size]; //on alloue la mémoire
    stack_size=size; //on stocke la taille
    ESP=size-1; //on met ESP au max
    EBP=0; //et EBP à 0 
}

//Destructeur de pile
Stack::~Stack(void)
{
    delete(tab_stack); //on libère la mémoire
}


//push n sur la pile
//n : entier 32 bits
void Stack::Push(int n)
{
    if((ESP-1)<0){ //si on ne déborde pas
        state=overflow; //on marque l'overflow
    }
    else tab_stack[--ESP]=n; //sinon on empile
}


//pope un élément de la pile
int Stack::Pop(void)
{
    if((ESP+1)==stack_size){ //si pile vide
        state=empty; //on marque la pile vide
        return STACK_EMPTY; //et on renvoie le code d'erreur (<0)
    }
    else return tab_stack[ESP++]; //sinon on dépile
}

//Ascenseur : State
int Stack::State(void)
{
    return state; //on renvoie l'état de la pile
}

La fonction State() est ce qu'on appelle une fonction ascenseur, c'est à dire qu'elle permet d'accéder à la valeur d'une variable privée, sans pouvoir la modifier. Cette fonction sera utile au gestionnaire d'instructions pour vérifier l'état de la pile avant les appels à PUSH et POP, et donc de détecter une éventuelle erreur.

La partie la plus intéressante de cette petite machine est le gestionnaire d'instruction, qui est en fait la partie qui émule le processeur. C'est elle qui interprète les codes opérations, et qui permet d'exécuter le pseudo-code. Les codes opérations sont situés dans le fichier Machine.h, dont voici un petit extrait :

//MOV :
#define MOV_REG_IMM   0x10
#define MOV_REG_REG   0x11
#define MOV_REG_PTREG 0x12
#define MOV_PTREG_IMM 0x13
#define MOV_PTREG_REG 0x14

//ADD :
#define ADD_REG_IMM   0x18
#define ADD_REG_REG   0x19
#define ADD_REG_PTREG 0x1A
#define ADD_PTREG_IMM 0x1B
#define ADD_PTREG_REG 0x1C

//SUB :
#define SUB_REG_IMM   0x1D
#define SUB_REG_PTREG 0x1E
#define SUB_REG_REG   0x1F
#define SUB_PTREG_IMM 0x20
#define SUB_PTREG_REG 0x21

//DIV :
#define DIV_REG_IMM   0x22
#define DIV_REG_PTREG 0x23
#define DIV_REG_REG   0x24
#define DIV_PTREG_IMM 0x25
#define DIV_PTREG_REG 0x26

//MUL :
#define MUL_REG_IMM   0x27
#define MUL_REG_PTREG 0x28
#define MUL_REG_REG   0x29
#define MUL_PTREG_IMM 0x2A
#define MUL_PTREG_REG 0x2B

Ces instructions ont plusieurs codes opérations, selon le mode d'adressage que l'on utilise : mémoire immédiate dans registre, entier pointé par un registre dans un registre, d'un registre à l'autre, mémoire immédiate à une adresse pointée par un registre, et finalement d'un registre dans un emplacement pointé par un registre. Ces différents modes de travail permettent de manipuler le pseudo-code lui-même, et donc d'autoriser le stockage dans des emplacements réservés (variables), voire d'employer des pointeurs. Le gestionnaire d'instruction est basé sur une instruction de type switch, qui agit en conséquence d'un code opération. La classe Machine se charge d'implémenter le gestionnaire d'instructions, dont notamment la fonction permettant l'exécution du pseudo-code :

int Machine::Execute(char *pcode,long size)
{
    int regs[6]; //registres
    int EFLAGS;
    
    CMP_RES comp_result=CMP_EQUAL;
    
    char src,dest;
    int *pimm32,imm32;
    Stack pile(0x200); //on déclare la pile principale 512 octets
    
    //Entry Point toujours en 0
    eip=0;
    
    while(eip<size)
    {
        switch(pcode[eip])
        {
                /**************************************
                                MOV
                **************************************/
                
                case MOV_REG_IMM :
                      dest=pcode[eip+1];
                      pimm32=(int *)&pcode[eip+2];
                      regs[dest]=(int)*pimm32;
                      eip+=6;
                      break;
                      
                case MOV_REG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]=regs[src];
                      eip+=3;
                      break;
                      
                case MOV_REG_PTREG : 
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      pimm32=(int*)&pcode[regs[src]];
                      regs[dest]=*pimm32;
                      eip+=3;
                      break;
                      
                case MOV_PTREG_IMM :
                      dest=pcode[eip+1];
                      pimm32=(int*)&pcode[eip+2];
                      *(int*)&pcode[regs[dest]]=*pimm32;
                      eip+=6;
                      break;
                      
                case MOV_PTREG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      *(int*)&pcode[regs[dest]]=regs[src];
                      eip+=3;
                      break;
                      
                /************************************
                                ADD
                ************************************/
                
                case ADD_REG_IMM :
                      dest=pcode[eip+1];
                      pimm32=(int*)&pcode[eip+2];
                      regs[dest]+=*pimm32;
                      eip+=6;
                      break;
                
                case ADD_REG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]+=regs[src];
                      eip+=3;
                      break;      
                
                case ADD_REG_PTREG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]+=*(int*)&pcode[regs[src]];
                      eip+=3;
                      break;
                
                case ADD_PTREG_IMM :
                      dest=pcode[eip+1];
                      imm32=*(int*)&pcode[eip+2];
                      *(int*)&pcode[regs[dest]]+=imm32;
                      eip+=6;
                      break;
                      
                case ADD_PTREG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      *(regs[dest]+pcode)+=regs[src];
                      eip+=3;
                      break;
                
                /**********************************
                                SUB
                **********************************/
                case SUB_REG_IMM :
                      dest=pcode[eip+1];
                      pimm32=(int*)&pcode[eip+2];
                      regs[dest]-=*pimm32;
                      eip+=6;
                      break;
                
                case SUB_REG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]-=regs[src];
                      eip+=3;
                      break;      
                
                case SUB_REG_PTREG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]-=*(int*)&pcode[regs[src]];
                      eip+=3;
                      break;
                
                case SUB_PTREG_IMM :
                      dest=pcode[eip+1];
                      imm32=*(int*)&pcode[eip+2];
                      *(int*)&pcode[regs[dest]]-=imm32;
                      eip+=6;
                      break;
                      
                case SUB_PTREG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      *(regs[dest]+pcode)-=regs[src];
                      eip+=3;
                      break;
                      
[...]    
                /**************************************
                                SHL
                **************************************/
                
                case SHL_REG_IMM :
                      dest=pcode[eip+1];
                      pimm32=(int*)&pcode[eip+2];
                      regs[dest]<<=*pimm32;
                      eip+=6;
                      break;
                
                case SHL_REG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]<<=regs[src];
                      eip+=3;
                      break;      
                
                case SHL_REG_PTREG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]<<=*(int*)&pcode[regs[src]];
                      eip+=3;
                      break;
                
                case SHL_PTREG_IMM :
                      dest=pcode[eip+1];
                      imm32=*(int*)&pcode[eip+2];
                      *(int*)&pcode[regs[dest]]<<=imm32;
                      eip+=6;
                      break;
                      
                case SHL_PTREG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      *(regs[dest]+pcode)<<=regs[src];
                      eip+=3;
                      break;
                
                /*************************************
                                SHR
                *************************************/
                
                case SHR_REG_IMM :
                      dest=pcode[eip+1];
                      pimm32=(int*)&pcode[eip+2];
                      regs[dest]>>=*pimm32;
                      eip+=6;
                      break;
                
                case SHR_REG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]>>=regs[src];
                      eip+=3;
                      break;      
                
                case SHR_REG_PTREG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      regs[dest]>>=*(int*)&pcode[regs[src]];
                      eip+=3;
                      break;
                
                case SHR_PTREG_IMM :
                      dest=pcode[eip+1];
                      imm32=*(int*)&pcode[eip+2];
                      *(int*)&pcode[regs[dest]]>>=imm32;
                      eip+=6;
                      break;
                      
                case SHR_PTREG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      *(regs[dest]+pcode)>>=regs[src];
                      eip+=3;
                      break;
                
                /***************************
                                CMP
                ***************************/
                
                case CMP_REG_IMM :
                      dest=pcode[eip+1];
                      pimm32=(int*)&pcode[eip+2];
                      if(regs[dest]==*pimm32) comp_result=CMP_EQUAL;
                      if(regs[dest]<*pimm32) comp_result=CMP_LOWER;
                      if(regs[dest]>*pimm32) comp_result=CMP_GREATER;
                      eip+=6;
                      break;
                
                case CMP_REG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      if(regs[dest]==regs[src]) comp_result=CMP_EQUAL;
                      if(regs[dest]<regs[src]) comp_result=CMP_LOWER;
                      if(regs[dest]>regs[src]) comp_result=CMP_GREATER;
                      eip+=3;
                      break;      
                
                case CMP_REG_PTREG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      if(regs[dest]==*(int*)&pcode[regs[src]]) comp_result=CMP_EQUAL;
                      if(regs[dest]<*(int*)&pcode[regs[src]]) comp_result=CMP_LOWER;
                      if(regs[dest]>*(int*)&pcode[regs[src]]) comp_result=CMP_GREATER;
                      eip+=3;
                      break;
                
                case CMP_PTREG_IMM :
                      dest=pcode[eip+1];
                      imm32=*(int*)&pcode[eip+2];
                      if(*(int*)&pcode[regs[dest]]==imm32) comp_result=CMP_EQUAL;
                      if(*(int*)&pcode[regs[dest]]<imm32) comp_result=CMP_LOWER;
                      if(*(int*)&pcode[regs[dest]]>imm32) comp_result=CMP_GREATER;
                      eip+=6;
                      break;
                      
                case CMP_PTREG_REG :
                      dest=pcode[eip+1];
                      src=pcode[eip+2];
                      if(*(regs[dest]+pcode)==regs[src]) comp_result=CMP_EQUAL;
                      if(*(regs[dest]+pcode)<regs[src]) comp_result=CMP_LOWER;
                      if(*(regs[dest]+pcode)>regs[src]) comp_result=CMP_GREATER;
                      eip+=3;
                      break;
                […]
        }
    }
}

Pour appeler notre VM, il suffit juste d'appeler la fonction MyVM de la DLL MyVM.dll, et de lui passer en argument une chaîne contenant notre pseudo-code. Le pseudo-code est directement exécuté par la VM, et exécute les actions que l'on a programmé.

c)Réalisation d'un code compilé pour la VM

L'écriture d'un code compilé est longue, mais facilement faisable à la main (bon courage). La création d'un compilateur spécifique est possible, j'aborderai ce thème dans un prochain magazine, mais sachez simplement que sa réalisation est vraiment simple si l'on emploie un langage de haut niveau, très orienté manipulation de chaînes, comme le perl ou le python. J'ai opté pour un compilateur en perl, sur lequel je suis en train de travailler.

Pour compiler un code manuellement, il suffit d'écrire dans un premier temps les instructions en assembleur, et ensuite de chercher quel code opération convient selon le mode d'adressage, pour enfin écrire la série d'octets correspondants. Exemple :

Code assembleur :

MOV EAX, 05Ch
ADD EAX, FFh
MOV EBX, EAX

Code compilé (en hexa) :

10 00 5C 00 00 00
18 00 FF 00 00 00
11 01 00

d)Améliorations de la VM

Pour améliorer cette machine virtuelle, plusieurs options sont possibles. Dans un premier temps, on peut implémenter une gestion complète de threads, en codant un ordonanceur spécifique à notre machine, qui sera chargé d'attribuer du temps de calcul à chaque thread, selon leur priorité. Cette amélioration est bien utile pour effectuer plusieurs tâches en même temps. Une autre amélioration possible serait l'ajout d'une interface programme appelant/machine virtuelle, qui permette au programme employant la machine virtuelle de passer des valeurs d'initialisation aux variables employées, et donc de pouvoir passer des paramètres plus facilement aux fonctions, au lieu de bidouiller avec un memcpy dans le pseudo-code, et utiliser l'adressage par pointeur.

e)Petit exemple illustratif

Voici le code d'un petit programme appelant notre VM (dans sa version minimaliste) :

#include <stdio.h>
#include <stdlib.h>
#include <windows.h>


int main(void){
HINSTANCE hDLL;
FARPROC Execute;

Char *pcode="\x10\x00\x5C\x00\x00\x00"
          "\x18\x00\xFF\x00\x00\x00"
          "\x11\x01\x00";
int size=13.

HDLL=LoadLibrary("MyVM.DLL");
If(hDLL){	
		Execute=GetProcAddress(hDLL, "MyVM");
	Execute(pcode,size);
} else MessageBox(0,"Error !","Error !",0);
}

Si on désassemble ce programme, on peut voir en clair le pseudo-code. Or si celui-ci est inconnu, la compréhension du code par un attaquant est vraiment complexe, et il se voit obligé de désassembler le fichier MyVM.dll nécessaire au bon fonctionnement du programme.

Conclusion

Employer une machine virtuelle peut être avantageux. En coder une peut l'être encore plus. L'avantage est double, comme nous l'avons vu : d'une part, la compréhension du pseudo-code est nécessaire de la part d'un attaquant, et d'une autre on s'affranchit du système. Cependant le codage reste long et fastidieux, mais loin d'être impossible. Ce genre de protection ralentit, mais ne bloque pas un bon reverse engineer, ce qui est dommage. Mais n'oubliez pas, on ne peut jamais arrêter un attaquant, juste le ralentir, le but étant de le ralentir au maximum.

La machine virtuelle que je vous ai présentée est encore en cours de conception, et à cet article fera suite une seconde partie concernant les gestionnaires avancés (multi-threading, exceptions) ainsi que leurs implémentations. La première version de MyVM est disponible en open-source sur www.virtualabs.tk.

Special thanks : AiSpirit, Kharneth, Neitsa, TTO, Drakenlabs (à quand le prochain MBK ;) ?)

Edit du 04/04/2006 - Je travaille actuellement sur une version C++ améliorée de ma VM, cf. article dans [System Addict #2], téléchargeable gratuitement. L'implémentation de cette VM, ainsi que la réalisation d'un petit compilateur, seront abordés au cours des numéros. Bien sur, l'axe du reverse-engineering est privilégié, mais le but primaire de la VM (implémentation et portabilité d'un programme) est aussi.--Virtualabs 4 avr 2006 à 02:35 (CEST)