1 puntos por GN⁺ 2024-03-31 | 1 comentarios | Compartir por WhatsApp
  • El backdoor de xz/liblzma no es solo un problema del payload binario final: la etapa Bash que se ejecuta durante la compilación está oculta tras varias capas de extracción y descifrado, lo que dificulta mucho el análisis
  • Las versiones afectadas son xz/liblzma 5.6.0 y 5.6.1, y el script ofuscado y el payload binario están dentro de dos archivos que parecen ser archivos de prueba
  • La etapa 0 empieza en m4/build-to-host.m4, recupera un flujo xz que parece dañado y extrae con xz -d el script de la etapa 1 para ejecutarlo
  • La etapa 1 recorta datos de good-large_compressed.lzma, los sustituye y luego ejecuta el script Bash de la etapa 2; en 5.6.1 se agregó código que verifica 5 veces si se está ejecutando en Linux
  • La etapa 2 usa file carving, un cifrado por sustitución y un descifrado con una variante de RC4 basada en AWK para insertar un archivo .o en el proceso de compilación y enlace; en 5.6.1 también incluye una estructura de extensión que podría ejecutar scripts adicionales en el futuro

Alcance de las etapas Bash del backdoor de xz/liblzma

  • Andres Freund anunció en la lista de correo oss-security el descubrimiento del backdoor de xz/liblzma, que afecta a OpenSSH server
  • El objeto de análisis no es el backdoor binario en sí, sino las etapas iniciales basadas en Bash que se ejecutan antes y sus métodos de ofuscación
  • El flujo en Bash va de la etapa 0 a la etapa 2, y también aparece parcialmente una posible etapa 3 que parece no estar implementada por completo
  • Las etapas ofuscadas y cifradas, junto con el backdoor binario posterior, están ocultas dentro de dos archivos de prueba
    • tests/files/bad-3-corrupt_lzma2.xz
    • tests/files/good-large_compressed.lzma
  • Las versiones afectadas son 5.6.0 y 5.6.1, con pequeñas diferencias entre ambas

Etapa 0: recuperación de un flujo xz que parece un archivo de prueba dañado

  • El punto de inicio es el código de m4/build-to-host.m4, que parece ejecutarse en algún momento del proceso de compilación
  • El pipeline principal conecta sed, tr y xz -d para extraer el script de la etapa 1
  • Después de leer los bytes de tests/files/bad-3-corrupt_lzma2.xz, sustituye algunos bytes con tr "\t \-_" " \t_\-"
    • El tabulador 0x09 se convierte en espacio 0x20
    • El espacio 0x20 se convierte en tabulador 0x09
    • El guion 0x2d se convierte en guion bajo 0x5f
    • El guion bajo 0x5f se convierte en guion 0x2d
  • Esta sustitución sirve para convertir de nuevo bad-3-corrupt_lzma2.xz en un flujo xz válido
  • Al final, descomprime el flujo recuperado con xz y obtiene el script de la etapa 1 ignorando errores, para ejecutarlo de inmediato
    • El flujo parece truncado, pero no importa porque la salida significativa ya fue escrita

Etapa 1: extraer la etapa 2 desde good-large_compressed.lzma

  • La etapa 1 es un archivo Bash corto que comienza con ####Hello####, tal como aparece en el correo de Andres Freund
  • La primera diferencia entre las dos versiones son bytes arbitrarios en el comentario de la segunda línea
    • 5.6.0: 86 F9 5A F7 2E 68 6A BC
    • 5.6.1: E5 55 89 B7 24 04 D8 17
  • En 5.6.1 se agregó una verificación de Linux con la forma [ ! $(uname) = "Linux" ] && exit 0, y la misma verificación se repite 5 veces
  • La extracción de la etapa 2 empieza en una larga línea export i=..., y luego este valor se usa como una especie de función invocada con eval $i
  • El flujo de extracción tiene una estructura de pipeline que concatena continuamente la salida estándar y la entrada estándar
    • Primero descomprime good-large_compressed.lzma con xz -dc
    • eval $i repite llamadas a head con un patrón de descartar 1024 bytes y emitir 2048 bytes
    • Al final del archivo, 5.6.0 emite 724 bytes y 5.6.1 emite 939 bytes
  • Esta cadena de head elimina regiones vacías de baja entropía y concatena solo las regiones que contienen datos reales
  • Luego descarta la parte inicial con tail -c +31233
    • En esa parte inicial está oculto el backdoor binario que se extrae en la siguiente etapa
    • En 5.6.0 esto equivale a descartar los primeros 31264 bytes, y en 5.6.1 los primeros 31232 bytes
  • A continuación, tr se usa como un cifrado por sustitución simple, y las claves de mapeo difieren entre 5.6.0 y 5.6.1
    • 5.6.0: tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377"
    • 5.6.1: tr "\114-\321\322-\377\35-\47\14-\34\0-\13\50-\113" "\0-\377"
  • Los datos sustituidos se descomprimen otra vez con xz -F raw --lzma1 -dc, y la etapa 2 resultante se ejecuta de inmediato

