Understanding ipTIME Configuration Backup File Format

Published on Sunday, 12 February 2017 in Reverse Engineering ; tagged with iptime, n704, v3, router, cfg, configuration, backup, reverse, ida, binwalk ; text version

Continuing to play with my ipTIME n704-v3, I wanted to understand how their configuration backup feature was working and in a word: it is very simple. I thought that it could be a good example of how one could proceed in order to reverse similar file formats on other devices.

$ python ipTIME_config.py -e config_n704v3_20000101_010901.cfg
PoC for extracting/repacking ipTIME backup configuration file
Extracting ipTIME configuration...
    [+] Extracting outer gzip
    [+] Dumping extracted header
        Magic: raw_nv
        Size of gz (compressed): 3183
        Sum of gz bytes: 0x677A4
        Max size: 32720
        FS id: 0x10000
    [+] Extracting inner tar.gz tarball
Extraction successful. You can now edit configuration files in ./etc/
Use -c to pack the new configuration

TL;DR: ipTIME .cfg backup file is a mix between a tar.gz of /etc/ and a custom binary header, the whole gzip once more. See this simple script I cooked up to unpack and re-pack ipTIME .cfg configuration files.

First hypothesis using binwalk

Downloading a backup of the configuration files from the router web interface gives us a .cfg file. Using binwalk:

$ binwalk config_n704v3_20000101_000208.cfg

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             gzip compressed data, maximum compression, from Unix, last modified: 2000-01-01 00:01:44
63            0x3F            gzip compressed data, maximum compression, from Unix, last modified: 2000-01-01 00:01:44

First thing I thought when reading binwalk's output was that either the first or the second gzip signature match was a false-positive. It would be surprising to have a gzip file of 62 bytes. Let's extract everything:

$ binwalk -Mer config_n704v3_20000101_000208.cfg
# [ omitted ]
$ tree _config_n704v3_20000101_000208.cfg.extracted 
_config_n704v3_20000101_000208.cfg.extracted
├── 0
├── 3F
├── 3F.gz
├── _0.extracted
│   ├── 30
│   └── _30.extracted
│       └── etc
│           ├── group
│           ├── iconfig.cfg
│           ├── igmpproxy.conf
│           ├── inetd.conf
│           ├── init.d
│           │   └── rcS
│           ├── ld.so.cache
│           ├── ld.so.conf
│           ├── messages
│           ├── mime.types
│           ├── miniupnpd.conf
│           ├── passwd
│           ├── ppp
│           │   └── options.pptpd
│           ├── pptpd.conf
│           ├── services
│           ├── udhcpd.conf
│           └── udhcpd.leases
└── _3F.extracted
    └── etc
        ├── iconfig.cfg
        ├── igmpproxy.conf
        ├── inetd.conf
        ├── init.d
        │   └── rcS
        ├── ld.so.cache
        ├── ld.so.conf
        ├── messages
        ├── mime.types
        ├── miniupnpd.conf
        ├── passwd
        ├── ppp
        │   └── options.pptpd
        ├── pptpd.conf
        ├── services
        ├── udhcpd.conf
        └── udhcpd.leases

It seems that the configuration file contains the /etc/ directory of the router. Seeing that it was extracted twice would suggest that we were right about the false-positive.

Using HTTP requests to find clues

Instead of guessing further, it would be easier to look at the HTTP request sent to the router when downloading the configuration backup and its response. From there we could search for strings in the firmware's CGI binaries. I personally use Burp but any HTTP proxy will do.

ipTIME Web Interface Backup Configuration

Request:

GET /sess-bin/download.cgi HTTP/1.1
Host: 192.168.0.1
Cookie: efm_session_id=ugX2NlylmXCkUGFX
Connection: close

Response:

HTTP/1.0 200 OK
Date: Sat, 01 Jan 2000 00:23:45 GMT
Server: Httpd/1.0
Connection: close
Content-type: text/html;
Content-Disposition: attachment; filename="config_n704v3_20000101_002345.cfg"
Content-Transfer-Encoding: binary
Content-Length: 3906

[ omitted binary data ]

From the request we should start looking for:

The firmware can be found on ipTIME's download website (version 9.98.6 latest version last time I checked) and extracted using binwalk (shown in a previous blog post). Looking inside the /cgibin/ directory we find download.cgi:

