Random IRC quote :      <@matalaz> pues yo prefiero pegar oxtias con las manos

Enséñame tu ZynOS (Reversing Zyxel NBG334W)

Aupa a tod@s! Hace unos días me cambié de compañía con la que tenía el ADSL y me dieron un router nuevo (aka juguete nuevo). El router es un Zyxel NBG334W, un modelo un poco superior a los anteriores Zyxel a los que estaba acostumbrado. Como a mí me gusta mucho «katxarrear» de vez en cuando, me decidí a sacar el firmware y mirar a ver que podía hacer con el juguetico con el único ánimo de aprender un poco como va la movida y, sobretodo, sacar la imagen de ZynOS para poder estudiarla y parchearla si me apetece.

Zyxel te permite descargar actualizaciones de su firmware desde su página web (aquí) así que descargué el firmware y empecé a mirar un poquito de que iba la cosa. Descargué el firmware con fecha 05-13-2009, que es un zip y empecé a analizar los archivos que venían dentro:

  1. Un PDF que no valdría ni para limpiarse el culo si estuviera en papel (al menos no matan arbolitos poniendo ese texto inútil con el producto).
  2. Un archivo con extensión «.art» (Kernel Linux utilizado para parchear el firmware).
  3. Un archivo con extensión «.rom» (Nuestro amigo ZynOS por ahí oculto).
  4. Un archivo con extensión «.bin» (Los «factory defaults»).


Lo que suelo hacer cuando miro algún firmware de algún cacharro y veo que los archivos para mí no tienen mucho sentido (en función de la extensión) es primero probar a ver que encuentra el comando «file» del paquete GNU File Utils:

$ file *.art *.rom *.bin
360AMS0.art:   ELF 32-bit MSB executable, MIPS, MIPS-III version 1 (SYSV), statically linked, stripped
360AMS4C0.rom: PDP-11 UNIX/RT ldp
360AMS4C0.bin: \012- 8086 relocatable (Microsoft)

«WTF!?». El comando file suele equivocarse mucho porque suele mirar poco más que los «magic» de la cabecera para determinar el tipo de archivo y, en este caso, me dice que mi juguetito nuevo puede ser un cacharro MIPS, PDP-11 (<<sonido de tocar el matasuegras>>) o un binario ejecutable para 8086. Lo más acertado parece lo primero, así que sacamos el IDA de paseo y empezamos a mirar el supuesto ejecutable ELF stripado para MIPS.

.text.init:8018A040  # =============== S U B R O U T I N E ===================
.text.init:8018A040
.text.init:8018A040
.text.init:8018A040                 .globl start
.text.init:8018A040 start:
.text.init:8018A040
.text.init:8018A040 var_10          = -0x10
.text.init:8018A040 var_8           = -8
.text.init:8018A040 arg_0           =  0
.text.init:8018A040
.text.init:8018A040                 la      $t0, dword_80237000
.text.init:8018A048                 sw      $0, (dword_80237000 – 0x80237000)($t0)
.text.init:8018A04C                 la      $t1, unk_8025F6BC
.text.init:8018A054
.text.init:8018A054 loc_8018A054:                            # CODE XREF: start+18j
.text.init:8018A054                 addiu   $t0, 4
.text.init:8018A058                 bne     $t0, $t1, loc_8018A054
.text.init:8018A05C                 sw      $0, 0($t0)
.text.init:8018A060                 la      $gp, unk_80188000
.text.init:8018A068                 addiu   $t0, $gp, 0x1FE0
.text.init:8018A06C                 addiu   $sp, $t0, -0x10
.text.init:8018A070                 sw      $t0, dword_8025F6B0
.text.init:8018A078                 jal     sub_8018D244
.text.init:8018A07C                 nop
()

Como vi que tenía buena pinta (el IDA era capaz de encontrar el punto de entrada -EP- y desensamblar todo bien) continué mirándolo. Al de poquito tiempo me di cuenta que era un kernel «Linux version 2.4.25-Atheros-UnTagged» (¿!?). Mi sorpresa fue bastante grande y gorda porque se supone que Zyxel tiene su propio sistema operativo: ZynOS (Zyxel Network Operating System). Algo no me cuadraba mucho pero continué mirándolo por si acaso me encontraba algo interesante.

