Zum Inhalt
wannacode

Nicht vertrauenswürdige Eingaben

Kurz gesagt: Nicht vertrauenswürdig ist alles, was von außen kommt (Anfrage, Formular, Datei); es wird auf dem Server gegen eine Allow-List geprüft, und bei jedem Fehler wird die Anfrage abgelehnt.

Problem

Kopfhörer für -177 €

Der Shop verkauft Kopfhörer für 59 €. Das Formular auf der Seite lässt keine Menge unter 1 oder über 10 zu, und der Preis steht in einem versteckten Feld:

<form method="post" action="/orders">
<input type="hidden" name="product" value="17">
<input type="hidden" name="price" value="59">
<input type="number" name="qty" min="1" max="10" value="1">
<button>Kaufen</button>
</form>

Der Server nimmt die Felder und rechnet die Summe aus:

string PlaceOrder(Dictionary<string, string> form)
{
int price = int.Parse(form["price"]);
int qty = int.Parse(form["qty"]);
return $"Bestellung: {qty} Stück à {price} € = {qty * price} €";
}
// Von der Seite: Der Browser hat das Formular geprüft
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "2" }));
// Dieselbe Anfrage, von Hand geschickt
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "1", ["qty"] = "2" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "-3" }));
Ausgabe
Bestellung: 2 Stück à 59 € = 118 €
Bestellung: 2 Stück à 1 € = 2 €
Bestellung: -3 Stück à 59 € = -177 €

Die erste Anfrage kommt von der Seite. Die anderen beiden nicht. Der Angreifer hat die Entwicklerwerkzeuge des Browsers geöffnet oder die Anfrage mit curl geschickt und hineingeschrieben, was er wollte:

POST /orders HTTP/1.1
Content-Type: application/x-www-form-urlencoded
product=17&price=59&qty=-3

Der Zahlungsanbieter bekommt die Summe -177 € und erstattet 177 € auf die Karte zurück. Die Prüfung min="1" war nicht da: Die Anfrage ist nie durch das Formular gegangen.

Die Idee

Die Vertrauensgrenze

Der Browser gehört dem Käufer. Alles darin, vom Formular bis zum JavaScript der Seite, kann der Angreifer ändern oder umgehen. Dein Code beginnt auf dem Server. Die Stelle, an der Daten von außen nach innen kommen, ist die Vertrauensgrenze.

Was sie überquert, ist nicht vertrauenswürdige Eingabe, bis du es geprüft hast:

  • die Felder des Formulars, auch die versteckten;
  • die Adresse und die Parameter darin;
  • die Header und Cookies der Anfrage;
  • Name, Typ und Größe einer hochgeladenen Datei;
  • eine Antwort eines fremden Systems, zum Beispiel des Zahlungsanbieters.

Deine Seite hat das versteckte Feld und das Cookie dort hingelegt. Aber der Browser des Käufers schickt sie zurück, und unterwegs können sie verändert worden sein.

Wie der Angreifer zu denken heißt, bei jedem Feld zu fragen: Was, wenn es negativ ist? Leer? Riesig? Text statt einer Zahl? Die Id einer fremden Bestellung? 10 MB statt 10 Zeichen?

Die Modellvalidierung in „Views mit Razor“ ([Range(1, 10)] und ModelState.IsValid) ist genau so eine Prüfung auf dem Server. Hier schreiben wir sie von Hand, um zu sehen, was sie tut.

Sieh es dir an

Was der Server hereinlässt

Der korrigierte Handler vertraut nichts in der Anfrage. So laufen die beiden Bestellungen durch ihn:

string PlaceOrder(Dictionary<string, string> form)
{
string product = form.GetValueOrDefault("product", "");
string qty = form.GetValueOrDefault("qty", "");
if (!prices.ContainsKey(product))
{
return "Abgelehnt: kein solches Produkt";
}
if (!Regex.IsMatch(qty, @"^([1-9]|10)\z"))
{
return "Abgelehnt: Die Menge ist 1 bis 10";
}
int count = int.Parse(qty);
int price = prices[product]; // aus dem Katalog
int total = count * price;
return $"Bestellung: {count} Stück à {price} € = {total} €";
}
Schritt für Schritt, in Worten
  1. Zwei Bestellungen kommen an. Eine kommt von der Seite, wo der Browser qty ≥ 1 geprüft hat. Die andere wurde von Hand geschickt: Preis 1, Menge -3. Für den Server sind beide nur Text.
  2. Sie überqueren die Vertrauensgrenze. Ab hier ist nichts geprüft. PlaceOrder liest nur product und qty; der Preis aus der Anfrage wird nie gelesen.
  3. Erste Prüfung: Ist das Produkt im Katalog? 17 ist es, bei beiden. Eine unbekannte Id würde hier stoppen.
  4. Zweite Prüfung: Die Menge muss einer der Texte "1" … "10" sein. "2" steht in der Liste. "-3" nicht: abgelehnt, und die Bestellung geht nicht weiter.
  5. Der Preis kommt aus dem Katalog: 59 €. Was die Anfrage zum Preis sagt, spielt keine Rolle.
  6. Die ehrliche Bestellung: 2 × 59 € = 118 €. Die gefälschte ist an den Prüfungen hinter der Grenze hängen geblieben. Hinein kommt nur, was jede Prüfung bestanden hat.