$ ls -al ./cramfs-root/cgibin/
total 2752
drwxr-xr-x  21 depierre  depierre      714 Jan 28 20:07 ./
drwxr-xr-x  19 depierre  depierre      646 Jan 28 20:07 ../
-rwxr-xr-x   1 depierre  depierre     5468 Jan  1  1970 captcha.cgi*
lrwxr-xr-x   1 depierre  depierre       19 Jan 28 20:07 d.cgi@ -> /cgibin/timepro.cgi
drwxr-xr-x   3 depierre  depierre      102 Jan 28 20:07 ddns/
-rwxr-xr-x   1 depierre  depierre     9644 Jan  1  1970 download.cgi*
lrwxr-xr-x   1 depierre  depierre       12 Jan 28 20:07 download_firewall.cgi@ -> download.cgi
lrwxr-xr-x   1 depierre  depierre       12 Jan 28 20:07 download_portforward.cgi@ -> download.cgi
lrwxr-xr-x   1 depierre  depierre       13 Jan 28 20:07 info.cgi@ -> /cgibin/m.cgi
-rwxr-xr-x   1 depierre  depierre     5484 Jan  1  1970 iux_upload.cgi*
drwxr-xr-x   6 depierre  depierre      204 Jan 28 20:07 login-cgi/
lrwxr-xr-x   1 depierre  depierre       27 Jan 28 20:07 login.cgi@ -> /cgibin/login-cgi/login.cgi
-rwxr-xr-x   1 depierre  depierre     9660 Jan  1  1970 login_handler.cgi*
-rwxr-xr-x   1 depierre  depierre    13884 Jan  1  1970 login_session.cgi*
-rwxr-xr-x   1 depierre  depierre    74364 Jan  1  1970 m.cgi*
lrwxr-xr-x   1 depierre  depierre       13 Jan 28 20:07 net_apply.cgi@ -> /cgibin/m.cgi
lrwxr-xr-x   1 depierre  depierre       13 Jan 28 20:07 sys_apply.cgi@ -> /cgibin/m.cgi
-rwxr-xr-x   1 depierre  depierre  1209812 Jan  1  1970 timepro.cgi*
-rwxr-xr-x   1 depierre  depierre    22988 Jan  1  1970 upgrade.cgi*
lrwxr-xr-x   1 depierre  depierre       13 Jan 28 20:07 wireless_apply.cgi@ -> /cgibin/m.cgi
lrwxr-xr-x   1 depierre  depierre       13 Jan 28 20:07 wol_apply.cgi@ -> /cgibin/m.cgi

Running strings returns a reference to the name of the attachment we saw in the HTTP response:

$ strings ./cramfs-root/cgibin/download.cgi| grep cfg
Content-Disposition: attachment; filename="config_%s_%s.cfg"
Content-Disposition: attachment; filename="config_portforward_%s.cfg"
Content-Disposition: attachment; filename="config_firewall_%s.cfg"

Let's load the CGI binary in a disassembler. I would recommend radare2 if you can! :) Personally I have some issues with r2 when loading MIPS binaries (I'm too stupid to use it properly I guess) so don't mind me using IDA.

Educated guess with simple reversing

Loading download.cgi in IDA, we find where the string Content-Disposition: attachment; filename="config_%s_%s.cfg" is referenced from and land in the main function.

ipTIME download.cgi Filename Reference

Looking around, the CGI binary doesn't seem to be doing anything interesting. In python pseudo-code, it would be similar to:

def main(args):
    bin_data = ''
    if 'download.cgi' in args:
        if file_exists('/var/run/savefs.gz'):
            bin_data = open('/var/run/savefs.gz').read()
        filename = 'config_%s_%s.cfg' % (hwinfo_get_product_info(), time(format='%04d%02d%02d_%02d%02d%02d'))
        send_http_response(data=bin_data, filename=filename)
    elif 'download_portforward.cgi':
        # . . .
    elif 'download_firewall.cgi':
        # . . .

Although download.cgi doesn't do much, it gives us a new lead: /var/run/savefs.gz. Let's find a reference to that string in another binary:

