Hopp til hovedinnhold
Erik Nilsen
8 min lesing

WP Defender låste ut skanneren min, og X-Forwarded-For hjalp ikke

  • #pentest
  • #wordpress
  • #waf
  • #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.

Det mest interessante i dette oppdraget var ikke at jeg kom meg inn – det gjorde jeg ikke. Det var at målets eget forsvar slo tilbake mens jeg skannet, låste ut min egen utgående IP (og nettleseren min på kjøpet), og at det mest opplagte trikset for å omgå utlåsingen ikke virket. Det er et innlegg om en negativ finding, som er en finding: en sikkerhetsplugin som faktisk gjorde jobben sin.

E-post-vinkelen på akkurat denne typen små-bedrift-oppsett har jeg skrevet om før, i Da WordPress holdt, men e-posten åpnet døren. Den gangen var DMARC p=none og manglende MFA den reelle inngangen. Denne gangen er e-post-funnet det samme kjedelige mønsteret – så jeg lar det ligge her og bruker plassen på det som var nytt: utlåsingen og forsøket på å bryte den.

Kontekst

Trillemur AS (trillemuras.no) er en liten norsk virksomhet med en WordPress-side på delt hosting, med Varnish som cache-lag foran Apache, og e-post på Microsoft 365. Scopet var ekstern, uautentisert testing: hva får en anonym angriper til mot dette?

Arbeidsmåten min på sånne oppdrag er AI i loopen. Jeg styrer retning, scope og prioritering; AI-en skriver og kjører rekognoseringsscript, kjører verktøy, parser output og foreslår neste steg. Jeg leser resultatene, bryter inn der det trengs, og tar de vurderingene som krever menneskelig dømmekraft – hva som er en reell finding kontra støy, hvor grensa for scope går, og hva som havner i rapporten. Det betyr også at jeg er den som må ta valget når automatiseringen løper inn i en vegg. Det skjedde tidlig her.

Skanningen låste ut meg selv

Etter passiv rekon lot jeg AI-en kjøre en plugin-enumerering med WPScan. Standard oppsett, med API-token for CVE-oppslag og litt struping for å være høflig:

wpscan --url https://trillemuras.no --api-token <REDACTED> \
  --enumerate p,vt,cb \
  --plugins-detection mixed --plugins-version-detection mixed \
  --random-user-agent --throttle 300

Midt i kjøringen begynte siden å oppføre seg rart. Forsiden, som hadde svart med en normal Elementor-side, krympet til en 9 KB stub som bare refererte til wp-defender. Et raskt kall bekreftet hva som skjedde:

curl -s -o /tmp/p.html -w "%{http_code}\n" https://trillemuras.no/
# 403
HTTP/2 403
...
Access Denied
You have been locked out due to too many attempts to access a file
that doesn't exist.
...
Powered by Defender

WP Defenders 404-deteksjon hadde reagert på at WPScan ba om tusenvis av ikke-eksisterende plugin-filer, og låste ut den utgående IP-en min. Siden lockouten er IP-basert traff den alt fra den IP-en – inkludert nettleseren min, som plutselig viste samme «Access Denied»-side med en nedtelling.

Her tok jeg det første menneskelige valget: stoppe, ikke presse på. Hver nye 404 forlenger lockouten, så å la AI-en fortsette å hamre ville bare grave dypere. Jeg drepte WPScan-prosessen umiddelbart.

Aggressiv enumerering og moderne sikkerhetsplugins er en dårlig kombinasjon. WPScan sitt --enumerate p med mixed-deteksjon genererer en flom av forespørsler mot ikke-eksisterende filer – akkurat det 404-utlåsing er bygget for å fange. Fingerprint sikkerhetslaget før du slipper løs den støyende delen.

Selve utlåsingen er verdt å notere som en positiv finding: Info – kontrollen fungerer, og den fungerte mot en reell, automatisert angrepsteknikk. Men den reiste umiddelbart et mer interessant spørsmål.

