Dofus et le reverse-engineering (2/2)

Suite de mes expériences démoniaques sur Dofus, un jeu vidéo Flash.

Cet article est la deuxième partie de "Dofus et le reverse-engineering". La première partie peut être lue ici.

Vous l'attendiez tous, VOICI LA PARTIE 2 !! Je tiens à préciser que j'ai fini tous ces trucs il y a maintenant plusieurs mois, mais que je me bouge seulement maintenant pour écrire le reste parce que ... j'étais occupé ?

Addendum sur la liaison TCP

Je vais commencer par vous parler d'une mauvaise conception que j'avais des transferts de données par TCP. Je vous ai dit dans le premier post:

On y apprend notamment la structure d'un paquet TCP Dofus

Ce qui implique qu'un paquet Dofus est entièrement contenu dans un paquet TCP. C'est le cas ... Si on a de la chance ! En réalité, un paquet TCP est limité à 64 Ko de taille. Et rien n'empêche votre carte réseau d'envoyer des paquets TCP de 14 Ko, juste pour le fun. En fait, il faut voir la communication TCP comme un flux: c'est un tuyau entre vous et une autre machine dans lequel passe des informations. C'est ensuite au développeur de construire une couche d'abstraction nécessaire pour ne pas s'occuper des détails chiants liés à l'implémentation. Et une fois n'est pas coutume, un dessin vaut mille mots:

La VÉRITÉ sur les paquets TCP ! EXPOSED !!

Ainsi, un paquet TCP peut contenir 3 paquets Dofus, comme il ne peut contenir qu'une seule partie d'un paquet Dofus. Il faut donc faire très attention quand on décode un paquet TCP à garder une zone tampon où on peut stocker les paquets Dofus qui ne sont pas encore entièrement finis de décoder - c'est là que les paramètres length des paquets Dofus prennent tout leur sens.

Alors, comment je me suis rendu compte de ça ? Mon sniffer ne m'affichait qu'un paquet sur 4, et il se mettait à planter de façon intempestive. Après m'être arraché les cheveux trois fois et en relisant le code source du client Dofus, j'ai pu concocter une version fonctionnelle.

Je vous épargne les détails gores des HASH_FUNCTION contenus dans certains paquets Dofus, qui modifient la taille du message à lire (en incluant une sorte de bidule chiffré inutile à la fin du paquet wtf ?!?). Hop, je remets tout ça dans ma marmite de sorcier, j'inclus une fonction pour sauvegarder et charger des captures de paquets Dofus au format JSON. On mélange ensuite à feu doux pendant une dizaine de minutes, on corrige quelques bugs et on obtient notre sniffer version 2:

... Bon ok vous allez juste devoir me croire sur parole là, j'ai réinstallé entièrement mon ordinateur depuis et j'ai un peu la flemme de devoir tout recompiler parce qu'une dépendance du sniffer ne marche plus, parce que JAVASCRIPT. Vous pouvez  cependant fermer les yeux et imaginer à quoi ressemble la version 2. Imaginez la même chose qu'avant mais avec un bouton "LOAD" et un autre bouton "SAVE" pour charger des captures de paquets Dofus. Voilà. Et aussi il y a moins de bugs mais ça, ça se voit pas visuellement.  Mais ça fait quand même plaisir. Au fait, vous pouvez rouvrir les yeux si vous voulez continuer à lire l'article, c'est plus pratique.

Balbutiements de notre propre serveur

Forts de nos réussites, nous allons pouvoir maintenant créer notre propre émulateur de serveur Dofus !! On y arrive enfin, après un post et demi, cool ! Voici comment on va s'y prendre: On commence par jouer au vrai jeu, avec notre sniffer en arrière-plan, et on enregistre tous les paquets qui transitent entre le vrai serveur Dofus et notre client. Comme ça, On peut voir comment réagit le vrai serveur quand je fais telle ou telle action. Ensuite, il faut réimplémenter la logique comme des grands garçons, de notre côté, sur notre faux serveur.

Pour des raisons de facilité que j'ai déjà expliquées précedemment, nous allons réaliser notre serveur en NodeJS. La première chose à faire est d'établir la connexion entre le client et notre serveur Dofus. On va ouvrir un socket pour que notre serveur attende la connexion d'un utilisateur:

