Detección de escaneo de vulnerabilidades web con PHP y Apache mod_status

Camilo Herrera
winkhostingla
Published in
10 min readApr 25, 2024

--

Photo by Markus Spiske on Unsplash

Hola!, ha pasado algún tiempo, pero estamos de vuelta. Hoy hablaremos sobre un tópico asociado a la mitigación de ataques a tus sitios web, si eres un administrador de servidores ó administras tu propio servidor para alojar tu página web, espero que sea de ayuda.

Durante los últimos meses he venido notando algunos picos de carga en nuestros servidores, al investigar la causa encontré un patrón en ellos, los infames y muy usados escáneres de vulnerabilidades web.

Si no estás familiarizado con el concepto, este tipo de herramientas cuentan con diccionarios de URLs o nombres de scripts con vulnerabilidades conocidas o archivos que un administrador descuidado puede dejar visibles desde Internet a un atacante y, al ser encontrados, son reportados para futuros ataques a través de vectores ya conocidos.

Ahora, puedes contar con un WAF y Firewall que son de mucha ayuda pero no llegan a ser muy efectivos para peticiones sencillas en las que se intenta descubrir si existe o no una URL o un script en un sitio web (con fines maliciosos) y allí es donde se requiere algo adicional.

La solución

Dado que no contaba con un mecanismo para detener este tipo de comportamiento en el que son realizadas grandes cantidades de peticiones a múltiples sitios web desde diferentes IPs tratando de “adivinar” URLs vulnerables, empecé a plantearme algún tipo de script que me ayudara monitorear, bloquear y dejar registro de este tipo de actividades mitigando posibles picos de carga en mis amados servidores.

Lo que necesito es lo siguiente:

  • Consultar las peticiones realizadas al servidor web de forma constante
  • Analizar cada URL en las peticiones a partir de reglas asociadas al contenido (texto) en el URL o cantidad de peticiones realizadas por una misma IP.
  • Realizar una o más acciones a partir de la información encontrada, en este caso bloquear la IP en nuestro firewall.

Vamos a explorar los requisitos a continuación.

Requisitos

  • Apache HTTP Server
  • mod_status activado en Apache HTTP Server
  • PHP 8.3 cli. Si, vamos a usar PHP en modo consola para esta guía.
  • Firewall (opcional), en este caso usaremos ufw en Linux Mint 21.2

Apache HTTP Server

Verifica si tienes instalado Apache en tu PC o en el entorno en el que vas a realizar tus pruebas. En mi caso estoy usando Linux Mint 21.2, si no lo tienes instalado, puedes hacerlo usando el siguiente comando (en mi versión de Linux, consulta tu versión de sistema operativo para ejecutar el comando correcto):

sudo apt install apache2

Puedes consultar la guía de instalación para versiones basadas en Ubuntu en el siguiente enlace:

Una vez instalado verifica que se encuentre en ejecución, puedes ingresar con tu navegador a http://localhost/ y debería ser mostrado un mensaje de bienvenida de Apache HTTP Server.

Apache localhost

Si no se encuentra en ejecución no olvides iniciar el servicio. Generalmente puedes hacerlo ejecutando sudo systemctl start apache2

mod_status

El segundo paso para avanzar es tener una fuente de información en la que pueda consultar constantemente las peticiones realizadas a mi servidor web, y allí es donde mod_status se vuelve útil.

mod_status es un módulo de Apache HTTP Server que le permite a un administrador consultar en cualquier momento su estado y las peticiones siendo atendidas en ese instante.

Puedes consultar la información sobre mod_status en el siguiente enlace:

Vamos a verificar si está activo el módulo en nuestro servidor web, esto puedes hacerlo ejecutando el siguiente comando:

apachectl -M

Una vez ejecutado será mostrada la lista de módulos cargados en el servidor web, allí puedes confirmar si se encuentra “status_module (shared)”, si es así, se encuentra activo. También puedes confirmarlo ingresando a http://localhost/server-status

