Por qué rechazamos la mayoría de los reportes de vulnerabilidades en Lodash

Escrito por Ulises Gascón

Jul 22, 202610 min read

Cerca del 95% de los reportes de vulnerabilidades que recibimos en Lodash se rechazan. No por poner barreras, ni porque no estemos de acuerdo con los hechos. La mayoría vienen de personas que de verdad intentan hacer el ecosistema más seguro, y muchos describen un problema de seguridad real: la prueba de concepto funciona y algo se rompe. Aun así los cerramos, porque una prueba de concepto que funciona no es lo mismo que una vulnerabilidad en la librería. El fallo existe, pero vive en la aplicación, en el runtime de JavaScript o en el comportamiento que Lodash tiene documentado.

En El futuro de Lodash trazamos un plan para reconstruir la postura de seguridad del proyecto, y buena parte ya es una realidad: un threat model público, un equipo de seguridad y CVEs emitidos a través del CNA de la OpenJS Foundation. Nada de eso, por sí solo, decide dónde encaja un reporte concreto: el threat model marca la línea, pero cada reporte hay que revisarlo contra ella caso por caso. El resto de este post trata de cómo trazamos esa línea, para que puedas ver dónde cae tu propio hallazgo antes de abrir un reporte.

Los números

Desde que activamos los GitHub security advisories, la bandeja de entrada tiene esta pinta:

  • 50 reportes solo este año, de 64 en total y muchos ya asistidos por IA.
  • Cerca del 95% cerrados como duplicados, inválidos o fuera del alcance.
  • 3 acabaron en CVE: dos de prototype pollution (_.unset y _.omit) y una de inyección de código a través de imports de _.template.

Qué cuenta como un bug de Lodash

Medimos cada reporte contra el threat model de Lodash. Su idea central es la responsabilidad compartida: algunas cosas debe hacerlas bien la librería, y otras son del consumidor. Si te interesa el contexto, mi charla What is a Vulnerability and What's Not? lo recorre en detalle. Tomamos mucho prestado de los threat models de Node.js y Express, pero el nuestro se resume en una sola frase:

Lodash es una librería de utilidades que opera por completo dentro del límite de confianza de quien la llama. Las vulnerabilidades dentro del alcance se limitan a los casos en los que Lodash no cumple su comportamiento documentado ante input no confiable, sin asumir el compromiso de componentes de confianza como el runtime, el sistema operativo o el propio código de la aplicación que la invoca.

Un fallo en cualquiera de esos componentes de confianza queda fuera del alcance, por muy real que sea el impacto. Lodash confía en cinco cosas:

  1. El runtime de JavaScript. Si puedes reproducir tu hallazgo sin Lodash, probablemente sea un bug del runtime (V8, SpiderMonkey, el core de Node), no de la librería.
  2. El entorno anfitrión. Si los built-ins del propio JavaScript (Object, Array, Function) fueron manipulados antes de que Lodash se cargara, el compromiso está aguas arriba.
  3. El código que la llama. La aplicación que invoca a Lodash es la capa de validación de input; Lodash no decide qué es seguro pasarle.
  4. Las dependencias y el código de alrededor. Los typosquats, los paquetes manipulados o el código que sobrescribe o envuelve a Lodash están todos aguas arriba, no son bugs de la librería.
  5. Los privilegios del proceso. Lodash hereda los que tenga el proceso; ejecutar como root es una cuestión operativa.

De lo que Lodash no se fía es de los datos que pasas a sus funciones. No puede saber qué claves son sensibles en tu aplicación, qué rutas controlan tus usuarios o qué cadenas de plantilla son estáticas en tu código. Esas decisiones son tuyas. Si consigues que Lodash rompa su comportamiento documentado con un input que quien la llama podría pasarle de forma razonable, eso es un bug de la librería. Todo lo demás es tarea de la aplicación.

Nada de esto significa que la vulnerabilidad no sea real. Que un reporte quede fuera del alcance de Lodash no cierra el agujero en tu aplicación. Por eso importa la responsabilidad compartida del threat model: la mitad que es tuya no desaparece porque Lodash no la asuma. Saber dónde cae esa línea es lo que mantiene a salvo a tus usuarios, no el CVE.

Los patrones que seguimos cerrando

Con el tiempo, los mismos reportes rechazados vuelven una y otra vez con dos formas: una denegación de servicio (DoS) y una prototype pollution. Cada hallazgo es real, pero el arreglo está en la aplicación, no en Lodash.

El DoS que tumba la aplicación

Los reportes de DoS llegan de varios tipos: un objeto muy anidado que se pasa a _.merge o _.cloneDeep, una cadena patológica que se le da a una expresión regular, un input lo bastante grande como para agotar la memoria. Todos comparten una misma forma. Una operación normal funciona hasta que el input llega al extremo de su rango, y entonces el proceso muere con algo como RangeError: Maximum call stack size exceeded.

Ese extremo no es exclusivo de Lodash. Toda abstracción tiene uno. Pruébalo tú mismo: abre la consola del navegador y ejecuta esto, sin ninguna librería de por medio:

const copy = []

// Un array de tamaño normal se copia sin problema
copy.push(...Array.from({ length: 1000 }, (_, i) => i))

// La misma llamada sobre uno grande revienta la pila
copy.push(...Array.from({ length: 500000 }, (_, i) => i))
// RangeError: Maximum call stack size exceeded