Drei Regeln:

  1. Lies nicht, was du weißt. Der Preis steht im Katalog. Die Anfrage sagt nur, welches Produkt und wie viele.
  2. Prüfe gegen eine Allow-List. Das Produkt muss im Katalog sein. Die Menge muss genau einer der Texte "1" bis "10" sein.
  3. Wenn etwas nicht durchgeht, lehne ab. Jeder Weg, der nicht bei der Bestellung ankommt, ist eine Ablehnung.
using System.Text.RegularExpressions;
var prices = new Dictionary<string, int> { ["17"] = 59, ["23"] = 9 };
string PlaceOrder(Dictionary<string, string> form)
{
string product = form.GetValueOrDefault("product", "");
string qty = form.GetValueOrDefault("qty", "");
if (!prices.ContainsKey(product))
{
return "Abgelehnt: kein solches Produkt";
}
if (!Regex.IsMatch(qty, @"^([1-9]|10)\z"))
{
return "Abgelehnt: Die Menge ist 1 bis 10";
}
int count = int.Parse(qty);
int price = prices[product]; // aus dem Katalog
int total = count * price;
return $"Bestellung: {count} Stück à {price} € = {total} €";
}
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "2" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "1", ["qty"] = "2" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "-3" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "99", ["price"] = "1", ["qty"] = "1" }));
Ausgabe
Bestellung: 2 Stück à 59 € = 118 €
Bestellung: 2 Stück à 59 € = 118 €
Abgelehnt: Die Menge ist 1 bis 10
Abgelehnt: kein solches Produkt
Regex.IsMatch prüft, ob der Text zu einem Muster passt (einem regulären Ausdruck). ^ ist der Anfang des Textes, \z das Ende, und [1-9]|10 bedeutet eine Ziffer von 1 bis 9 oder genau 10.

Probier es aus

Woher der Preis kommt

Ergänze die Zeile, in der der Handler den Preis bestimmt:

string PlaceOrder(Dictionary<string, string> form)
{
    string product = form.GetValueOrDefault("product", "");
    string qty = form.GetValueOrDefault("qty", "");
    if (!prices.ContainsKey(product))
    {
        return "Abgelehnt: kein solches Produkt";
    }
    if (!Regex.IsMatch(qty, @"^([1-9]|10)\z"))
    {
        return "Abgelehnt: Die Menge ist 1 bis 10";
    }
    int count = int.Parse(qty);
    int price = ;
    int total = count * price;
    return $"Bestellung: {count} Stück à {price} € = {total} €";
}

Probier es aus

Drei Anfragen

Was gibt der korrigierte Handler für diese drei Anfragen aus?

Console.WriteLine(PlaceOrder(new() { ["product"] = "23", ["price"] = "0", ["qty"] = "10" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["qty"] = "11" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["qty"] = " 3" }));
Antwort

Die Idee

Erlaubt, nicht verboten

Viele denken zuerst daran, das Schlechte zu verbieten: „Die Menge darf nicht negativ oder null sein.“ Das ist eine Deny-List. Der andere Weg beschreibt genau das Gute: „Die Menge ist einer der Texte von 1 bis 10.“ Das ist eine Allow-List.

Hier sind beide mit denselben Eingaben:

using System.Text.RegularExpressions;
bool DenyList(string qty) => !qty.StartsWith("-") && qty != "0";
bool AllowList(string qty) => Regex.IsMatch(qty, @"^([1-9]|10)\z");
string Word(bool ok) => ok ? "angenommen" : "abgelehnt";
Console.WriteLine($"{"Wert",-6}{"deny-list",-11}allow-list");
foreach (string qty in new[] { "2", "-3", "0", "500", "2.5", "1e3" })
{
Console.WriteLine($"{qty,-6}{Word(DenyList(qty)),-11}{Word(AllowList(qty))}");
}
Ausgabe
Wert deny-list allow-list
2 angenommen angenommen
-3 abgelehnt abgelehnt
0 abgelehnt abgelehnt
500 angenommen abgelehnt
2.5 angenommen abgelehnt
1e3 angenommen abgelehnt

Die Deny-List hat 500, 2.5 und 1e3 durchgelassen. Beim Schreiben hat niemand an sie gedacht. Der Angreifer probiert genau das, woran du nicht gedacht hast. Bei einer Allow-List wird das Unbekannte abgelehnt, ohne dass du es vorhersehen musst.

Also: Beschreibe, wie eine richtige Eingabe aussieht (Typ, Länge, Zeichen, Bereich, ein Wert aus einer bekannten Liste), und lehne alles andere ab.