import net from 'net';

const server = net.createServer();
server.listen(5555, '127.0.0.1');

Logger.debug(`Server started !`);

auth.on('connection', socket => {
	Logger.debug(`New client on ${kind} from ${socket.remoteAddress}:${socket.remotePort}`);
    
    socket.on('data', (data: Buffer) => {
			Logger.trace(`Client sent: ${data.toString('hex')}`);
			if (data !== undefined) {
            	// on va essayer de décoder le paquet que le client a envoyé
				PacketManager.dispatch(data, client);
			} else {
				Logger.trace(`Got empty packet from ${socket.remoteAddress}`);
			}
	});
});
On attend une connexion d'un client grâce à un socket

On se place sur le port 5555, celui utilisé par Dofus, et ... on attend. Quand un utilisateur se connecte, ça nous affiche un petit message dans la console, et chaque fois que cet utilisateur nous envoie un paquet TCP on va tenter de décoder le ou les paquets Dofus contenus avec notre méthode PacketManager.dispatch().

Maintenant, la prochaine étape est de modifier le client de jeu dofus pour lui dire de se connecter à notre serveur plutôt qu'au vrai serveur de jeu, ce qui ne devrait pas être si facile que ça !

A moins que ... dans les répertoires du jeu on a un petit fichier tout mignon à notre disposition qui va nous sauver la vie: config.xml.

<!--======================================================================-->
<!--                  Constantes pour l'accès au serveur                  -->
<!--======================================================================-->
	
<entry key="connection.host">34.252.21.81,52.17.231.202,63.34.214.78</entry>
<entry key="connection.host.signature">AARBS1NGAAEAAACACZ9+mr1yAiQ8Iug2hODrUxKfQeviR0QJB/fzthrCQRA3/rSEDPkqL3ojfJsLu/07VkebfaCegbYtR5tQlg6voAMgDMX9FlP6KtQWnLXVUKkOye1pt01gb3uLUBrqm9o/3Lrgo38xuHs67jvAy1Yyo1bwk6JycE0H9yWnjg+WBmI=</entry>
<entry key="connection.port">5555,443</entry>
Le fichier config.xml, simplifié

Il nous suffit de modifier l'adresse ip dans le champ connection.host! Ainsi, le client Dofus se connectera sur notre serveur plutôt que sur les serveurs officiels d'Ankama. Trop beau pour être vrai, non ? Faisons-ça et testons si le client Dofus arrive à se connecter à notre serveur.

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHHHHHHHHHH

Et voilà, trop beau pour être vrai ! Le jeu vérifie que l'adresse ip du serveur correspond bien à un serveur officiel. Mais vu que cette vérification est faite dans les fichiers sources du jeu,  elle est théoriquement contournable. On va donc aller farfouiller dans les sources, pour tomber sur cette variable `validHosts`

if(!validHosts) {
	_log.warn("Host signature could not be verified, connection refused.");
    this.commonMod.openPopup(I18n.getUiText("ui.common.error"),I18n.getUiText("ui.popup.connectionFailed.unauthenticatedHost"),[I18n.getUiText("ui.common.ok")]);

	KernelEventsManager.getInstance().processCallback(HookList.SelectedServerFailed);
	return false;
}
Code vérifiant si le serveur est officiel

Patchage du client de jeu Dofus

Idéalement, on souhaiterait faire sauter cette vérification. Ca tombe bien, JPEXS permet de modifier le code ActionScript ! JPEXS doit ensuite recompiler le code ActionScript en P-code, un espèce de code machine avec lequel flash fonctionne. On souhaite donc modifier le mininum de code possible pour éviter une erreur lors de la recompilation. Si on regarde le morceau de code que je viens de poster, il suffit d'inverser la condition: si on a if(validHost), le jeu refusera de se lancer si le serveur est un serveur officiel, et se lancera dans tous les autres cas !

Malheureusement, la modification du code ActionScript est expériementale, et même changer une simple condition fait crasher Dofus au démarrage. On va donc devoir mettre les mains dans le cambouis et modifier directement le P-code.

le P-code