Kunne jeg spoofe meg forbi?

Det avgjørende premisset lå allerede i svar-headerne: via: 1.1 ... (Varnish). Det er en reverse proxy foran applikasjonen. Når Defender kjører bak en proxy, ser den ikke den ekte klient-IP-en i REMOTE_ADDR – den må lese en videresendt header for å vite hvem som egentlig ringer. Og hvis den stoler naivt på en klient-kontrollert header, kan jeg sette den selv.

Det er nettopp dette CVE-2024-25595 handler om: IP-spoofing i Defender via videresendings-headere, fikset i 4.4.2. Jeg visste ikke versjonen (readme-en var blokkert), så det var verdt å teste empirisk.

Jeg ba AI-en skrive en kontrollert test: tilskriv en haug med 404-er til en reservert test-IP via X-Forwarded-For, og se hvem som faktisk blir låst – den spoofede IP-en eller min ekte. Poenget med å bruke en fast, reservert IP (203.0.113.x, TEST-NET) er at hvis Defender ærer headeren, er det den fiktive IP-en som tar lockouten, ikke jeg.

SPOOF="203.0.113.99"
 
# tilskriv 404-byrden til den spoofede IP-en
for i in $(seq 1 10); do
  curl -s -H "X-Forwarded-For: $SPOOF" \
    -o /dev/null "https://trillemuras.no/wp-content/zz-$i-$RANDOM.txt"
  sleep 0.3
done
 
# hvem ble låst?
echo -n "spoofet IP: "; curl -s -H "X-Forwarded-For: $SPOOF" -o /dev/null -w "%{http_code}\n" https://trillemuras.no/robots.txt
echo -n "ekte IP:    "; curl -s                              -o /dev/null -w "%{http_code}\n" https://trillemuras.no/robots.txt

Første runde låste ingen – terskelen lå høyere enn ti. Jeg lot AI-en kjøre den inkrementelt videre i små bolker og sjekke etter hver bolk, med en hard regel om å stoppe i det øyeblikket enten IP ble låst, så jeg ikke hamret i blinde. Rundt tjue 404-er slo det til:

after 10 spoofed-404s: SPOOFED-IP=403  REAL-IP=403
>>> ekte IP låst – Defender bruker connection-IP, ignorerer XFF

Begge probene returnerte lockout-siden. Hadde Defender æret X-Forwarded-For, ville den spoofede IP-en blitt låst mens min ekte IP fortsatt svarte 200. I stedet ble min ekte IP låst – som betyr at Defender (eller proxy-laget foran) bruker den faktiske connection-IP-en og ignorerer den klient-satte headeren.

Bypasset feilet, og det er det riktige utfallet. Defender lot seg ikke lure av en forfalsket X-Forwarded-For. Enten er pluginen ≥ 4.4.2, eller så normaliserer hosting-proxyen headeren før den når WordPress. Uansett: kontrollen holder mot den mest opplagte omgåelsen.

Bonusen var at jeg låste meg selv ute igjen. Det er den ærlige prisen for å teste en lockout-bypass: du må faktisk nå terskelen for å vite om den brytes. Den inkrementelle tilnærmingen med stopp-på-første-lockout holdt skaden til et par minutters nedetid på min egen IP, ikke en lang serie blindtreff.

To andre blindspor

Med en oppdatert, godt forsvart kjerne var det fristende å lete etter en kjede som ga inngang likevel. To spor så lovende ut og førte ingen vei.

Nonce-orakelet i Essential Addons. Jeg ba AI-en parse plugin-ens frontend-JS og hente ut AJAX-endepunktene den faktisk bruker. Ett av dem stakk seg ut:

curl -s -X POST https://trillemuras.no/wp-admin/admin-ajax.php \
  --data 'action=eael_get_token'
{"success":true,"data":{"nonce":"<REDACTED>"}}