La primera llamada funciona. La segunda convierte medio millón de elementos en medio millón de argumentos, y la pila cede. Array.prototype.push no es vulnerable, y el operador spread tampoco. Tienen un límite, y un input lo bastante grande lo encuentra.

Que llegar a ese extremo sea una vulnerabilidad se reduce a una pregunta: ¿rompe un uso normal y razonable? Si Lodash se cayera con inputs corrientes, eso sí sería un bug nuestro, y lo arreglaríamos. Pero un objeto muy anidado construido para agotar la pila, o una regex diseñada para hacer backtracking sin fin, no es un input corriente; es un payload dirigido a ese extremo. Y "razonable" es subjetivo. Solo la aplicación puede definirlo, porque Lodash es de propósito general y no puede saber qué profundidad de anidamiento es normal para tu API y cuál es un ataque.

Así que las barreras de protección viven una capa más arriba: limita el tamaño del cuerpo de las peticiones, rechaza inputs que pasen de una profundidad máxima, pon timeouts por petición. Y si tu aplicación de verdad necesita funcionar a esa escala, trátalo como el caso límite que es y somételo a pruebas de estrés, porque un objeto con medio millón de propiedades se romperá en más sitios que Lodash: tu serializador, el driver de tu base de datos, el parser de JSON que lo construyó. El fallo de _.merge suele ser lo primero que cede, no lo único.

El PoC de prototype pollution que funciona

Aquí tienes un PoC de prototype pollution simplificado, de esos de manual (para un recorrido más amplio de cómo funcionan estos bugs, mira la charla de Olivier Arteau Prototype Pollution Attacks in Node.js Applications). A primera vista parece una contaminación global. Abre la consola del navegador y ejecútalo, sin ninguna librería, y fíjate en dónde aterriza de verdad la escritura:

const data = JSON.parse('{"__proto__":{"polluted":"yes"}}')
const c = Object.assign({}, data)

// ¡parece contaminado!
console.log(c.polluted) // 'yes'

c.polluted es de verdad 'yes', así que a primera vista el PoC funciona. Ahora mira más de cerca:

// los demás objetos están bien
console.log(({}).polluted) // undefined
// el prototipo global está intacto
console.log(Object.prototype.polluted) // undefined
// solo cambió el prototipo de c
console.log(Object.getPrototypeOf(c) === Object.prototype) // false

No pasó nada a nivel global. La clave __proto__ activó el setter del prototipo, así que solo c recibió un nuevo prototipo; Object.prototype y todos los demás objetos quedan intactos. Dónde aterriza la escritura es lo que la convierte en vulnerabilidad, y esta aterrizó en un único objeto desechable.

Eso no exculpa a quien la llama. Si un input no confiable que reenviaste llega a Object.prototype, el threat model tiene claro de quién es el bug:

Si un desarrollador fusiona intencionadamente input de usuario en objetos globales o no aísla sus estructuras de datos, eso es un mal uso de la API documentada de Lodash, no un defecto de Lodash.

Así que revisa primero tu propio código: de dónde vinieron los datos y si los aislaste.

El caso contrario sí es un bug real de la librería: que ese mismo input no confiable llegue a un prototipo compartido usando solo lo que la API documenta. Eso sí merece la pena reportarlo.

Cómo es un reporte "válido" de verdad

Veamos uno válido, CVE-2019-10744. En Lodash 4.17.0, defaultsDeep recorría una ruta constructor.prototype directa hasta el prototipo compartido:

// Lodash 4.17.0 (CVE-2019-10744)
const _ = require('lodash')
const payload = '{"constructor":{"prototype":{"polluted":true}}}'
_.defaultsDeep({}, JSON.parse(payload))

const newObject = {}
console.log(newObject.polluted)        // true, en cada objeto del proceso
console.log(Object.prototype.polluted) // true, el prototipo global cambió de verdad

Estos son los reportes sobre los que actuamos: Lodash rompiendo su propio contrato documentado, sin ningún mal uso de la aplicación que lo explique. Merece la pena echar un vistazo a algunos otros, de entre todos los CVE de Lodash registrados:

Antes de reportar, hazte la pregunta correcta

Distinguir tu propio hallazgo de los de arriba se reduce a una sola pregunta:

¿Puedes decir, en una sola frase, qué hizo mal Lodash con un input que la aplicación tenía razón en pasarle?

Si puedes, probablemente tengas un reporte real. Si no, lo más seguro es que se cierre como fuera del alcance. Estas cuatro comprobaciones son la forma de llegar a esa frase:

  1. ¿Dónde controla el input el atacante? Si la aplicación lo reenvió, el bug es de la aplicación.
  2. ¿La API está documentada como insegura con input no confiable? Si es así, ya se conoce, no es nuevo.
  3. ¿JavaScript a secas mostraría el mismo comportamiento? Si es así, no es un bug de Lodash.
  4. ¿Necesita un segundo bug para ser explotable? Si es así, reporta mejor ese otro bug.

Aprendo mucho de cada reporte que nos envías, sea válido o no, y cada uno nos ha ayudado a trazar esta línea con más claridad. Espero que este artículo te ayude a ver dónde cae el tuyo. Si puedes escribir esa frase, abre un reporte. La puerta está abierta, y gracias por enviarlos.