lucas.zip

Cours de JavaScript (stream h25)

Pourquoi ce document ? Il s’agit d’une reprise Ă©crite du cours que j’ai pu donner pendant le stream de 25 heures organisĂ© par h25 Ă  l’occasion du confinement provoquĂ© par l’épidĂ©mie de Coronavirus qui frappe actuellement l’Europe. Une rediffusion est disponible ici.

Objectif

Ce cours a pour objectif de prĂ©senter les aspects essentiels du langage JavaScript, en particulier ceux utiles au joueur de Capture The Flag (CTF). En rĂ©alitĂ©, j’avais besoin d’un prĂ©texte pour parler de JavaScript


Aucune connaissance du langage JavaScript n’est requise.

Attention ! On ne parlera pas ici de XSS ou autres vulnérabilités client.

Introduction

JavaScript est un langage qui fait dĂ©bat, toujours trĂšs controversĂ©. Plusieurs enquĂȘtes, notamment celles de GitHub (qui vient d’ailleurs d’acquĂ©rir npm), le placent en tĂȘte des langages les plus utilisĂ©s au monde (on peut dĂ©battre sur ce que plus utilisĂ© signifie, certes) et il est aujourd’hui absolument partout : dans nos navigateurs, dans nos serveurs web, dans nos appareils mobiles, dans nos applications de bureau


Mais il est aussi dans nos CTFs ! Et on le retrouve dans plusieurs catĂ©gories : web, bien sur, mais aussi crypto, forensique, malware, mobile, etc. Le JavaScript peut bien sur ĂȘtre le sujet de l’épreuve en elle-mĂȘme, mais il peut Ă©galement ĂȘtre simplement le moyen d’intĂ©ragir avec l’épreuve (comme on l’a vu lors du dernier stream H25).

Le problĂšme, c’est que beaucoup de joueurs de CTFs ne connaissent pas vraiment le JavaScript. C’est Ă  dire : beaucoup de joueurs sont capables d’infĂ©rer ce que fait un bout de code en se ratachant Ă  des mots clĂ©s, Ă  des noms de mĂ©thode, ou Ă  des constructions de programmation orientĂ©e objet qui leurs sont familiers parce que similaires Ă  ce qui se fait dans d’autres langages. Le bat blesse quand il s’agit d’écrire du code, et plus seulement en lire. Ou bien, lorsque le code Ă©tudiĂ© prĂ©sente des fonctionnalitĂ©s avancĂ©es de JavaScript, en particulier celles relatives Ă  la programmation asynchrone.

Fondamentaux

Commençons par l’étude d’un bout de code qui va nous permettre de clarifier certains aspects simples, mais pourtant souvent sources de confusion, du langage


// Quelques notions de JavaScript
// D'autres constructions utiles sont présentées plus tard

// Il n'y a pas de fonction main
// Chaque fichier est un script indépendant, évalué comme tel
// Des fonctionnalités d'importation permettent de diviser les sources
// en plusieurs fichiers. Voir l'instruction "import" et la fonction
// "require" pour plus d'informations.

// Le mot clé var permet d'associer un identifiant (le "nom" de la variable)
// a une valeur
var name = 'some_value';

// Il est possible de réaffecter cet identifiant
// On peut utiliser des guillemets simples, ou doubles
// Attention, var est trĂšs souvent banni, au profit de let et const
name = "some 'other' value";
name = 'some "other" value';
name = 'some \'other\' value';

// On peut aussi utiliser l'accent grave pour profiter des "template literals"
// (en français : littéraux de gabarits, quel plaisir), et donc de
// fonctionnalités d'interpolation
// Les accents graves permettent également de faire du multi-lignes
name = `I am ${Math.floor(Math.random() * 10)} years old!`;

// (Optionnel)
// Pour plus d'informations, voir le "hoisting"
// https://www.w3schools.com/js/js_hoisting.asp
// Les noms déclarés à l'aide de let et const ne SONT PAS hoistés

// Le mot clé let est une autre maniÚre d'associer un identifiant à une valeur
// Tout comme var, on peut réaffecter l'identifiant
// En revanche, un identifiant déclaré avec let a une durée de vie
// limitée : le bloc courant
let other_name = name;
other_name = "my own name";