$ grep -r savefs ./cramfs-root/cgibin/* 2>/dev/null
Binary file ./cramfs-root/cgibin/download.cgi matches
Binary file ./cramfs-root/cgibin/download_firewall.cgi matches
Binary file ./cramfs-root/cgibin/download_portforward.cgi matches
Binary file ./cramfs-root/cgibin/timepro.cgi matches

Since download_firewall.cgi and download_portforward.cgi are both symbolic links to download.cgi, only timepro.cgi remains.

Looking for strings containing savefs, three are referenced from the function print_iframe_sysconf_misc_configmgmt_restore and the disassembly looks promising:

.text:0042E140                 .globl print_iframe_sysconf_misc_configmgmt_restore
.text:0042E140 print_iframe_sysconf_misc_configmgmt_restore:  # CODE XREF: print_iframe+924p
; [ ... ]
.text:0042E1A8                 la      $a0, (aTrIdOnline+8)
.text:0042E1AC                 nop
.text:0042E1B0                 addiu   $a0, (aVarRunRsavefs_ - 0x4E0000)  # "/var/run/rsavefs.gz"
.text:0042E1B4                 la      $a1, (aTrIdOnline+8)
.text:0042E1B8                 nop
.text:0042E1BC                 addiu   $a1, (aW - 0x4E0000)  # "w+"
.text:0042E1C0                 la      $t9, fopen
.text:0042E1C4                 nop
.text:0042E1C8                 jalr    $t9 ; fopen
.text:0042E1CC                 nop
; [ . . . ]
.text:0042E22C                 la      $a0, (aTrIdOnline+8)
.text:0042E230                 nop
.text:0042E234                 addiu   $a0, (aGzipDVarRunRsa - 0x4E0000)  # "gzip -d /var/run/rsavefs.gz"
.text:0042E238                 la      $t9, system
.text:0042E23C                 nop
.text:0042E240                 jalr    $t9 ; system
.text:0042E244                 nop
; [ . . . ]
.text:0042E254                 la      $a0, (aTrIdOnline+8)
.text:0042E258                 nop
.text:0042E25C                 addiu   $a0, (aVarRunRsavefs - 0x4E0000)  # "/var/run/rsavefs"
.text:0042E260                 la      $t9, restore_backup_config
.text:0042E264                 nop
.text:0042E268                 jalr    $t9 ; restore_backup_config
.text:0042E26C                 nop
; [ . . . ]

A quick guess is that this function will handle the configuration restore feature of the router (when you upload the .cfg file you previously downloaded). We are now sure that .cfg outer layer is a gzip file thanks to the call to system:

$ cp config_n704v3_20000101_000208.cfg config.gz
$ gzip -d config.gz 
$ file config
config: data
$ ls -l config
-rw-r--r--  1 depierre  depierre  3161 Feb  6 22:06 config

Gzip was successful but the output file does not seem to match any known signature. Let's run binwalk once again and see what it finds:

$ binwalk config

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
48            0x30            gzip compressed data, maximum compression, from Unix, last modified: 2000-01-01 00:01:44

There seems to be a new gzip archive in the file but only starting at offset 0x30. What is before? Maybe xxd could give us a clue:

$ head -c 64 config | xxd
00000000: 7261 775f 6e76 0000 0000 0000 0000 0000  raw_nv..........
00000010: 290c 0000 172b 0600 d07f 0000 0000 0100  )....+..........
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 1f8b 0800 e843 6d38 0203 ed1c db72 dbb8  .....Cm8.....r..

The bytes 1f8b (offset 0x30) are the gzip magic header, which binwalk detected correctly. The 0x30 bytes before however do not seem to match any known format, except appearing in the boot log messages of different routers (ipTIME, TOTOLINK, potentially others).

Just guessing but here is what I think:

More reverse to confirm our hypotheses

We can test our assumptions by continuing our reverse engineering of the firmware and finding the implementation of the function restore_backup_config, imported and used by timepro.cgi.

Running some grep/strings, it seems to be implemented in the library ./cramfs-root/usr/lib/libbcm.so:

.text:00004070                 .globl restore_backup_config
.text:00004070 restore_backup_config:
; [ . . . ]
.text:00004084                 move    $a2, $a0
.text:00004088                 li      $a1, 0x20000
.text:0000408C                 nop
.text:00004090                 addiu   $a1, (aSbinRestorebac - 0x20000)  # "/sbin/restorebackup %s"
.text:00004094                 addiu   $a0, $sp, 0x120+var_108
.text:00004098                 sw      $ra, 0x120+var_4($sp)
.text:0000409C                 sw      $gp, 0x120+var_8($sp)
.text:000040A0                 la      $t9, sprintf
.text:000040A4                 nop
.text:000040A8                 jalr    $t9 ; sprintf
.text:000040AC                 nop
.text:000040B0                 nop
.text:000040B4                 lw      $gp, 0x120+var_110($sp)
.text:000040B8                 addiu   $a0, $sp, 0x120+var_108
.text:000040BC                 la      $t9, system
.text:000040C0                 nop
.text:000040C4                 jalr    $t9 ; system
; [ . . . ]

All it does it call system to run the binary restorebackup with the argument "/var/run/rsavefs". We keep digging and load ./cramfs-root/sbin/restorebackup in IDA. Looking around, we find multiple interesting strings and references.

For instance, the string raw_nv is referenced in the function tar_restore. Let's see:

.text:0040E150                 .globl tar_restore
.text:0040E150 tar_restore:                             # CODE XREF: main+990p
; [ . . . ]
; f = open('/dev/mtd/3')
; s0 = malloc(0x8000)
; s0 = f.read(0x8000)
.text:0040E20C                 la      $a1, loc_430000
.text:0040E210                 nop
.text:0040E214                 addiu   $a1, (aRaw_nv - 0x430000)  # "raw_nv"
.text:0040E218                 move    $a0, $s0
.text:0040E21C                 li      $a2, 6
.text:0040E220                 la      $t9, memcmp
.text:0040E224                 nop
.text:0040E228                 jalr    $t9 ; memcmp
.text:0040E22C                 nop
.text:0040E230                 nop
.text:0040E234                 lw      $gp, 0x38+var_28($sp)
.text:0040E238                 bnez    $v0, loc_40E264
; [ . . . ]

The function tar_restore first reads the content of /dev/mtd/3 and compares the 6 first bytes with the string raw_nv. If they are equal, it continues further. Seems to be confirming our first guess about offset 0x00.

I personally didn't know for sure that /dev/mtd/3 would contain the running configuration of the router. I assumed that it would since the logic implemented in tar_restore made sense that way. Here is a more complete example of a configuration of a flash.

; [ . . . ]
.text:0040E244                 lw      $a1, 0x10($s0)
.text:0040E248                 nop
.text:0040E24C                 addiu   $v0, $a1, -1
.text:0040E250                 sltiu   $v0, 0x7FD0
.text:0040E254                 addiu   $s3, $s0, 0x30
.text:0040E258                 bnez    $v0, loc_40E360
.text:0040E25C                 nop
; [ . . . ]

It then loads the byte at offset 0x10 and continues if its value minus 1 is lower than 0x7FD0. While at first it's not clear what it means, it becomes more obvious once you realize that 0x8000 - 0x7FD0 == 0x30:

Then:

; [ . . . ]
.text:0040E360 loc_40E360:                              # CODE XREF: tar_restore+108j
.text:0040E360                 move    $a0, $s3
.text:0040E364                 la      $t9, loc_410000
.text:0040E368                 nop
.text:0040E36C                 addiu   $t9, (sub_40D4CC - 0x410000)
.text:0040E370                 nop
.text:0040E374                 jalr    $t9 ; sub_40D4CC
.text:0040E378                 nop
.text:0040E37C                 nop
.text:0040E380                 lw      $gp, 0x38+var_28($sp)
.text:0040E384                 lw      $v1, 0x14($s0)
.text:0040E388                 nop
.text:0040E38C                 bne     $v1, $v0, loc_40E264
.text:0040E390                 nop
; [ . . . ]

It calls sub_40D4CC with the buffer containing the inner gzip file as parameter and compare its return value to the value at offset 0x14 in the header. What we assumed to be a checksum seems to hold true so let's understand how to compute the checksum ourself. Could it be a CRC32?

.text:0040D4CC sub_40D4CC:                              # CODE XREF: tar_restore+224p
.text:0040D4CC                                          # DATA XREF: tar_restore+21Co
.text:0040D4CC                 li      $gp, 0xFC7CE84
.text:0040D4D4                 addu    $gp, $t9
.text:0040D4D8                 move    $a2, $zero  ; i = 0
.text:0040D4DC                 blez    $a1, locret_40D504  ; len == header[0x10] (compressed size)
.text:0040D4E0                 move    $a3, $zero  ; checksum = 0
.text:0040D4E4                 nop
.text:0040D4E8
.text:0040D4E8 loc_40D4E8:                              # CODE XREF: sub_40D4CC+2Cj
.text:0040D4E8                 addu    $v0, $a0, $a2
.text:0040D4EC                 lbu     $v1, 0($v0)  ; $v1 = buffer[i]
.text:0040D4F0                 addiu   $a2, 1  ; i += 1
.text:0040D4F4                 slt     $v0, $a2, $a1  ; $v0 = 1 if i > len
.text:0040D4F8                 bnez    $v0, loc_40D4E8  ; goto exit if $0 == 1
.text:0040D4FC                 addu    $a3, $v1  ; checksum += $v1
.text:0040D500                 nop
.text:0040D504
.text:0040D504 locret_40D504:                           # CODE XREF: sub_40D4CC+10j
.text:0040D504                 jr      $ra
.text:0040D508                 move    $v0, $a3  ; return checksum
.text:0040D508  # End of function sub_40D4CC

Sweet, it's much simpler than CRC32! In python it's a one-liner:

checksum = sum(c for c in buffer)

So we update our assumptions accordingly:

Once the checksum has been validated, tar_restore saves the inner gzip buffer in the file /tmp/etc.tar.gz. Then it calls system twice:

; [ . . . ]
.text:0040E428                 la      $a0, loc_430000
.text:0040E42C                 nop
.text:0040E430                 addiu   $a0, (aGzipDTmpEtc_ta - 0x430000)  # "gzip -d /tmp/etc.tar.gz"
.text:0040E434                 la      $t9, system
.text:0040E438                 nop
.text:0040E43C                 jalr    $t9 ; system
.text:0040E440                 nop
.text:0040E444                 nop
.text:0040E448                 lw      $gp, 0x38+var_28($sp)
.text:0040E44C                 nop
.text:0040E450                 la      $a0, loc_430000
.text:0040E454                 nop
.text:0040E458                 addiu   $a0, (aTarXvfTmpEtc_t - 0x430000)  # "tar xvf /tmp/etc.tar > /tmp/restorelog"
.text:0040E45C                 la      $t9, system
.text:0040E460                 nop
.text:0040E464                 jalr    $t9 ; system
.text:0040E468                 nop
; [ . . . ]

At this point, we have gathered enough information to be able to unpack a configuration backup file and re-pack it to be valid and accepted by the router.

Time to put it back together

To sum up the .cfg configuration file is composed of:

+-----------------------------------+
| gzip (outer) file                 |
|   +-----------------------------+ |
|   | ipTIME binary header        | |
|   |   0x30 bytes                | |
|   |   0x0: magic 'raw_nv'       | |
|   |   ...                       | |
|   +-----------------------------+ |
|   | gzip (inner) file           | |
|   |   +-----------------------+ | |
|   |   | tar file              | | |
|   |   |   /etc/               | | |
|   |   |   /etc/groups         | | |
|   |   |   /etc/iconfig.cfg    | | |
|   |   |   /etc/igmpproxy.conf | | |
|   |   |   /etc/...            | | |
|   |   |                       | | |
|   |   +-----------------------+ | |
|   +-----------------------------+ |
+-----------------------------------+

ipTIME custom header is composed of the following elements:

+--------+--------------+--------------------------------------------------------------+
| Offset | Size (bytes) | Description                                                  |
+--------+--------------+--------------------------------------------------------------+
|  0x00  |     0x06     | Magic String ("raw_nv")                                      |
|  0x07  |     0x0a     | 0x00 padding (16-byte alignment)                             |
|  0x10  |     0x04     | Size in bytes of the compressed inner tar.gz archive         |
|  0x14  |     0x04     | Sum of the bytes of the compressed inner tar.gz archive      |
|  0x18  |     0x04     | Maximum size in bytes of the compressed inner tar.gz archive |
|  0x1c  |     0x04     | FS id (based on debug log string in `restorebackup` binary)  |
|  0x20  |     0x10     | 0x00 padding (16-byte alignment)                             |
+--------+--------------+--------------------------------------------------------------+

I wrote a python script to extract the configuration backup file. You can then easily edit the configuration by hand (e.g. /etc/iconfig.cfg) or the other files in /etc/ (e.g. /etc/init.d/rcS, although n704 doesn't seem to care. I suppose that rcS is loaded from the default directory because /tmp/etc/ is likely not available yet). And finally you can use the script again to re-pack everything.

I pushed the script in a GitHub repository: https://github.com/DePierre/iptime_utils


contactdepier.re License WTFPL2