Vulnerability database › CVE-2026-19949
CVE-2026-19949
All-in-One WP Migration and Backup ≤ 7.109 — SQLi de segundo orden no autenticada, de trackback a RCE
All-in-One WP Migration and Backup ≤ 7.109 — unauthenticated second-order SQL injection, from trackback to RCE
§1Qué esWhat it is
Al importar una copia de seguridad, el plugin reescribe cada sentencia SQL del volcado con
replace_table_values() para sustituir URLs y prefijos de tabla. Para encontrar los literales de
cadena usa un regex con un lookbehind negativo de un solo byte:
When restoring a backup, the plugin rewrites every SQL statement in the dump through
replace_table_values() to replace URLs and table prefixes. To locate string literals it uses a regex
with a single-byte negative lookbehind:
7.109 — vulnerablevulnerable
// class-ai1wm-database.php:1637 preg_replace_callback( "/'(.*?)(?<!\\\\)'/S", … )
7.110 — parchepatch
// tokenizador correcto de literales preg_replace_callback( "/'((?:[^'\\\\]++|\\\\.)*+)'/sS", … )
En un volcado MySQL, un dato que termina en barra invertida se escribe …\\': la comilla de
cierre va precedida de un número par de barras, así que la cadena realmente termina ahí. El regex solo
mira el último byte, la cree escapada y sobre-captura el literal siguiente. El callback ejecuta entonces
unescape_mysql → replace_serialized_values → escape_mysql sobre el valor sobre-capturado y re-emite
una secuencia desequilibrada de \' que voltea el límite de la cadena MySQL: lo que eran datos
pasa a ser SQL ejecutable.
In a MySQL dump, data ending in a backslash is written …\\': the closing quote is preceded
by an even number of backslashes, so the string truly ends there. The regex only checks the last byte,
deems it escaped and over-captures the next literal. The callback then runs
unescape_mysql → replace_serialized_values → escape_mysql over the over-captured value and re-emits
an unbalanced \' sequence that flips the MySQL string boundary: data becomes executable SQL.
§2La cadena de ataqueThe attack chain
-
atacante · sin autenticarPlanta dos trackbacksPlants two trackbacks en cualquier entrada con pings abiertos (
wp-trackback.php). El blog name termina en barra invertida; la URL porta el payload. WordPress los guarda comocomment_authorycomment_author_urlsin tocar nada, y auto-aprueba los trackbacks. on any post with pings open (wp-trackback.php). The blog name ends in a backslash; the URL carries the payload. WordPress stores them ascomment_author/comment_author_urluntouched, and auto-approves trackbacks. -
administrador · acción de rutinaExporta y restaura el sitioExports and restores the site El primer trackback actúa de «bomba de tiempo»: fuerza un corte de pase del importador para que el payload se ejecute en un pase posterior, cuando el plugin ya ha devuelto la
ai1wm_secret_keyreal awp_options. The first trackback acts as a "time bomb": it forces the importer to pause mid-pass so the payload runs on a later pass, once the plugin has restored the realai1wm_secret_keyintowp_options. -
importador · sql del atacanteEl regex volteado ejecuta el payloadThe flipped regex executes the payload El INSERT del comentario se reescribe con el límite de cadena girado. El payload es una subconsulta que copia la clave a
comment_author_urlde un comentario aprobado y de tipo comment. The comment's INSERT is rewritten with the string boundary flipped. The payload is a subquery that copies the key intocomment_author_urlof an approved, type-comment row. -
atacante · sin autenticarLee la clave en la RESTReads the key from the REST API
GET /wp-json/wp/v2/commentsdevuelve el campoauthor_urlpúblicamente: laai1wm_secret_keyqueda expuesta sin credenciales.GET /wp-json/wp/v2/commentsreturnsauthor_urlpublicly: theai1wm_secret_keyis exposed with no credentials. -
atacante · sin autenticar · RCEImporta su propio .wpressImports his own .wpress
admin-ajax.php?action=ai1wm_importestá registrado también para anónimos y su única barrera es la clave. Con ella, sube un archivo con un mu-plugin que el paso 270 extrae awp-content/mu-plugins/: ejecución de código.admin-ajax.php?action=ai1wm_importis registered for anonymous users too, gated only by the key. With it, he uploads an archive containing a mu-plugin that step 270 extracts intowp-content/mu-plugins/: code execution.
§3El payload, explicadoThe payload, explained
Todo el material se planta con una única petición POST sin autenticar a
wp-trackback.php. Tres campos hacen el trabajo:
All the material is planted with a single unauthenticated POST to
wp-trackback.php. Three fields do the work:
| Campo | Field | Valor | Value | Función | Purpose |
|---|---|---|---|---|---|
| blog_name | Jack Blogs\\ | Termina en barra invertida: es lo que induce al regex a sobre-capturar durante la importación. | Ends in a backslash: this is what makes the regex over-capture during restore. | ||
| excerpt | …backups https://el-sitio.com | Lleva la URL del propio sitio: el importador solo reescribe líneas que la contienen (filtro strpos). |
Carries the site's own URL: the importer only rewrites lines containing it (strpos filter). |
||
| url | el payload — recórrelo segmento a segmento:the payload — explore it segment by segment: | ||||
¿Y esto por qué es SQL válido? El volteo en tres pasos
Why is this valid SQL? The flip in three steps
dump MySQL
INSERT INTO `…_comments` VALUES (
2,4,
'Jack Blogs\\\\','',
'PAYLOAD','172.18.0.1', …
,'trackback',0,0);
tras replace_table_values (7.109)
after replace_table_values (7.109)
INSERT INTO `…_comments` VALUES ( 2,4, 'Jack Blogs\\\\\','', PAYLOAD (¡código!)(code!),… ,'trackback',0,0);
El match sobre-capturado re-emite …\\\\\',': la quinta barra forma un
\' con la comilla, el autor «se traga» su cierre y el separador, y todo lo que sigue —la URL del
atacante— queda fuera de comillas, es decir, SQL ejecutable. La coma inicial del payload restaura el
separador devorado; los once valores siguientes rellenan las columnas hasta las 15 exactas; )
cierra la tupla y # comenta el resto de la línea original (el # de MySQL no necesita
espacio, y es de los pocos caracteres que sanitize_url() respetaba).
The over-captured match re-emits …\\\\\',': the fifth backslash pairs with the quote as
\', the author value "swallows" its own closing quote and the separator, and everything that
follows — the attacker's URL — ends up outside quotes, i.e. executable SQL. The payload's leading comma
restores the eaten separator; the next eleven values fill the columns up to exactly 15; ) closes
the tuple and # comments out the rest of the original line (MySQL's # needs no
trailing space, and it's among the few characters sanitize_url() kept intact).
Restricciones que el payload sortea
Constraints the payload works around
| Filtro | Filter | Cómo se sortea | How it's bypassed |
|---|---|---|---|
| sanitize_url() | Sin espacios; mantiene ( ) , / * # =; antepone http:// a lo que no empieza por / |
No spaces; keeps ( ) , / * # =; prepends http:// unless the string starts with / | |
Comillas del dato → \' en el dump | Data quotes → \' in the dump |
Una comilla escapada en zona de código deja una barra huérfana (error 1064): por eso ceros comillas — el nombre de opción va en hex y los separadores son /**/ |
An escaped quote in code position leaves an orphan backslash (error 1064): hence zero quotes — the option name is hex and separators are /**/ |
Prefijo de tabla desconocido (wp_ no garantizado) | Unknown table prefix (wp_ not guaranteed) |
SERVMASK_PREFIX_options: el importador traduce los prefijos SERVMASK al real antes del regex — el payload se adapta solo a cualquier prefijo |
SERVMASK_PREFIX_options: the importer maps SERVMASK prefixes to the real one before the regex — the payload adapts itself to any prefix |
| 15 columnas exactas en el INSERT | Exactly 15 columns in the INSERT | La URL aporta 12 expresiones (subconsulta + 11 rellenos) y cierra con ); el resto de la línea queda comentado |
The URL supplies 12 expressions (subquery + 11 fillers) and closes with ); the rest of the line is commented out |
| Visibilidad del leak | Leak visibility | Columna 5 = comment_author_url (público), columna 11 = 0x31 («1», aprobado), columna 13 = 0x636f6d6d656e74 («comment», visible en la REST sin autenticar) |
Column 5 = comment_author_url (public), column 11 = 0x31 ("1", approved), column 13 = 0x636f6d6d656e74 ("comment", visible via unauthenticated REST) |
§4VerificaciónVerification
Cadena completa reproducida de forma autónoma en el laboratorio Docker de este repositorio
(make full-demo), con el mismo payload sin ajustes:
Full chain reproduced end-to-end in this repository's Docker lab (make full-demo),
same payload untouched:
| WordPress | Plant | Restore | Leak clave | Key leak | RCE |
|---|---|---|---|---|---|
| 7.1.0 | ✓ | ✓ | ✓ | ✓ | |
| 7.0.4 | ✓ | ✓ | ✓ | ✓ | |
| 6.9.4 | ✓ | ✓ | ✓ | ✓ | |
| 6.8.3 | ✓ | ✓ | ✓ | ✓ | |
| 7.110 (control, parcheado) | 7.110 (patched control) | ✓ | ✓ | ✗ fila intactarow intact | ✗ |
"nbtSZqbihacU" # ← ai1wm_secret_key real, sin autenticación
$ cat /var/www/html/wp-content/PWNED_CVE_2026_19949.txt
RCE confirmada: CVE-2026-19949 — 2026-09-08T07:59:47+00:00
§5MitigaciónMitigation
- Actualizar a ≥ 7.110 — el parche es el propio regex tokenizador; sin él no hay volteo y el payload queda inerte (verificado en el control).
- Update to ≥ 7.110 — the patch is the tokenizer regex itself; without it there is no flip and the payload stays inert (verified in the control).
- Cerrar trackbacks y pingbacks (ajustes de debate / WAF) elimina el vector de plantado sin autenticación.
- Disabling trackbacks and pingbacks (discussion settings / WAF) removes the unauthenticated planting vector.
- Si el sitio estaba expuesto y se restauraron copias recientemente: auditar comentarios aprobados con
author_urlde 12 caracteres alfanuméricos, la opciónai1wm_secret_key, y ficheros nuevos enwp-content/mu-plugins/. - If the site was exposed and backups were recently restored: audit approved comments with 12-char alphanumeric
author_url, theai1wm_secret_keyoption, and new files underwp-content/mu-plugins/.
§6Laboratorio y CLILab & CLI
Todo lo anterior es reproducible con dos comandos desde el raíz del repositorio github.com/686f6c61/POC-AIOWPM-CVE-2026-19949:
Everything above is reproducible with two commands from the root of github.com/686f6c61/POC-AIOWPM-CVE-2026-19949:
$ python3 cli/poc.py lab # ídem vía CLI, con informe en informes/
$ python3 cli/poc.py scan https://www.tusitio.com # detección no invasiva
El payload está documentado línea a línea en exploit/01_plant_trackback.sh; el análisis de derivación y los arneses de verificación, en exploit/investigacion/.
The payload is documented line by line in exploit/01_plant_trackback.sh; the derivation analysis and verification harnesses live in exploit/investigacion/.
§7ReferenciasReferences
- Wordfence — 5 Million WordPress Sites Affected by SQL Injection Vulnerability in All-in-One WP Migration and Backup (sept. 2026)
- NVD · CVE-2026-19949
- WPScan · Unauthenticated Second-Order SQLi via Archive Restore
- all-in-one-wp-migration 7.110 — el diff vulnerable→parcheado está en
class-ai1wm-database.php:1637