Quiz: ¿puede defenderse de la vulnerabilidad XSS?

hace 2 años por David Grudl  

¡Pon a prueba tus conocimientos en el quiz de seguridad! ¿Puede evitar que un atacante tome el control de la página HTML?

En todas las tareas resolverá la misma pregunta: cómo imprimir correctamente la variable $str en la página HTML para que no surja una vulnerabilidad XSS. La base de la defensa es el escape, lo que significa reemplazar caracteres con significado especial por las secuencias correspondientes. Por ejemplo, al imprimir una cadena en texto HTML, donde el carácter < tiene un significado especial (señala el inicio de una etiqueta), lo reemplazamos por la entidad HTML &lt; y el navegador mostrará correctamente el símbolo <.

Tenga cuidado, porque la vulnerabilidad XSS es muy grave. Puede hacer que un atacante tome el control de la página o incluso de la cuenta de usuario. ¡Mucho éxito y que logre mantener segura la página HTML!

El primer trío de preguntas

Indique qué caracteres y de qué manera es necesario tratar en el primer, segundo y tercer ejemplo:

1) <p><?= $str ?></p>
2) <input value="<?= $str ?>">
3) <input value='<?= $str ?>'>

Si la salida no se tratara de ninguna manera, se convertiría en parte de la página mostrada. Si un atacante introdujera en la variable la cadena 'foo" onclick="evilCode()' y la salida no se tratara, provocaría que al hacer clic en el elemento se ejecutara su código:

$str = 'foo" onclick="evilCode()'
❌ sin tratar: <input value="foo" onclick="evilCode()">
✅ con tratamiento:  <input value="foo&quot; onclick=&quot;evilCode()">

Solución de los ejemplos individuales:

  1. los caracteres < y & representan el inicio de una etiqueta HTML y entidades, los reemplazamos por &lt; y &amp;
  2. los caracteres " y & representan el final del valor del atributo y el inicio de una entidad HTML, los reemplazamos por &quot; y &amp;
  3. los caracteres ' y & representan el final del valor del atributo y el inicio de una entidad HTML, los reemplazamos por &apos; y &amp;

Por cada respuesta correcta obtiene un punto. Por supuesto, en los tres casos se pueden reemplazar también otros caracteres por entidades, no pasa nada, pero no es necesario.

Pregunta n.º 4

Continuamos. ¿Qué caracteres es necesario reemplazar al imprimir la variable en este contexto?

<input value=<?= $str ?>>

Solución: Como puede ver, aquí faltan las comillas. Lo más fácil es simplemente añadir las comillas y luego escapar igual que en la pregunta anterior. Existe una segunda solución, que es reemplazar en la cadena por entidades HTML el espacio y todos los caracteres que tienen un significado especial dentro de la etiqueta, es decir, >, /, = y algunos otros.

Pregunta n.º 5

Ahora empieza a ser más interesante. ¿Qué caracteres es necesario tratar en este contexto?

<script>
	let foo = '<?= $str ?>';
</script>

Solución: Dentro de la etiqueta <script>, las reglas para el escape las determina JavaScript. Las entidades HTML no se usan aquí, sin embargo, aplica una regla especial. Entonces, ¿qué caracteres escapamos? Dentro de una cadena de JavaScript, escapamos por supuesto el carácter ', que la delimita, usando una barra inclinada, es decir, lo reemplazamos por \'. Dado que JavaScript no soporta cadenas multilínea (solo como template literal), debemos escapar también los caracteres de fin de línea. Pero cuidado, además de los caracteres habituales \n y \r, JavaScript considera como fines de línea también los caracteres unicode \u2028 y \u2029, que debemos escapar también. Y finalmente, la regla especial mencionada: en la cadena no debe aparecer </script. Esto se puede prevenir, por ejemplo, reemplazándolo por <\/script.

Si sabía esto, felicidades.

Pregunta n.º 6

El siguiente contexto parece solo una variación del anterior. ¿Cree que el tratamiento será diferente?

<p onclick="foo('<?= $str ?>')"></p>