Etapa 2: modificación del proceso de compilación y mecanismo de extensión

  • La etapa 2 es el archivo infected.txt que Andres Freund adjuntó al correo original, y es un script Bash donde ocurre la modificación real del proceso de compilación
  • Desde el punto de vista de la ofuscación, la etapa 2 tiene tres fragmentos interesantes, de los cuales dos aparecen solo en 5.6.1
  • Los dos fragmentos de 5.6.1 son casi idénticos entre sí y buscan archivos con firmas específicas dentro de tests/files/
    • El fragmento 1 busca ~!:_ W y |_!{ -
    • El fragmento 3 busca jV!.^% y %.R.1Z
  • Si se encuentran las firmas, extrae el nombre de archivo y el offset, y recorta el rango de bytes que va desde después de la primera firma hasta antes de la segunda
  • Los datos recortados se descifran por sustitución con la clave tr usada en 5.6.0, luego se descomprimen con xz -F raw --lzma2 -dc y se ejecutan con eval
  • En los archivos TAR de 5.6.0 y 5.6.1 examinados no había archivos con esas firmas
  • Esta estructura parece un sistema de extensión o parcheo que permitiría ejecutar scripts futuros agregando solo un nuevo archivo de prueba, sin volver a modificar el archivo de prueba que transporta el payload existente

Etapa 2: extracción del archivo .o del backdoor binario

  • Otro elemento central de la etapa 2 es el código que extrae un archivo .o y lo inserta en el proceso de compilación y enlace
  • El resultado extraído es liblzma_la-crc64-fast.o, que es el backdoor binario final
  • El proceso de extracción conecta varias herramientas estándar en un pipeline, como en las etapas anteriores
    • Descomprime good-large_compressed.lzma con xz -dc
    • Vuelve a usar la cadena de head de eval $i empleada en la etapa 1
    • Con sed "s/\(.\)/\1\n/g", agrega un salto de línea después de cada byte para producir una línea por byte
  • El siguiente script AWK realiza un descifrado similar a RC4 sobre el flujo de entrada
    • El arreglo de claves se genera con ((i * 7) + 5) % 256
    • 5.6.0 salta los primeros 4096 bytes del flujo PRNG de salida
    • 5.6.1 salta 8192 bytes
    • RC4 original usa XOR, pero este código usa suma con la forma (v + k) % 256
  • Los datos descifrados se descomprimen con xz -dc --single-stream
  • Luego se recorta solo el rango de bytes necesario con head y se guarda como liblzma_la-crc64-fast.o
    • El tamaño del payload comprimido, pero parcialmente modificado, es de 88792 bytes en 5.6.0 y 88664 bytes en 5.6.1
    • El rango final extraído es de unos 86 KB

Naturaleza de la ofuscación hecha solo con herramientas estándar

  • Todas las etapas Bash están compuestas únicamente por herramientas estándar de línea de comandos
    • sed
    • tr
    • xz
    • head
    • tail
    • awk
    • grep
    • cut
    • expr
  • El payload está oculto en archivos binarios que parecen archivos de prueba normales, y solo las regiones necesarias se extraen mediante file carving
  • La ofuscación combina un cifrado por sustitución simple con un descifrado de variante RC4 basado en AWK
  • La ejecución encadena un total de 3 etapas, y 5.6.1 incluye una estructura que permitiría ejecutar scripts más adelante agregando un archivo de prueba separado
  • La estructura completa está diseñada con mucho esfuerzo para parecer inofensiva y quedar bien oculta; si este caso se descubrió por casualidad, deja abierta la duda de cuántos casos similares aún no han sido descubiertos

1 comentarios

 
GN⁺ 2024-03-31
Comentarios en Hacker News
  • Gracias a la explicación simplificada y la comparación con la imagen con ruido, ya se entiende qué quieren decir con sofisticación
    También vi en reddit que el método de “sandboxing” se arruinó por un solo punto, y se puede ver un punto al extremo izquierdo de la línea justo después de #include
    https://git.tukaani.org/?p=xz.git;a=commitdiff;h=328c52da8a2...
    https://old.reddit.com/r/linux/comments/1brhlur/xz_utils_bac...

    • De verdad es perverso. En el diff solo se ve como +, +., +, y ese único punto pasa desapercibido
    • De verdad odio usar este tipo de condicionales en tiempo de compilación/build. Es difícil probar que se activen cuando deben activarse y se desactiven cuando deben desactivarse, y es todavía peor si están dentro de un sistema de build sin framework de pruebas unitarias
      Ya es un dolor de cabeza cuando por un simple error las pruebas siempre fallan o siempre pasan; se entiende por qué son un blanco ideal para comportamiento malicioso
    • Es muy probable que esto no haya sido intencional, sino un simple error
      a) prácticamente nadie compila este paquete con cmake
      b) si lo compilas con cmake y -DENABLE_SANDBOX=landlock, la compilación simplemente falla: https://i.imgur.com/7xbeWFx.png
      Ese punto no desactiva el sandboxing, solo hace imposible compilar con cmake. Si alguien de verdad hubiera intentado compilar con cmake, habría visto el error y notado que algo estaba mal, así que no tiene mucho sentido verlo como un intento malicioso de reducir la seguridad
  • La pregunta más importante es: “si esto se descubrió por casualidad, ¿cuánto más queda sin descubrir?”
    No hay forma de que Andres Freund haya encontrado por accidente el único proyecto popular de código abierto con una puerta trasera implantada. No me sorprendería que ya hubiera una docena de cosas así ahí afuera

    • No fue tanto que lo descubriera por accidente, sino más bien que lo percibió. Son cosas distintas
      El próximo atacante será mucho menos descuidado a la hora de dejar rastros como un aumento en el tiempo de ejecución
    • Puede ser, pero en la práctica quizá no haya tantos casos críticos. Si hubiera muchos, parecería que nos toparíamos con situaciones así más seguido
  • La idea incómoda aquí es que las pruebas unitarias abrieron la ruta del ataque. Sin ellas, habría sido mucho más difícil ocultarlo así

    • El atacante incluso encubrió el rastro del payload inicial con un párrafo inocuo en el README. Algo como “¡no hay nada que ver aquí!”
      bad-3-corrupt_lzma2.xz tiene tres streams, y el primero y el tercero son streams xz válidos. El stream del medio tiene encabezado de stream, encabezado de bloque, índice y footer de stream correctos; solo los datos LZMA2 están corruptos, y dice que debería descomprimirse con --single-stream
      Las cadenas ####Hello#### y ####World#### hacen que, si realmente sigues las instrucciones del README, el resultado parezca una salida normal y plausible
      $ cat tests/files/bad-3-corrupt_lzma2.xz | xz -d --single-stream
      ####Hello####
      Estas cadenas son comentarios de shell, así que no interfieren con la ejecución del payload
      Por último, también sirven como marcadores para que después una expresión regular pueda encontrar el archivo sin referirse directamente al nombre del archivo ni usar las cadenas Hello/World reales
      $ gl_am_configmake=\grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null``
      $ echo $gl_am_configmake
      ./tests/files/bad-3-corrupt_lzma2.xz
    • En un proyecto importante para la seguridad, parecería correcto configurar la infraestructura de build para que dé error o al menos una advertencia cuando se incluyan archivos binarios en el build
      Esta verificación tendría que aplicarse de forma transitiva, de modo que cuando una distribución de Linux intente actualizar a una nueva versión de liblzma, el build también falle o emita una advertencia por la nueva dependencia binaria
      No sé qué tan común es esta práctica en los builds de distribuciones Linux. Si lo fuera, limpiar todo eso requeriría un trabajo enorme, y ni siquiera está claro si sería posible. Con bazel parece viable, pero no sé sobre otros sistemas de build
  • Me pregunto si alguien ya buscó en GitHub trucos similares de head | tail. Cuesta creer que esto se haya inventado específicamente para esto

    • He visto bastante este método en instaladores Unix de proveedores de software comercial
      Un enorme archivo .sh muestra la licencia, pide aceptación y luego se hace cat a sí mismo para pasar por un pipe con head/tail hacia cpio y extraer los archivos reales
    • Es ingenioso, pero no es algo totalmente nuevo; está bastante cerca de un caso de uso previsto para estas herramientas
    • Es una oportunidad para escribir un paper
  • No tengo una mejor respuesta, pero siento que este montón de bash enredado ya de por sí huele mal
    Como pasa en otras áreas del mundo del desarrollo, ¿no se pudo haber escrito de una forma menos opaca para que se viera más claro qué estaba ocurriendo?
    Entiendo que un mantenedor puede meter código malicioso sin una revisión tan estricta como la de un contribuidor externo, pero tiene que haber una mejor salida que un montón de código “conciso” que en la práctica parece ofuscación no intencional

    • Ese es un olor muy viejo
      El problema de fondo es que en los 80 y 90 había muchísimos sistemas tipo Unix, cada uno con sus propios defectos y carencias, y los autores de software querían minimizar las dependencias de compilación
      Por eso muchas comunidades se estandarizaron en automatizar los builds con scripts de shell que funcionaran en cualquier lado. Pero escribir scripts de shell era doloroso, así que la gente terminó generándolos con herramientas como el preprocesador de macros M4
      El resultado es que muchos proyectos acabaron con enormes bloques opacos de scripts de shell, por si alguien quería ejecutar el código en AIX o en algún Unix antiguo y roto
      Para deshacerse de esta maleza impenetrable de shell habría que reducir mucho la cantidad de plataformas soportadas, estandarizar herramientas de build más limpias y construir más infraestructura clave en lenguajes donde los builds portables no necesiten shell
      Pero eso es un trabajo gigantesco, y buena parte de las bibliotecas C fundamentales las mantienen uno o dos voluntarios no remunerados. Dejar de dar soporte a “Obscurnix-1997” suele ser una decisión bastante polémica
      Así que mucha de la infraestructura crítica sigue rodeada por un pantano de scripts de shell generados por máquinas y de origen dudoso
    • Ese shell no lo escribió una persona, es código generado. Como autoconf se usa muchísimo, se han acumulado montañas de código de configuración shell generado, y autoconf produce miles de líneas de scripts de shell portables difíciles de leer a partir de scripts del preprocesador de macros M4
      No son pocas las herramientas que se construyen de esta manera
    • A simple vista, no creo que el hecho de que bash parezca críptico sea por sí mismo una señal de alarma. Los scripts de bash escritos de forma apretada a veces se ven así. Si deberían escribirse tan apretados es otro debate
      Lo que sí se ve sospechoso son las llamadas repetidas a tr. Cuando veo algo así, pienso que alguien está tratando de pasarse de listo, y aquí “listo” es en sentido negativo. Si yo fuera mantenedor, habría pedido que explicaran qué hacía ese código antes de aceptarlo, porque casi siempre hay una forma mejor que evitar ese tipo de encadenamiento
      El verdadero problema es que no había otro mantenedor que revisara esto cuando entró. Una pieza importante del stack dependía de una sola persona, y en este caso esa persona era maliciosa
    • No fue “sin querer”; el punto central es que estaba ofuscado de forma intencional
    • Puede que haya entendido mal el riesgo del backdoor de XV, pero ¿habrá alguna forma de ejecutar bash para impedir que haga exec de algo? Me pregunto si bash debería tener una especie de “modo seguro”
      Aunque pensándolo mejor, no me imagino cómo se podría ejecutar bash en ese hipotético “modo seguro” para el script configure de xv, así que lo retiro
  • https://github.com/tukaani-project/.github/issues/2

    • Bastante gracioso. No solo es un backdoor deliberado y terrible, también es una violación de la GPL, porque el backdoor es una obra derivada, no incluía el código fuente y no se distribuyó en la “forma preferida para hacer modificaciones”
  • Todo el ecosistema de C, incluyendo las herramientas de build y hasta las utilidades viejas de Unix, es un desastre de seguridad esperando ser explotado, y al final lo van a explotar
    Basta ver lo fácil que es arruinarlo todo con un solo punto. La gente ya debería darse cuenta de que no se puede seguir apostando la seguridad del mundo a C
    Ojalá usaran Ada o Rust con toolchains modernos

    • ¿Y si le agregas un punto a Rust tampoco se rompe?
  • De verdad no entiendo cómo esto pasó code review y terminó mergeado. Salvo que me esté perdiendo algo, suena ridículamente descuidado

    • El actor malicioso era co-mantenedor del repositorio, llevaba un tiempo más activo que el mantenedor original y tenía permisos completos de commit. Se hizo commit directo a master, sin PR ni review
      Además, estaba fuertemente ofuscado dentro de archivos binarios marcados como archivos de prueba, o sea, archivos de prueba de compresión xz “good”/“bad”. Si no sabías qué buscar, no había forma de notarlo
    • El mensaje de commit de los archivos de prueba afirma que se generaron con un generador de números aleatorios. La persona que armó el tarball de release agregó la última línea en el lugar correcto, pero eso no se hizo commit al repositorio
    • Los paquetes con un solo mantenedor activo no tienen code review
  • Usar una distribución LTS puede darte cierta protección. Slackware parece usar lzma, o sea tar.xz, para sus paquetes, pero la última release estable, excluyendo -current, no tenía este problema
    Si quieres ir un paso más allá del lado del software libre, Hyperbola GNU tampoco tenía este problema
    Además, Slackware -current tampoco enlaza sshd con xz, y tampoco usa systemd