No Se Ejecuta Nada Cuando Pides Una Página
Esta semana el proyecto PHP publicó los arreglos de PHP2Shell, y su aviso decía que el ataque puede dispararse «before any application code runs»1. Escribí sobre lo que eso significa para PHP en PHP: Veintinueve Años Dejando La Puerta Abierta. Este post va de la otra mitad de la respuesta. ¿Y si no se ejecuta nada en absoluto?
Así funciona este blog. Cuando pediste esta página, ningún programa mío arrancó, leyó una base de datos, comprobó un login ni generó HTML. Se devolvió un fichero que ya existía. Nada más.
Así se ve esa diferencia, medida el mismo día, contra un WordPress de serie recién instalado en un laboratorio y contra este sitio tal como está en producción ahora:
| WordPress 7.1.3 de serie | Este sitio | |
|---|---|---|
| Rutas de la API REST | 133 | 0 |
| Rutas REST que aceptan escrituras | 62 | 0 |
| Métodos XML-RPC | 80 | 0 |
| Ficheros que se ejecutan en el servidor | 1 514 PHP | 0 |
| Ficheros escribibles por el usuario que sirve el sitio | 3 785 de 3 785 | ninguno: no se ejecuta nada que pueda escribir |
| Base de datos | 12 tablas, cuenta de la aplicación con todos los privilegios | ninguna |
| Página de login | wp-login.php, activa | ninguna; la ruta devuelve 404 |
| Un POST, PUT o DELETE a la página de inicio | PHP se ejecuta y muestra la página igualmente | rechazado en el borde con 405; no se ejecuta nada |
Ninguno de esos ceros es una opción que yo haya desactivado. Son ceros porque lo que cuentan no existe aquí. No puedes atacar una página de login que nunca se construyó.
Lo Que Un WordPress De Serie Pone En Internet
No quería discutir esto de memoria, así que lo medí. Las imágenes de contenedor oficiales de wordpress y mariadb, en podman en mi propia máquina y atadas a localhost, instaladas sin añadir nada: ningún plugin más allá de lo que viene, el tema por defecto, un administrador. WordPress 7.1.3 sobre PHP 8.3.36. Luego conté a qué responde.
La API REST es la mayor superficie por sí sola. Cinco espacios de nombres, 133 rutas, 257 pares de ruta y método, y 62 rutas que aceptan algún tipo de escritura. Llegó al núcleo con WordPress 4.7, «with REST API endpoints for posts, comments, terms, users, meta, and settings»2. Y te dicen que no la apagues: «You should not disable the REST API; doing so will break WordPress Admin functionality that depends on the API being active.»3 Así que se queda encendida.
| Lo que se expone por defecto | Lo que dice la propia documentación de WordPress |
|---|---|
| XML-RPC, 80 métodos | activo por defecto desde la 3.5: «Enable XML-RPC by default, remove Writing Settings option»4 |
| la API REST, 133 rutas | «content that is public on your site is generally publicly accessible via the REST API»5 |
| la lista de usuarios | GET /wp/v2/users devuelve el nombre visible, el slug, el enlace y el avatar de cada autor en su vista por defecto6 |
wp-login.php y xmlrpc.php | «xmlrpc.php is a frequent brute‑force target»; «Apply rate limits to /wp-login.php and /xmlrpc.php globally.»7 |
wp-cron.php | «WP-Cron works by checking, on every page load, a list of scheduled tasks to see what needs to be run.»8 |
| el editor de temas y plugins | «This is often the first tool an attacker will use if able to login, since it allows code execution.»9 |
La lista de usuarios merece una segunda mirada. En mi laboratorio, una petición anónima a ese endpoint devolvió la única cuenta de administrador, con su nombre visible y su slug. Alguien que nunca ha iniciado sesión sabe cuáles son las cuentas del sitio antes de haber probado nada, en una instalación limpia, por diseño. Nadie tuvo que configurarlo mal.
Luego los ficheros. La imagen oficial ejecuta Apache como www-data, y www-data es dueño de toda la raíz web. Los 3 785 ficheros, entre ellos los 1 514 ficheros PHP, son escribibles por el mismo usuario que ejecuta el código. Todos los ficheros. El código incluido. wp-config.php, con la contraseña de la base de datos dentro, está en la raíz web con modo 644. La cuenta de base de datos que usa WordPress tiene ALL PRIVILEGES sobre su base de datos.
Es el modelo de un solo proceso del post de PHP, en vivo y sin modificar, desde la imagen oficial. No es un sitio mal administrado. Es el punto de partida de todo sitio WordPress, antes de que entre el primer plugin.
Y de los plugins viene la mayor parte. Patchstack contó 11 334 vulnerabilidades nuevas en el ecosistema WordPress en 2025. «91% of new vulnerabilities were found in plugins, and 9% were found in themes. There were only 6 vulnerabilities reported in the WordPress core.»10 Casi la mitad no tenían arreglo cuando se hicieron públicas, y la mediana hasta la explotación masiva de las que interesaban a los atacantes fue de cinco horas10.
Lo Que Este Sitio Pone En Internet
Este blog son 6 343 ficheros. Aquí está todo, por tipo:
| Tipo de fichero | Cantidad | Qué es |
|---|---|---|
.html | 3 486 | las páginas, en cinco idiomas |
.xml | 1 576 | feeds RSS y sitemaps |
.svg | 1 181 | diagramas, portadas, iconos |
.png | 46 | capturas de pantalla |
.json | 10 | el índice de búsqueda y los sellos de construcción |
.js | 4 | pequeños scripts que se ejecutan en tu navegador, no en el servidor |
.css, fuentes y el resto | 40 | estilos, y ficheros adjuntos a los posts |
| Ejecutado en el servidor | 0 |
Hay algunos ficheros .py y .sh ahí dentro. Son scripts adjuntos a los posts para que los lectores los descarguen, y el servidor los envía como texto. Nada en el servidor los ejecuta. No hay nada en el servidor con qué ejecutarlos.
Se despliega en Cloudflare como recursos estáticos, sin ningún script de Worker. La configuración de despliegue no tiene punto de entrada, y la documentación de Cloudflare dice lo que eso significa: «The main key is optional for assets-only Workers.»11 Cuando una ruta pedida coincide con un fichero, «that file will be served» sin invocar código de Worker, y «If no Worker script is present, a 404 Not Found response is returned.»12 Y tampoco cuesta nada: «Requests to static assets are free and unlimited.»13
Cloudflare es una elección, no un requisito. La salida es una carpeta de archivos, y cualquier servidor HTTPS que sepa enviar un archivo puede servirla: nginx, Caddy, un Apache que solo sirva archivos estáticos, o un bucket de almacenamiento de objetos detrás de una CDN. Los enlaces son relativos, así que la misma compilación funciona sin cambios desde la raíz de un dominio o desde un subdirectorio; comprobado sirviendo el sitio compilado bajo /preview/ y verificando que las 592 referencias locales de seis páginas se resolvían. Cambiar de servidor es copiar una carpeta. La única pieza propia de Cloudflare es el archivo _headers; en otro sitio, las mismas cabeceras van en la configuración del servidor. Lo que se lleva consigo es la propiedad que importa. Lo que sirve el sitio solo tiene que enviar archivos, así que nunca tiene que ejecutar tu código.
Así que el sitio en producción responde así:
$ for m in POST PUT DELETE; do curl -s -o /dev/null -w "$m %{http_code}\n" -X $m https://blogs.damiendye.uk/en/; done
POST 405
PUT 405
DELETE 405
$ for p in wp-login.php xmlrpc.php wp-admin/ wp-json/; do curl -s -o /dev/null -w "%{http_code} $p\n" https://blogs.damiendye.uk/$p; done
404 wp-login.php
404 xmlrpc.php
404 wp-admin/
404 wp-json/
No responde nada. Ese es el diseño.
Las cabeceras de seguridad salen con cada fichero desde un fichero de texto plano _headers en el repositorio, que Cloudflare lee al desplegar14. La Content Security Policy ata cada script, estilo, imagen, fuente y conexión a este único origen. Voy a ser claro con el hueco que tiene: script-src y style-src todavía llevan 'unsafe-inline', porque el tema escribe unos pocos scripts pequeños dentro de la página. Es el trabajo pendiente en ese fichero, y está apuntado en el repositorio exactamente así.
Y publicar no necesita ninguna puerta. En WordPress, publicar es una escritura por HTTP: el editor, la REST API y XML-RPC existen porque así es como las entradas llegan al servidor, y cada uno de ellos es un camino de entrada. Aquí escribo una entrada como un archivo de texto y la subo con push a un repositorio Git, y la compilación sube archivos terminados por la API de despliegue de Cloudflare. Nada de lo que sirve el sitio acepta una escritura desde la web, porque nada la necesita. Además, el repositorio es privado. Pídeselo a GitHub sin mis credenciales y la respuesta es un 404, así que el código fuente, los borradores y el historial tampoco están en internet.
La Superficie De Ataque Es Una Cuenta, No Una Sensación
OWASP define la superficie de ataque como «the sum of all paths for data/commands into and out of the application, and the code that protects these paths»15, y dice que la manera de reducirla es «turning off features and interfaces that aren’t being used»15. El National Cyber Security Centre del Reino Unido lo dice más a las claras: «If would-be attackers can’t reach an interface, they can’t attack it.»16 Su esquema Cyber Essentials lo convierte en requisito: «remove or disable unnecessary software (including applications, system utilities and network services)»17.
Así que cuenta los caminos, por lo que son y no por cuántos hay:
| Camino de entrada o salida | WordPress | Sitio estático |
|---|---|---|
| login y adivinar contraseñas | wp-login.php, XML-RPC | nada |
| interfaz de administración | /wp-admin/, con editor de código | nada |
| API programable | REST y XML-RPC, con escrituras | nada |
| código en el servidor por petición | núcleo, tema, cada plugin | nada |
| base de datos | sí, accesible desde todo lo anterior | nada |
| almacenamiento escribible en el servidor | uploads, y en la imagen de serie, todo | nada |
| tareas programadas que disparan los visitantes | wp-cron.php al cargar cada página | nada |
| código de terceros que tienes que mantener parcheado | cada plugin y tema, en el servidor | las herramientas de construcción, fuera del servidor |
| lo que consigue un atacante con una sesión robada | el sitio | nada en lo que iniciar sesión |
Nueve filas. Ninguna ejecuta código en el servidor. Todas por la misma razón. El trabajo se hizo antes de que nadie pidiera una página.
La penúltima fila es la trampa honesta, y el resto de este post va de ella. Un sitio estático sigue ejecutando código. Solo que lo ejecuta en otra parte, una vez, antes de publicar.
Cómo Mantiene Hugo Honesta La Construcción
Hugo es «a static site generator written in Go»18, así que el programa que construye es él mismo compilado y con seguridad de memoria, lo cual importa después del post de PHP. Su página de seguridad traza la línea justo donde la traza este post: el resultado «runs directly in the browser», pero «during development and site building, the hugo executable itself is the runtime environment.»19 Y sobre servir, sin rodeos: «We have no online production server»19. Bien.
Dentro de la construcción, está cerrado por defecto. «Hugo’s built-in security policy, which restricts access to os/exec, remote communication, and similar operations, is configured via allowlists. By default, access is restricted.»20
| Lo que Hugo trae por defecto | Lo que impide |
|---|---|
| «No arbitrary commands»19 | una plantilla no puede lanzar comandos en la máquina |
exec.allow limitado a Sass, Go, Git, Node y PostCSS20 | solo herramientas con nombre, con flags fijos |
funcs.getenv limitado a ^HUGO_ y ^CI$20 | las plantillas no pueden leer tus secretos del entorno |
| las descargas remotas rechazan hosts que sean una dirección IP o localhost20 | una plantilla no puede meterse en tu propia red |
módulos comprobados contra go.sum19 | una dependencia manipulada hace fallar la construcción |
Este sitio amplía uno de esos a propósito. Añade las variables de commit del CI a funcs.getenv, para que el sello de construcción en /en/version.json pueda registrar qué commit construyó el sitio en producción. Es una decisión tomada y apuntada, que es justo para lo que sirve una lista de permitidos. Así que no es un agujero. Es una decisión.
Adónde Se Va El Riesgo: La Construcción
Quita el servidor y el riesgo no desaparece. Se va a lo que construye los ficheros. El framework SLSA nombra las dos amenazas con precisión: una «build integrity threat», en la que un adversario introduce «behavior to an artifact without changing its source code», y una «dependency threat», «compromising some other artifact that the former depends on at build time»21.
Este sitio está en la fila del medio, y no voy a fingir lo contrario. Se construye en el contenedor de construcción de Cloudflare con cada push al repositorio. Ese contenedor no es el servidor que te responde, y desaparece cuando termina la construcción. Pero mientras dura la construcción, se le confía todo lo que acaba en el sitio.
Y la construcción tenía dos huecos, y los dos los encontré escribiendo este post. Uno está arreglado. El otro es una elección.
| Hueco | Por qué importa | Estado |
|---|---|---|
| el binario de Hugo se aceptaba de buena fe: la propia descarga del script no comprobaba ningún checksum, y en el CI ni siquiera descargaba y usaba la copia que tenía instalada la imagen de construcción de Cloudflare | un binario cambiado construiría lo que le diera la gana | arreglado, 23 de octubre de 2026: la construcción siempre descarga Hugo por sí misma, lo comprueba contra el SHA-256 publicado en la release22 y se niega a construir si no coincide |
el tema sigue su rama master de upstream en cada construcción | un cambio en upstream puede alterar el sitio en producción sin ningún commit aquí | abierto, por elección; el arreglo es fijarlo a un commit, o pasarlo a un módulo de Hugo cubierto por go.sum19 |
El primero es de esas cosas que solo encuentras leyendo tu propio script de construcción línea a línea. El script sí comprobaba la versión. Pero una cadena de versión es algo que un binario dice de sí mismo. No prueba nada sobre los bytes. Ahora se comprueban los bytes:
Fetching Hugo Extended 0.167.0...
Hugo verified: sha256 0163f5c3deddac1f494a1629ddc40c65d18de9d5794facd98f7f96ac2c7d8957
Y una copia del mismo tarball con un byte añadido se rechaza:
Refusing to build: hugo_extended_0.167.0_linux-amd64.tar.gz does not match the release checksum
expected 0163f5c3deddac1f494a1629ddc40c65d18de9d5794facd98f7f96ac2c7d8957
got 92682d765d9d34bc53d6f818d56e598d34b5b8267a1cc3b9971da0f53c792251
El tema es un compromiso deliberado, actualidad a cambio de reproducibilidad, y está apuntado como tal. Pero es exactamente la amenaza de dependencias que describe SLSA, y lo honesto es decirlo. Ninguno de los dos huecos expuso nunca el sitio en producción a un visitante. Los dos eran confianza depositada en la construcción, y prefiero listarlos a que los encuentre un lector.
Hasta el checksum tiene un límite. Prueba que el fichero que descargaste es el fichero de la página de la release. No prueba quién lo puso ahí: no hay firmas entre los ficheros de la release22. Eso es integridad, no autenticidad, y es lo máximo que ofrece el proyecto hoy.
Construirlo Sin Conexión, Una Vez
La versión más estricta saca la construcción de la máquina de cualquier otro. Construye en una máquina que sea tuya y que controles, comprueba lo que has construido y sube solo los ficheros terminados. Nada en internet ejecuta nunca tu construcción, y el host nunca ve otra cosa que el resultado.
| Paso | Por qué |
|---|---|
| instala la release exacta de Hugo y compruébala contra el SHA-256 publicado | sabes qué binario construyó tu sitio |
fija el tema y cualquier módulo a commits, y haz commit de go.sum | la construcción solo cambia cuando la cambias tú |
construye en un directorio de salida limpio, sin --buildDrafts | lo que publicas es lo que querías publicar |
| busca cualquier cosa ejecutable en la salida antes de que salga | un sitio estático no debería contener nada que el servidor fuera a ejecutar |
| sube solo el directorio de salida, con una credencial que solo sirva para desplegar | el host nunca guarda tu código fuente, tus herramientas ni tu historial |
| sírvelo desde un host que no ejecute código para ficheros estáticos | no queda nada que parchear al servir |
Son unos pocos comandos, no un proyecto. Y cambia dónde tiene que estar un atacante para hacerte daño. Para cambiar un sitio WordPress, le basta una petición al servidor. Para cambiar un sitio de este tipo, necesita tu máquina de construcción. Es un objetivo mucho más pequeño, y es uno que puedes desenchufar.
Diez mil páginas, medidas
La superficie de ataque es un coste de ejecutar código en cada petición. La electricidad es otro, y el disco también. Así que puse los dos bajo la misma carga: 10 000 peticiones a la página de inicio, diez a la vez, desde el mismo portátil, por HTTP simple en localhost para que ninguno pagara el TLS. A un lado, el WordPress de serie del laboratorio. Al otro, los archivos compilados reales de este sitio servidos por nginx en un contenedor, porque al borde de Cloudflare no le puedo poner un contador. Leí el tiempo de CPU de cada contenedor en su cgroup antes y después de cada pasada, tres pasadas cada uno.
| 10 000 peticiones de páginas | WordPress 7.1.3 de serie | Este sitio en nginx |
|---|---|---|
| Tiempo de CPU, tres pasadas | 409 s, 530 s, 575 s | 0,15 s, 0,18 s, 0,19 s |
| Tiempo de CPU por página | 41 a 57 ms | 15 a 19 µs |
| Páginas por segundo | 168 a 236 | 23 679 a 28 044 |
| Memoria bajo carga | 302 a 310 MiB, más 145 MiB de la base de datos | 15 MiB |
| Peticiones fallidas | 0 | 0 |
Eso es entre dos mil y tres mil quinientas veces el tiempo de procesador para las mismas diez mil páginas. Cada uno de esos segundos de CPU salió del enchufe y se convirtió en calor. El lado estático sirvió las 10 000 en más o menos el tiempo de procesador que WordPress gastó en tres o cuatro de sus páginas.
El disco cuenta la misma historia:
| En disco y en memoria | WordPress de serie | Este sitio |
|---|---|---|
| Imágenes de contenedor para ejecutarlo | 803 MB WordPress, 334 MB MariaDB | 166 MB nginx, o nada en absoluto en Cloudflare |
| El sitio en sí | 115 MB de raíz web, para un sitio vacío | 114 MB, cinco idiomas, cada entrada y cada diagrama |
| Base de datos | 163 MB de archivos con 0,72 MB de datos | ninguna |
| Memoria en reposo | 170 MiB, más 139 MiB de la base de datos | 13 MiB |
Las cifras de WordPress son una instalación nueva con una entrada de ejemplo. Las cifras estáticas son este blog entero.
Para ser justos con WordPress, un plugin de caché de página completa cerraría buena parte de la diferencia de CPU. Pero mira qué es ese arreglo. Es una copia estática de cada página, puesta delante del PHP para que el PHP no se ejecute. La cura es justo lo que defiende este post, atornillada al costado de lo que critica, con la página de login, la API y la base de datos todavía ahí detrás.
A Qué Renuncias
La lista honesta, porque un sitio estático no es la herramienta adecuada para todo:
| A qué renuncias | Qué hace este sitio en su lugar |
|---|---|
| comentarios | nada; los lectores pueden mandar un correo |
| búsqueda en el servidor | un índice JSON construido con el sitio, en el que se busca desde tu navegador |
| formularios que envían al servidor | nada |
| logins y zonas de socios | nada; todo aquí es público |
| editar en un navegador | los posts son ficheros de texto en un repositorio git |
| publicar al instante sin construir | una construcción tarda un minuto o dos |
Si necesitas una tienda, una zona de socios o cuentas de usuario, necesitas algo ejecutándose en un servidor, y este post no te dice lo contrario. Lo que te dice es que un blog, una web corporativa o un conjunto de documentación no necesita nada de eso. La mayoría de los sitios WordPress son una de esas tres cosas. Sé honesto sobre cuál es el tuyo.
Constrúyelo Una Vez Y Sírvelo Muerto
Lo que me revienta es cuánto riesgo se carga sin ninguna razón. Una página de empresa que cambia dos veces al año, con una base de datos, una página de login, una API con 62 rutas escribibles y un ejecutor de tareas programadas que salta cada vez que alguien la visita. Todo eso ahí para producir el mismo HTML que produjo ayer. Cada día.
Eso no es una decisión técnica. Es una costumbre, y es una costumbre que alguien factura. El hosting, los plugins, los complementos de seguridad, la limpieza cuando sale mal. Lo que vale en la tarifa es un plan de WordPress gestionado. Lo que cuesta es cada uno de esos caminos abierto, y parcheado, y vigilado, para un contenido que podría haber sido una carpeta de ficheros.
El estándar al que someto este sitio es simple. No se ejecuta nada que no tenga que ejecutarse. Cada hueco está apuntado donde lo encontrará el siguiente, incluidos los dos de la construcción, y uno de ellos ya está cerrado. No se trata de ser listo. Un estándar es lo que mantienes cuando nadie lo comprueba, y nadie comprueba un blog hasta el día en que empieza a servir el código de otro.
Así que constrúyelo una vez, comprueba lo que has construido y sírvelo muerto. Lo que quede por atacar está en tu máquina, que es donde tiene que estar.
PHP — News archive 2026, 21st October — «We consider the risk highest for mod_php, with a demonstrated exploit where an unauthenticated request can trigger the attack before any application code runs». ↩︎
WordPress 4.7 release notes — «WordPress 4.7 comes with REST API endpoints for posts, comments, terms, users, meta, and settings.» ↩︎
WordPress REST API Handbook — Frequently Asked Questions — «You should not disable the REST API; doing so will break WordPress Admin functionality that depends on the API being active.» ↩︎
WordPress 3.5 release notes — «Enable XML-RPC by default, remove Writing Settings option». ↩︎
WordPress REST API Handbook — «content that is public on your site is generally publicly accessible via the REST API». ↩︎
WordPress REST API reference — Users —
GET /wp/v2/users, contexto por defecto «view»; name, slug, link y avatar_urls en el contexto view. ↩︎WordPress — Brute force attacks — «xmlrpc.php is a frequent brute‑force target»; «Apply rate limits to /wp-login.php and /xmlrpc.php globally.» ↩︎
WordPress Plugin Handbook — Cron — «WP-Cron works by checking, on every page load, a list of scheduled tasks to see what needs to be run.» ↩︎
WordPress — Hardening WordPress — el editor del Dashboard «is often the first tool an attacker will use if able to login, since it allows code execution.» ↩︎
Patchstack — State of WordPress Security in 2026 — datos de 2025: 11 334 vulnerabilidades nuevas; 91 % en plugins, 9 % en temas, 6 en el núcleo; 46 % sin arreglo a tiempo para la divulgación; mediana de 5 horas hasta la explotación masiva. ↩︎ ↩︎
Cloudflare — Wrangler configuration — «The main key is optional for assets-only Workers.» ↩︎
Cloudflare — Workers static assets — un fichero que coincide «will be served» sin invocar código de Worker; «If no Worker script is present, a 404 Not Found response is returned.» ↩︎
Cloudflare — Static assets billing and limitations — «Requests to static assets are free and unlimited.» ↩︎
Cloudflare — Static assets headers — cabeceras definidas con «a plain text file called _headers». ↩︎
OWASP — Attack Surface Analysis Cheat Sheet — «the sum of all paths for data/commands into and out of the application, and the code that protects these paths»; se reduce con «turning off features and interfaces that aren’t being used». ↩︎ ↩︎
NCSC — Making compromise difficult, 2.2 Reduce attack surface — «If would-be attackers can’t reach an interface, they can’t attack it.» ↩︎
NCSC — Cyber Essentials: Requirements for IT infrastructure v3.2 — «remove or disable unnecessary software (including applications, system utilities and network services)». ↩︎
Hugo — Introduction — «Hugo is a static site generator written in Go, optimized for speed and designed for flexibility.» ↩︎
Hugo — Security model — «during development and site building, the hugo executable itself is the runtime environment»; «We have no online production server»; «No arbitrary commands»; un checksum de go.sum que no coincide hace fallar la construcción. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Hugo — Configure security — «By default, access is restricted»; listas de permitidos por defecto para exec, getenv y http. ↩︎ ↩︎ ↩︎ ↩︎
SLSA v1.2 — Threats — amenaza a la integridad de la construcción: «behavior to an artifact without changing its source code»; amenaza de dependencias: «compromising some other artifact that the former depends on at build time». ↩︎
Hugo v0.167.0 release — 36 ficheros, entre ellos
hugo_0.167.0_checksums.txt(SHA-256); ningún fichero de firma, SBOM ni atestación. ↩︎ ↩︎