Skip to main content

JavaScript-Typverwechslung: Eingabevalidierung umgangen (und wie Sie das beheben)

Artikel von
Headshot of Alessio Della Libera

Alessio Della Libera

blog feature snyk open source blue

3. November 2021

0 Min. Lesezeit

In einem früheren Blogbeitrag haben wir gezeigt, wie Typmanipulation (oder Typverwechslung) dazu verwendet werden kann, aus Template-Sandboxes auszubrechen und so Schwachstellen wie Cross-Site-Scripting (XSS) oder Code-Injection zu verursachen.

Eines der Hauptziele dieser Recherche war es, im JavaScript-Ökosystem zu untersuchen, wie und ob sich bestimmte Sicherheitskorrekturen oder Eingabevalidierungen durch einen Typverwechslungsangriff umgehen lassen (d. h. durch die Übergabe eines unerwarteten Eingabetyps).

Wir begannen unsere Recherche mit der Untersuchung, wie häufige Sicherheitslücken wie Prototype Pollution und XSS behoben werden. Dafür nutzten wir Daten aus unserer Snyk Intel Vulnerability Database, der branchenweit größten Datenbank für Open-Source-Schwachstellen. Anhand dieser Informationen konnten wir Muster identifizieren, die verschiedene Maintainer verwenden, um bestimmte Klassen von Schwachstellen zu verhindern.

Nun ist es an der Zeit, Typverwechslungsschwachstellen genauer zu betrachten. In diesem Blogbeitrag zeigen wir häufige Szenarien, in denen sich die Eingabebereinigung und -validierung durch die Übergabe eines unerwarteten Eingabetyps umgehen lassen. Außerdem stellen wir Beispiele für Abhilfemaßnahmen und Vorschläge für Open-Source-Maintainer bereit, die solche Umgehungen der Eingabevalidierung beheben möchten.

JavaScript-Grundlagen

Bevor wir besprechen, wie sich ein Array-Wert zum Umgehen bestimmter Eingabevalidierungen und damit möglicherweise zum Auslösen einer Sicherheitslücke verwenden lässt, sehen wir uns zunächst einige grundlegende Konzepte zur Funktionsweise von JavaScript an. Sie werden später sehr hilfreich sein, insbesondere bei der Fallstudie zu Prototype Pollution.

Werte vergleichen: === und ==

In JavaScript gibt es zwei verschiedene Operatoren zum Vergleichen von Werten:

  • Strikte Gleichheit ===

  • Lose Gleichheit ==

Einer der Hauptunterschiede zwischen ihnen: Der Operator === gibt immer false zurück, wenn die beiden Operanden unterschiedliche Typen haben (es findet keinerlei Typumwandlung statt). Beim Operator == werden Operanden unterschiedlichen Typs zunächst in einen gemeinsamen Typ umgewandelt und dann verglichen. Weitere Informationen zur Funktionsweise dieser Operatoren finden Sie in der offiziellen Dokumentation zum Operator für strikte Gleichheit und zum Operator für lose Gleichheit.  

Das folgende Beispiel zeigt dieses Verhalten:

let a = "test"
console.log(a == "test") // true
console.log(a === "test") // true

let b = ["test"]
console.log(b == "test") // true: ["test"] is first converted to "test" and then compared
console.log(b === "test") // false!

Werden Operanden mit demselben Wert und Typ verglichen, geben sowohl == als auch === den Wert true zurück (Zeilen 2 und 3). Vergleichen wir jedoch ein Array mit einem anderen Operanden, der dieselbe Zeichenfolgendarstellung hat, wird nur dann true zurückgegeben, wenn der Operator == verwendet wird. In Zeile 6 wird der Wert ["test"] zunächst in seine Zeichenfolgendarstellung umgewandelt und dann mit dem anderen Operanden "test" verglichen. Haben die Operanden unterschiedliche Typen, gibt der Operator === immer false zurück (Zeile 7).

Hinweis: Die obigen Überlegungen gelten auch für die Ungleichheitsoperatoren !== und !=.