// Finalement, le mot clé const permet également d'associer un idenfiant
// Ă  une valeur
// Grosse diffĂ©rence : un idenfiant dĂ©clarĂ© avec const ne peut ĂȘtre rĂ©affectĂ©
const constant_name = "this is so constant";

// !! Ne fonctionne pas
// constant_name = "nop, sorry";

// Attention, seul l'identifiant est garanti constant
// La valeur ne n'est pas
const some_object = {
    prop_one: 'value one',
    prop_two: 'value two'
};

// Aucun problĂšme : on met Ă  jour la valeur et non l'identifiant
some_object.prop_two = 'value twotwo';
some_object.prop_three = 'value three';

// Les ; en fin d'instruction sont obligatoires (d'aprĂšs le standard)
// Mais, dans de nombreux cas, un code sans ; pourra ĂȘtre correctement exĂ©cutĂ©
// parce que l'interpréteur peut les ajouter à la volée

// Le mot clé function permet de déclarer une fonction
function some_function(some_argument) {
    // Si un indentifiant a été déclaré mais n'a pas été initialisé,
    // sa valeur est undefined
    if (some_argument !== undefined) {
        console.log(`I got an argument: ${some_argument}!`);
    }
}

// Ne génÚre aucune erreur, some_argument aura simplement une valeur de
// undefined au sein de la fonction some_function
some_function();

// JavaScript dispose d'un systĂšme de typage dynamique
some_function("some string");
some_function(1337);

// Les fonctions sont des objets de premiĂšre classe
// (en anglais : "first class citizen")
// On peut donc les passer comme argument, les utiliser comme
// valeur de retour, etc
some_function(some_function);

// Il existe une autre syntaxe permettant de déclarer une
// fonction : la fat arrow
// On parle aussi de fonctions fléchées
// Ces fonctions introduisent bien un nouveau bloc (scope) mais pas
// de this ou de super
a => console.log(a);
(a, b) => a + b;
(a, b, c) => {
    let d = a + b;
    return d * c;
}

// Toutes les fonctions déclarées ci-dessus sont anonymes : ce sont des valeurs
// qui n'ont pas été associées à un identifiant

// A l'instar de var, function est parfois banni au profit de
// la construction suivante
const my_function = (arg1, arg2) => {
    console.log(arg1, arg2);
    return arg1 * arg2;
};

// Les tableaux
const some_array = [1, 2, 3, 4];
some_array.push('hello'); // Le tableau n'est pas fortement typé

// Ou bien, via constructeur
const another_array = new Array(); // Renvoie []

// ContrĂŽler la longueur initiale
const more_array = new Array(5); // Renvoie un tableau de 5 éléments vides

// Attention, si plus de deux arguments, les arguments deviennent des valeurs
const awesome_array = new Array(5, 6, 7); // [5, 6, 7]

// Les objets
// JSON : JavaScript Object Notation
const my_object = {
    property_one: 42,
    property_two: 'hello',
    method_one: () => 'hello from method_one',
    method_two: function () {
        return this.property_one;
    }
};

// AccĂšs par .
console.log(my_object.property_one);

// AccĂšs par [] (genre dictionnaire)
console.log(my_object['method_one']);
const attr = 'method_one'; console.log(my_object[attr]);

// Boucle while
let i = 0;
while (i < 10) {
    console.log(i++); // on a aussi --i, ++i et i--
}

// Boucle for
const arr = ['hello', 'les', 'amis'];

// Simple
for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
}

// AccĂšs aux valeurs
// On remarque le const : un nouveau scope est introduit Ă  chaque tour
for (const i of arr) {
    console.log(i);
}

// AccĂšs aux indices
// MĂȘme principe pour le const
for (const i in arr) {
    console.log(i);
}

On peut exĂ©cuter du JavaScript dans diffĂ©rents environnements. Le plus Ă©vident est un navigateur WEB : on navigue sur un site web qui utilise du JavaScript. Mais on peut aussi exĂ©cuter du JavaScript en dehors du navigateur, par exemple Ă  l’aide de Node.js.

Si on part du principe que deux environnements d’exĂ©cution implĂ©mentent la mĂȘme version du standard, tous les Ă©lĂ©ments de syntaxe que l’on vient d’étudier seront valables et produiront des rĂ©sultats identiques dans les deux environnements.