Voilà à quoi ressemble une partie du code ActionScript précédent en P-code. C'est pas joli joli et c'est difficile à comprendre pour nous simples humains ! La seule instruction qui nous intéresse est la condition, le iftrue ligne 532. En mode total freestyle je l'ai transformé en iffalse, j'ai relancé Dofus ... ET PLUS D'ERREUR !!

Essayons maintenant de nous connecter à notre serveur:

New client on AuthServer !

Notre client est détecté ! On va pouvoir maintenant lui envoyer les paquets nécessaires pour lui indiquer qu'on a bien reçu sa connexion. En examinant les requêtes du serveur officiel, j'ai déterminé qu'il nous fallait déjà un paquet ProtocolRequired qui va indiquer la version du jeu. Si le client et le serveur n'ont pas la même version, il ne faut pas autoriser la connexion. Fun fact, en Avril le paquet ProtocolRequired était constitué de deux champs de nombres entiers, mais maintenant en Octobre il n'est constitué seulement que d'une chaîne de caractères, j'ai donc dû mettre à jour le serveur juste pour vous. C'est donc très facile d'envoyer l'indication de la version du protocole, on a déjà fait tout le travail:

import config from '../config.json';

static async greatAuth(client: Client) {
	let proto = new ProtocolRequired(config.protocol.current);
	PacketManager.sendClient(proto, client);
}
Envoi du paquet indiquant le protocole requis

Mais nous ne sommes pas au bout de nos surprises ...

HelloConnectMessage: le mini-boss

On doit aussi envoyer un paquet répondant au doux nom de HelloConnectMessage au client. Voici la classe TypeScript générée lui correspondant:

export class HelloConnectMessage {

	public salt: string;
	public key: number[];

	constructor(salt: string = '', key: number[] = []) {
		this.salt = salt;
		this.key = key;
	}
	serialize(output: CustomDataWrapper) {
		output.writeUTF(this.salt);
		output.writeVarInt(this.key.length);
		for(var _i2 = 0; _i2 < this.key.length; _i2++)
		{
		output.writeByte(this.key[_i2]);
		}
	}
	deserialize(input: CustomDataWrapper) {
		var _val2 = 0;
		this.salt = input.readUTF();
		var _keyLen = input.readVarInt();
		for(var _i2 = 0; _i2 < _keyLen; _i2++)
		{
		_val2 = input.readByte();
		this.key.push(_val2);
		}
	}
	getMessageId() { return 2607; }
}
Classe TypeScript pour HelloConnectMessage

Et là si vous trouviez galère les échanges réseaux en TCP, accrochez-vous, on rentre dans le joli monde de la CRYPTOGRAPHIE ! En effet, le but de HelloConnectMessage est d'établir une connexion sécurisée et chiffrée entre le client et le serveur. On a deux champ à envoyer au client: salt et key. Reste maintenant à savoir à quoi ils servent.

Le champ salt est assez standard, il permet d'ajouter des caractères aléatoires à des données afin d'éviter une attaque par dictionnaire. (Au début j'avais commencé une explication mais après 15 lignes à compliquer les choses je me suis dit qu'il vaut mieux en rester là)

Maintenant qu'est ce qu'on peut bien envoyer dans le champ key ? Après environ une semaine de recherche sur cadernis.fr et d'expérimentations, j'ai la réponse !

C'est en fait lié au système d'authentification qu'utilise Dofus. Une fois que le client se connecte au serveur, le serveur lui fournit une clé RSA pour pouvoir dialoguer de façon sécurisée sans que le contenu de l'échange soit décryptable par un tiers. Le chiffrement RSA est dit asymmétrique: deux personnes qui communiquent en chiffrant leurs données avec RSA n'utilisent pas la même clé. Quand on crée des clés RSA pour pouvoir s'échanger des informations, on crée en fait une paire de clé: une clé privée et une clé publique.

L'intérêt de la clé privée est que seule une personne de confiance la possède. C'est la seule à pouvoir chiffrer des messages avec. A l'inverse, tout le monde à le droit d'avoir la clé publique, qui permet de déchiffrer les messages faits avec la clé privée. Mais alors, quel est l'intérêt ?