Las funciones en el IDA no tenían nombres y empezar a «adivinar» que era cada una es un coñazo. Habitualmente me suelo hacer algún script «ad hoc» para buscar nombres de archivos o printfs de errores cogiendo la primera palabra (siempre que no sea un «warning» o «error») para intentar identificar los nombres de funciones pero en este caso ni eso me hacía falta. En el segmento «__ksymtab» me encontré que había una tabla de offsets pares donde el primer offset (el impar) apuntaba a una variable global o bien a una función y el segundo offset (el par) apuntaba a una cadena de texto con el nombre de dicho símbolo. Me hice un pequeño script en IDA (<a href=»http://www.joxeankoret.com/scripts/nbg_zyxel_scripts.zip»>aquí</a> todo el paquete de scripts) para recrear la tabla de símbolos y el resultado mejoró bastante, al menos ya tenía todas las funciones con nombre. El script (por si no queréis bajaros todo el archivo) es el siguiente:

# Symbols fixer by Joxean Koret
import re
import sys

class CKSymtabFixer:

    def fixSelection(self, startEa, endEa):
        ea = startEa

        while 1:
            MakeDword(ea)
            ea += 4

            if ea >= endEa:
                break
            else:
                s = GetString(Dword(ea), -1, ASCSTR_C)

                if s:
                    x = re.compile("\w+", re.IGNORECASE)
                    if x.match(s):
                        MakeName(Dword(ea-4), s)
                        OpOff(ea-4, 0, BADADDR)

    def fixSymtab(self):
        ea = SegByName("__ksymtab")
        if ea == BADADDR:
            raise Exception("Invalid 360 rom!")

        startEa = SegStart(ea)
        endEa = SegEnd(ea)

        self.fixSelection(startEa, endEa)

def main():
    fixer = CKSymtabFixer()
    fixer.fixSymtab()
    #fixer.fixSelection(SelStart(), SelEnd())

if __name__ == "__main__":
    main()

Seguí jugueteando un rato con el firmware a ver si veía algo interesante y, entre otras cosas, me encontré una zona que el IDA no reconocía y que parecía estar comprimida: Era la ramdisk. En la ramdisk se encuentra un pequeño «busybox» (terminal estilo bash con ciertas utilidades típicas de Unix, pero muy capado). La misma se encuentra en el offset «.data:801B1000» y tiene tamaño 548864 bytes. Esto no me acuerdo como me di cuenta de ello, pero el caso es que es así. Me hice otro mini script en Python para extraer la ramdisk y ver que tenía:

#!/usr/bin/python

import sys

def main(filename):
        buf = file(filename, "rb").read()
        pos = buf.find("\x1F\x8B\x08\x08")
        buf = buf[pos:pos+548864]
        f = file("busybox.gz", "wb")
        f.write(buf)
        f.close()
        print "Done"

def usage():
        print "Usage:", sys.argv[0], ""

if __name__ == "__main__":
        if len(sys.argv) > 1:
                main(sys.argv[1])
        else:
                usage()

Para montar la imagen una vez extraída desde Unix/Linux podéis ejecutar el comando siguiente:

$ sudo mount -o loop busybox mount_point

Dentro de la imagen no había nada del otro mundo, por desgracia, exceptuando lo siguiente:

  • Un archivo /etc/shadow con un único password encriptado (el de root). El password es «5up» sin las comillas. El colega «john»-txu me ayudó en mi búsqueda xD (y el hash, por si acaso, es este: root:$1$$zdlNHiCDxYDfeF4MZL.H3/:10933:0:99999:7:::).
  • Un binario llamado «mdk_client.out» que, según parece, es el binario utilizado para parchear el dispositivo.

La verdad es que me desilusioné bastante cuando vi que se le podía meter linux así sin más ni más (de un modo tan fácil) al cacharrito pero seguí mirando: Quería llega a ZynOS para poder estudiarlo un poco.

Siguiente fichero: El archivo «.rom». Este archivo no parece tener ningún sentido a priori, el comando file nos dice que es un ejecutable para PDP-11 (va a ser que no) y el IDA no lo entiende tampoco muy bien (me dice que es un binario de Z80) pero, al menos, sabemos que la arquitectura del procesador de dicho router es MIPS. Bueno, algo es algo. Cargamos el archivo binario en el IDA indicándole que el procesador es MIPS y empezamos a mirar si vemos algo interesante (alguna función que se encuentre por casual, etc…): Nada, absolutamente nada. Había que buscar algo más.

Este archivo era muy pequeño, demasiado para poder tener algo medianamente interesante. Las únicas cadenas «útiles» que vi fueron «boot», «spt.dat» y «autoexec.net» así como unos comandos de configuración del router. Tate! Eran los factory defaults. A dejar de mirar este archivo.

Siguiente archivo: El archivo con extensión «.bin». Aquí es donde está la madre del cordero, nuestro amigo ZynOS al que le queremos ver sus partes íntimas. Antes de empezar a mirarlo con el IDA empiezo por hacer un «strings» a ver que veo. Entre otras cosas, las cadenas que más me indicaron que era fueron las siguientes:

  • Select Flash Type:
  • netgear
  • d-link
  • lucent
  • zyxel
  • Upload firmware have some error !!
  • Can’t execute Linux – invalid entry address
  • Now booting linux kernel:

Vale, es definitivo: Es el bootloader y se encarga de cargar la imagen de Linux (el kernel Atheros anteriormente mencionado) para parchear el dispositivo (que puede ser D-Link, NetGear, Lucent o propio de Zyxel). Volvemos a nuestra muy mejor amiga IDA y cargamos el fichero binario especificándole que el procesador es MIPS (ya que por defecto te va a ofrecer 8086).

En este archivo lo primero que nos encontramos (los 4 primeros bytes) son un DWORD: 80 E0 00 00 correspondientes a la dirección 0x80E00000. Esta dirección es la dirección base de la imagen. Esto no lo sé por intuición o por ciencia infusa sino porque la imagen anterior (el kernel Linux) estaba cargado en la dirección 0x80041000 así que, tras rebasear el único segmento (Edit->Segments->Rebase program) a la dirección del kernel anterior y ver que las XREF me salían mal, hice la prueba a poner el DWORD este que me encontré justo en el comienzo del archivo (muy lejos no iba a andar tampoco la dirección de esta imagen a la dirección del kernel Linux).

Sea como sea, y antes de darme cuenta de la dirección base del segmento empecé a bajar poco a poco por el IDA pulsando «c» para crear código y ver si este tenía sentido. Sorpresa! En los primeros bytes (dirección 0x80E00030) el IDA reconoce una construcción de código (el inicio del bootloader) y empieza a crearme un montón de funciones (Ilfak, te queremos). Yujú! Pero, claro, no reconoce todas, así que necesitaba fijarme en los prólogos de funciones y hacerme un script para encontrar las funciones que el IDA no se había empapado de ellas buscando los prólogos más comunes entre los que me había creado el IDA automágicamente (luego también me compilé el gcc como cross compiler para MIPS y así poder ver cuales eran los prólogos, epílogos y construcciones más típicas, pero esa es otra historia…). El script está en el mismo paquete que antes y se llama 360_mips_fixer.py. Y, por si acaso, os lo pongo también aquí:

# MIPS prologs fixer by Joxean Koret
import sys

class CMipsPrologsFixer:

    doAnyway = False
    prologs = ["27 BD FF", "27 BD FA", "27 BD FE"]

    def atLeastOneFunc(self):
        for x in Functions():
            return True
        return False

    def totalFunctions(self):
        i = 0
        for x in Functions():
            i+=1
        return i

    def fixBasicPrologs(self):
        for prolog in self.prologs:
            self.fixProlog(prolog)

    def fixProlog(self, prolog):
        ea = MinEA()
        while 1:
            ea = FindBinary(ea, SEARCH_DOWN, prolog)

            if ea == BADADDR:
                break
            else:
                print "Creating fuction at 0x%x" % ea
                MakeCode(ea)
                MakeFunction(ea, BADADDR)
                ea += 1

    def fixAfterFunctions(self):
        ea = MinEA()
        oldEa = ea

        while 1:
            ea = FindText(ea, SEARCH_DOWN, 0, 0, "End of function")
            ea += 4

            if ea == BADADDR or oldEa == ea:
                break
            else:
                print "Creating fuction at 0x%x" % ea
                MakeCode(ea)
                MakeFunction(ea, BADADDR)
                oldEa = ea

    def fixPrologs(self):
        if not self.atLeastOneFunc() or self.doAnyway:
            self.fixBasicPrologs()

        total = self.totalFunctions()

        while 1:
            self.fixAfterFunctions()
            new = self.totalFunctions()

            if new == total:
                total = new

def main():
    fixer = CMipsPrologsFixer()
    fixer.doAnyway = True
    fixer.fixPrologs()

if __name__ == "__main__":
    main()

Ahora tenemos un montón de funciones reconocidas pero, todavía, hay una parte que el IDA la ve como una zona de «datos»: La práctica totalidad del fichero. Esto es porque el bootloader (el cacho código que hemos encontrado) es el encargado de descomprimir el firmware real (nuestro colega ZynOS) y ponerlo en memoria (justo esa parte, es el firmware, el escondido ZynOS). Pero claro, como ya he comentado, está comprimido (que al menos no encriptado, donde si que estaría bastante más jodido…). En anteriores versiones habían utilizado siempre BZ2 pero en esta han decidido cambiar el algoritmo: Utilizan LZMA. ¿Porqué lo sé? Mirando las cadenas de texto del fichero basta:

«Call lzmaReadCompressed(), return *buffer = %p, length = %d»

Suficiente. De todos modos, GZ, BZ y LZMA son los algoritmos que más he visto utilizar en dispositivos empotrados. Bueno, pues ahora a buscar los archivos que están dentro y descomprimirlos. Viendo la barra de navegación en el IDA se ve claramente donde está el boot loader y donde empiezan los datos, y viendo algunas de las cadenas reconocidas sabemos cual es el algoritmo de compresión utilizado por el fabricante:

zyxel1

Dada la vista que el IDA nos da, es fácil averiguar desde donde pueden comenzar los datos pero no tanto averiguar exactamente desde donde comienzan. Lo habitual es buscar los típicos «magics», es decir, la cabecera de ciertos tipos de archivos y formatos como por ejemplo BZ, etc… El problema es que el fabricante de dicho dispositivo decidió utilizar LZMA y es un formato bastante «flexible», es decir, no tiene una cabecera fija sino que para saber si un archivo es LZMA o no hay que mirar que sus estructuras tengan sentido parseando las mismas. Ya que de ese modo no vamos a encontrar nada, otra posibilidad es buscar bloques de datos grandes donde no haya carácteres 0x00: Esto suele ser un indicativo de que ese bloque de datos está cifrado y/o comprimido. A partir de la dirección 0x80E3003C+2 no se encuentra ningún carácter 0x00 durante unos cuantos megas, así pues, hemos encontrado los datos. Viendo el comienzo de los datos nos podemos hacer una idea, otra vez, de por donde empieza el archivo comprimido:

80E2FFFC  00 00 00 00 00 00 00 00  00 00 53 49 47 04 00 00  ..........SIG...
80E3000C  88 14 00 00 22 1F E0 00  64 3F 4C 84 4E 42 47 33  ê...".?.d?LäNBG3
80E3001C  33 34 57 20 56 31 2E 30  30 00 00 00 00 00 00 00  34W V1.00.......
80E3002C  00 00 00 00 5D 00 00 80  00 14 88 00 00 00 00 00  ....]..Ç..ê.....
80E3003C  00 00 1E 02 12 6F 01 20  51 0A 03 4F 3C C5 11 D0  .....o. Q..O<+.+
80E3004C  F3 30 19 47 D3 F4 98 41  54 B2 A7 DA EB C1 46 D2  ?0.GL?ÿAT?º+?+FT
80E3005C  D6 C0 32 38 6D FC C5 94  F8 11 0B 9D 75 0F 19 E1  +L28mn+ö°..¥u..ß
80E3006C  01 FC CB EE A7 92 15 A0  82 4F C9 9B 6E B5 42 11  .nT?ºÆ.áéO+¢n+B.
80E3007C  CC CA 43 01 BC 1A E4 7C  5B E1 18 70 4F 3C F1 87  ++C.+.?|[ß.pO<±ç

Un montón de ceros, la cadena «SIG» (utilizada para marcar el comienzo de una estructura), unos datos que aún desconozco (pero que serán casi seguro checksums), el nombre del ejecutable (NBG334W V1.00), padding con carácteres 0x00 y, después, los bytes «5D 00 00 80 …». La primera prueba es cortar el archivo desde ese primer byte (5D) en adelante con un sencillo script en python y después probar a descomprimirlo con «lzma -d». Primero el script en python:

buf = file("360AMS4C0.bin", "rb").read()
pos = buf.find("\x5D\x00\x00\x80")
f = file("zynos.lzma", "wb")
f.write(buf[pos:])
f.close()

Y después la dificultosa tarea de descomprimir la imagen:

$ lzma -d zynos.lzma

No ha habido error así que «algo ha hecho». Si abrimos este último archivo con el IDA (seleccionando como procesador MIPS) veremos algo como lo siguiente:

ZynOS antes de desensamblar

Pero, si pulsamos una simple «c» en el primer byte del código comienza la magia del IDA quedándonos algo similar a la siguiente imagen:
ZynOS después de crear la primera línea de código ensamblador
Para los casos anteriores ya nos habíamos creado scripts para buscar más funciones en binarios compilados para MIPS así que el siguiente paso es probar a lanzar dicho script. Tras la ejecución del mismo, el mapa de navegación nos quedará tal que así:
ZynOS después de buscar más funciones con el script de búsqueda de prólogos de antes
La diferencia es notable. De todos modos, aún no lo tenemos todo correctamente: Algunos offsets nos aparecen como incorrectos (porque se salen del rango de memoria de la imagen, etc…) ya que aún tenemos que asignarle la dirección base correcta para este binario. En este caso ninguna de las direcciones bases de antes valían pero, por suerte, entre las primeras instrucciones de código ensamblador nos encontramos un offset «prometedor»:

ROM:00000000 sub_0:                                   # DATA XREF: sub_0+18o
ROM:00000000                                          # 00B10018o ...
ROM:00000000                 lui     $t0, 0xB100
ROM:00000004                 li      $t1, 0
ROM:00000008                 sw      $t1, 0xB1000024 # Bad offset

¿Tal vez la dirección base sea 0xB1000000 así redondeando? Probamos a rebasear y la mayoría de XREFs ahora aparecen correctamente así como el código inicial que también ha variado un poco. Pero, aún así, esta no es la dirección base correcta. ¿Porqué? Si seguimos analizando el archivo bajando poco a poco de función a función, nos encontramos con saltos que van a casa cristo:

ROM:B1000418                 sw      $v0, 0x20+var_10($fp)
ROM:B100041C                 j       0xB0000C70
ROM:B1000420                 nop

Vaaaale, nos hemos equivocado de dirección. ¿Tal vez sea, entonces, la dirección 0xB0000000 redondeando otra vez? Rebaseamos de nuevo el único segmento y ahora ya no tenemos saltos que no van a ninguna parte, es decir, ¡Ya podemos analizar ZynOS!. Ha costado un poco pero finalmente hemos llegado hasta nuestro colega ZynOS.
ZynOS con la imagen ya rebaseada finalmente
Ahora solo me queda ver como verifican los checksums en la imagen anterior (la que contenía el boot loader y la imagen de ZynOS comprimida) y atreverme a flashear mi router 😉 Pero eso para otro día. Espero que no haya sido demasiada txapa y que os haya gustado. Agur!!!

8 Comentarios para “Enséñame tu ZynOS (Reversing Zyxel NBG334W)”

  1. Comment por Ruben | 09/20/09 at 11:53 am

    Me llena de orgullo y satisfación que hayas escrito este artículo chacho! Que grande eres! Muy currado e interesante.

  2. ash
    Comment por ash | 09/20/09 at 3:05 pm

    Que vergüenza… y yo que cuando recibí mi router Zyxel me limité a conectarlo y mirar páginas porn… digo, navegar! Nice Job!

  3. Comment por Zori | 09/20/09 at 5:14 pm

    «ad hoc» dice el tio… te nos estas volviendo un pijo nagoritiko desos o ke? Buen curro ;-P

  4. Comment por Miguel | 09/20/09 at 6:19 pm

    /*++

    Eso, eso, pierde el tiempo destripando routers. En vez de detectar virus metamorficos de esos. Claro, asi cuando vuelva yo al curro me voy a encontrar con todos los problemas que deje al irme de vacaciones… Y que, despues de estos vas a destripar la cafetera no? Pero que poca verquenza. Si ya me decia me madre que los melenudos…

    xDDDDDDDDDDDDDDDDDDDDDDDD

    –*/

  5. Comment por quecaña | 09/21/09 at 5:43 am

    Un curro impresionante! Creo que analizar ROMs así en plan raw es uno de los trabajos más difícil en reversing y te lo has currado muchísimo. Mis felicitaciones porque el artículo está de puta madre. Se te ve con madera para este tipo de análisis, deberías meterte al mundillo de la liberación de móviles!! jeje te harías de oro.

  6. Comment por Quique | 09/21/09 at 10:08 am

    Este mensaje és para Ruben y toda la comunidad 48 bits: hemos colgado un enlace al facebook de COMRàdio del video de la entrevista que hiciste esta mañana sobre seguridad en la red. ¡Muchas grácias!

    http://www.facebook.com/comradio

  7. Comment por Shaddy | 09/22/09 at 4:18 am

    Hoy me he levantado de buen ánimo y digo «Ilfak es muy grande»… a ver si dura ;).

    Buen artículo tío, sigue aburriéndote tanto los fines de semana :D.

    Agur!

  8. Comment por Boken | 09/23/09 at 4:41 am

    Muy buen articulo, te felicito!!

    Espero la segunda parte con ganas.

    Saludos!!

Se han cerrado los comentarios