Mögliche Wege, die Zeichenfolgendarstellung eines Werts zu erhalten:

  • die Methode toString() aufrufen (Zeile 2)

  • mit der Funktion String in eine Zeichenfolge umwandeln (Zeile 2)

  • den Wert mithilfe des Operators + mit einer leeren Zeichenfolge verketten (Zeile 3)

let arr = ["test"]
console.log(arr.toString() === "test") // true
console.log(String(arr) === "test") // true
console.log(('' + arr) === "test") // true

Bei einem Array-Wert besteht seine Zeichenfolgendarstellung aus der durch Kommas getrennten Verkettung seiner Elemente.

Eigenschaftszugriff

Ein weiteres wichtiges Merkmal von JavaScript ist, dass sich beim Zugriff auf Objekteigenschaften mit der Klammernotation Werte verschiedener Typen (nicht nur Zeichenfolgen) angeben lassen. Ist der Wert keine Zeichenfolge (oder kein Symbol), wird er zunächst in eine Zeichenfolge umgewandelt und dann für den Zugriff auf die Objekteigenschaft verwendet.

Sehen wir uns das folgende Beispiel an:

let obj = {}

let prop1 = ["test"]
obj[prop1] = 1
console.log(prop1.toString()) // 'test'
console.log(obj["test"]) // 1

let prop2 = {} // empty object
obj[prop2] = 2
console.log(prop2.toString()) // [object Object]
console.log(obj['[object Object]']) // 2

let prop3 = [] // empty array
obj[prop3] = 3
console.log(prop3.toString()) // ''
console.log(obj['']) // 3

Wie wir sehen, kann der Schlüssel für den Zugriff auf eine Eigenschaft unterschiedliche Typen haben. In Zeile 4 wird die Eigenschaft ["test"] zunächst in eine Zeichenfolge umgewandelt (also in den Wert "test"), und das Ergebnis dieser Operation wird als Schlüssel für den Zugriff auf die Objekteigenschaft verwendet. Wenn wir in Zeile 6 die Zeichenfolge "test" verwenden, erhalten wir den in Zeile 4 festgelegten Wert.

Auf dieselbe Weise können wir mit einem leeren Objekt {} (Zeile 9), dessen Zeichenfolgendarstellung [object Object] lautet, einen Objektwert schreiben und anschließend über die Zeichenfolge [object Object] direkt auf denselben Wert zugreifen (Zeile 11).

Methoden von String und Array

Es gibt einige integrierte Methoden, die sowohl für String als auch für Array definiert sind und denselben Namen haben. Beispiele dafür sind includes, indexOf, lastIndexOf usw.

Warum ist das für diesen Blogbeitrag relevant? Diese Methoden verhalten sich je nach Typ der Eingabe, auf die sie angewendet werden, unterschiedlich. Wird eine dieser Methoden zur Validierung von Benutzereingaben verwendet, kann die Validierung möglicherweise umgangen werden, wenn eine Eingabe eines anderen Typs (zum Beispiel ein Array) übergeben und nicht überprüft wird.

Das folgende Beispiel zeigt, wie sich integrierte Methoden, die sowohl für String als auch für Array definiert sind, unterschiedlich verhalten:

let a = "<script>"
console.log(a.includes("<script>")) // true
console.log(a.indexOf("<")) // 0

let b = ["<script>"]
console.log(b.includes("<script>")) // true
console.log(b.indexOf("<")) // -1

let c = [["<script>"]]
console.log(c.includes("<script>")) // false
console.log(c.indexOf("<")) // -1

console.log(a.toString() === b.toString()) // true
console.log(b.toString() === c.toString()) // true

Hinweis: Alle Werte im Beispiel ("<script>", ["<script>"] und [["<script>"]]) haben dieselbe Zeichenfolgendarstellung.

In Zeile 3 wird beispielsweise die Methode String.prototype.indexOf() aufgerufen. Sie prüft, ob das Zeichen < in der Zeichenfolge vorkommt, und gibt dann den Index seines ersten Vorkommens zurück. Andernfalls gibt sie -1 zurück.

