Was ein grep nicht finden kann
Wer eine große Codebasis systematisch durchgeht, führt Buch. Bei mir heißt das: Für jede Fehlerklasse steht in den Notizen, wann sie geprüft wurde, wie, und mit welchem Ergebnis. Das ist nicht Bürokratie, sondern die Voraussetzung dafür, überhaupt fertig zu werden – ohne diese Liste dreht man ewig im Kreis.
Am 15.07.2026 hatte ich eine dieser Klassen geschlossen: SQL-Injection über einen Wert, den eine Tabellenansicht aus der Anfrage übernimmt. In der Begründung stand unter anderem dieser Satz:
Keine Tabelle verkettet das Sortierfeld unmaskiert in SQL – die Suche nach
ORDER BY in Verbindung mit dem Aufruf, der das Sortierfeld liefert,
ergibt null Treffer.
Der Satz ist wahr. Die Suche ergibt tatsächlich null Treffer. Und sie ist trotzdem wertlos, denn sie kann diese Bauform gar nicht finden.
Der Grund ist einfach, sobald man ihn einmal gesehen hat: Eine Textsuche findet Dinge, die zusammen auf einer Zeile stehen – also den Fall, in dem jemand das Sortierfeld direkt dort in die Abfrage schreibt, wo er es abholt. Hier war es anders. Die Tabelle holt den Wert ab und übergibt ihn als Parameter an eine Methode in einer anderen Datei. Quelle und Senke stehen nie zusammen auf einer Zeile; sie stehen nicht einmal im selben Verzeichnis.
Der Denkfehler, nicht der Tippfehler
Die Suche war richtig geschrieben. Falsch war, was ich aus ihrem Ergebnis geschlossen habe. „Ich finde die Quelle nirgends neben der Senke“ heißt nicht „es gibt keinen Weg von der Quelle zur Senke“ – es heißt nur, dass der Weg länger ist als eine Zeile. Ein Ergebnis von null ist bei einer Textsuche deshalb nie ein Beweis für Abwesenheit. Es ist eine Aussage über die Suche, nicht über den Code.
Die Lehre habe ich mir als eigene Regel notiert, und sie ist der eigentliche Gewinn dieses Befunds: Die Menge der zu prüfenden Stellen leitet sich aus dem Typ ab, nicht aus einem Suchmuster. Es gibt eine endliche Zahl von Tabellenklassen, und jede von ihnen hat genau einen Aufruf, mit dem sie ihr Sortierfeld abholt. Diese Aufrufe lassen sich abzählen. Dann geht man jeden einzeln nach vorne weiter, bis man weiß, wo der Wert landet. Das ist mehr Arbeit als ein grep. Es ist aber die einzige Variante, die eine Aussage trägt.
Der zweite Durchgang
Zwölf Tage später, am 27.07.2026, kam die Klasse aus einem anderen Anlass noch einmal auf den Tisch. Ich arbeitete gerade die Komponenten ab, die bis dahin nur oberflächlich angesehen worden waren – diesmal nicht auf der Suche nach einer bestimmten Stelle, sondern mit der stumpfen Frage: Wo wird hier überhaupt eine SQL-Anweisung aus Zeichenketten zusammengesetzt?
Das ist eine mühsame Art zu arbeiten und über weite Strecken langweilig. Der größte Teil dessen, was man dabei findet, erledigt sich nach zwei Blicken: Der Wert läuft sichtbar über eine Maskierungsfunktion, wird in eine Ganzzahl verwandelt oder stammt gar nicht von außen. Für den Rest gibt es keine Abkürzung. Da lautet die Frage bei jedem einzelnen Treffer: Woher kommt dieser Wert, und wer bestimmt ihn?
An einer Stelle endete diese Frage nicht wie üblich nach zwei Schritten bei einem Wert, den das Programm selbst gesetzt hatte. Die Spur lief weiter und weiter – bis in die Adresszeile. Es war die Papierkorb-Abfrage des Repositorys. Und weil ihr Wert von einer Tabellenansicht kam, war damit auch die Klasse wieder offen, die ich zwölf Tage vorher geschlossen hatte.
Die Sackgassen – und was sie zeigen
Im Rückblick liest sich so ein Fund immer wie eine geradlinige Spur. Er ist es nicht. Zum selben Durchgang gehören mehrere Stellen, die genauso aussahen und beim Nachlesen hielten – und die sind eigentlich das Interessanteste am ganzen Befund. Denn sie zeigen: Das Problem ist im Projekt bekannt, und es ist dort mehrfach richtig gelöst. An einer Stelle über einen Abgleich gegen die zulässigen Werte, an einer anderen über eine ausdrückliche Zuordnung auf den echten Spaltennamen, an einer dritten über einen Aufzählungstyp für die Sortierrichtung. Verschiedene Wege, alle tragfähig.
Eine Stelle baut die Sortierangabe sogar sorgfältig zusammen und übergibt sie dann an eine Methode, die überhaupt keine Parameter annimmt – der Wert kommt also nie an. Sicherheitlich ist das unbedenklich. Bemerkt hat es aber offenbar seit Jahren niemand.
Die Lösung war im Projekt also nicht unbekannt. Sie war nur nirgends zentral. Der Papierkorb ist die Stelle, an der niemand sie noch einmal von Hand nachgebaut hat – und das ist kein Vorwurf an eine einzelne Person, sondern die absehbare Folge davon, dass jede Ansicht diese Aufgabe selbst lösen muss.
Am Tag darauf habe ich dann getan, was ich zwölf Tage vorher hätte tun sollen: die Stellen abgezählt, an denen im Projekt überhaupt ein Sortierfeld aus einer Tabelle abgeholt wird, und jede einzeln bis zu ihrem Ende verfolgt. Der Papierkorb blieb der einzige Fall, in dem der Wert ungeprüft in einer Abfrage ankommt. Diesmal trägt die Aussage – nicht, weil eine Suche nichts gefunden hat, sondern weil die Liste abgearbeitet ist.
Der Weg des Werts, Glied für Glied
Tabellen in ILIAS sortieren, wenn man auf eine Spaltenüberschrift klickt. Welche Spalte gemeint ist, steht danach in der Adresse – das Tabellen-Framework legt Sortierfeld, Sortierrichtung und Offset gemeinsam in einem Parameter ab. Das ist völlig gewöhnlich und funktioniert seit Jahren.
Das Framework zerlegt diesen Parameter und übernimmt den ersten Teil als Sortierfeld:
$nav = explode(":", $this->nav_value);
// $nav[0] is order by
$req_order_field = $nav[0] ?? "";
$req_order_dir = $nav[1] ?? "";
$req_offset = (int) ($nav[2] ?? 0);
$this->setOrderField(($req_order_field != "") ? $req_order_field : $this->getDefaultOrderField());
$this->setOrderDirection(($req_order_dir != "") ? $req_order_dir : $this->getDefaultOrderDirection());
Der Offset wird in eine Ganzzahl umgewandelt – dort hat jemand mitgedacht. Die Richtung
wird weiter unten auf asc oder desc normalisiert, alles andere
fällt auf asc zurück; über die Richtung ließ sich also nichts einschleusen.
Beim Feld passiert nichts davon. Es wird genommen, wie es ankommt.
Auf dem Weg dorthin durchläuft der Wert die allgemeine Eingabebehandlung der Anwendung. Die klingt beruhigend, ist es hier aber nicht: Sie entfernt HTML-Auszeichnung – und sonst nichts. Was in einem SQL-Ausdruck Bedeutung trägt, übersteht sie unverändert. Für einen Wert, der später als SQL-Ausdruck weiterverarbeitet wird, ist ein Filter gegen HTML ungefähr so nützlich wie ein Regenschirm gegen Hochwasser.
Jetzt braucht es nur noch eine Ansicht, die dieses Feld auch wirklich an die Datenbank durchreicht. Die meisten tun das nicht: Sie holen die Daten und sortieren sie in PHP. Die Papierkorb-Tabelle gehört zu den anderen. Sie stellt ausdrücklich ein, dass die Datenbank sortieren soll – und gibt das Feld deshalb weiter:
Genau an diesem Übergang ist meine Suche vom 15.07. gescheitert. Die Tabelle holt das
Sortierfeld ab und gibt es als einen von mehreren Parametern an eine Methode weiter, die
in einer anderen Datei liegt. Hier heißt der Wert noch getOrderField(), dort
heißt er nur noch $order_field – und dort steht auch die verwundbare Zeile:
$order = ' ';
if ($order_field) {
$order = 'ORDER BY ' . $order_field . ' ' . $order_direction;
}
$query = $select . $from . $this->appendTrashNodeForContainerQueryFilter($filter) . $order;
// ...
$res = $this->db->query($query);
Bemerkenswert ist, was in derselben Abfrage richtig gemacht wird. Die Filter der Papierkorb-Tabelle – Suchbegriff, löschende Person, Zeitraum – laufen alle über die Maskierungsfunktionen der Datenbankschicht, und der Filter für die löschende Person wird vorher in eine Benutzer-ID aufgelöst. Der Unterschied zur Sortierklausel ist dabei kein Unterschied in der Sorgfalt, sondern einer in der Sache: Filter sind Werte, und für Werte gibt es das Standardmittel. Für eine Sortierangabe gibt es das nicht – und genau deshalb ist sie die Stelle, an der so etwas passiert.
Warum hilft hier kein Platzhalter?
Die Standardantwort auf SQL-Injection lautet: Werte nicht in die Anweisung schreiben, sondern als Parameter übergeben. Für Spaltennamen funktioniert das nicht. Ein Platzhalter kann einen Bezeichner nicht ersetzen – wer nach einer wählbaren Spalte sortieren will, muss deren Namen in die Anweisung hineinschreiben. Die Sortierklausel ist damit eine der wenigen Stellen, an denen das übliche Mittel prinzipiell nicht greift und jedes Projekt sich selbst etwas überlegen muss: in aller Regel eine Positivliste. Das ist keine ILIAS-Eigenheit, sondern gilt für jede Anwendung, die sortierbare Tabellen anbietet.
Und wer kommt an diesen Bildschirm?
Eine Senke ist nur so viel wert wie der Zugang zu ihr. Hier war der Zugang das Überraschende: Der Papierkorb-Befehl prüft eine einzige Bedingung – das Schreibrecht auf dem Container – und sonst gar nichts. Wer irgendwo einen Kurs, eine Gruppe, eine Kategorie oder einen Ordner administriert, hat dieses Recht. Kein Administrationszugang, keine besondere Rolle.
Für Betreiber ist dabei vor allem eine Erkenntnis wichtig, und es ist die unangenehme: Die Betroffenheit hängt an der Version, nicht an der Konfiguration. Der Bildschirm ist nicht nur über den sichtbaren Papierkorb-Reiter erreichbar – der zugehörige Befehl wird von der allgemeinen Befehlsverteilung der Container-Oberfläche entgegengenommen und sichert sich allein über das Schreibrecht ab. Auch ein instanzweit abgeschalteter oder ein leerer Papierkorb ändert daran nichts. Es gibt hier also keine Einstellung, an der man drehen könnte; es hilft nur das Update.
Die Liste, die es schon gab
Bis hierhin ist das eine gewöhnliche Geschichte: ein Wert aus der Anfrage, eine fehlende Prüfung, eine Abfrage. Interessant wird sie an der Stelle, an der man nachsieht, was das Framework eigentlich hätte tun können.
Denn die Prüfung, die hier fehlt, ist nicht schwer zu bauen – und sie muss auch nicht gebaut werden. Sie existiert bereits. Jede Tabelle meldet beim Aufbau ihre Spalten an, und dabei sagt sie zu jeder, ob nach ihr sortiert werden darf. Das Framework führt darüber eine Liste. Es hat also in dem Moment, in dem der Wert aus der Anfrage eintrifft, die vollständige Aufzählung dessen, was zulässig wäre, im Zugriff.
Es fragt sie nur nicht. Der Abgleich gegen diese Liste steht ausschließlich in dem Zweig, der eine zuvor gespeicherte Sortierung aus der Sitzung wiederherstellt. Und dort entscheidet er auch nur darüber, welche Sortierrichtung verwendet wird. Kommt der Wert frisch aus der Anfrage, wird dieser Zweig übersprungen. Die Liste bleibt liegen.
Eine Prüfung, die am falschen Zweig hängt, ist keine halbe Prüfung. Sie ist keine – und sie sieht im Code aus wie eine.
Das ist der Punkt, an dem ich verstanden habe, warum diese Stelle so lange überlebt hat. Wer die Datei liest, findet dort einen Abgleich gegen die erlaubten Spalten. Er sieht richtig aus, er ist richtig – nur nicht auf dem Weg, auf dem die Werte tatsächlich von außen kommen. Eine vorhandene, aber falsch platzierte Prüfung tarnt eine fehlende besser als jede Abwesenheit. Bei einer Datei ohne jede Prüfung wird man misstrauisch. Bei dieser hier hakt man ab.
Von Lesen zu Schreiben
Ein Fund im Quelltext ist eine Vermutung. Belastbar wird er erst, wenn er läuft – und bei diesem Befund hing an der Frage „läuft er, und wie weit?“ die gesamte Einstufung.
Was die Klausel überhaupt zulässt
Man könnte annehmen, eine Sortierklausel sei ein eng begrenzter Ort: Da steht ein Spaltenname, mehr geht nicht. Unter MySQL und MariaDB ist das falsch. Dort sind in dieser Klausel vollwertige Ausdrücke erlaubt, einschließlich Unterabfragen. Die Klausel ist damit kein Sonderfall mit eingeschränkten Möglichkeiten, sondern ein vollständiger Lesezugriff auf den gesamten Datenbestand – Passwort-Hashes, personenbezogene Daten, Sitzungsdaten, hinterlegte Schnittstellen-Geheimnisse. Dass eine Instanz keine Datenbankfehler anzeigt, hilft dabei nicht; darauf ist ein solcher Zugriff nicht angewiesen.
Bis das lief, waren ein paar Eigenheiten dieses Parameters zu berücksichtigen – nichts Grundsätzliches, aber genug für einen Abend Arbeit. Die gehören nicht in diesen Text.
Der Sprung: eine Voreinstellung unterhalb der Anwendung
Und dann kommt ein Umstand dazu, der mit dem Papierkorb nichts zu tun hat und auch nicht mit ILIAS, der die Einstufung dieses Befunds aber mehr verändert als alles andere: Die eingesetzte PHP-Datenbankschnittstelle lässt in ihrer Voreinstellung mehrere Anweisungen pro Aufruf zu.
Diese Voreinstellung liegt eine Ebene tiefer als der Anwendungscode, und sie wird selten bewusst gewählt – Anwendungen übernehmen in der Regel, was die Schnittstelle mitbringt. Wo sie offen steht, bleibt es an einer SQL-Injection allerdings nicht beim Lesen: Was zusätzlich zur Ausführung kommt, darf alles, was der Datenbankbenutzer der Anwendung darf – und der darf naturgemäß schreiben. Das gilt für jede PHP-Anwendung mit dieser Voreinstellung, unabhängig davon, wo ihre verwundbare Stelle sitzt. Sie lässt sich abschalten, und wo die Betriebsumgebung es zulässt, ist das eine lohnende allgemeine Härtung – unabhängig von diesem Befund.
Nachgesehen habe ich das nicht auf Verdacht. Bei der Einstufung eines Befunds ist der Unterschied zwischen „liest die Datenbank“ und „schreibt in die Datenbank“ der Unterschied zwischen zwei ganz verschiedenen Dringlichkeiten, und ich wollte nicht raten müssen. Also habe ich die Verbindungsoptionen der Datenbankschicht von Hand durchgesehen, einmal für jeden betroffenen Versionsstand, statt mich auf eine Annahme über die Voreinstellung zu verlassen.
Die Bestätigung
Dass eine Voreinstellung theoretisch offen steht, ist eine Aussage über Quelltext. Ob eine zweite Anweisung tatsächlich ausgeführt wird, ist eine Aussage über eine laufende Instanz, und die beantwortet nur ein Versuch. Geprüft habe ich das auf den dafür aufgesetzten Instanzen – ebenfalls von Hand, einmal je Versionsstand.
Das Ergebnis war eindeutig, und es war auf allen vier damals gepflegten Zweigen dasselbe – den drei Release-Linien und dem Entwicklungsstand der nachfolgenden Hauptversion: Die zweite Anweisung wird ausgeführt. Nachgewiesen habe ich das mit der harmlosesten Änderung, die den Punkt belegt – dem Umbenennen eines Kontos, ein Feld, das sich mit einer Zeile zurückdrehen lässt. Es ging nicht darum, weit zu kommen; es ging darum, den Unterschied zwischen Lesen und Schreiben belegen zu können. Danach war es zurückgedreht.
Was diese eine Voreinstellung an der Bewertung macht
Ohne die Möglichkeit, eine zweite Anweisung abzusetzen, liegt dieser Befund bei 6.9 (Medium) – vollständiger Lesezugriff, kein Schreibzugriff. Mit ihr liegt er bei 8.7 (High), weil dann auch Zugangsdaten überschrieben werden können und damit die Übernahme der Instanz in Reichweite liegt. Derselbe Programmierfehler, dieselbe Zeile, dieselbe Erreichbarkeit – 1,8 Punkte Unterschied, entschieden von einem Schalter, den niemand umgelegt hat.
Ein Nebeneffekt, der die Wirkung verlängert
Zum Schluss noch eine Eigenschaft, die ich erst beim Aufräumen bemerkt habe: Das Tabellen-Framework merkt sich die zuletzt gewählte Sortierung in den Tabelleneinstellungen des handelnden Kontos. Das ist eine Bequemlichkeitsfunktion – man soll die Ansicht so wiederfinden, wie man sie verlassen hat.
Für diesen Befund heißt das: Eine eingeschleuste Sortierangabe wird nicht einmal ausgeführt, sondern bei jedem weiteren Aufruf dieses Bildschirms erneut – bis sie durch eine gültige Sortierung ersetzt wird. Wer im Nachhinein prüft, ob an einer Instanz etwas passiert ist, sollte deshalb nicht nur in die Zugriffsprotokolle sehen, sondern auch in die gespeicherten Tabelleneinstellungen. Dort kann eine Spur zurückgeblieben sein, die noch läuft.
Zurückgehalten
Dieser Text zeigt den verwundbaren Quelltext, weil der Patch seit dem 12.08.2026 verfügbar und der Commit des Herstellers öffentlich ist – wer die Stelle lesen will, findet sie ohnehin. Was hier nicht steht, ist alles, was beim Nachbauen helfen würde: keine Adressen, keine Parameternamen, keine Sortierangaben zum Kopieren, keine Abfolge von Aufrufen und auch nicht die Eigenheiten, die beim Einschub zu beachten waren. Das ist bewusst so, und es fehlt nichts, was zur Beurteilung der eigenen Installation nötig wäre. Proof-of-Concept-Code gebe ich auch auf Anfrage nicht heraus.
Die Korrektur
Fünfzehn Tage nach der Meldung lag der Patch vor. Der Hersteller hat an der Senke angesetzt: Das Sortierfeld wird jetzt als Bezeichner maskiert, die Richtung zusätzlich normalisiert.
$valid_direction = strtolower($order_direction) === 'desc' ? 'DESC' : 'ASC';
$order = 'ORDER BY ' . $this->db->quoteIdentifier($order_field) . ' ' . $valid_direction;
Das schließt diese Stelle zuverlässig, und es ist eine gute Korrektur: Sie macht aus dem Wert ausdrücklich einen Bezeichner, und ein Bezeichner kann keine Unterabfrage sein. Wer nur diesen einen Befund im Blick hat, ist damit fertig.
In der Meldung hatte ich zusätzlich etwas anderes vorgeschlagen – nicht anstelle der Korrektur an der Senke, sondern davor. Nämlich das, was das Framework mit der Liste tun könnte, die es bereits führt:
$req_order_field = $nav[0] ?? "";
if ($req_order_field !== "" && !in_array($req_order_field, $this->sortable_fields, true)) {
$req_order_field = ""; // fällt auf das Standardfeld zurück
}
$this->setOrderField(($req_order_field != "") ? $req_order_field : $this->getDefaultOrderField());
Vier Zeilen, keine neue Datenstruktur, keine Änderung an einer Schnittstelle: Der Abgleich benutzt genau die Liste, die jede Tabelle beim Aufbau ohnehin füllt. Der Reiz daran ist nicht die Kürze, sondern die Reichweite – er wirkt auf einen Schlag für jede Ansicht, die in der Datenbank sortiert, auch für die, die es morgen erst gibt. Die Korrektur an der Senke wirkt für eine Abfrage.
Übernommen wurde dieser Teil nicht. Ich halte das für nachvollziehbar: Ein Eingriff in das gemeinsam genutzte Tabellen-Framework berührt jede Tabellenansicht der Anwendung, und wenn irgendwo eine Spalte sortierbar ist, ohne ordentlich angemeldet zu sein, fällt das erst im Betrieb auf. Das ist eine Änderung mit Testaufwand, und wie sie gegenüber anderen Aufgaben zu priorisieren ist, kann das Projekt besser beurteilen als ich. Dass mehrere ILIAS-Komponenten diese Zuordnung heute schon selbst vornehmen – siehe die Tabelle weiter oben –, zeigt aber, dass der Bedarf da ist. Die Lösung existiert im Projekt sechs- oder siebenmal. Sie ist nur nirgends an der Stelle, an der alle sie hätten.
Fazit / Ausblick
Der Befund selbst ist unspektakulär: ein Wert aus der Anfrage, eine vergessene Prüfung, eine Verkettung. So etwas findet man, wenn man lange genug sucht. Was ich aus diesem Fall mitgenommen habe, steht nicht in der Codestelle, sondern in den zwölf Tagen davor.
- Ein Suchergebnis von null ist keine Aussage über den Code. Es ist eine Aussage über die Suche. Eine Textsuche findet Nachbarschaft auf einer Zeile; ein Datenfluss hält sich nicht an Zeilen und nicht an Dateien. Wer eine Fehlerklasse schließen will, muss die zu prüfenden Stellen abzählen – aus dem Typ, nicht aus einem Muster – und jede einzeln zu Ende verfolgen. Alles andere protokolliert nur das eigene Vertrauen.
- Eine falsch platzierte Prüfung tarnt besser als keine. Der Abgleich gegen die erlaubten Spalten war da, er war korrekt, und er hing am falschen Zweig. Code ohne jede Prüfung macht misstrauisch; Code mit einer Prüfung an der falschen Stelle liest sich wie erledigt. Die Frage lautet deshalb nie „wird geprüft?“, sondern immer „wird auf diesem Weg geprüft?“.
- Sortierklauseln sind ein struktureller blinder Fleck. Nicht in ILIAS – überall. Sie sind die Stelle, an der die Standardantwort auf SQL-Injection prinzipiell nicht greift, weil ein Platzhalter keinen Bezeichner ersetzen kann. Jede Anwendung mit sortierbaren Tabellen muss sich dort selbst etwas überlegen, und jede tut es ein Stück anders. Wenn Sie in einer eigenen Anwendung nach einer Stunde etwas finden wollen: Fangen Sie hier an.
Dieser Befund war der Anfang. Die Frage, aus der er entstand – wo wird ein Wert weitergereicht, ohne dass ihn auf dem Weg jemand prüft? –, lässt sich auch ganz anders stellen: nicht auf Werte, die in eine Abfrage wandern, sondern auf gespeicherten Zustand, der wieder zu einem Objekt wird. Wenige Tage später führte genau diese Variante zu einem Befund, für den nicht einmal ein Konto nötig war: die unauthentifizierte PHP-Objektinjektion im Shibboleth-Logout-Endpunkt.