TL;DR: Eine vollständig gepatchte Content-Plattform gab trotzdem Datensätze an jeden aus, weil der Berechtigungs-Callback nach dem Handler lief. Ich zeige, wie ich die Routenliste gelesen und den per Parameter geschützten Schreibpfad gefunden habe.
Ausgangslage
Ich habe das Ziel als Black-Box geprüft: keine Zugangsdaten, nur die öffentlich sichtbare Fläche. Das ist, was ich sah, bevor ich etwas angefasst habe.
Eine Unternehmensberatung auf einem aktuellen Content-Management-Release mit einem Plugin-Ökosystem, betreut von einer externen Agentur. Geprüft wurden REST-Routen-Autorisierung, Benutzer-Enumeration, E-Mail-Authentifizierung und Content Security Policy der öffentlichen Oberfläche.
Ich habe alle identifizierenden Informationen entfernt. Das Ziel wird ausschließlich nach Branche und Technologieklasse beschrieben, damit das Muster übertragbar bleibt.
Das Muster
Das Muster, das ich erkannt habe — und das ich in ähnlichen Landschaften immer wieder finde:
Patch-Management beantwortet die Frage, welche Versionen als verwundbar bekannt sind. Es beantwortet nicht die Frage, ob die installierten Versionen das durchsetzen, was die Seite annimmt. Ein aktuelles Plugin mit zwölf Befunden ist kein Widerspruch, sondern das normale Ergebnis, wenn niemand die Berechtigungs-Callbacks von Routen liest, die niemand nutzt.
Der strukturelle Grund: REST-Endpunkte werden getrennt von den Seiten registriert, die auf sie verweisen. Ein Plugin-Autor registriert eine Route für ein internes Werkzeug, markiert sie als öffentlich, weil das Frontend die Daten braucht, und die administrative Variante nutzt dieselbe Route mit einem Parameter oder vergisst sie.
Der Meldung erscheint dadurch wie ein veralteter Zustand. Versions-Banner, Benutzer-Enumeration und ein offener Endpunkt lesen sich in einem Bericht wie Patch-Fehler. Sie sind es nicht, und die Behebung ist völlig anders: einmal Update, einmal eine Entscheidung, wer welche Zeilen sehen darf.
Befunde
| Schweregrad | Befund | Nachweis |
|---|---|---|
| KRITISCH | Interne REST-Route ohne Authentifizierung erreichbar | Interne Reporting-Daten an anonyme Aufrufer zurückgegeben |
| HOCH | Benutzer-Enumeration über die öffentliche REST-Collection | Autorenkonten und Kennungen aufgelistet |
| HOCH | Administrative Variante teilt sich eine öffentliche Route | Schreibpfad nur durch einen Request-Parameter geschützt |
| MITTEL | E-Mail-Authentifizierung nicht erzwungen | Keine Enforcement-Policy für die Absenderdomain |
| MITTEL | Content Security Policy fehlt auf der Hauptoberfläche | Keine Policy auf einer einzigen First-Party-Seite |
| MITTEL | Servicekennungen im Seitenquelltext | Nicht-Produktions-Endpunkt in Kommentaren benannt |
Der Angriffspfad
Reproduktion im Labor
Jeder Befehl unten zielt auf einen Lab-Container, den ich selbst kontrolliere. Nichts davon ist auf ein laufendes System gerichtet.
Die Routen einer aktuellen Installation enumerieren
# Ziel: Ihre eigene Laborinstanz
$ curl -s http://localhost:8080/wp-json/ | python3 -c '
import json,sys
d = json.load(sys.stdin)
for ns in d["namespaces"]:
print(ns)'
# jeder Namespace liefert eine Routenliste; lesen
$ curl -s http://localhost:8080/wp-json/reporting/v1/ | python3 -m json.tool | head -40
# jetzt dieselbe Route ohne Zugangsdaten abfragen
$ curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:8080/wp-json/reporting/v1/entries'
$ curl -s 'http://localhost:8080/wp-json/reporting/v1/entries' | head -c 300
Erkennung in eigener Infrastruktur
Routen finden, die ohne Berechtigungsprüfung antworten
$ curl -s https://example.org/wp-json/ | python3 -c '
import json,sys
d = json.load(sys.stdin)
for ns in d["namespaces"]:
print(ns)' | while read -r ns; do
curl -s "https://example.org/wp-json/$ns" | python3 -c '
import json,sys
try:
routes = json.load(sys.stdin).get("routes", {})
except Exception:
raise SystemExit
for r, v in routes.items():
methods = [m for m in v.get("methods", []) if m != "OPTIONS"]
if methods:
print(",".join(methods), r)'
done | sort | uniq
Die E-Mail-Authentifizierungskette der Absenderdomain prüfen
$ dig +short TXT example.org | grep -i spf
$ dig +short TXT _dmarc.example.org
Behebung
Vor dem Handler geschlossen aussteigen
// jede Route antwortet erst, nachdem der Berechtigungs-Callback erfolgreich war
add_action( 'rest_api_init', function () {
register_rest_route( 'reporting/v1', '/entries', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'reporting_entries',
'permission_callback' => function ( $request ) {
$user = wp_get_current_user();
if ( ! $user || ! current_user_can( 'read_reporting' ) ) {
return new WP_Error( 'forbidden', 'Nicht erlaubt.', array( 'status' => 403 ) );
}
return true;
},
'args' => array( 'tenant' => array( 'validate_callback' => 'reporting_validate_tenant' ) ),
) );
});
// und den Mandanten niemals allein aus dem Request übernehmen
function reporting_validate_tenant( $value, $request, $key ) {
$allowed = reporting_tenants_for_current_user();
return in_array( (int) $value, $allowed, true ) ? $value
: new WP_Error( 'invalid_tenant', 'Nicht Ihr Mandant.', array( 'status' => 403 ) );
}
Sobald ein Angreifer Zugriff hat, kann er folgende Aktionen durchführen:
Fazit — was ich daraus mitnehme
- Ein aktuelles Plugin ist kein autorisiertes Plugin.
- Lesen Sie die Berechtigungs-Callbacks von Routen, auf die niemand verweist.
- Trennen Sie Lese- und Schreibpfade; ein Parameter ist keine Berechtigung.
- Versions-Banner sehen wie Patch-Fehler aus und erzeugen die falsche Behebung.