Ist die Eingabe jedoch ein Array, wird die Methode Array.prototype.indexOf() aufgerufen. Sie prüft, ob das Element < im Array vorkommt. Da das Array nur ein Element enthält (die Zeichenfolge "<script>"), gibt die Funktion -1 zurück. Dadurch wird eine mögliche Bereinigung dieses Zeichens verhindert.

Zusammenfassung der JavaScript-Grundlagen

Fassen wir die bisherigen Erkenntnisse zusammen:

  • Haben Operanden unterschiedliche Typen, gibt der Operator === immer false zurück.

  • Beim Zugriff auf Objekteigenschaften lassen sich Schlüssel unterschiedlicher Typen verwenden (bei Verwendung der Klammernotation).

  • Einige integrierte Methoden, die sowohl für String als auch für Array definiert sind (wie indexOf oder includes), können sich je nach Eingabetyp unterschiedlich verhalten.

Bevor wir fortfahren, sehen wir uns einige Szenarien an, in denen bestimmte der oben genannten Methoden zur Validierung von Benutzereingaben verwendet werden.

Die folgende Prüfung reicht möglicherweise nicht aus, um zu verhindern, dass der Schlüssel isAdmin für das Objekt obj festgelegt wird (wir gehen davon aus, dass die Variable prop vom Benutzer kontrolliert wird):

let prop = ["isAdmin"] // user controlled

let obj = {}

if(prop === "isAdmin") { // ["isAdmin"] === "isAdmin" -> false
    throw new Error("Ops!");
} else {
    obj[prop] = true; // obj[["isAdmin"]] is equivalent to obj["isAdmin"]
}

console.log(obj["isAdmin"]) // true

Wird der Wert von prop in "isAdmin" geändert, löst das obige Beispiel eine Ausnahme aus.

Das folgende Beispiel zeigt eine weitere „schwache“ Prüfung, mit der potenzielles XSS verhindert werden soll, indem nach gefährlichen Zeichen in der Eingabe gesucht wird:

let user = ["<img src=x onerror='alert(1)'/>"] // user controlled

if (user.indexOf("<") || user.indexOf(">")){
    throw new Error("Characters not allowed!");
}

message = "Hello : " + user
console.log(message) // Hello : <img src=x onerror='alert(1)'/>

Wichtig ist, dass diese Umgehungen nur unter bestimmten Bedingungen möglich sind. Beispielsweise muss die Eingabe aus bestimmten Quellen stammen (siehe nächster Abschnitt), und auf die Eingabe dürfen keine integrierten Methoden angewendet werden, die ausschließlich für String definiert sind. Andernfalls gibt die Anwendung einen Fehler bzw. eine Ausnahme zurück (da diese Methoden für andere Typen nicht definiert sind).

Im folgenden Beispiel wird die Methode toLowerCase() auf die Eingabe angewendet. Zwar haben wir bereits gesehen, dass sich der Vergleich === mit einem Array-Wert umgehen lässt; wird jedoch ein Array übergeben, löst die Anwendung eine Ausnahme aus, da die Methode toLowerCase() nur für Zeichenfolgen definiert ist.

let prop = ["isAdmin"] // user controlled

let obj = {}

prop = prop.toLowerCase() // Uncaught TypeError: toLowerCase is not a function (it's only defined on String)

if(prop === "isAdmin") {
   throw new Error("Ops!");
} else {
   obj[prop] = true;
 }

console.log(obj["isAdmin"])

Array-Werte erhalten

Vielleicht fragen Sie sich an dieser Stelle, wie sich Array-Werte aus entfernten Daten beziehen lassen.

Es gibt verschiedene Möglichkeiten, Array-Werte zu erhalten:

  • Serverseitig: Bei Verwendung des beliebten Express-Frameworks können Werte aus req.query oder req.body (wenn die Middleware express.json() verwendet wird) auch als Arrays oder Objekte geparst werden, nicht nur als Zeichenfolgen.

  • Clientseitig: Daten aus der postMessage-API.

Serverseitig

Bei GET-Anfragen lassen sich bei Verwendung des beliebten Express-Frameworks Array-Werte über die Notation parameter[]=value beziehen, wenn der Wert aus req.query stammt. Allgemein gilt dies für jede Anwendung, die die beliebte Bibliothek qs zum Parsen des querystring verwendet.

