— Artikel — № 109

109 —Security

Curl-recon op legacy sites: zeven one-liners voor je offerte

Een bureau dropt om 23:41 een URL en vraagt morgenvroeg een hardening-offerte. Zeven curl one-liners kosten vijf minuten en wegen het werk eerlijk.

Bovenaanzicht van CURL RECON-werkblad op linnen met headerprints, koperen stopwatch, mapje, vulpen en rode lakzegel.
Hero · gestileerd stilleven№ 109

Een bureau dropt om 23:41 een URL in de brief en vraagt voor de ochtend een hardening-offerte. Voordat we het voorsteltemplate openen, draaien we zeven curl-commando's. Die kosten zo'n vijf minuten, leveren genoeg signaal om het werk eerlijk te dimensioneren, en laten geen spoor achter in welk vendor-dashboard dan ook.

De site is meestal een legacy site op PHP 7.4 of 8.0, met WordPress, Drupal 7, Joomla 3 of een zelfgebouwd CMS dat iemands broer in 2014 schreef. De patronen herhalen zich. De curl one-liners ook.

Recon in vijf minuten

Geen van de commando's hieronder vraagt om authenticatie, een VPN of een betaalde scanner. Ze draaien allemaal tegen een publieke URL, met hetzelfde soort request dat de browser sowieso al doet. Lees de output als startpunt, niet als oordeel. Een ontbrekende header op de homepage staat misschien wel op /wp-admin ingesteld. Een open .git/config kan een symlink naar een sandbox zijn. Behandel de cijfers als triagelijst voor een echte audit, niet als de audit zelf.

Stel het doel één keer in zodat elk commando kort blijft:

T=https://target.example

Headers

Het eerste commando haalt de response-headers op en grep filtert op de security-relevante regels. Komt er niets terug, dan draait de site op browser-defaults, wat op een legacy stack meestal betekent: geen HSTS, geen CSP, geen frame-bescherming.

curl -sI "$T" | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy'

Gebruik deze output om een header-blok te scopen in .htaccess of het nginx server block. De MDN-pagina over Strict-Transport-Security is het document dat we in het voorstel plakken als de klant vraagt waarom die regel erin staat.

Het tweede commando checkt op fingerprinting-headers die stilletjes aan elke scanner verklappen welke PHP-versie draait:

curl -sI "$T" | grep -iE '^(server|x-powered-by|x-generator|x-drupal-cache|x-aspnet-version):'

Een response met Server: Apache/2.4.29 (Ubuntu) en X-Powered-By: PHP/7.2.34 doet het huiswerk van de aanvaller. We offerten een regel om dit te onderdrukken in hetzelfde hardening-blok en gaan door. Op Apache 2.4 zijn de regels kort:

Header unset Server
Header unset X-Powered-By
ServerTokens Prod
ServerSignature Off

De eerste twee vragen om mod_headers; de laatste twee horen in de vhost of top-level config, niet in .htaccess. Op nginx is het equivalent server_tokens off; in het http-blok, plus more_clear_headers 'Server' 'X-Powered-By'; als ngx_headers_more meegecompileerd is.

Blootliggende paden

Het derde commando loopt een korte lijst paden af die nooit een 200 zouden moeten teruggeven. Versiebeheer-metadata is het klassieke voorbeeld: een deploy-script dat git pull uitvoerde in document root laat .git/ op het web staan, en de hele repo is te reconstrueren vanuit .git/objects.

for p in .git/config .git/HEAD .svn/entries .env .DS_Store backup.sql; do
  printf '%-18s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "$T/$p")"
done

Alles wat 200, 301 of 302 teruggeeft naar een echt bestand, gaat direct de offerte in. De fix is twee regels in .htaccess:

RedirectMatch 404 /\.git
RedirectMatch 404 /\.env$

Het vierde commando mikt op de meest voorkomende beheerdersfout die we tegenkomen: een editor-swap of een snelle back-up laten staan naast wp-config.php:

for ext in .bak .old .orig .save .swp '~' .gz .txt; do
  printf 'wp-config.php%-5s %s\n' "$ext" "$(curl -s -o /dev/null -w '%{http_code}' "$T/wp-config.php$ext")"
done

Een 200 op wp-config.php.bak levert de database-credentials in plain text uit. We hebben dit gevonden op een Magento 1-installatie die we mochten migreren, op een Drupal 7-site die het jaar ervoor nog geaudit was, en op meer WordPress-installaties dan we prettig vinden om op te schrijven. De fix is één FilesMatch-blok in .htaccess:

<FilesMatch "wp-config\.php(\.(bak|old|orig|save|swp|gz|txt))?$">
  Require all denied
</FilesMatch>

CMS-endpoints en versielekken

Het vijfde commando port de WordPress-endpoints af die het meeste misbruikverkeer in de access log opvangen. xmlrpc.php is de klassieke brute-force-versterker en wp-login.php het klassieke credential-stuffing-doel.

curl -sI -X POST "$T/xmlrpc.php" | head -1
curl -s -o /dev/null -w '%{http_code} wp-login\n' "$T/wp-login.php"
curl -s "$T/?rest_route=/wp/v2/users" | head -c 400

De derde regel van dat blok somt users op via de REST API als de site dat niet heeft uitgezet. Een response met "slug":"admin" is het verschil tussen een fix van een uur en een incident.

Het zesde commando leest de generator-meta van de homepage, die op de meeste CMS-installaties een exact versienummer lekt:

curl -s "$T" | grep -iE 'meta name=.generator' | head -1

Een response met content="WordPress 5.8.1" vertelt ons dat de site ongeveer vijf jaar achterloopt op point-releases, dat de OWASP-categorie vulnerable and outdated components van toepassing is, en dat de offerte vóór al het andere een regel core-update nodig heeft. Op WordPress haal je de meta-tag weg met remove_action('wp_head', 'wp_generator'); in een klein mu-plugin. Drupal 7 en Joomla lekken dezelfde string via dezelfde meta-tag en willen dezelfde behandeling.

TLS-versies

Het zevende commando checkt niet rechtstreeks op kwetsbaarheden. Het kijkt of de server nog protocolversies onderhandelt die browsers allang hebben gedumpt.

for v in 1.0 1.1 1.2 1.3; do
  printf 'TLS %s  %s\n' "$v" "$(curl -sI --tlsv$v --tls-max $v "$T" -o /dev/null -w '%{http_code}' 2>&1)"
done

Een 200 op TLS 1.0 of 1.1 betekent dat de site nog verbindingen accepteert die geen enkele huidige browser maakt, wat meestal het symptoom is van een gemiste nginx- of Apache-config-update tijdens een serververhuizing. Het minimum dat we leveren is:

SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1

of, op nginx, ssl_protocols TLSv1.2 TLSv1.3; in het server block. Mozilla's server-side TLS-gids is de referentie die we er in het voorstel naast plakken.

Van output naar offerte

De zeven commando's hierboven leveren misschien veertig regels terminaloutput. Die plakken we in een scratch-bestand, sorteren de bevindingen in drie bakken (one-line fixes in .htaccess, config-wijzigingen die een deploy nodig hebben, en structureel werk dat geplande downtime vereist) en prijzen elke bak apart. Klanten keuren de eerste bak meestal nog tijdens hetzelfde gesprek goed.

Toen we Pier bouwden liepen we bij elke offerte tegen exact deze loop aan, en hoe we het uiteindelijk hebben opgelost was de recon in de app inbouwen: dezelfde zeven probes draaien tegen de gedockte FTP-target op het moment dat je verbinding maakt. De bevindingen staan naast de bestandsboom en de MySQL editor, met one-click versiegeschiedenis op elke wijziging die je in reactie doorvoert.

Als je vandaag één ding wilt oppakken: draai het tweede commando op een site die je beheert. Bevat de response een Server:-header met versienummer, open dan de vhost-config en zet er ServerTokens Prod en ServerSignature Off bij. Vijf regels werk, één gratis bevinding minder voor de recon van de volgende aanvaller.

— Vragen —

Geeft het draaien van deze commando's tegen een klantsite problemen?

Nee. Elke request is één ongeauthenticeerde GET of POST tegen een publieke URL, met verkeer gelijk aan een browser. De access log ziet het, maar geen rate limiter slaat aan op zeven probes.

En Drupal, Joomla en Magento?

De xmlrpc- en wp-login-probes zijn niet van toepassing, maar de header-, generator-, .git-, back-up- en TLS-checks wel. Vervang wp-config.php door sites/default/settings.php, configuration.php of app/etc/local.xml.

Moeten deze commando's vanaf een specifiek IP draaien?

Nee. Draai ze vanaf een laptop op welk netwerk dan ook. Hangt het resultaat af van het source-IP, dan staat er een WAF voor de site en dat verdient een eigen regel in de offerte.