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.
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:
download.cgi
, the binary responsible for handling the requestfilename="config_*.cfg"
, the name of the attachment
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.
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:
- Offset 0x00:
raw_nv
, a magic header string (likeJFIF
orPNG
or\x1f\x8b
) - Offset 0x10:
0x290c
, the size of the gzip file starting at offset0x30
- 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
:
.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
:
- 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 of0x8000
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:
; [ . . . ] .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:
- The value at offset
0x10
is indeed the size of the file (it cannot be larger than0x7FD0
) - 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:
; [ . . . ] .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