Et uautentisert endepunkt som deler ut en gyldig nonce. Ved første øyekast ser det ut som en full omgåelse av nonce-/CSRF-beskyttelsen. Men da jeg leste JS-en nærmere, var det en innebygd mekanisme: pluginen henter en fersk nonce slik at sider servert fra full-page-cache (Varnish, her) ikke sitter med en utløpt en. Og den fersk-mintede noncen åpner ingen dører som faktisk teller – Elementors egne REST-endepunkter krever en innlogget cookie i tillegg:

curl -s -H "X-WP-Nonce: <REDACTED>" \
  https://trillemuras.no/wp-json/elementor/v1/form-submissions
# {"code":"rest_cookie_invalid_nonce", ... "status":403}

Skjemainnsendingene – nettopp den kundedataen en angriper ville vært ute etter – lå bak en korrekt autorisasjonssjekk og svarte 401/403 uten en gyldig sesjon. Et orakel som deler ut nonces er en svakhet på papiret, men uten et privilegert endepunkt å bruke dem mot er det en parentes, ikke en inngang.

CVE-ene som ikke hadde en flate. AI-en korrelerte alle plugin-versjonene mot WPScan-APIet. Essential Addons lå noen sikkerhetsversjoner bak, med flere uautentiserte CVE-er på listen. Men da jeg leste hva de faktisk var, falt de fra hverandre mot dette miljøet: den ene krevde WooCommerce, som ikke var installert, og resten lekket kun ikke-publisert innhold – og siden hadde ett publisert innlegg og null privat innhold. Den historisk stygge kontoovertakelses-kjeden i samme plugin var patchet i versjonen som kjørte. En CVE uten en flate i akkurat dette oppsettet er ikke en finding, den er en fotnote i rapporten.

Konklusjonen ble at det ikke fantes noen uautentisert programvarevei til admin eller kodekjøring. Den reelle inngangen på et sånt oppsett går via mennesket og e-posten – samme DMARC-historie som det andre innlegget, og grunnen til at e-postfunnet var det jeg løftet høyest i leveransen.

Hva jeg lærte

  • AI-drevet skanning trigger moderne forsvar – planlegg for det. En --enumerate-flom er akkurat det 404-utlåsing fanger. Enten fingerprinter du sikkerhetsplugin-en først og struper hardt, eller så avtaler du en IP-allowlist med kunden for å beholde dekning. Å la AI-en hamre i blinde er bortkastet og bråkete.
  • 404-utlåsing er billig og effektivt. Den stoppet en automatisert enumerering kaldt, og den lot seg ikke lure av en forfalsket X-Forwarded-For. Verdt å dokumentere som en kontroll som virket.
  • Test bypass empirisk, men inkrementelt. Om en header-spoof omgår en IP-lockout avhenger helt av hvordan proxy-laget håndterer headeren – ikke anta, test. Og test i små bolker med stopp-på-første-lockout, ellers låser du bare deg selv ute uten å lære noe.
  • Negative findings er findings. «Nonce-orakelet fører ingen vei» og «CVE-en har ingen flate her» er konklusjoner kunden skal ha, ikke ting jeg skjuler fordi de ikke ble til en exploit.
  • Dette er der menneske-i-loopen tjener pengene. AI-en fant endepunktet, skrev løkka og korrelerte CVE-ene fint. Men vurderingen av at nonce-orakelet var ufarlig, at CVE-ene manglet flate, og beslutningen om å slutte å hamre på lockouten – det var avgjørelser, ikke verktøy-output.

Veien videre

Neste gang fingerprinter jeg sikkerhetslaget før den støyende enumereringen, og avklarer en kortvarig IP-allowlist med kunden i forkant der scopet tillater det – slik at automatiseringen får full dekning uten å bli sin egen fiende. Og jeg fortsetter å skrive opp blindsporene like nøye som treffene. Et oppdrag der ingenting brøt sammen er ikke et mislykket oppdrag; det er en rapport som sier at forsvaret holdt, med bevis for hvorfor.