En revanche, le contexte sera diffĂ©rent. Un bout de code JavaScript exĂ©cutĂ© dans le navigateur peut accĂ©der Ă  l’objet document, qui reprĂ©sente la page web. Node.js ne peut accĂ©der Ă  cet objet car il ne fait pas sens dans son contexte. En revanche, Node.js fourni le module fs qui est une API pour intĂ©ragir avec le systĂšme de fichier. Le navigateur ne propose pas cette API, car il n’a pas accĂšs au systĂšme de fichier (c’est en train d’évoluer ceci dit).

En fonction de l’environnement, les connaissances requises pour gĂ©rer une Ă©preuve peuvent ĂȘtre trĂšs diffĂ©rentes. Une Ă©preuve qui se passe dans le navigateur pourra demander d’ĂȘtre Ă  l’aise avec les manipulations du DOM tandis qu’une Ă©preuve serveur demandera de connaitre le fonctionnement de la librairie Express.

Constructions utiles

Maintenant que nous avons les bases, voyons quelques constructions qui reviennent fréquemment en CTF


Manipulations ASCII

Bout de code adaptĂ© de l’étape 1 des Hexpresso FIC Quals 2019. Write-up complet disponible ici.

for (i = 0; i < some_string.length; i++) {
    if (some_string.charCodeAt(i) + flag.charCodeAt(i) + i * 42 != some_array[i]) {
        alert("NOPE");
        return;
    }
}

On remarque:

Voyons un code solution :

let r = "";
for (const i in flag) {
    r += String.fromCharCode(some_array[i] - some_string.charCodeAt(i) - i * 42);
}

