Skip to main content

Confusión de tipos en JavaScript: cómo evadir la validación de entrada (y cómo corregirlo)

Escrito por
Headshot of Alessio Della Libera

Alessio Della Libera

blog feature snyk open source blue

3 de noviembre de 2021

0 minutos de lectura

En una publicación anterior del blog, mostramos cómo la manipulación de tipos (o confusión de tipos) puede usarse para escapar de los entornos aislados de plantillas y provocar vulnerabilidades de cross-site scripting (XSS) o de inyección de código.

Uno de los principales objetivos de esta investigación fue explorar (en el ecosistema de JavaScript) cómo y si es posible evadir algunas correcciones de seguridad o validaciones de entrada mediante un ataque de confusión de tipos (es decir, al proporcionar un tipo de entrada inesperado).

Comenzamos nuestra investigación analizando cómo se corrigen vulnerabilidades de seguridad comunes, como la contaminación de prototipos y XSS. Para ello, aprovechamos los datos de nuestra base de datos de vulnerabilidades Snyk Intel, la mayor base de datos de vulnerabilidades de código abierto de la industria. Gracias a esta información, pudimos identificar algunos patrones que distintos responsables de mantenimiento usan para prevenir ciertas clases de vulnerabilidades.

Ahora es momento de analizar las vulnerabilidades por confusión de tipos. En esta publicación del blog, buscamos mostrar escenarios comunes en los que es posible evadir la sanitización y validación de entradas al proporcionar un tipo de entrada inesperado. También compartiremos ejemplos de corrección y sugerencias para quienes mantienen proyectos de código abierto y buscan solucionar este tipo de evasiones de la validación de entradas.

Conceptos básicos de JavaScript

Antes de hablar de cómo un valor de tipo arreglo puede usarse para evadir algunas validaciones de entrada y provocar una posible vulnerabilidad de seguridad, repasemos algunos conceptos fundamentales sobre el funcionamiento de JavaScript. Serán muy útiles más adelante, especialmente cuando analicemos el caso de estudio sobre contaminación de prototipos.

Comparación de valores: === y ==

En JavaScript, hay dos operadores diferentes que se pueden usar para comparar valores:

  • Igualdad estricta ===

  • Igualdad no estricta ==

Una de las principales diferencias entre ellos es que el operador === siempre devuelve false si los operandos son de tipos diferentes (no realiza ninguna forma de coerción). En el caso del operador ==, si los operandos son de tipos diferentes, primero se convierten a un tipo común y luego se comparan. Para obtener más detalles sobre el funcionamiento de estos operadores, consulta la documentación oficial del operador de igualdad estricta y del operador de igualdad no estricta.  

El siguiente ejemplo muestra este comportamiento:

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!

Cuando se comparan operandos con el mismo valor y tipo, los operadores == y === devuelven true (líneas 2 y 3). Sin embargo, si comparamos un arreglo cuya representación como cadena coincide con la del otro operando, solo devolverá true si se usa el operador ==. En la línea 6, vemos que el valor ["test"] primero se convierte a su representación como cadena y luego se compara con el otro operando "test". Cuando los operandos son de tipos diferentes, el operador === siempre devuelve false (línea 7).

Nota: Lo anterior también se aplica a los operadores de desigualdad !== y !=.

Algunas formas de obtener la representación como cadena de un valor son:

  • llamar al método toString() (línea 2)

  • convertir a una cadena mediante la función String (línea 2)

  • concatenar el valor con una cadena vacía mediante el operador + (línea 3)

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

En el caso de un valor de tipo arreglo, su representación como cadena es la concatenación de sus elementos separados por comas.

Accesor de propiedades

Otra característica importante de JavaScript es que, al acceder a las propiedades de un objeto mediante la notación de corchetes, es posible especificar valores de distintos tipos (no solo cadenas). Si el valor no es una cadena (ni un Symbol), primero se convierte a una cadena y luego se usa para acceder a la propiedad del objeto.

Veamos el siguiente ejemplo:

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