Deberías visualizar algo similar a esto:

En caso tal que no encuentres el módulo, puedes cargarlo usando el comando a2enmod así:

sudo a2enmod status

IMPORTANTE: Si vas a activar mod_status en un entorno de producción, asegura que solo sea visible desde las IPs y Dominios autorizados, normalmente se permite el acceso únicamente a peticiones que provengan de localhost, pero es algo que recomiendo tener presente.

PHP 8.3 cli

Verifica que tengas instalado PHP en tu PC, el código fuente de los scripts es compatible con PHP 8.x si tienes una versión previa puede que no requieras cambios y funcione normalmente.

Puedes verificar la versión y si tienes PHP en modo cli ejecutando el comando a continuación:

php -v

Firewall

El firewall en tu PC es opcional, pero la idea general del script es ejecutar un comando para enviar al firewall la IP de origen de la petición y bloquear el acceso. En este caso usaremos ufw, puedes conocer más sobre este firewall para Ubuntu en el siguiente enlace:

Y estos son los requisitos, vamos a la solución!

Photo by Simon Abrams on Unsplash

Crea un directorio para guardar los archivos que vamos a usar, la estructura será la siguiente:

Archivos

Ahora veamos la función de cada archivo, vamos a empezar con nuestro motor de detección.

Scanengine.php

Este archivo contiene dos funciones, la primera “getApacheStatus()”permite consultar y procesar la respuesta de mod_status usando cURL y la librería de procesamiento de HTML DomDocument. Aquí hacemos un poco de scraping buscamos un tag que identifique la tabla en la que se encuentran las peticiones y extraemos los registros en texto plano dentro de un arreglo.

IMPORTANTE: no olvides activar estos módulos en caso tal que tu instalación de PHP no los tenga por defecto.

La segunda función es “checkRequests()” y es la encargada de tomar las peticiones encontradas y procesarlas usando las reglas y acciones predefinidas en el constructor de la clase “$scanRules” y “$scanActions

A continuación el código fuente:

<?php