On remarque alors que String.fromCharCode(i) (mĂ©thode statique) permet l’opĂ©ration inverse de charCodeAt(i). On obtient alors le caractĂšre (attention, il s’agit en fait d’une chaĂźne de caractĂšres de longueur

  1. correspondant au nombre i.

Utilisateurs de Python, attention, JavaScript permet l’opĂ©rateur + sur des chaĂźnes de caractĂšre mais il ne permet pas la multiplication. On utilise pour ça la mĂ©thode statique String.repeat().

On commence Ă  comprendre que JavaScript fourni de trĂšs nombreux utilitaires. Le langage dispose Ă©galement d’un Ă©cosystĂšme imposant. Il est trĂšs rare de ne pas trouver une dĂ©pendance permettant de rĂ©pondre Ă  son problĂšme.

Minification

Beaucoup d’épreuves prĂ©sentent de la minification. Le premier cas d’usage en JavaScript est certainement la rĂ©duction de la taille du code. Parce que le code JavaScript client est tĂ©lĂ©chargĂ© depuis un serveur WEB, on cherche Ă  rĂ©duire la taille de ce code afin d’accĂ©lĂ©rer sa rĂ©cupĂ©ration.

Note : dans la grande majoritĂ© des cas, un code minifiĂ© ne s’exĂ©cute pas plus rapidement qu’un code non-minifiĂ©. Il est simplement plus rapide Ă  tĂ©lĂ©charger.

Dans le contexte d’un CTF, la minification devient une forme d’obfuscation. Le code suivant:

const obj = {
    firstname: 'Geo',
    lastname: 'Le Berlingot'
};

function getFullName(person) {
    return person.firstname + ' ' + person.lastname
}

console.log(getFullName(obj));

Devient, une fois minifié avec JavaScript Minifier :

function getFullName(e){return e.firstname+" "+e.lastname}const obj={firstname:"Geo",lastname:"Le Berlingot"}
console.log(getFullName(obj))

On utilise alors le minimum de caractĂšres possibles. Les espaces sont retirĂ©s, les noms sont manglĂ©s
 Mais ce code est tout Ă  fait lisible et on peut trĂšs facilement retomber proche du code initial en utilisant un outil tel que l’outil de formatage de Chrome.

Mais il existe aussi des outils proposant une obfuscation beaucoup plus franche. Par exemple, avec JavaScript Obfucator, notre code initial devient:

const _0x1616=['lastname','firstname','Geo','Le\x20Berlingot'];(function(_0x177eb0,_0x1616a6){const _0x1e9abe=function(_0x204014){while(--_0x204014){_0x177eb0['push'](_0x177eb0['shift']());}};_0x1e9abe(++_0x1616a6);}(_0x1616,0xc6));const _0x1e9a=function(_0x177eb0,_0x1616a6){_0x177eb0=_0x177eb0-0x0;let _0x1e9abe=_0x1616[_0x177eb0];return _0x1e9abe;};const obj={'firstname':_0x1e9a('0x0'),'lastname':_0x1e9a('0x1')};function getFullName(_0xb6014e){return _0xb6014e[_0x1e9a('0x3')]+'\x20'+_0xb6014e[_0x1e9a('0x2')];}console['log'](getFullName(obj));

Et on a quelque chose qui est dĂ©jĂ  beaucoup moins lisible. La mĂ©thodologie varie en fonction du temps que l’on peut investir et le type d’obfuscation mais dans le cas gĂ©nĂ©ral, on peut commencer par faire passer le code dans un outil de dĂ©obfuscation tel que de4js et ensuite terminer Ă  la main. On va chercher principalement Ă  :

J’avais dĂ©jĂ  Ă©voquĂ© l’obfuscation en JavaScript dans ce writeup d’une Ă©preuve des qualifications de la Nuit Du Hack 2018.

Encodages

JavaScript donne accĂšs, mĂȘme sans dĂ©pendances, Ă  plusieurs encodages dont les challenges ont tendance Ă  abuser. Si on ne les connait pas, ils peuvent donner l’impression qu’on doit gĂ©rer un chiffrement complexe ou une bizarrerie JavaScript alors qu’un simple appel de fonction suffit. On a :

Attention également à la fonction eval.

Quelques writeups

Les writeups suivant couvrent bien le spectre d’épreuves qu’on peut rencontrer.

Concurrence

JavaScript inplĂ©mente une boucle d’évĂšnement (en anglais: event loop), qui l’ammĂšne Ă  ne pas ĂȘtre bloquant. En Python, lorsqu’on fait une requĂȘte HTTP avec le module request, il est impossible (du moins, sans efforts) d’exĂ©cuter plus de code pendant que cette requĂȘte a lieu. On dit alors que l’appel Python est bloquant, car il monopolise le runtime et ne “rend pas la main”.

Attention ! Si vous dĂ©couvrez la concurrence, il est trĂšs important que vous preniez le temps d’exĂ©cuter les bouts de code fournis. Essayez de deviner la sortie puis voyez si votre prĂ©diction est correcte.

Callbacks

L’approche la plus simpliste est celle du callback. La fonction permettant de faire une requĂȘte HTTP prend alors un autre paramĂštre : une fonction, qui sera appelĂ©e lorsque le rĂ©sultat sera disponible. Ce callback reçoit en paramĂštre le (ou les, y compris les erreurs) rĂ©sultat(s) de l’opĂ©ration asynchrone.

const request = require('request');

request.get('https://geographer.fr/', (err, result) => {
    if (err) {
        console.error(err);
        return;
    }

    console.log(result.body.slice(0, 500));
});

console.log('Where am I?');

On remarque que la mĂ©thode request.get retourne immĂ©diatement. Le flux d’exĂ©cution n’est donc pas bloquĂ© pendant la requĂȘte HTTP, on peut continuer d’exĂ©cuter le code qui se trouve plus bas.

Le callback, qui est ici une fonction anonyme, sera appelĂ© une fois que le rĂ©sultat de l’opĂ©ration asynchrone sera disponible. Le runtime fera alors une pause dans l’exĂ©cution de son fil “principal” afin d’exĂ©cuter le callback.

const request = require('request');

console.log('BEFORE request call!');

request.get('https://geographer.fr/', (err, result) => {
    console.log('START of callback!');

    if (err) {
        console.error(err);
        return;
    }

    console.log(`Body has a length of ${result.body.length}.`);
    console.log('END of callback!');
});

console.log('AFTER request call!');

Il est plus rare de voir des callbacks aujourd’hui en raison du callback hell. C’est cette situation oĂč on se retrouve Ă  imbriquer plusieurs callbacks, ce qui rend le code trĂšs peu lisible. Imaginons un code qui effectue les actions suivantes : requĂȘte HTTP, sauvegarde du rĂ©sultat dans un fichier texte, puis duplication de ce fichier. Le code aurait cette allure :

const request = require('request');
const fs = require('fs');

request.get('https://geographer.fr/', (e, r) => {
    if (e) {
        console.error(e);
        return;
    }

    fs.writeFile('/tmp/output.txt', r.body, (e, r) => {
        if (e) {
            console.error(e);
            return;
        }

        fs.copyFile('/tmp/output.txt', '/tmp/output_copy.txt', (e, r) => {
            if (e) {
                console.error(e);
                return;
            }

            console.log('Work is done!');
        });
    });
});

Quel plaisir
 Il est trop difficile de suivre le fil de ce code, sans compter qu’il vire dangereusement à droite. Voyons comment les promesses peuvent nous aider !

Promise

L’API Promise est un systĂšme qui vient se substituer au callback quand il s’agit de gĂ©rer des appels asynchrones. Voyons Ă  quoi ressemble le code prĂ©cĂ©dent avec des promesses :

const request = require('request-promise-native');
const fs = require('fs').promises;

request.get('https://geographer.fr/')
    .then(r => fs.writeFile('/tmp/result_promise.txt', r))
    .then(r => fs.copyFile('/tmp/result_promise.txt', '/tmp/copy_promise.txt'))
    .then(() => console.log('Work is done!'))
    .catch(e => console.error(e));

Beaucoup mieux ! On remarque deux mĂ©thodes : then et catch, et l’objet sur lequel elles sont appliquĂ©es n’apparait pas clairement. Il s’agit en fait d’un objet Promise qui vient enrober les valeurs de retour des appels asynchrones. Pour mieux comprendre, Ă©tudions ce code qui implĂ©mente une fonction retournant une promesse :

const fs = require('fs');

const writeFilePromise = (path, data) => new Promise((resolve, reject) => {
    fs.writeFile(path, data, (err, result) => {
        if (err) {
            reject(err);
            return;
        }

        resolve(result);
    });
});

writeFilePromise('/tmp/hello.txt', 'Hello!')
    .then(() => console.log('Work is done!'))
    .catch(e => console.error(e));

On obtient donc une fonction writeFilePromise qui se substitue Ă  fs.writeFile. Cette nouvelle fonction ne prend pas de callback en paramĂštre mais retourne immĂ©diatement une variable Promise. La fonction Ă  exĂ©cuter est passĂ©e en paramĂštre au constructeur Promise. Ce constructeur reçoit deux autres fonctions en paramĂštre : resolve, et reject. La premiĂšre sera appelĂ©e lorsqu’il faut transfĂ©rer une valeur de retour qui n’est pas une erreur. La seconde sera appelĂ©e afin de transfĂ©rer une erreur.

Toujours au sein de la fonction anonyme passĂ©e en paramĂštre Ă  Promise, on retrouve finalement l’appel Ă  fs.writeFile. Ce dernier prend toujours un callback en paramĂštre mais maintenant que nous avons une promesse Ă  disposition, nous pouvons utiliser sa fonction de rĂ©solution (resolve) et de rejet (reject) afin d’exfiltrer les valeurs de retour afin de ne pas crĂ©er de callback hell.

Revenons au dernier bloc de code, l’appel Ă  writeFilePromise. Nous savons maintenant que cette fonction retourne un objet Promise. Cet objet admet diffĂ©rentes mĂ©thodes, dont then et catch. La mĂ©thode then est appelĂ©e lorsque c’est resolve qui a Ă©tĂ© appelĂ© dans le callback de promesse (donc, lorsqu’il n’y a pas eu d’erreur). La mĂ©thode catch est appelĂ©e lorsque c’est reject qui a Ă©tĂ© appelĂ© dans le callback de promesse. Les mĂ©thodes then et catch admettent des fonctions en paramĂštre. Ces fonctions reçoivent elles-mĂȘmes en paramĂštres les valeurs qui ont Ă©tĂ© passĂ©s Ă  resolve et reject.

Enfin, on peut noter qu’il est possible d’enchainer les promesses, comme on a pu le voir plus haut. On se retrouve alors avec une construction trĂšs lisible qu’on appelle une chaĂźne de promesse. De ce fait dĂ©coule la possibilitĂ© de ne placer qu’un seul catch, Ă  la fin de la chaĂźne. Celui-ci couvre les erreurs de l’intĂ©gralitĂ© de la chaĂźne, ce qui allĂšge considĂ©rablement le code (mais ammĂšne d’autres problĂšmes, malheureusement c’est hors-sujet).

Async/Await

Les mots clĂ©s async et await sont des sucres syntaxiques autour des promesses. Ils permettent d’écrire du code trĂšs lisible, plus proche du schĂ©ma de pensĂ©e sĂ©quentielle auquel nous sommes gĂ©nĂ©ralement habituĂ©s. Voyons tout de suite un exemple :

const request = require('request-promise-native');
const fs = require('fs').promises;

const main = async () => {
    const body = await request.get('https://geographer.fr/');
    await fs.writeFile('/tmp/result_async.txt', body);
    await fs.copyFile('/tmp/result_async.txt', '/tmp/copy_async.txt');
    console.log('Work is done!');
};

main().catch(e => console.error(e));

La fonction main est marquĂ©e async : on pourra donc utiliser le mot clĂ© await dans son scope. On remarque ensuite que le mot clĂ© await semble automatiser la rĂ©solution de l’objet Promise qui est retournĂ© par les diffĂ©rents appels asynchrones. D’une part, le mot clĂ© await fait office de barriĂšre : on ne passe pas Ă  l’instruction suivante tant que la promesse n’est pas rĂ©solue. D’autre part, il permet d’extraire le rĂ©sultat de l’objet Promise (ce que l’on recevait auparavant dans le callback cĂŽtĂ© then) lorsqu’il n’y a pas d’erreur.

Il semble il y avoir un problĂšme avec ce code : le cas d’erreur n’est pas gĂȘrĂ©. C’est vrai : on aurait pu utiliser un try/catch afin de rĂ©cupĂ©rer une eventuelle erreur. Mais on peut aussi profiter d’une autre propriĂ©tĂ© intĂ©ressante des promesses. Si une promesse n’est pas gĂȘrĂ©e dans son scope courant, elle est remontĂ©e au scope parent. L’erreur sera donc gĂȘrĂ©e au niveau du main().catch(). On comprend alors qu’une fonction marquĂ©e async retourne une Promise.

Un crawler concurrent

Pour conclure notre petite balade dans le monde féérique de la concurrence, nous allons nous faire un petit crawler qui visite des pages web et nous liste toutes celles contenant le mot “JavaScript”.

const request = require('request-promise-native');
const PAGES = [...]; // Truncated

const work = async page => {
    const url = `https://blog.geographer.fr/${page}`;
    const body = await request.get(url);
    return { result: body.toLowerCase().includes('javascript'), url };
}

console.log(`Crawling ${PAGES.length} pages...`);
const tasks = PAGES.map(work);

Promise.all(tasks)
    .then(r => r.filter(e => e.result).forEach(e => console.log(e.url)))
    .catch(console.error);

On remarque la méthode statique Promise.all qui prend un tableau de Promise et nous renvoie un tableau de résultats.

Voici un équivalent Python :

import requests

PAGES = []  # Truncated

results = []
for page in PAGES:
    url = f'https://blog.geographer.fr/{page}'
    response = None

    try:
        response = requests.get(url)
    except requests.exceptions.RequestException as e:
        print(e)
        sys.exit(1)

    results.append({
        'result': 'javascript' in response.text.lower(),
        'url': url
    })

for e in list(filter(lambda r: r['result'], results)):
    print(e['url'])

Bien entendu, il est absurde de comparer une implémentation concurrente JavaScript avec une implémentation non-concurrente Python. Nous allons donc les comparer:

$ time node crawler.js > /dev/null
Executed in  712,13 millis    fish           external
   usr time  295,40 millis  111,00 micros  295,29 millis
   sys time   74,41 millis  557,00 micros   73,85 millis

$ time python crawler.py > /dev/null
Executed in    1,48 secs   fish           external
   usr time  651,95 millis   92,00 micros  651,86 millis
   sys time   61,17 millis  487,00 micros   60,68 millis

La version JavaScript concurrente est environ deux fois plus rapide. Le nombre de lignes écrites est trÚs similaire.

← Back to the index