Como podemos ver, la clave usada para acceder a una propiedad puede ser de distintos tipos. En la línea 4, la propiedad ["test"] primero se convierte a una cadena (es decir, al valor "test") y el resultado de esta operación se usa como clave para acceder a la propiedad del objeto. De hecho, si usamos la cadena "test" en la línea 6, podemos obtener el valor asignado en la línea 4.

Del mismo modo, si usamos un objeto vacío {} (línea 9), cuya representación como cadena es [object Object], para escribir un valor en un objeto, luego podemos acceder al mismo valor usando directamente la cadena [object Object] (línea 11).

Métodos de String y Array

Hay algunos métodos integrados definidos tanto en String como en Array que tienen el mismo nombre. Algunos ejemplos son includes, indexOf, lastIndexOf, etc.

¿Por qué es relevante esto para esta publicación del blog? Estos métodos se comportan de forma diferente según el tipo de entrada al que se aplican. Si se usa uno de estos métodos para validar la entrada del usuario, la validación podría ser vulnerable a una posible evasión si se proporciona una entrada de otro tipo (por ejemplo, un arreglo) y no se realizan comprobaciones.

El siguiente ejemplo muestra cómo se comportan de forma diferente los métodos integrados definidos tanto en String como en Array:

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

Nota: Todos los valores del ejemplo ("<script>", ["<script>"] y [["<script>"]]) tienen la misma representación como cadena.

Por ejemplo, en la línea 3, el método que se llama es String.prototype.indexOf(). Este método comprueba si el carácter < está en la cadena y luego devuelve el índice de su primera aparición; de lo contrario, devuelve -1.

Sin embargo, si la entrada es un arreglo, el método que se llama es Array.prototype.indexOf(), que comprueba si el elemento < está en el arreglo. Como el arreglo solo tiene un elemento (la cadena "<script>"), la función devuelve -1, lo que impide la posible sanitización de ese carácter.

Resumen de conceptos fundamentales de JavaScript

Resumamos lo que hemos visto:

  • Si los operandos son de tipos diferentes, el operador === siempre devuelve false

  • Es posible usar claves de distintos tipos para acceder a las propiedades de objetos (cuando se usa la notación de corchetes)

  • Algunos métodos integrados definidos tanto en los tipos String como Array (como indexOf o includes) pueden comportarse de forma diferente según el tipo de entrada

Antes de continuar, veamos los siguientes escenarios en los que se usan algunos de los métodos anteriores para validar la entrada del usuario.

La siguiente comprobación podría no ser suficiente para impedir que se establezca la clave isAdmin en el objeto obj (suponemos que el usuario controla la variable prop):

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

Si el valor de prop cambia a "isAdmin", el ejemplo anterior generará una excepción.

A continuación, se muestra otro ejemplo de una comprobación «débil» que busca prevenir un posible XSS detectando si la entrada contiene ciertos caracteres peligrosos:

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)'/>

Es importante tener en cuenta que estas evasiones solo son posibles en determinadas condiciones. Por ejemplo, la entrada debe provenir de fuentes específicas (consulta la siguiente sección) y no se deben llamar en ella métodos integrados definidos únicamente en String; de lo contrario, la aplicación devolverá un error o una excepción (porque no están definidos para otros tipos).

En el siguiente ejemplo, se llama al método toLowerCase() en la entrada. Aunque ya vimos que es posible evadir la comparación === con un valor de tipo arreglo, si se proporciona un arreglo, la aplicación generará una excepción porque el método toLowerCase() solo está definido para cadenas.

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"])

Cómo obtener valores de tipo arreglo

En este punto, quizá te preguntes cómo podemos obtener valores de tipo arreglo a partir de datos remotos.

Hay distintas formas de obtener valores de tipo arreglo:

  • Del lado del servidor: Si usas el popular framework express, los valores de req.query o req.body (si se usa el middleware express.json()) también pueden analizarse como arreglos u objetos (no solo como cadenas).

  • Del lado del cliente: Datos provenientes de la API postMessage.

Del lado del servidor