Bei POST-Anfragen wird der Request-Body als JSON-Objekt geparst, wenn die Middleware express.json() verwendet wird.

Die folgende Express-Anwendung zeigt, wie sich Array-Werte beziehen lassen:

const express = require('express')
const app = express()
const port = 3000

// curl -i -X GET "https://localhost:3000/test1?path[0][]=foo&path[]=bar&val=test"
app.get('/test1', (req, res) => {
    console.log(typeof req.query.path, req.query.path); // object [ [ 'foo' ], 'bar' ]   
    res.send('Hello World!')
})

app.use(express.json());

// curl -i -H "Content-Type: application/json" -X POST --data '{"path":[["foo"], "bar"], "val":"test"}' "https://localhost:3000/test2"
app.post('/test2', (req, res) => {
    console.log(typeof req.body.path, req.body.path); // object [ [ 'foo' ], 'bar' ]   
    res.send('Hello World!')
})

app.listen(port, () => {
    console.log(`App listening at https://localhost:${port}`)
})

Clientseitig

Daten aus der postMessage-API können ebenfalls unterschiedliche Typen haben, nicht nur Zeichenfolgen:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
  postMessage example
  <script>
      window.addEventListener('message', function(event) {
          let name = event.data.name
          console.log(typeof name, name)
      });
  </script>
</body>
</html>

Um den obigen Code zu testen, öffnen Sie die Entwicklerkonsole des Browsers und führen Sie Folgendes aus: window.postMessage({name: ["array"]}, "*"). Die folgende Ausgabe sollte angezeigt werden:

Browser-Entwicklerkonsole mit einem postMessage-Beispiel und einem Objekt, das einen Array-Wert enthält.

Ergebnisse

Im Rahmen dieser Recherche haben wir unter anderem folgende Probleme gefunden und offengelegt:

Modul

Schwachstelle

Snyk Advisory

CVE

object-path

Prototype Pollution

CVE-2021-23434

immer

Prototype Pollution

CVE-2021-23436

mpath

Prototype Pollution

CVE-2021-23438

set-value

Prototype Pollution

CVE-2021-23440

edge.js

Cross-Site-Scripting (XSS)

CVE-2021-23443

jointjs

Prototype Pollution

CVE-2021-23444

datatables.net

Cross-Site-Scripting (XSS)

CVE-2021-23445

teddy

Cross-Site-Scripting (XSS)

CVE-2021-23447

Wir haben mehrere Maintainer verantwortungsvoll und vertraulich kontaktiert und uns dabei an unsere Vulnerability Disclosure gehalten (einige Funde befinden sich noch im Offenlegungsverfahren).

Vor allem möchten wir allen kontaktierten Maintainern für ihre Zeit danken.

In den folgenden Fallstudien erklären wir, wie sich eine Korrektur gegen Prototype Pollution und eine XSS-Eingabevalidierung durch die Übergabe von Array-Werten umgehen ließen.

Fallstudie: Prototype Pollution in object-path

object-path ist eine Bibliothek, mit der sich über einen Pfad auf tief verschachtelte Eigenschaften zugreifen lässt. Die Bibliothek akzeptiert auch Array-Pfade. Das bedeutet, dass Pfade in der Form ['part1', 'part2', etc.] angegeben werden können. Die Bibliothek war bereits von einer Prototype-Pollution-Schwachstelle betroffen (CVE-2020-15256). Die eingeführte Korrektur lautet:

if (options.includeInheritedProps && (currentPath === '__proto__' ||
  (currentPath === 'constructor' && typeof currentValue === 'function'))) {
  throw new Error('For security reasons, object\'s magic properties cannot be set')
}

Diese Korrektur löst korrekt eine Ausnahme aus, wenn der Pfad eine dieser gefährlichen Komponenten enthält (und diese Zeichenfolgen sind). Bei der folgenden Nutzlast löst die Funktion tatsächlich einen Fehler aus:

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, ['__proto__', 'polluted'], 'yes');
console.log(polluted); // Error: For security reasons, object's magic properties cannot be set

