-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
NEFARIOUSPLAN-CANONICAL-V1
{"body_md":"## The check was whether the role existed\n\nDivi Engine sells Divi Form Builder as a commercial plugin; the source ships only in the paid binary. The PoC published on 21 May 2026 includes an annotated extract from `includes/shared/handlers/FormSubmissionHandler.php`, on or near line 1691 of the `create_user()` method:\n\n```php\n$role = isset($form_data['role'])\n ? sanitize_text_field($form_data['role'])\n : 'subscriber';\n\n$roles_obj = function_exists('wp_roles') ? wp_roles() : null;\nif ($roles_obj && is_object($roles_obj) && is_array($roles_obj->roles)\n && !isset($roles_obj->roles[$role])) {\n $role = 'subscriber';\n}\n\n$user = new WP_User($user_id);\n$user->set_role($role);\n```\n\n`sanitize_text_field()` strips control characters and HTML tags from a string. The WordPress codex describes it as a function for \"post titles, comments, etc.\" It has no security relationship to role names; calling it on a role does not make the role any safer to grant than the unsanitized version. The next block is the gate, and the gate is `isset($roles_obj->roles[$role])`. That is an existence check on a string against the global role registry.\n\nWordPress ships with five roles: `subscriber`, `contributor`, `author`, `editor`, `administrator`. Every one returns true from `isset()`. WooCommerce adds `customer` and `shop_manager`. BuddyPress, LMS plugins, multi-vendor plugins, membership plugins, every other plugin that registers a role at activation, they all add entries that return true from `isset()`. The accepted-values set is the union of every role on the install. The intended-for-public-registration subset is, in any reasonable design, two of them.\n\nThe handler does not consult the form's saved configuration. It does not consult the caller's session. It does not call `current_user_can('promote_users')`. It asks one question: does this role exist on this WordPress install? If yes, `$user->set_role($role)` runs with the value the network sent. `isset()` does exactly what it documents: it tests whether the key exists. The plugin's call site treated the boolean as proof the role was safe to grant. The function is honest; the caller is not.\n\n## The handler runs without authentication\n\nThe submission target is `wp-admin/admin-ajax.php?action=de_fb_ajax_submit_ajax_handler`. The action name's prefix is the convention WordPress uses for unauthenticated AJAX handlers: `wp_ajax_nopriv_de_fb_ajax_submit_ajax_handler`. The `_nopriv_` half of the hook name is, by WordPress's own documentation, \"no privilege required.\" That is the registration the form needs in order for an unauthenticated visitor to fill out the public registration form at all. It is also the registration that exposes the role-grant primitive to anyone who can speak HTTP.\n\nThe handler does check a CSRF nonce, `fb_nonce`. The nonce is rendered into the public form page in a hidden input alongside the role field; any unauthenticated client can request the form page, read the nonce out of the response HTML, and replay it on the AJAX submission. WordPress nonces are CSRF tokens. They prove that the caller fetched the form at some point in the last twelve hours. They prove nothing about who the caller is, and they do not become authentication by being mandatory.\n\n## The hidden input was the runtime translation of a form-builder setting\n\nDivi Form Builder ships a User Registration Form type in its builder UI. One of that form's configurable fields is User Role. The form developer picks `Customer` from a dropdown in the builder's admin screen, saves the form, and publishes the page. The plugin's runtime then renders the form on every page view, and the renderer translates the User Role configuration into a hidden HTML input:\n\n```html\n\n```\n\nThe class name carries the developer's mental model: this is a *hidden* user-role field. The form developer believes \"hidden\" means the visitor cannot change the value. In the rendered page that is what the developer sees: an unstyled input that does not appear in the form's visible layout. The visitor never types into it. The form looks, in the browser, like it does not have a role input at all.\n\n`hidden` in HTML means \"do not render this element.\" It does not mean \"do not submit this element,\" and it does not mean \"the network cannot change this element.\" Any browser's DOM inspector exposes the field with its value alongside email and password, fully editable. Any HTTP client can set the value to whatever it likes before posting. The submission handler does not check that `role` arrived from a Divi-rendered form, that the form's saved configuration had a `df_hidden_user_role` field at all, or that the submitted value matches what the form was configured for. It reads `$form_data['role']` directly off the request.\n\nThe form-builder UI presented a server-side configuration option. The plugin's runtime translated that option into the default value of an HTTP form field. The form developer was configuring the default value of a string the network controlled.\n\n## The patch had to move the role off the wire\n\nDivi Engine does not ship a public diff. The 5.1.3 changelog entry forces the patch's shape: \"Registration forms always assign the role chosen in the form settings.\" Before 5.1.3, the form's stored configuration and the submitted `role` field were two copies of the same string, and the runtime consulted the wrong one. After 5.1.3, the runtime reads the role from the form's saved configuration, keyed by form ID. Form ID still arrives over the wire; it is the lookup key. The role itself is server-side state.\n\nThe word \"always\" in the changelog is the vendor admitting that, before 13 April, the role only came from the form settings *when nobody else sent one*. The PoC's edit to the hidden input is the case where someone else did.\n\n## From subscriber to administrator to PHP\n\nOnce `$user->set_role('administrator')` runs, the new account is a WordPress administrator in every sense the rest of the codebase recognises. Login through `/wp-login.php` lands the attacker in `/wp-admin/`. The administrator role includes the `edit_themes` and `edit_plugins` capabilities, which expose the theme and plugin editors at `Appearance > Theme File Editor` and `Plugins > Plugin File Editor`. Both editors write PHP files into the webroot under `wp-content/themes/` and `wp-content/plugins/` respectively, and the webserver executes those files on the next request. WordPress's own documentation describes the editor as equivalent to FTP access for the user holding it. The path from \"submit a registration form\" to PHP execution on the host is two HTTP requests plus a paste.\n\nThe grant-an-admin-via-public-form primitive is unusual among WordPress privilege-escalation bugs because it converts directly to persistence. An attacker who creates the administrator account does not need to chain a second bug for code execution; the account itself is the persistence mechanism, and the editor inside `/wp-admin/` is the code-execution mechanism. Detection requires looking at the `wp_users` table for accounts the operator did not create, not at request logs for unusual paths.\n\n## Existence is authorization\n\nThere is a pattern in our catalog for this and we have written about it twice before. n8n's chat WebSocket handler called [`checkIfExecutionExists`](/posts/n8n-chat-existence-was-the-gate), treated the boolean return as the authorization decision, and accepted any sequential execution ID anyone could enumerate. The Linux kernel's [ksmbd SMB server](/posts/ksmbd-durable-handle-was-the-credential) accepted a 64-bit `persistent_id` on `smb2_check_durable_oplock()`, looked the file up in a global table, and treated the lookup's success as proof of caller identity. Both share Divi's shape: a caller-supplied identifier, a handler that confirms the named thing exists, and an implicit equation of existence with authorization. The catalog name is [existence-is-authorization](/patterns/existence-is-authorization).\n\nThe Divi instance puts the pattern at the WordPress role layer. WordPress roles are a globally enumerable list per install. Every Codex page lists the defaults; every plugin's documentation lists the roles it registers; the `wp role list` WP-CLI command prints all of them. The role itself carries no per-call credential. It is a global label. Anyone who can name the label becomes the user who holds it.\n\nThe fix in every prior exhibit and in this one takes the same shape: introduce something that does carry a per-call credential, then check that instead of, or in addition to, existence. n8n's patch loaded the chat session's actual data and compared the requesting client against it. ksmbd's patch added a `struct durable_owner` to the file row so the consumer-side check had a field to read. Divi's patch consulted the form's stored configuration, where the role was authored by an admin, not by the network. In all three, the existence check stays; it just stops being the gate.\n\n## Internal-only by convention, on a hidden input\n\nThe hidden input itself is a second pattern. The form-builder runtime writes the input on every render, translating saved developer configuration into HTML on its way to the wire. The contract \"the value of this input represents the developer's intent\" is held by the convention that the form-builder's renderer is the only writer. It is documentation, not enforcement. It does not survive a `curl` from anywhere outside the developer's browser. The pattern's catalog name is [internal-only-by-convention](/patterns/internal-only-by-convention).\n\nThe closest WordPress-plugin sibling is the [User Registration & Membership plugin's `is_admin_creation_process()`](/posts/user-registration-is-admin-creation-process-was-a-string), which read `$_REQUEST['action'] === 'createuser'` and treated the string as proof the request came from `wp-admin/user-new.php`. The \"only wp-admin sends this string\" status of the parameter was the convention; the network was free to send the same string from any URL. The canonical instance of the same shape one layer up, on the framework boundary, is [Next.js's `x-middleware-subrequest` header in CVE-2025-29927](/posts/next-middleware-cve-2025-29927-recursion-guard-was-the-bypass). Divi's `df_hidden_user_role` is the same pattern on the form-builder layer: a string the plugin's renderer writes for its own use, read back from inbound network input with security-relevant authority. The `hidden` attribute on the HTML element documents who the developer expected to be writing the value. The network did not consult the documentation.\n\n## The PoC's shape\n\nThe two GitHub repositories that surfaced CVE-2026-5118 on 21 May 2026 are public artifacts. The richer one (`zycoder0day/CVE-2026-5118`) is a README in Bahasa Indonesia documenting a four-phase exploit chain: target discovery across roughly fifty candidate registration paths, parameter extraction from a Divi-rendered form, role injection via the `de_fb_ajax_submit_ajax_handler` AJAX action, and verified administrator login through `/wp-login.php`. The README includes verbatim multipart request bodies, the nonce-harvesting flow against `wp-admin/admin-ajax.php`, and a lab-verification block claiming an isolated Docker environment running WordPress 6.5 with DFB v5.0.0. The author's disclaimer at the bottom says only \"deteksi pasif\" (passive detection) is performed on live targets and that the document is intended for \"edukasi dan responsible disclosure.\"\n\nThe 5.1.3 patch shipped on 13 April 2026. The public PoC dropped on 21 May 2026, the same day the author's timeline records the report being sent to Divi Engine Security. The patch was thirty-eight days old by the time the disclosure went out. Reasonable readers can decide which way the disclosure ran.\n\nPoC: [zycoder0day/CVE-2026-5118](https://github.com/zycoder0day/CVE-2026-5118)","closing_line":"A hidden input is hidden from the visitor. It is not hidden from the network.","hook_md":"The Divi Form Builder 5.1.3 changelog, dated 13 April 2026, reads: \"Registration forms always assign the role chosen in the form settings, so new accounts cannot be given a higher role than the one you configured.\" The word doing work in that sentence is \"always.\" Before 5.1.3, the role chosen in the form settings was rendered into a hidden HTML `` and the plugin read the value back from the submission. The submission decided what value arrived. The plugin's only check against the value was `isset($wp_roles->roles[$role])`. `isset($wp_roles->roles['administrator'])` returns true on every WordPress install in the world. That was the check.","post_id":427,"slug":"divi-form-builder-cve-2026-5118-administrator-existed","title":"CVE-2026-5118: Divi Form Builder's Role Check Asked Whether the Role Existed. Administrator Existed.","type":"initial","unreadable_sentence":"`isset($wp_roles->roles['administrator'])` returns true on every WordPress install in the world. That was the check."}
-----BEGIN PGP SIGNATURE-----
iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCag8c/wAKCRDeZjl4jgkQ
JtEjAQCUCBQHGoMSXjILLS8k9zqr9AkIpbZglQN08lP36PyMIgEAisKnhPHvcZeZ
G/7zqqTv+XtjvAYv0n7MXQXvXGYMYgg=
=vwXO
-----END PGP SIGNATURE-----