En las solicitudes GET, si usas el popular framework express y el valor proviene de req.query, es posible obtener valores de tipo arreglo mediante la notación parameter[]=value. En general, esto puede aplicarse a cualquier aplicación que use la popular biblioteca qs para analizar la querystring.

En las solicitudes POST, si se usa el middleware express.json(), el cuerpo de la solicitud se analiza como un objeto JSON.

La siguiente aplicación express muestra cómo es posible obtener valores de tipo arreglo:

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}`)
})

Del lado del cliente

Los datos provenientes de la API postMessage también pueden ser de distintos tipos (no solo cadenas):

<!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>

Para probar el código anterior, abre la consola de desarrollador del navegador y ejecuta: window.postMessage({name: ["array"]}, "*"). Deberías obtener el siguiente resultado:

Consola de desarrollador del navegador que muestra un ejemplo de postMessage con un objeto que contiene un valor de tipo matriz.

Resultados

Como parte de esta investigación, algunos de los problemas que encontramos y divulgamos son:

Módulo

Vulnerabilidad

Aviso de Snyk

CVE

object-path

Contaminación de prototipos

CVE-2021-23434

immer

Contaminación de prototipos

CVE-2021-23436

mpath

Contaminación de prototipos

CVE-2021-23438

set-value

Contaminación de prototipos

CVE-2021-23440

edge.js

Cross-site scripting (XSS)

CVE-2021-23443

jointjs

Contaminación de prototipos

CVE-2021-23444

datatables.net

Cross-site scripting (XSS)

CVE-2021-23445

teddy

Cross-site scripting (XSS)

CVE-2021-23447

Nos comunicamos de forma responsable y privada con varios responsables de mantenimiento, siguiendo nuestro proceso de divulgación de vulnerabilidades (algunos hallazgos aún están en proceso de divulgación).

Sobre todo, queremos agradecer a todos los responsables de mantenimiento con quienes nos comunicamos por su tiempo.

En los siguientes casos de estudio, explicaremos cómo fue posible evadir una corrección para la contaminación de prototipos y una validación de entrada contra XSS al proporcionar valores de tipo arreglo.

Caso de estudio: contaminación de prototipos en object-path

object-path es una biblioteca que se usa para acceder a propiedades anidadas mediante una ruta. La biblioteca también acepta una ruta en forma de arreglo, lo que significa que es posible proporcionar rutas con el formato ['part1', 'part2', etc.]. Esta biblioteca ya era vulnerable a la contaminación de prototipos (CVE-2020-15256). La corrección introducida es la siguiente:

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

Esta corrección genera correctamente una excepción cuando la ruta contiene uno de esos componentes peligrosos (y son cadenas). De hecho, con la siguiente carga, la función generará un error:

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

Sin embargo, como vimos antes:

  • Se puede usar cualquier tipo de clave para acceder a las propiedades de objetos mediante la notación de corchetes

  • El operador === devuelve false si los operandos son de tipos diferentes. La condición currentPath === '__proto__' devolverá false si currentPath es ['__proto__']  (lo mismo sucede con constructor).

Esto significa que envolver la clave peligrosa en un arreglo de un solo elemento (la clave misma) permitirá evadir la corrección y seguirá provocando contaminación de prototipos:

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

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

Corrección

Snyk se comunicó en privado con el responsable de mantenimiento el 25 de agosto de 2021 y el problema se corrigió rápidamente el 27 de agosto de 2021 en v0.11.6. La corrección introducida evita este escenario al convertir los componentes de la ruta a cadenas (si no son de tipo número o cadena) antes de comprobarlos:

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')
}

La carga útil anterior ya no funcionará:

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

Caso práctico: Cross-Site Scripting (XSS) en edge.js

edge.js es un motor de plantillas para Node.js. Esta biblioteca incluye funcionalidades que se pueden habilitar para prevenir vulnerabilidades de seguridad, como XSS. En particular, según la documentación, «el resultado de la interpolación (el código entre llaves) se escapa como HTML para evitar ataques XSS». La función original encargada de escapar los caracteres HTML peligrosos para prevenir XSS es la siguiente:

// 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
}

Como podemos ver, input solo se escapa como HTML si es de tipo string. Esto significa que, si la entrada controlada por el usuario es de tipo objeto (es decir, un arreglo) y no es un SafeValue, aunque se use {{ }} en la plantilla, se devuelve sin escapar (sin errores ni excepciones) y, por lo tanto, puede provocar una vulnerabilidad XSS.

Para demostrar cómo se puede explotar esto, veamos la siguiente aplicación web, que usa esta biblioteca como motor de plantillas para mostrar un valor controlado por el usuario en una plantilla:

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}`)
})

