Xz/liblzma: explicación de la ofuscación en la etapa Bash
(gynvael.coldwind.pl)- 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 conxz -del 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
.oen 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.xztests/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,tryxz -dpara extraer el script de la etapa 1 - Después de leer los bytes de
tests/files/bad-3-corrupt_lzma2.xz, sustituye algunos bytes contr "\t \-_" " \t_\-"- El tabulador
0x09se convierte en espacio0x20 - El espacio
0x20se convierte en tabulador0x09 - El guion
0x2dse convierte en guion bajo0x5f - El guion bajo
0x5fse convierte en guion0x2d
- El tabulador
- Esta sustitución sirve para convertir de nuevo
bad-3-corrupt_lzma2.xzen un flujo xz válido - Al final, descomprime el flujo recuperado con
xzy 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
- 5.6.0:
- 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 coneval $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.lzmaconxz -dc eval $irepite llamadas aheadcon 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
- Primero descomprime
- Esta cadena de
headelimina 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,
trse 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"
- 5.6.0:
- 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.txtque 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
~!:_ Wy|_!{ - - El fragmento 3 busca
jV!.^%y%.R.1Z
- El fragmento 1 busca
- 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
trusada en 5.6.0, luego se descomprimen conxz -F raw --lzma2 -dcy se ejecutan coneval - 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
.oy 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.lzmaconxz -dc - Vuelve a usar la cadena de
headdeeval $iempleada 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
- Descomprime
- 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
- El arreglo de claves se genera con
- Los datos descifrados se descomprimen con
xz -dc --single-stream - Luego se recorta solo el rango de bytes necesario con
heady se guarda comoliblzma_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
sedtrxzheadtailawkgrepcutexpr
- 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
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
#includehttps://git.tukaani.org/?p=xz.git;a=commitdiff;h=328c52da8a2...
https://old.reddit.com/r/linux/comments/1brhlur/xz_utils_bac...
+,+.,+, y ese único punto pasa desapercibidoYa 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
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.pngEse 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
El próximo atacante será mucho menos descuidado a la hora de dejar rastros como un aumento en el tiempo de ejecución
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í
bad-3-corrupt_lzma2.xztiene 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-streamLas 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.xzEsta 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
Un enorme archivo
.shmuestra la licencia, pide aceptación y luego se hacecata sí mismo para pasar por un pipe conhead/tailhaciacpioy extraer los archivos realesNo 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
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
No son pocas las herramientas que se construyen de esta manera
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
execde 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
configurede xv, así que lo retirohttps://github.com/tukaani-project/.github/issues/2
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
De verdad no entiendo cómo esto pasó code review y terminó mergeado. Salvo que me esté perdiendo algo, suena ridículamente descuidado
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
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 problemaSi quieres ir un paso más allá del lado del software libre, Hyperbola GNU tampoco tenía este problema
Además, Slackware
-currenttampoco enlazasshdcon xz, y tampoco usa systemd