Genauer, für Neugierige
  • Gültig heißt nicht deins. 1042 ist eine völlig korrekte Bestellnummer, kann aber einem anderen Käufer gehören. Eine Formatprüfung reicht nicht: Der Server prüft auch, dass die Bestellung dem angemeldeten Käufer gehört. Ohne das hast du eine der häufigsten Lücken echter Seiten (IDOR, insecure direct object reference).
  • Erst normalisieren, dann prüfen. Liest das System weiter hinten die Eingabe anders als die Prüfung (Leerzeichen, Groß- und Kleinschreibung, Unicode, Kodierung in der Adresse), prüfe die Eingabe, nachdem sie normalisiert wurde. Sonst sieht die Prüfung das eine und das System bekommt das andere.
  • Begrenze die Größe. Jedes Feld hat eine Obergrenze für die Länge und jede hochgeladene Datei eine für die Größe. Sonst frisst ein „Name“ von 50 MB den Speicher des Servers.
  • Reguläre Ausdrücke und das Ende des Textes. Regex.IsMatch sucht das Muster irgendwo im Text: Ohne ^ und \z lässt es auch "500" durch. In .NET akzeptiert $ auch ein \n am Ende, deshalb steht am Ende \z.
  • In einem echten Projekt ist die Validierung deklarativ. Attribute wie [Range(1, 10)] und [RegularExpression], die ASP.NET Core beim Model Binding prüft. Die Regeln bleiben gleich: eine Allow-List, bei einem Fehler ablehnen, auf dem Server prüfen.

Problem

Die Prüfung, die bei einem Fehler durchlässt

Der Sale hat ein Limit: höchstens 10 Kopfhörer pro Bestellung. Die Prüfung fragt eine Tabelle mit Limits, und bei einem unbekannten Produkt wirft sie eine Exception. Der Code fängt die Exception, schreibt sie ins Log und macht weiter:

var limits = new Dictionary<string, int> { ["17"] = 10, ["23"] = 50 };
int LimitOf(string product)
{
if (!limits.TryGetValue(product, out int limit))
{
throw new ArgumentException($"unbekanntes Produkt {product}");
}
return limit;
}
bool MayBuy(string product, int qty)
{
bool allowed = true;
try
{
allowed = qty <= LimitOf(product);
}
catch (ArgumentException e)
{
Console.WriteLine($"Log: {e.Message}");
}
return allowed;
}
Console.WriteLine($"17 x 3: {(MayBuy("17", 3) ? "ja" : "nein")}");
Console.WriteLine($"17 x 500: {(MayBuy("17", 500) ? "ja" : "nein")}");
Console.WriteLine($"99 x 500: {(MayBuy("99", 500) ? "ja" : "nein")}");
Ausgabe
17 x 3: ja
17 x 500: nein
Log: unbekanntes Produkt 99
99 x 500: ja

500 Stück von Produkt 99 sind durchgegangen. Der Fehler in der Prüfung wurde zu „erlaubt“.

Probier es aus

Finde die Zeile

Welche Zeile lässt die Bestellung bei einem Fehler durch?

Wähle die Zeile mit dem Fehler.

Das ist die Regel Fail closed. Das Unbekannte, das Leere, ein Fehler in der Prüfung und eine unerwartete Exception führen alle zu „nein“. Das Gegenteil (Fail open) ist bequem, bis jemand die Eingabe findet, die die Prüfung bricht.

Übung

Der Rabattcode

Löse die Aufgabe in deinem Editor.

Der Gutschein. Der Warenkorb ist 120 € wert. Schreibe Discount(code), das die Summe nach dem Rabatt zurückgibt oder „abgelehnt“. Die Codes sind AUTUMN26 (25 %) und STUDENTS (10 %). Lass nur genau 8 Zeichen von A bis Z und 0 bis 9 durch, und nur einen Code, der in der Tabelle steht. Alles andere wird abgelehnt. Probiere diese Eingaben:

Erwartet
"AUTUMN26": 90 €
"autumn26": abgelehnt
"AUTUMN26' OR '1'='1": abgelehnt
"": abgelehnt
"STUDENTS": 108 €
"NOPE1234": abgelehnt

autumn26 wird abgelehnt: Die Regel sagt Großbuchstaben und nicht „repariere es für den Käufer“. Der Shop kann entscheiden, Kleinbuchstaben anzunehmen, aber dann schreibt er das in die Regel und prüft das.

Weitere Aufgaben

  • Die Lieferadresse. Beschreibe mit Regeln, wie eine Postleitzahl (in Bulgarien 4 Ziffern), ein Ort und eine Telefonnummer aussehen. Welche davon kann eine Allow-List sein und welche nur eine Begrenzung von Länge und Zeichen?
  • Eine fremde Bestellung. GET /orders/1042 gibt die Bestellung 1042 zurück. Die Eingabe 1042 besteht jede Formatprüfung. Was muss der Server noch prüfen?

Test

Teste dich

1. Das Mengenfeld auf der Seite ist <input type="number" min="1" max="10">. Muss der Server die Menge prüfen?
2. Was davon ist für den Server des Shops eine nicht vertrauenswürdige Eingabe?
3. Warum ist eine Allow-List sicherer als eine Deny-List?
4. Die Limit-Prüfung wirft eine Exception, weil sie das Produkt nicht kennt. Wofür soll sich der Code entscheiden?
5. Der Preis des Produkts steht in einem versteckten Feld des Formulars. Was macht der Server damit?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·