/**
* Clase Scanengine
*
* Una clase para escanear el estado del servidor Apache y aplicar reglas.
*/
class Scanengine
{
/** @var array $currApacheRequests Un array para almacenar las solicitudes actuales al servidor Apache. */
private array $currApacheRequests;

/** @var string $apacheURL La URL de la página de estado del servidor Apache. */
private string $apacheURL;

/** @var array $scanRules Un array que contiene las reglas de escaneo. */
private array $scanRules;

/** @var array $scanActions Un array que contiene las acciones a tomar basadas en los resultados del escaneo. */
private array $scanActions;

/**
* Constructor de Scanengine.
* Inicializa las propiedades de la clase y establece los valores predeterminados.
*/
public function __construct()
{
$this->currApacheRequests = array();
$this->apacheURL = "http://localhost/server-status";

$this->scanRules = array(
"scripts" => function (array $currApacheRequests): void {
echo PHP_EOL . "-- Análisis usando diccionario de cadenas de texto --" . PHP_EOL;

// Carga la lista de términos y palabras a buscar en las URLs para el escaneo de peticiones sospechosas
$scriptDictionary = file("scripts.txt", FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES);

if (!is_array($scriptDictionary)) {
echo "Hubo un error al leer scripts.txt, verifica si el archivo existe dentro de la carpeta raíz." . PHP_EOL;
return;
}

$hits = 0;

foreach ($scriptDictionary as $id => $script) {
foreach ($currApacheRequests as $reqId => $request) {
if (str_contains($request["Request"], $script)) {
echo PHP_EOL . "Coincidencia encontrada para el script sospechoso '{$script}' en la solicitud '{$request["Request"]}' desde la IP {$request["Client"]}" . PHP_EOL;

$this->scanActions["firewallBlock"]($request["Client"]);
$hits++;
}
}
}

echo PHP_EOL . "Encontré {$hits} hit(s) en las peticiones analizadas." . PHP_EOL;
},
"IPHits" => function (array $currApacheRequests): void {

// Escaneo de hits de IP
$maxHits = 3;
$IPHits = array();

echo PHP_EOL . "-- Análisis de cantidad de hits por IP, Máximo {$maxHits} hit(s) --" . PHP_EOL;

foreach ($currApacheRequests as $reqId => $request) {
if (!isset($IPHits[$request["Client"]])) {
$IPHits[$request["Client"]] = 1;
} else {
$IPHits[$request["Client"]]++;
}
}

$hits = 0;

foreach ($IPHits as $ip => $hitsCount) {
if ($hitsCount > $maxHits) {
echo PHP_EOL . "Número de hits permitidos superado ({$maxHits}) desde la IP {$ip}" . PHP_EOL;
$this->scanActions["firewallBlock"]($ip);
$hits++;
}
}

echo PHP_EOL . "Encontré {$hits} hit(s) en las peticiones analizadas." . PHP_EOL;
}
);

$this->scanActions = array(
"firewallBlock" => function (string $clientIP): void {
//No te golpees en la cara tu mismo.
$arrIgnoreIps = array("127.0.0.1", "::1");

if (!in_array($clientIP, $arrIgnoreIps)) {
echo "¡IP {$clientIP} agregada a la lista negra del firewall!" . PHP_EOL;
}
}
);
}

/**
* Establece la URL del estado del servidor Apache.
*
* @param string $apacheURL La URL de la página de estado del servidor Apache.
*/
public function setApacheURL(string $apacheURL): void
{
$this->apacheURL = $apacheURL;
}

/**
* Recupera el estado del servidor Apache y analiza las solicitudes.
*
* @return void
*/
public function getApacheStatus(): void
{
$url = $this->apacheURL;

$curl = curl_init($url);
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_HEADER, false);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($curl);

if ($response === false) {
echo 'Error de Curl: ' . curl_error($curl) . PHP_EOL;
exit;
}

curl_close($curl);

$dom = new DomDocument();
@$dom->loadHTML($response);

$tables = $dom->getElementsByTagName('table');
$foundRequests = false;

foreach ($tables as $table) {
foreach ($table->attributes as $attr) {
if ($attr->name == "border" && $attr->value == 0) {

echo "Lista de solicitudes encontrada en {$this->apacheURL}" . PHP_EOL;

$foundRequests = true;

$fieldHeaders = $table->getElementsByTagName('th');
$arrFieldKeys = array();

foreach ($fieldHeaders as $fieldHeader) {
$arrFieldKeys[] = $fieldHeader->nodeValue;
}

$requests = $table->getElementsByTagName('tr');

foreach ($requests as $req) {
$arrFields = array();
$fields = $req->getElementsByTagName('td');

if (count($fields) == 0) {
continue;
}

foreach ($arrFieldKeys as $key => $tag) {
$arrFields[$tag] = str_replace("\n", "", $fields->item($key)->nodeValue);
}

if (!empty($arrFields)) {
$this->currApacheRequests[] = $arrFields;
}
}
}
}

if ($foundRequests) {
break;
}
}

if (!$foundRequests) {
echo "Lista de solicitudes en {$this->apacheURL} no encontrada, ¿quizás una URL incorrecta?" . PHP_EOL;
}
}

/**
* Ejecuta controles basados en reglas en las solicitudes del servidor Apache.
*
* @return void
*/
public function checkRequests(): void
{
$this->scanRules["scripts"]($this->currApacheRequests);
$this->scanRules["IPHits"]($this->currApacheRequests);
}
}

Como puedes ver en el arreglo de reglas de escaneo “$scanRules”, la finalidad de este arreglo es almacenar las rutinas que analizan las peticiones detectadas en el URL /server-status, puedes crear tantas como requieras. Para este artículo implementamos la regla de detección usando un diccionario de nombres de scripts/texto dentro de una URL y cantidad de hits simultáneos por IP de origen, que también puede indicar un escaneo malicioso a tu servidor.