Cela permet en fait, dans notre cas, de vérifier l'identité du serveur. Si le serveur m'envoie un message chiffré avec sa clé privée et que j'arrive à déchiffrer ce message avec la clé publique, cela signifie que le serveur est bien celui que je crois, et pas une personne malicieuse qui se fait passer pour lui.

Mais si vous avez bien suivi, (je sais c'est compliqué) il y a un hic: la conversation ne peut être chifrée que dans un sens. Notre client peut chiffrer avec sa clé publique un message que seul le serveur pourra déchiffrer. Mais le serveur ne peut pas chiffrer un message que seul le client peut déchiffrer. En effet, tout ceux qui ont la clé publique pourront aussi le déchiffrer !

Comment on résout se problème ?

ON GÉNÈRE UNE DEUXIÈME PAIRE DE CLÉS !!

Voilà le fonctionnement final en image:

Fonctionnement du chiffrement RSA avec deux paires de clés

Il y a donc une deuxième sécurité à contourner: une clé de vérification est contenue dans les sources du client et vérifie que la clé signature du serveur est bien valide. Mais nous ne disposons pas de la clé signature, qui est sur les serveurs d'Ankama ! On peut par contre créer notre propre paire de clé signature/vérification puis essayer de remplacer la clé de vérification dans le client Dofus par la notre.

Note: habituellement les émulateurs de serveur Dofus suppriment toutes ces parties d'authentification, ce qui les rend potentiellement vulnérables à une mutlitude d'attaques, mais comme on est pas des connards on va faire ça bien !

On commence donc par générer des clés random pour les utiliser comme clés de signature/vérification. Quand je dis random, c'est pas vraiment random hein parce que j'ai du aller jusqu'à chercher dans les sources de la librairie de cryptographie utilisée par Dofus, Hurlant, les paramètres à utiliser pour générer des clés RSA compatibles :(

Soit dit en passant, la librairie est maintenant archivée et n'accepte plus les modifications depuis 2017. Super la sécurité dans Dofus !

Et voici pour vous récompenser le morceau de code résultat d'heures acharnées de recherches, oui oui, ces pauvres 45 lignes et pas une de plus !

import crypto from 'crypto';
import fs from 'fs';
import Logger from '../log/Logger';

/**
 * generates random RSA key pairs
 */
Logger.setLevel(Logger.DEBUG);
Logger.info('Starting the RSA keys generation ...');

crypto.generateKeyPair('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: {
        type: 'spki',
        format: 'pem'
    },
    privateKeyEncoding: {
        type: 'pkcs1',
        format: 'pem',
    }
}, (err: any, publicKey: string, privateKey: string) => {

    fs.writeFileSync(__dirname + '/public.pem', publicKey);
    fs.writeFileSync(__dirname + '/private.pem', privateKey);
    Logger.debug('Successfully generated a RSA private key and a RSA public key.');
    Logger.warn('Do not share the private key !');
});

crypto.generateKeyPair('rsa', {
    modulusLength: 2440,
    publicKeyEncoding: {
        type: 'spki',
        format: 'pem'
    },
    privateKeyEncoding: {
        type: 'pkcs1',
        format: 'pem'
    }
}, (err: any, publicKey: string, privateKey: string) => {

    fs.writeFileSync(__dirname + '/verify.pem', publicKey);
    fs.writeFileSync(__dirname + '/sign.pem', privateKey);
    Logger.debug('Successfully generated the sign key and the verify key.');
    Logger.warn('Do not share the sign key !');
});
Mon fichier de génération de clés RSA, en entier

Ceux qui suivent encore auront remarqué que je génère seulement une clé publique et privée alors que je devrais le faire à chaque connexion d'un client, c'était pour me simplifier la vie dans mes tests.

Il ne nous reste plus qu'à remplacer la clé de vérification dans le client Dofus. Heureusement, JPEXS nous sauve la mise avec une petite option pour remplacer n'importe quel fichier binaire d'une appli Flash, et pour une fois, ça marche SANS PLANTER !

Ankama et les spaghettis

Alors c'est bon on a réussi à envoyer un HelloConnectMessage du serveur au client ? Hahaahahahahahaha, si seulement vous saviez. Quand on passe les données binaires key à HelloConnectMessage, on a seulement quelques centaines d'octets de longueur au lieu des 2440 requis. POUQUOI ?!?