Wie wir jedoch bereits gesehen haben:

  • Beim Zugriff auf Objekteigenschaften über die Klammernotation können Schlüssel beliebigen Typs verwendet werden.

  • Der Operator === gibt false zurück, wenn die Operanden unterschiedliche Typen haben. Die Bedingung currentPath === '__proto__' gibt false zurück, wenn currentPath den Wert ['__proto__'] hat (dasselbe gilt für constructor).

Das bedeutet: Wird der gefährliche Schlüssel in ein Array mit nur einem Element (dem Schlüssel selbst) eingeschlossen, lässt sich die Korrektur umgehen und es kommt weiterhin zu Prototype Pollution:

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, [['__proto__'], 'polluted'], 'yes');
console.log(polluted); // yes

Abhilfemaßnahmen

Snyk kontaktierte den Maintainer am 25. August 2021 vertraulich. Das Problem wurde am 27. August 2021 umgehend in v0.11.6 behoben. Die eingeführte Korrektur verhindert dieses Szenario, indem sie die Pfadkomponenten vor der Prüfung in Zeichenfolgen umwandelt (sofern sie nicht vom Typ Number oder String sind):

var currentPath = path[0];
if (typeof currentPath !== 'string' && typeof currentPath !== 'number') {
  currentPath = String(currentPath)
}
var currentValue = getShallowProperty(obj, currentPath);
if (options.includeInheritedProps && (currentPath === '__proto__' ||
  (currentPath === 'constructor' && typeof currentValue === 'function'))) {
  throw new Error('For security reasons, object\'s magic properties cannot be set')
}

Die vorherige Payload funktioniert nicht mehr:

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, [['__proto__'], 'polluted'], 'yes');
console.log(polluted); // Error: For security reasons, object's magic properties cannot be set

Fallstudie: Cross-Site-Scripting (XSS) in edge.js

edge.js ist eine Templating-Engine für Node.js. Diese Bibliothek bietet integrierte Funktionen, die aktiviert werden können, um Sicherheitslücken wie beispielsweise XSS zu verhindern. Laut der Dokumentation wird „die Ausgabe von Interpolationen (der Code innerhalb der geschweiften Klammern) HTML-escaped, um XSS-Angriffe zu verhindern“. Die ursprüngliche Funktion, die für das Escaping gefährlicher HTML-Zeichen zum Schutz vor XSS zuständig ist, lautet:

// https://github.com/edge-js/edge/blob/1ade7fbb81fbc1b52757650214d6baca140d3eb0/src/Template/index.ts#L183
public escape<T>(input: T): T extends SafeValue ? T['value'] : T {
  return typeof input === 'string'
    ? string.escapeHTML(input)
    : input instanceof SafeValue
    ? input.value
    : input
}

Wie Sie sehen, wird input nur dann HTML-escaped, wenn es vom Typ string ist. Das bedeutet: Ist die benutzergesteuerte Eingabe vom Typ object (z. B. ein Array) und kein SafeValue, wird sie auch bei Verwendung von {{ }} im Template ohne Escaping zurückgegeben (ohne Fehler oder Ausnahme). Das kann zu einer XSS-Sicherheitslücke führen.

Um zu zeigen, wie sich dies ausnutzen lässt, sehen wir uns die folgende Webanwendung an, die diese Bibliothek als Template-Engine verwendet, um einen benutzergesteuerten Wert in einem Template darzustellen:

const express = require('express')
const app = express()
const port = 3000
const { join } = require('path')
const edge = require('edge.js').default

edge.mount(join(__dirname, 'views'))

// curl -i -X GET "https://localhost:3000/test?name[]=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E"
app.get('/test', (req, res) => {
    let n = req.query.name

    console.log(typeof n, n);

    edge.render('welcome', {
      greeting: n
    }).then(html => res.send(html))
})

app.listen(port, () => {
    console.log(`App listening at https://localhost:${port}`)
})

Die Datei views/welcome.edge enthält Folgendes:

<p> {{ greeting }} </p>