El contenido del archivo views/welcome.edge es el siguiente:

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

Como vimos antes, los datos provenientes de ciertas fuentes también pueden ser de distintos tipos. Al visitar la URL http://localhost:3000/test?name=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E, la función escapa la entrada. Sin embargo, al visitar esta otra URL (observa los paréntesis []) http://localhost:3000/test?name[]=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E, se puede provocar un XSS porque el parámetro req.query.name se analizará como un arreglo y, por lo tanto, no se escapará.

Solución

Snyk se puso en contacto de forma privada con la persona encargada del mantenimiento el 1 de septiembre de 2021, y el problema se solucionó rápidamente en v5.3.2. La corrección implementada aborda este escenario al escapar todas las entradas que no sean de tipo SafeValue:

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

Otras formas posibles de prevenir estas situaciones son convertir la entrada en una cadena antes de escaparla (y así evitar comprobar el tipo de entrada, como en el caso anterior) o devolver una cadena vacía (o un mensaje de error) si la entrada no es de tipo string.

Conclusiones para...

Desarrolladores

Al usar el operador === para realizar una sanitización, es importante asegurarse de que ambos operandos sean del mismo tipo. Al escapar entradas, también es importante manejar el caso en que la entrada no sea de tipo string (sobre todo si no está documentado cómo se comporta la función con valores que no son cadenas).

Responsables de mantenimiento

Si una función o API se encarga de sanitizar entradas y no contempla el tipo de entrada, conviene documentar este comportamiento para evitar confusiones. Es posible que la persona encargada del mantenimiento solucione algunos problemas de sanitización (como escapar la entrada si es una cadena), pero no otros (como manejar el caso en que la entrada no sea una cadena).

Investigadores de seguridad

Nos centramos en las vulnerabilidades de contaminación de prototipos y XSS, pero creemos que también puede haber otros casos o escenarios en los que se use este vector de ataque para eludir las sanitizaciones existentes y provocar posibles problemas de seguridad. Si encuentras problemas similares (o cualquier otra vulnerabilidad de seguridad) en un proyecto de código abierto que cuente con el apoyo de nuestro programa, no dudes en enviárnoslos mediante el formulario de divulgación de vulnerabilidades de Snyk.

Referencias

Empieza con los desafíos de Capture the Flag

Aprende a resolver desafíos de captura la bandera con nuestro taller virtual introductorio a pedido.

Leer más

blog feature snyk container custom base recommendations
Blog

Presentamos Evo Agentic AppSec: seguridad que sigue el ritmo del desarrollo con IA

El desarrollo impulsado por IA avanza más rápido que los flujos de trabajo de seguridad tradicionales. Agentic AppSec de Snyk usa agentes de seguridad para encontrar, corregir y verificar vulnerabilidades de forma continua, mientras las personas establecen el nivel.

blog feature ai violet
Blog

VulnBump: ¿Pueden los agentes de IA corregir dependencias vulnerables sin romper la compilación?

VulnBump prueba agentes de IA con 307 actualizaciones reales de dependencias. Una corrección cuenta solo si elimina la vulnerabilidad y la compilación y las pruebas siguen pasando.

Blog

Los modelos de frontera encontraron las vulnerabilidades. Solo el atacante encontró las cadenas.

El análisis estático encontró las fallas, pero solo las pruebas de ataque en vivo demostraron cómo podían encadenarse para provocar brechas. Una comparación de Evo COS, Claude Security y Claude Code Security.