Après avoir rempli mon code de console.log("et là on rentre dans la fonction ???") je me suis rendu compte que l'erreur venait quelque part ici (en vrai je me rappelle plus trop ça fait longtemps mais je crois que c'était là) :

    writeVarInt(value: number): void {
        var b: any = 0;
        var ba: any = new ByteArray();
        if (value >= 0 && value <= CustomDataWrapper.MASK_01111111) {
            ba.writeByte(value);
            this._data.writeBytes(ba);
            return;
        }
        var c: number = value;
        for (var buffer: any = new ByteArray(); c != 0;) {
            buffer.writeByte(c & CustomDataWrapper.MASK_01111111);
            buffer.position = buffer.length - 1;
            b = buffer.readByte();
            c = c >>> CustomDataWrapper.CHUNCK_BIT_SIZE;
            if (c > 0) {
                b = b | CustomDataWrapper.MASK_10000000;
            }
            ba.writeByte(b);
        }
        this._data.writeBytes(ba);
    }
????

Je tiens à dire tout de suite que je n'ai pas écrit cette monstruosité, je l'ai trouvée telle quelle dans les sources de Dofus et le j'ai gentiment recopiée sans jamais remettre en doute le bon fonctionnement de l'opérateur >>> qui me fait encore faire des cauchemars. Non, le "bug" est encore plus insidieux: Dofus s'appuie sur un mécanisme non-documenté de la librairie ByteArray de Flash, le dépassement d'entier. Pour faire simple, si je stocke un nombre dans une variable limitée à 255 et que je décide pour faire chier le monde de mettre 256 dedans, ma variable va retomber à 0. DOFUS UTILISE CONSCIEMMENT LE DÉPASSEMENT D'ENTIERS !! (je le répète pour que vous intégriez).

J'ai donc du patcher la librairie bytearray-node de notre cher ami Zaseth pour intégrer ce mécanisme, et il à même intégré mes changements !

On peut maintenant tester notre mécanisme d'authentification, j'ai ajouté quelques informations dans la console de notre serveur pour comprendre ce qui se passe:

On voit les données en hexadecimal qu'on envoie du serveur au client, et plus important encore, le client nous répond avec un IdentificationMessage !

IdentificationMessage: le boss de fin

Après, c'est toujours le même jeu: implémenter petit à petit les paquets manquants que le client demande. Voyons à quoi correspond ce IdentificationMessage :

export class IdentificationMessage {

	public version: Version;
	public lang: string;
	public credentials: number[];
	public serverId: number;
	public autoconnect: boolean;
	public useCertificate: boolean;
	public useLoginToken: boolean;
	public sessionOptionalSalt: number;
	public failedAttempts: number[];

	...
}
La classe TypeScript générée pour IdentificationMessage

Et là on a encore pas mal de choses à faire, la plus importante étant de récupérer les credentials de l'utilisateur, c'est à dire son mot de passe. Et là ça devient encore plus wild, si c'est possible !

Il faut commencer par déchiffrer ces credentials avec notre clé privée qu'on vient de générer. Ensuite ceux-ci se présentent sous cette forme:

El famoso crendentials

Donc là normalement vous paniquez parce que vous découvrez un nouvel acronyme: AES. La clé AES permet un chiffrement symétrique entre le serveur et le client: grosso modo, c'est une sorte de mot de passe qui permet de chiffrer ou déchiffrer des données. C'est quand même BEAUCOUP plus pratique à utiliser que de se trimballer avec des doubles paires de clés RSA partout. Et on peut aussi remarquer que Ankama a accès à un moment à vos mots de passe en clair ! Niveau sécurité c'est très vraiment nul, en général on utilise des techniques comme le hashage pour éviter ça. Une petite fuite de données et boum! le monde entier sait que votre mot de passe est mamamanlareinedesmamans.

La encore y'a des petits tricks à maitriser pour bien utiliser la clé AES, mais je vous épargne encore et toujours les détails :D