Wie bereits erwähnt, können Daten aus bestimmten Quellen auch unterschiedliche Typen haben. Wenn Sie die URL http://localhost:3000/test?name=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E aufrufen, escaped die Funktion die Eingabe. Über diese andere URL (achten Sie auf die Klammern []) http://localhost:3000/test?name[]=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E lässt sich jedoch XSS auslösen, da der Parameter req.query.name als Array geparst und deshalb nicht escaped wird.

Behebung

Snyk kontaktierte den Maintainer am 1. September 2021 vertraulich, und das Problem wurde umgehend in v5.3.2 behoben. Der eingeführte Fix berücksichtigt dieses Szenario, indem alle Eingaben escaped werden, die nicht vom Typ SafeValue sind:

export function escape(input: any): string {
  return input instanceof SafeValue ? input.value : string.escapeHTML(String(input))
}

Eine mögliche Alternative, um solche Szenarien zu verhindern, besteht darin, die Eingabe vor dem Escaping in einen String umzuwandeln (und so eine Typprüfung wie im obigen Fall zu vermeiden). Alternativ kann eine leere Zeichenfolge (oder eine Fehlermeldung) zurückgegeben werden, wenn die Eingabe nicht vom Typ String ist.

Wichtige Erkenntnisse für …

Entwickler

Wenn Sie den Operator === für eine Bereinigung verwenden, müssen Sie sicherstellen, dass beide Operanden denselben Typ haben. Beim Escaping von Eingaben muss außerdem berücksichtigt werden, dass die Eingabe möglicherweise nicht vom Typ String ist (insbesondere, wenn das Verhalten der Funktion bei Nicht-String-Werten nicht dokumentiert ist).

Maintainer

Wenn eine Funktion oder API für die Bereinigung von Eingaben zuständig ist, aber deren Typ nicht behandelt wird, sollte dieses Verhalten dokumentiert werden, damit es nicht zu Verwirrung kommt. Ein Maintainer behandelt möglicherweise einige Probleme bei der Bereinigung (etwa das Escaping von Eingaben, wenn es sich um Strings handelt), aber nicht andere (den Umgang mit Eingaben, die keine Strings sind).

Sicherheitsforscher

Wir haben uns auf Prototype Pollution und XSS-Sicherheitslücken konzentriert. Wir sind jedoch überzeugt, dass es weitere Fälle und Szenarien geben könnte, in denen sich dieser Angriffsvektor nutzen lässt, um vorhandene Bereinigungsmaßnahmen zu umgehen und potenzielle Sicherheitsprobleme zu verursachen. Wenn Sie ähnliche Probleme (oder andere Sicherheitslücken) in einem Open-Source-Projekt finden, das von unserem Programm unterstützt wird, können Sie diese gerne über das Snyk Vulnerability Disclosure-Formular an uns melden.

Referenzen

Starten Sie mit Capture-the-Flag

Erfahren Sie in unserem virtuellen On-Demand-Workshop für Einsteiger, wie Sie Capture-the-Flag-Challenges lösen.

Weiterlesen

Blog

Frontier-Modelle fanden die Schwachstellen. Nur der Angreifer fand die Angriffsketten.

Die statische Analyse fand die Schwachstellen, doch erst Live-Angriffstests bewiesen, wie sie sich zu Angriffen verketten lassen. Ein Vergleich von Evo COS, Claude Security und Claude Code Security.

feature insights context
Blog

Autonome Angriffe sind bereits Realität. Die Verteidigung muss Schritt halten.

Autonome Angreifer verkürzen das Zeitfenster für die Verteidigung. Erfahren Sie, wie kontinuierliches Erkennen, Beheben, Validieren und Verhindern Sicherheitsteams helfen kann, Schritt zu halten.

Blog

Warum KI-Coding-Agenten immer wieder fehlerhafte Zugriffskontrollen schreiben

KI-Coding-Agenten können Autorisierungslogik erzeugen, die kompiliert und die Prüfung besteht, dabei aber die Daten eines Mandanten für einen anderen offenlegt. Erfahren Sie, warum fehlerhafte Zugriffskontrollen schwer zu erkennen sind und wie Sie sie verhindern.