Solución: Aquí también aplican las reglas para el escape en cadenas de JavaScript, pero a diferencia del contexto anterior, donde no se escapaba mediante entidades HTML, aquí sí se escapa. Es decir, primero escapamos la cadena de JavaScript usando barras inclinadas y luego reemplazamos los caracteres con significado especial (" y &) por entidades HTML. ¡Cuidado, el orden correcto es importante!

Como puede ver, ¡el mismo literal de JavaScript puede estar codificado de manera diferente en el elemento <script> y de manera diferente en el atributo!

Pregunta n.º 7

Volvemos de JavaScript a HTML. ¿Qué caracteres debemos reemplazar dentro del comentario y de qué manera?

<!-- <?= $str ?> -->

Solución: dentro de un comentario HTML (y XML), todos los caracteres especiales tradicionales, como <, &, " y ', pueden aparecer. Lo que está prohibido, y esto quizás le sorprenda, es el par de caracteres --. El escape de esta secuencia no está especificado, así que depende de usted cómo la reemplace. Puede intercalar espacios. O quizás reemplazarla por ==.

Pregunta n.º 8

Ya nos acercamos al final, así que intentaremos variar la pregunta. Intente reflexionar sobre qué hay que tener en cuenta al imprimir la variable en este contexto:

<a href="<?= $str ?>">...</a>

Solución: además del escape, es importante verificar también que la URL no contenga un esquema peligroso como javascript:, porque una URL construida de esta manera llamaría al código del atacante al hacer clic.

Pregunta n.º 9

Para terminar, una joya para los verdaderos entendidos. Es una muestra de una aplicación que utiliza un framework de JavaScript moderno, concretamente Vue. A ver si se le ocurre qué hay que tener en cuenta al imprimir la variable dentro del elemento #app:

<div id="app">
    <?= $str ?>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
const app = new Vue({
    el: '#app',
    ...
})
</script>

Este código crea una aplicación Vue que se renderizará en el elemento #app. Vue entiende el contenido de este elemento como su plantilla. Y dentro de la plantilla interpreta las llaves dobles, que significan la impresión de una variable o la llamada de código JavaScript (p.ej., {{ foo }}).

Por lo tanto, dentro del elemento #app, además de los caracteres < y &, también tiene un significado especial el par {{, que debemos reemplazar por otra secuencia correspondiente para que Vue no lo interprete como su etiqueta. Sin embargo, el reemplazo por entidades HTML no ayuda en este caso. ¿Cómo solucionarlo? Funciona un truco: entre las llaves insertamos un comentario HTML vacío {<!-- -->{ y Vue ignora tal secuencia.

Resultados del quiz

¿Cómo le fue en el quiz? ¿Cuántas respuestas correctas tiene? Si respondió correctamente al menos a 4 preguntas, pertenece al 8% de los mejores participantes. ¡Felicidades!

Sin embargo, para garantizar la seguridad de su sitio web, es esencial tratar correctamente la salida en todas las situaciones.

Si le sorprendió cuántos contextos diferentes pueden aparecer en una página HTML común, debe saber que no mencionamos ni de lejos todos. Eso sería un quiz mucho más largo. Sin embargo, no necesita ser un experto en escape en cada contexto si su sistema de plantillas puede manejarlo.

Así que vamos a probarlos.

¿Cómo les va a los sistemas de plantillas?

Todos los sistemas de plantillas modernos presumen de la función de autoescaping, que escapa automáticamente todas las variables impresas. Si lo hacen correctamente, su sitio web está seguro. Si lo hacen mal, el sitio web está expuesto al riesgo de vulnerabilidad XSS con todas sus graves consecuencias.

Probaremos los sistemas de plantillas populares de las preguntas de este quiz para averiguar cuán efectivo es su autoescaping. Comienza la prueba comparativa de sistemas de plantillas para PHP.

Twig ❌

El primero en la lista es el sistema de plantillas Twig (versión 3.5), que se usa más comúnmente en combinación con el framework Symfony. Le daremos la tarea de responder a todas las preguntas del quiz. La variable $str siempre estará llena de una cadena engañosa y veremos cómo maneja su impresión. Los resultados los ve a la derecha. También puede explorar en el playground sus respuestas y comportamiento.

   {% set str = "<'\"&" %}
1) <p>{{ str }}</p>
2) <input value="{{ str }}">
3) <input value='{{ str }}'>

   {% set str = "foo onclick=evilCode()" %}
4) <input value={{ str }}>

   {% set str = "'\"\n\u{2028}" %}
5) <script>	let foo = '{{ str }}'; </script>
6) <p onclick="foo('{{ str }}')"></p>

   {% set str = "-- ---" %}
7) <!-- {{ str }} -->

   {% set str = "javascript:evilCode()" %}
8) <a href="{{ str }}">...</a>

   {% set str = "{{ foo }}" %}
9) <div id="app"> {{ str }} </div>

✅ <p>&lt;&#039;&quot;&amp;</p>
✅ <input value="&lt;&#039;&quot;&amp;">
✅ <input value='&lt;&#039;&quot;&amp;'>


❌ <input value=foo onclick=evilCode()>


❌ <script> let foo = &#039;&quot;u{2028}; </script>
❌ <p onclick="foo(&#039;&quot;u{2028})"></p>


❌ <!-- -- --- -->


❌ <a href="javascript:evilCode()">...</a>


❌ <div id="app"> {{ foo }} </div>