Mais maintenant qu'on a les données de connexion, on peut vérifier que notre utilisateur est bien inscrit et a rentré le bon mot de passe ! Pour cela, on va devoir stocker notre liste autorisée d'utilisateurs dans une base de données. J'ai utilisé MongoDb avec Mongoose pour le petit côté exotique du truc.

La logique que j'ai mise en place pour vérifier la connexion d'un utilisateur est plutôt élégante, de mon point de vue. Je vous inclus le code suivant pour vous en montrer un extrait et vous rappeler qu'il fau penser à tous les cas de figures: Est-ce que l'utilisateur est inscrit ? Si oui, est-il banni ? A-t-il les droits admin ? etc.

Ce qui peut sembler simple à implémenter sur le papier est en réalité  souvent beaucoup plus complexe.

const user = await Account.findOne({
        login,
        password
    });

    if (!user) {
        const fail = new IdentificationFailedMessage(IdentificationFailureReasonEnum.WRONG_CREDENTIALS);
        PacketManager.sendClient(fail, client);
        Logger.debug(`${client.socket.remoteAddress} failed to login`);
        return;
    }

    // good login and password
    client.account = user;
    Logger.debug(`${user.login} (${client.socket.remoteAddress}:${client.socket.remotePort}) logged in`);

    // check if the user is banned
    if (user.banned) {
        const ban = new IdentificationFailedBannedMessage(IdentificationFailureReasonEnum.BANNED, 0);
        PacketManager.sendClient(ban, client);
        return;
    }

    // send the success message
    const success = new IdentificationSuccessMessage();
    // account ids are stored as a MongoDb object, and the client doesn't really care about its id.
    success.accountCreation = 1582385010000;
    success.accountId = 0;
    success.communityId = 0;
    success.hasConsoleRight = user.rights === 'ADMIN';
    success.hasRights = success.hasConsoleRight;
    success.secretQuestion = '';
    success.subscriptionElapsedDuration = 0;
    success.subscriptionEndDate = 0;
    success.wasAlreadyConnected = false;
    success.login = user.login;
    success.nickname = user.login;
    PacketManager.sendClient(success, client);
Une partie de la gestion de l'authentification avec Mongoose et les Promesses JavaScript

Je vais maintenant m'ajouter dans la liste des utilisateurs, et Inch'allah.

Ouais je suis admin

Regardez maintenant cette vidéo pour voir le bon fonctionnement de notre serveur !

Et voilà le travail !

Maintenant on a quasiment fini le travail ! Il faut juste donner à notre client un ticket qui atteste que celui-ci est bien connecté. Comme j'en ai marre d'écrire, je vous balance les fameuses 40 lignes de crypto, à 1h de réflexion par ligne !

	/**
     * Generates a random ticket encrypted with AES 
     * @param key the AES key to encrypt with
     */
    static generateTicket(key: ByteArray): { encrypted: ByteArray, ticket: string } {

        // we're first generating a base64 string, which is going to be more useful later as it can be ascii-encoded.
        const rand = crypto.randomBytes(12);
        
        // 12 bytes === 96 bits === 16*6 bits === 16 base64 ascii characters
        const b64String = rand.toString('base64');
        
        // we now have 16 random bytes, which generates an encrypted AES message of 32 bytes
        const body = Buffer.from(b64String, 'ascii');

        // the iv is made of the first 16 bits of the key according to dofus source
        const iv = new ByteArray();
        iv.writeBytes(key,0,16);

        // create our AES cipher
        const cipher = crypto.createCipheriv('aes-256-cbc', key.buffer, iv.buffer);

        // Dofus does not use any padding (which is fine since the key changes everytime)
        cipher.setAutoPadding(false);

        let ticket = cipher.update(body, null, 'hex');
        ticket += cipher.final('hex');

        return {
            encrypted: new ByteArray(Buffer.from(ticket, 'hex')),
            ticket: b64String
        };
    }
La génération d'un ticket d'authentification

On a maintenant fini tout ce qui est authentification, il ne nous reste plus qu'à coder toute la logique de Dofus ... Ne connaissant absolument pas le jeu je ne me suis pas arrêté beaucoup plus loin !

Sur ce, je clos cette série d'articles liés à Dofus. J'espère que vous aurez apprécié la lecture même si les articles sont un peu touffus.