En el arreglo de acciones “$scanActions” implementamos una acción que simula la ejecución de un bloqueo en el firewall del servidor a partir de lo encontrado por nuestras reglas de escaneo.

IMPORTANTE: Para realizar este tipo de acciones, como agregar una IP en la lista negra del servidor, tu script php generalmente debe ejecutarse como root o con un usuario que tenga permisos para esta operación, recuerda que ejecutar scripts o procesos con permisos root puede implicar riesgos.

Si quieres agregar un llamado al firewall para bloquear la IP puedes hacerlo incluyendo una línea como esta en el cuerpo de la función “firewallBlock” en “$scanActions”:

$this->scanActions = array(
"firewallBlock" => function (string $clientIP): void {
$result = shell_exec("ufw deny from {$clientIP} to any");
echo $result.PHP_EOL;
echo "¡IP {$clientIP} agregada a la lista negra del firewall!" . PHP_EOL;
}
);

Este es el corazón de nuestra solución, ahora que ya lo analizamos vamos a pasar a los dos archivos restantes, scripts.txt y apache-scan.php

scripts.txt

Este archivo almacena la lista de cadenas de texto usadas en ataques con diccionarios a nuestros sitios web, para este caso usaremos algunos términos como se muestran a continuación:

admin.php
Exchange.asmx
phpMyAdmin
backup
phpmyadmin3
xx.php
backup.zip
dump.sql
config.php
.env.production
web.config
config.xml
config.yml
sql.sql.xz
backups.sql.zip
web.sql.zip
website.sql.zip
backup_4.sql.zip
fm1.php?x=zourt
www.sqlitedb
.git/HEADE

Estas cadenas son cargadas al inicio de la ejecución de la regla “scripts” y cada término es buscado en el listado de peticiones para encontrar coincidencias.

IMPORTANTE: para gran cantidad de términos en un diccionario se debería considerar una estrategia de búsqueda más eficiente que la presentada aquí.

apache-scan.php

Finalmente analicemos este archivo, en él se encuentra el punto de entrada de nuestra solución y es el encargado de crear una instancia de Scanengine y hacer el llamado a las funciones de nuestro motor de detección.

<?php
require("Scanengine.php");

$scan = new Scanengine();
$scanInterval = 5;

do {

$scan->getApacheStatus();
$scan->checkRequests();
sleep($scanInterval);
} while (true);

Como puedes ver, también cuenta con un ciclo que realiza la verificación y espera cierta cantidad de segundos, de esta forma se puede realizar continuamente la consulta de peticiones.

IMPORTANTE: esta solución no tiene una opción para verificar si ya aplicó un bloqueo a una IP y pueden presentarse escenarios donde intente realizar un bloqueo ya ejecutado si la petición a Apache sigue visible en mod_status.

Ahora con nuestra solución lista, solo es necesario ejecutar apache-scan.php así:

php <ruta de tu script>/apache-scan.php

Si todo sale bien, debes visualizar mensajes como estos:

Lista de solicitudes encontrada en http://localhost/server-status

-- Análisis usando diccionario de cadenas de texto --

Coincidencia encontrada para el script sospechoso 'gifLogo1.gif' en la solicitud 'GET /classic/img/gifLogo1.gif HTTP/1.1' desde la IP x.x.x.x
¡IP x.x.x.x agregada a la lista negra del firewall!

Encontré 1 hit(s) en las peticiones analizadas.

-- Análisis de cantidad de hits por IP, Máximo 3 hit(s) --

Número de hits permitidos superado (3) desde la IP x.x.x.x
¡IP x.x.x.x agregada a la lista negra del firewall!

Número de hits permitidos superado (3) desde la IP y.y.y.y
¡IP y.y.y.y agregada a la lista negra del firewall!

Encontré 2 hit(s) en las peticiones analizadas.

Y es todo, recuerda que en Winkhosting.co somos mucho más que hosting!

--

--