CVE-2026-64638 is a pre-authentication reflected XSS in WordPress 6.4 through 7.0.2. A failed username passes through two sanitizers that parse malformed tags differently. The first treats the value as text; the second repairs it into allowed HTML. On the login page, that HTML reaches a WordPress script and becomes JavaScript execution.
Every case takes a real Uphack lab and walks the exact steps that find its flaw, one at a time. The lab itself is waiting at the end.
It plays like a video, except you drive it: scroll, and the panel beside the text walks the screens, the traffic and the code down to the exact line that fails.
Every screen, request and line of code is taken from the running lab, down to the line numbers. Nothing here is illustrative.
CVE-2026-64638 is a reflected XSS in the WordPress login page. An attacker can reach it through wp-login.php without an account. The submitted username passes through two sanitizers that disagree about whether malformed input contains an HTML tag.
This target runs stock WordPress 7.0.1. The flaw is in WordPress core, so it does not depend on the theme, plugins or unusual configuration.
The Presenter login link opens the standard WordPress login page at wp-login.php. The vulnerable code path is present on a normal WordPress installation.
The username crosses two server-side sanitizers before it reaches a script intended for the profile page. That script then makes a request to the WordPress REST API. The failure lies in how these components interpret and pass the same value between them.
WordPress uses a generic error for an incorrect password, but it includes an unknown username in the corresponding error message. The submitted value therefore returns in the response without requiring an account.
That reflection is the injection point for the XSS.
The error banner places the username inside a <strong> element. Any tag that survives processing will enter an existing HTML context and can render as an element rather than as text.
Submitting the login form sends a POST request. The username is in the log parameter and the password is in pwd.
The response returns the submitted username inside <strong> tags added by the server. This confirms that the input is reflected into HTML markup. Exploitability now depends on how WordPress transforms the value before rendering it.
An ordinary <b> tag is removed while its text remains. WordPress is stripping tags rather than encoding the input. If the value were encoded, the source would contain <b> and the browser would display the literal tag.
Encoding treats every angle bracket as text. Stripping must first decide whether the characters form a tag, so the parser's rules determine what survives.
WordPress applies two sanitizers before displaying the value. The first eventually calls PHP's strip_tags(), which recognizes a tag only when a letter immediately follows <. The browser follows the same rule, so < b> is still text at this stage.
Adding a space after each opening angle bracket changes the result. The first sanitizer leaves the malformed tags in place, while a later sanitizer removes the spaces and returns a working <b> element. The value becomes valid HTML after the first stage classified it as text.
This gives us HTML injection, but not yet script execution. Common XSS payloads still have to pass the HTML allowlist.
These three payloads show the allowlist boundary. The script element is removed but its text remains, svg is removed entirely, and img survives with src intact but onerror removed.
The second sanitizer is KSES, WordPress's HTML allowlist. It keeps allowed elements, removes disallowed attributes and normalizes the remaining markup. That behavior is expected; the problem is that KSES receives a value an earlier stage treated as text.
The usable output is now limited to allowed elements and attributes such as id, class and href. Reaching script execution requires JavaScript already on the page that selects and acts on those elements.
The username enters an HTML message in wp_authenticate_username_password(). sprintf() inserts it into the %s placeholder of a translated string that already contains markup.
The username is not escaped at this insertion point because it has already passed through sanitize_user(). Escaping the complete translation would also render its intended <strong> elements as text, so the relevant boundary is the interpolated value.
In non-strict mode, sanitize_user() calls wp_strip_all_tags(), which wraps PHP's strip_tags(). It then removes accents, percent-encoded characters and HTML entities.
A nearby function for ordinary text fields calls wp_pre_kses_less_than() before stripping tags. It converts a plain < into < so that later processing cannot turn the surviving text into a functional tag.
That protection anticipates a second parsing stage. The username path does not use it.
After authentication fails, the error passes through wp_signon(), wp-login.php and login_header(). The wp_admin_notice() helper then sends the complete message through wp_kses_post().
Using an HTML allowlist is appropriate for notices because their translated strings may contain markup. It also means the username is parsed again after strip_tags() has processed it.
WordPress 6.4 moved login errors to wp_admin_notice(). In 6.3.7, the error string was echoed directly. Malformed tags left by strip_tags() therefore reached the browser as text rather than passing through another parser.
The KSES tokenizer itself did not change between 6.3.7 and 6.4. The rendering refactor introduced the vulnerable sequence by placing a repairing HTML parser after a tag-stripping parser.
The affected range is WordPress 6.4 through 7.0.2. Version 7.0.3 contains the fix.
KSES uses its own tokenizer. The \s* in this expression permits whitespace after <, so KSES reads < area as a tag while strip_tags() reads it as text.
The preceding comment identifies the input as seriously malformed. Repairing malformed input is intentional when KSES processes user content that may contain HTML. It becomes unsafe here because an earlier sanitizer treated the same input as plain text.
The server-side chain therefore relies on incompatible assumptions: sanitize_user() produces text, while wp_kses_post() treats that text as repairable HTML.
The HTML injection becomes executable because wp-login.php loads user-profile.min.js, a script intended for /wp-admin/profile.php. The login file also serves the password-reset flow, so WordPress enqueues the script for the entire file.
On page load, the script finds .reset-pass-submit and triggers its generate-password button. Injected elements can satisfy those selectors even when the current page is not a profile or password-reset form.
The same file binds a delegated click handler to #color-picker for any child matching .color-option. The triggered click bubbles into this handler. If its two cached user IDs match, it calls $.post( ajaxurl, ... ).
On the profile page, these selectors refer to trusted interface elements. They normally match nothing on the login page, but injected markup can create the expected structure and activate the same handler.
The first payload supplies the two elements required by the handlers. The div matches #color-picker for the delegated click handler and .reset-pass-submit for the load handler. Its child button matches both wp-generate-pw and color-option.
Before injection, the error message contains only the normal username error.
After submission, the injected button appears inside the error sentence. This confirms that the crafted username rendered as HTML elements. Visually, the result looks like a layout error.
The browser console shows that user-profile.min.js ran against the injected DOM and stopped because ajaxurl is undefined.
Reading the stack from the bottom, the load handler called trigger, the click bubbled through the injected div, and the delegated button handler executed. WordPress has already treated the injected elements as part of its own interface; supplying ajaxurl will let the handler send its request.
The handler continues only when user_id === current_user_id. Both values come from inputs that exist on the profile page but are absent from the login page.
jQuery returns undefined for each missing input, so the comparison becomes undefined === undefined and passes. The check does not distinguish two missing values from two matching user IDs.
The attacker can inject HTML but cannot define a JavaScript variable directly. DOM clobbering provides the value instead: browsers may expose an element with an id as a property of window, so id=ajaxurl can satisfy the missing global.
The payload uses an <area> element because $.post() converts its destination to a string. A link element converts to its href, giving the attacker control of the request URL. The initial value, /test, verifies the behavior without returning executable content.
As soon as the page loads, the browser sends a POST request to the href supplied in the username. The test endpoint returns a 404 response with an HTML body, which jQuery does not execute.
To turn the request into script execution, the same-origin destination must return JavaScript.
The unauthenticated WordPress REST index can return a JSONP response. Adding _jsonp sets the response type to application/javascript and wraps the data in the requested callback. The index is available at ?rest_route=/.
The REST server selects the content type from the presence of _jsonp, validates the callback and prefixes it to the JSON response.
The call to $.post() does not declare an expected response type, so jQuery uses the response header. When the server returns application/javascript, jQuery executes the body.
The final href targets the REST index and supplies the JSONP callback. Because the clobbered call uses POST while the index is a GET route, the payload also includes WordPress's standard _method=GET override.
Submitting the crafted username now causes the browser to execute the JSONP response as JavaScript in the site's origin. The attack requires no account and no victim interaction beyond opening the crafted link.
Code running in that origin inherits the victim's access to the site. If the victim is a logged-in administrator, it can perform authenticated actions with their session.
The callback validation permits dots through ^[a-zA-Z0-9_.]+$, so a property chain such as window.opener.approve.click also passes. The published RCE chain uses this behavior, but additionally requires a logged-in administrator, Application Passwords and permission to upload plugins. Those conditions are not required for the reflected XSS itself.
The \s* is the parser difference that makes this payload possible. Without the optional whitespace, KSES would not repair < area into an element. That line exposes the mismatch, but it is not the failed trust boundary.
KSES is designed to repair allowed HTML, including some malformed input. Changing its tokenizer could affect legitimate content. The security boundary fails when the username is inserted into an HTML message without context-specific escaping.
The wp_pre_kses_less_than() guard runs on every string entering KSES. It escapes a < only when the span has no closing >.
The payload < area id=x> has a closing bracket, so the guard leaves it unchanged and the KSES tokenizer later normalizes it into an element. The predicate detects unterminated markup; it does not preserve an earlier parser's decision that the value is text.
When reviewing a guard like this, the relevant question is which invariant it enforces and whether that invariant still holds when the next parser receives the value.
The two functions in this file show the mismatch. _sanitize_text_fields() anticipates later HTML parsing and protects bare angle brackets. sanitize_user() assumes its output will remain text.
Callers cannot infer that distinction from the name sanitize_user(). The security property depends on where the value is used next, not on a generic sanitize_* label.
WordPress 7.0.3 applies esc_html() to the username before inserting it into the translated message. The username can no longer become markup when the complete notice later passes through KSES.
The patch does not change either sanitizer. strip_tags() still ignores < area, KSES still repairs it, and the rendering path introduced in 6.4 remains. Escaping at the HTML boundary removes the need for those parsers to classify the username identically.
The same release escapes values in the wrong-password message, its email-address variant and the administrator address in the registration error.
The patch therefore covers the same interpolation pattern across related messages rather than changing only the reported username path.
The fix leaves wp_kses_post() in wp_admin_notice(). Notices legitimately contain markup, so an allowlist remains appropriate for the complete message. Untrusted values inside that message must first be escaped for their output context.
Adding another sanitizer would introduce another parser without resolving the boundary. In this path, contextual output escaping removes the ambiguity before KSES receives the finished message.