¡Twig falló en seis de nueve pruebas!

Desafortunadamente, el escape automático de Twig funciona solo en texto y atributos HTML, y además solo si están encerrados entre comillas. Tan pronto como faltan las comillas, Twig no informa ningún error y crea un agujero de seguridad XSS.

Esto es particularmente desagradable porque así es como se escriben los valores de los atributos en librerías populares como React o Svelte. Un programador que usa simultáneamente Twig y React puede olvidar las comillas de forma bastante natural.

El autoescaping de Twig falla también en todos los demás ejemplos. En los contextos (5) y (6) es necesario escapar manualmente usando {{ str|escape('js') }}, para otros contextos Twig ni siquiera ofrece una función de escape. Tampoco dispone de protección contra la impresión de un enlace malicioso (8) ni soporte para plantillas de Vue (9).

Blade ❌❌

El segundo participante es el sistema de plantillas Blade (versión 10.9), que está estrechamente integrado con Laravel y su ecosistema. Nuevamente verificaremos sus capacidades en nuestras preguntas del quiz. También puede explorar en el playground sus respuestas.

   @php($str = "<'\"&")
1) <p>{{ $str }}</p>
2) <input value="{{ $str }}">
3) <input value='{{ $str }}'>

   @php($str = "foo onclick=evilCode()")
4) <input value={{ $str }}>

   @php($str = "'\"\n\u{2028}")
5) <script> let foo = {{ $str }}; </script>
6) <p onclick="foo({{ $str }})"></p>

   @php($str = "-- ---")
7) <!-- {{ $str }} -->

   @php($str = "javascript:evilCode()")
8) <a href="{{ $str }}">...</a>

   @php($str = "{{ foo }}")
9) <div id="app"> {{ $str }} </div>

✅ <p>&lt;&#039;&quot;&amp;</p>
✅ <input value="&lt;&#039;&quot;&amp;">
✅ <input value='&lt;&#039;&quot;&amp;'>


❌ <input value=foo onclick=evilCode()>


❌ <script>	let foo = &#039;&quot; ; </script>
❌ <p onclick="foo(&#039;&quot; )"></p>


❌ <!-- -- --- -->


❌ <a href="javascript:evilCode()">...</a>


❌❌ <div id="app"> &lt;?php echo e(foo); ?&gt; </div>

¡Blade falló en seis de nueve pruebas!

El resultado es similar al de Twig. Nuevamente aplica que el escape automático funciona solo en texto y atributos HTML y solo si están encerrados entre comillas. El autoescaping de Blade falla también en todos los demás ejemplos. En los contextos (5) y (6) es necesario escapar manually usando {{ Js::from($str) }}. Para otros contextos Blade ni siquiera ofrece una función de escape. No dispone de protección contra la impresión de un enlace malicioso (8) ni soporte para plantillas de Vue (9).

Lo que es sorprendente, sin embargo, es el fallo de la directiva @php en Blade, lo que provoca la impresión del propio código PHP directamente en la salida, como se ve en la última línea.

Smarty ❌❌❌

Ahora probaremos el sistema de plantillas más antiguo para PHP, que es Smarty (versión 4.3). Para gran sorpresa, este sistema no tiene activo el escape automático. Por lo tanto, al imprimir variables, debe indicar cada vez el filtro {$var|escape}, o activar el escape automático de HTML. La información sobre esto está bastante escondida en la documentación.

   {$str = "<'\"&"}
1) <p>{$str}</p>
2) <input value="{$str}">
3) <input value='{$str}'>

   {$str = "foo onclick=evilCode()"}
4) <input value={$str}>

   {$str = "'\"\n\u{2028}"}
5) <script>	let foo = {$str}; </script>
6) <p onclick="foo({$str})"></p>

   {$str = "-- ---"}
7) <!-- {$str} -->

   {$str = "javascript:evilCode()"}
8) <a href="{$str}">...</a>

   {$str = "{{ foo }}"}
9) <div id="app"> {$str} </div>

✅ <p>&lt;&#039;&quot;&amp;</p>
✅ <input value="&lt;&#039;&quot;&amp;">
✅ <input value='&lt;&#039;&quot;&amp;'>


❌ <input value=foo onclick=evilCode()>


❌ <script> let foo = &#039;&quot;\u2028; </script>
❌ <p onclick="foo(&#039;&quot;\u2028)"></p>


❌ <!-- -- --- -->


❌ <a href="javascript:evilCode()">...</a>


❌ <div id="app"> {{ foo }} </div>

¡Smarty falló en seis de nueve pruebas!

