title: Understanding ipTIME Configuration Backup File Format author: depierre published: 2017-02-12 categories: Reverse Engineering keywords: iptime, n704, v3, router, cfg, configuration, backup, reverse, ida, binwalk Continuing to play with my [ipTIME n704-v3](https://iptime.com/iptime/?page_id=11&pf=15&pt=176&pd=1), 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. :::bash $ 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](https://github.com/DePierre/iptime_utils/blob/master/ipTIME_config.py) 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`: :::bash $ 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: :::bash $ 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](/static/images/iptime/iptime_backup.png) Request: :::http hl_lines="1" GET /sess-bin/download.cgi HTTP/1.1 Host: 192.168.0.1 Cookie: efm_session_id=ugX2NlylmXCkUGFX Connection: close Response: :::http hl_lines="6" 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: + `download.cgi`, the binary responsible for handling the request + `filename="config_*.cfg"`, the name of the attachment The firmware can be found [on ipTIME's download website](http://download.iptime.co.kr/online_upgrade/n704v3_kr_9_986.bin) (version 9.98.6 latest version last time I checked) and extracted using binwalk ([shown in a previous blog post](https://depier.re/iptime_uart_magic_and_not_so_useful_key/)). Looking inside the `/cgibin/` directory we find `download.cgi`: :::bash hl_lines="8" $ 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: :::bash hl_lines="2" $ 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](http://rada.re/) 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](/static/images/iptime/iptime_filenameref.png) Looking around, the CGI binary doesn't seem to be doing anything interesting. In python pseudo-code, it would be similar to: :::python 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: :::bash hl_lines="5" $ 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: :::nasm hl_lines="17 20 25 28" .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`: :::bash hl_lines="3 4" $ 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: :::bash $ 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: :::bash $ 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](http://www.onicos.com/staff/iz/formats/gzip.html), 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](https://pierrekim.github.io/advisories/2015-iptime-0x02.txt), [TOTOLINK](https://www.exploit-db.com/exploits/37623/), potentially others). Just guessing but here is what I think: + Offset 0x00: `raw_nv`, a magic header string (like `JFIF` or `PNG` or `\x1f\x8b`) + Offset 0x10: `0x290c`, the size of the gzip file starting at offset `0x30` + Offset 0x14: `0x172b 0x0600 0xd07f`, a checksum maybe? + Offset 0x1e: `0x0100`, a version number? + Offset 0x20: `0x00` * 16, some padding? # 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`: :::nasm hl_lines="7 8 13 17 20" .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: :::nasm hl_lines="9 10 14" .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](http://www.denx.de/wiki/DULG/FlashFilesystemsMTD) is a more complete example of a configuration of a flash. :::nasm hl_lines="2 4 5" ; [ . . . ] .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`: + Our first guess was that the value at offset `0x10` was the size of the gzip inner file + Here `tar_restore` checks that the size is smaller than the allocated buffer of `0x8000` bytes (allocated earlier in the function) minus the size of the header (`0x30` bytes) + If it is smaller, it then increments the buffer pointer by `0x30` (in other words, it skips the header so that buffer starts at the inner gzip file) Then: :::nasm hl_lines="3 8 12 14" ; [ . . . ] .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? :::nasm hl_lines="5 7 12 16" .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: :::python checksum = sum(c for c in buffer) So we update our assumptions accordingly: + The value at offset `0x10` is indeed the size of the file (it cannot be larger than `0x7FD0`) + The value at offset `0x14` is the sum of all the bytes of the inner gzip file + The value at offset `0x18` is the maximum size of the inner gzip file (`0x7FD0`) 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: :::nasm hl_lines="4 7 14 17" ; [ . . . ] .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](https://github.com/DePierre/iptime_utils)