El resultado es a primera vista similar al de las librerías anteriores. Smarty solo puede escapar automáticamente en texto y atributos HTML, y solo cuando los valores están encerrados entre comillas. En todos los demás casos falla. En los contextos (5) y (6) es necesario escapar manualmente usando {$str|escape:javascript}. Pero esto solo es posible si el escape automático de HTML no está activo, de lo contrario, estos escapes entran en conflicto entre sí. Smarty es, por lo tanto, desde el punto de vista de la seguridad, el fracaso absoluto de esta prueba.

Latte ✅

El trío lo cierra el sistema de plantillas Latte (versión 3.0). Probaremos su autoescaping. También puede explorar en el playground sus respuestas y comportamiento.

   {var $str = "<'\"&"}
1) <p>{$str}</p>
2) <input value="{$str}">
3) <input value='{$str}'>

   {var $str = "foo onclick=evilCode()"}
4) <input value={$str}>

   {var $str = "'\"\n\u{2028}"}
5) <script>	let foo = {$str}; </script>
6) <p onclick="foo({$str})"></p>

   {var $str = "-- ---"}
7) <!-- {$str} -->

   {var $str = "javascript:evilCode()"}
8) <a href="{$str}">...</a>

   {var $str = "{{ foo }}"}
9) <div id="app"> {$str} </div>

✅ <p>&lt;'"&amp;</p>
✅ <input value="&lt;&apos;&quot;&amp;">
✅ <input value='&lt;&apos;&quot;&amp;'>


✅ <input value="foo onclick=evilCode()">


✅ <script> let foo = "'\"\n\u2028"; </script>
✅ <p onclick="foo(&quot;&apos;\&quot;\n\u2028&quot;)"></p>


✅ <!--  - -  - - -  -->


✅ <a href="">...</a>


✅ <div id="app"> {<!-- -->{ foo }} </div>

¡Latte sobresalió en las nueve tareas!

Logró manejar la falta de comillas en los atributos HTML, procesar JavaScript tanto en el elemento <script> como en los atributos, y lidiar con la secuencia prohibida en los comentarios HTML.

Además, previno la situación en la que hacer clic en un enlace falsificado por un atacante podría ejecutar su código. Y logró manejar el escape de etiquetas para Vue.

Prueba de bonificación

Una de las capacidades esenciales de todos los sistemas de plantillas es el trabajo con bloques y la herencia de plantillas asociada. Por lo tanto, intentaremos dar a todos los sistemas de plantillas probados una tarea más. Crearemos un bloque description que imprimiremos en un atributo HTML. En el mundo real, por supuesto, la definición del bloque se encontraría en la plantilla hija y su impresión en la plantilla padre, es decir, por ejemplo, en el layout. Esta es solo una forma simplificada, pero suficiente para probar el autoescaping al imprimir bloques. ¿Cómo les fue?

Twig: falló ❌ al imprimir bloques no trata los caracteres

{% block description %}
	rock n' roll
{% endblock %}

<meta name='description'
	content='{{ block('description') }}'>




<meta name='description'
	content=' rock n' roll '> ❌

Blade: falló ❌ al imprimir bloques no trata los caracteres

@section('description')
	rock n' roll
@endsection

<meta name='description'
	content='@yield('description')'>




<meta name='description'
	content=' rock n' roll '> ❌

Latte: pasó ✅ al imprimir bloques trató correctamente los caracteres problemáticos

{block description}
	rock n' roll
{/block}

<meta name='description'
	content='{include description}'>




<meta name='description'
	content=' rock n&apos; roll '> ✅

¿Por qué tantos sitios web son vulnerables?

El autoescaping en sistemas como Twig, Blade o Smarty funciona simplemente reemplazando cinco caracteres <>"'& por entidades HTML y no distingue el contexto de ninguna manera. Por eso funciona solo en algunas situaciones y falla en todas las demás. El autoescaping ingenuo es una función peligrosa porque crea una falsa sensación de seguridad.

Por lo tanto, no es sorprendente que actualmente más del 27% de los sitios web tengan vulnerabilidades críticas, principalmente XSS (fuente: Acunetix Web Vulnerability Report). ¿Cómo salir de esto? Usar un sistema de plantillas que distinga contextos.

Latte es el único sistema de plantillas en PHP que no percibe la plantilla solo como una cadena de caracteres, sino que entiende HTML. Comprende qué son las etiquetas, los atributos, etc. Distingue contextos. Y por eso escapa correctamente en texto HTML, de manera diferente dentro de una etiqueta HTML, de manera diferente dentro de JavaScript, etc.

Latte representa así el único sistema de plantillas seguro.


Además, gracias a su comprensión de HTML, ofrece los maravillosos n:attributes, que encantan a los usuarios:

<ul n:if="$menu">
	<li n:foreach="$menu->getItems() as $item">{$item->title}</li>
</ul>