KoalaSafe - Retrospective research

Introduction

Looking through the box of devices, I stumbled onto the small device I once helped kickstart called “KoalaSafe”, it was a device aimed to make it simple and easy for parents control and filter their children’s internet access by creating a special Wi-Fi network that is controlled by a simple mobile app.

KoalaSafe gave this device the model number of KS150N, but further investigation reveals the device is actually a white labelled GL.iNet GL-AR150 which suggests it could also be repurposed to run a full functional firmware or even better OpenWRT.

In early 2021 KoalaSafe announced they would be shutting down operations, unlike many vendors they did attempt to keep the devices from landfill by pushing out OpenWRT to any that were online. For my device it was offline during this time and thats helpful as it lets us for a bit of retrospective research, so with that lets get digging!

Physical

Starting from the outside we can see the device has the exact same physical attributes of the GL-AR150

  • 2x RJ45 100Mbps Ethernet Ports (WAN and LAN)
  • 1x MicroUSB port for power in
  • 1x USB port
  • 1x Push button for reset
  • 1x Tri-state switch (casing limits to two states)
  • 2x ventilation holes
  • 3x Status LEDs (Power / Wifi / LAN)
  • 58 x 58 x 25mm dimensions
  • 40g weight

On the bottom of the unit is a QR code used to link the device to the app/service, decoding the QR code reveals this is simply the MAC address of the device.

KS150N

Tear down

Opening the device is as simple as prying the base off, with no screws or glue used at all.

  • Atheros AR9331-AL3A
    • WiSoC with MIPS 24Kc CPU running at 400MHz
    • 802.11n WLAN
    • 1x USB2.0 Host/Device
    • 5x 10/100Mbps ports (switched)
  • Winbond 5Q128FVSG
    • 128Mb (16MB) SPI Flash
  • elixir N2DS51216DS-5T
    • 512Mb (64MB) DDR1 SDRAM

Interesting to note is the conveniently labelled and pre-soldered UART pins, saving the steps of testing the pins.

KS-150N BottomKS-150N PCB BottomKS-150N PBC Top

Getting console

Attaching to the UART pins with an adapter, and starting a terminal with the common 11500 baud rate, reveals an instant root shell.

[    0.660000] console [ttyATH0] enabled, bootconsole disabled
[    0.660000] console [ttyATH0] enabled, bootconsole disabled
[    0.670000] ath79-spi ath79-spi: master is unqueued, this is deprecated
[    0.680000] m25p80 spi0.0: found w25q128, expected m25p80
[    0.680000] m25p80 spi0.0: w25q128 (16384 Kbytes)
[    0.690000] 4 cmdlinepart partitions found on MTD device spi0.0
[    0.700000] Creating 4 MTD partitions on "spi0.0":
[    0.700000] 0x000000000000-0x000000040000 : "u-boot"
[    0.710000] 0x000000040000-0x000000050000 : "u-boot-env"
[    0.710000] 0x000000050000-0x000000ff0000 : "firmware"
[    0.730000] 2 uimage-fw partitions found on MTD device firmware
[    0.740000] 0x000000050000-0x000000160000 : "kernel"
[    0.740000] 0x000000160000-0x000000ff0000 : "rootfs"
[    0.750000] mtd: device 4 (rootfs) set to be root filesystem
[    0.750000] 1 squashfs-split partitions found on MTD device rootfs
[    0.760000] 0x000000910000-0x000000ff0000 : "rootfs_data"
[    0.770000] 0x000000ff0000-0x000001000000 : "art"
[    0.790000] libphy: ag71xx_mdio: probed
[    1.340000] ag71xx ag71xx.0: connected to PHY at ag71xx-mdio.1:04 [uid=004dd041, driver=Generic PHY]
[    1.350000] eth0: Atheros AG71xx at 0xb9000000, irq 4, mode:MII
[    1.900000] ag71xx-mdio.1: Found an AR7240/AR9330 built-in switch
[    2.940000] eth1: Atheros AG71xx at 0xba000000, irq 5, mode:GMII
[    2.940000] TCP: cubic registered
[    2.950000] NET: Registered protocol family 17
[    2.950000] Bridge firewalling registered
[    2.950000] 8021q: 802.1Q VLAN Support v1.8
[    2.970000] VFS: Mounted root (squashfs filesystem) readonly on device 31:4.
[    2.980000] Freeing unused kernel memory: 284K (80329000 - 80370000)
procd: Console is alive
procd: - watchdog -
[    6.370000] usbcore: registered new interface driver usbfs
[    6.370000] usbcore: registered new interface driver hub
[    6.380000] usbcore: registered new device driver usb
[    6.430000] SCSI subsystem initialized
[    6.440000] ehci_hcd: USB 2.0 'Enhanced' Host Controller (EHCI) Driver
[    6.450000] ehci-platform: EHCI generic platform driver
[    6.450000] ehci-platform ehci-platform: EHCI Host Controller
[    6.460000] ehci-platform ehci-platform: new USB bus registered, assigned bus number 1
[    6.470000] ehci-platform ehci-platform: irq 3, io mem 0x1b000000
[    6.500000] ehci-platform ehci-platform: USB 2.0 started, EHCI 1.00
[    6.500000] hub 1-0:1.0: USB hub found
[    6.500000] hub 1-0:1.0: 1 port detected
[    6.510000] uhci_hcd: USB Universal Host Controller Interface driver
[    6.520000] usbcore: registered new interface driver usb-storage
procd: - preinit -
Press the [f] key and hit [enter] to enter failsafe mode
Press the [1], [2], [3] or [4] key and hit [enter] to select the debug level
jffs2 is ready
No jffs2 marker was found
[   10.600000] jffs2: notice: (320) jffs2_build_xattr_subsystem: complete building xattr subsystem, 2 of xdatum (1 unchecked, 1 orphan) and 63 of xref (0 dead, 42 orphan) found.
switching to overlay
procd: - early -
procd: - watchdog -
procd: - ubus -
procd: - init -
Please press Enter to activate this console.
[   14.440000] NET: Registered protocol family 10
[   14.460000] nf_conntrack version 0.5.0 (960 buckets, 3840 max)
[   14.470000] ip6_tables: (C) 2000-2006 Netfilter Core Team
[   14.480000] Netfilter messages via NETLINK v0.30.
[   14.490000] ip_set: protocol 6
[   14.520000] Loading modules backported from Linux version master-2014-05-22-0-gf2032ea
[   14.530000] Backport generated by backports.git backports-20140320-37-g5c33da0
[   14.540000] ip_tables: (C) 2000-2006 Netfilter Core Team
[   14.560000] ctnetlink v0.93: registering with nfnetlink.
[   14.610000] xt_time: kernel timezone is -0000
[   14.650000] cfg80211: Calling CRDA to update world regulatory domain
[   14.650000] cfg80211: World regulatory domain updated:
[   14.660000] cfg80211:  DFS Master region: unset
[   14.660000] cfg80211:   (start_freq - end_freq @ bandwidth), (max_antenna_gain, max_eirp), (dfs_cac_time)
[   14.670000] cfg80211:   (2402000 KHz - 2472000 KHz @ 40000 KHz), (N/A, 2000 mBm), (N/A)
[   14.680000] cfg80211:   (2457000 KHz - 2482000 KHz @ 40000 KHz), (N/A, 2000 mBm), (N/A)
[   14.690000] cfg80211:   (2474000 KHz - 2494000 KHz @ 20000 KHz), (N/A, 2000 mBm), (N/A)
[   14.690000] cfg80211:   (5170000 KHz - 5250000 KHz @ 160000 KHz), (N/A, 2000 mBm), (N/A)
[   14.700000] cfg80211:   (5250000 KHz - 5330000 KHz @ 160000 KHz), (N/A, 2000 mBm), (0 s)
[   14.710000] cfg80211:   (5490000 KHz - 5730000 KHz @ 160000 KHz), (N/A, 2000 mBm), (0 s)
[   14.720000] cfg80211:   (5735000 KHz - 5835000 KHz @ 80000 KHz), (N/A, 2000 mBm), (N/A)
[   14.730000] cfg80211:   (57240000 KHz - 63720000 KHz @ 2160000 KHz), (N/A, 0 mBm), (N/A)
[   14.890000] cfg80211: Calling CRDA for country: US
[   14.900000] cfg80211: Regulatory domain changed to country: US
[   14.900000] cfg80211:  DFS Master region: FCC
[   14.900000] cfg80211:   (start_freq - end_freq @ bandwidth), (max_antenna_gain, max_eirp), (dfs_cac_time)
[   14.910000] cfg80211:   (2402000 KHz - 2472000 KHz @ 40000 KHz), (N/A, 3000 mBm), (N/A)
[   14.920000] cfg80211:   (5170000 KHz - 5250000 KHz @ 80000 KHz), (N/A, 1700 mBm), (N/A)
[   14.930000] cfg80211:   (5250000 KHz - 5330000 KHz @ 80000 KHz), (N/A, 2300 mBm), (0 s)
[   14.940000] cfg80211:   (5735000 KHz - 5835000 KHz @ 80000 KHz), (N/A, 3000 mBm), (N/A)
[   14.950000] cfg80211:   (57240000 KHz - 63720000 KHz @ 2160000 KHz), (N/A, 4000 mBm), (N/A)
[   14.950000] ieee80211 phy0: Atheros AR9330 Rev:1 mem=0xb8100000, irq=2
[   29.500000] IPv6: ADDRCONF(NETDEV_UP): eth0: link is not ready
[   29.500000] device eth0 entered promiscuous mode
[   29.510000] IPv6: ADDRCONF(NETDEV_UP): br-koala: link is not ready
[   30.570000] IPv6: ADDRCONF(NETDEV_UP): eth1: link is not ready
[   30.570000] device eth1 entered promiscuous mode
[   30.580000] br-koala: port 2(eth1) entered forwarding state
[   30.580000] br-koala: port 2(eth1) entered forwarding state
[   30.590000] br-koala: port 2(eth1) entered disabled state
[   31.790000] cfg80211: Calling CRDA for country: CN
[   31.800000] cfg80211: Regulatory domain changed to country: CN
[   31.800000] cfg80211:  DFS Master region: FCC
[   31.800000] cfg80211:   (start_freq - end_freq @ bandwidth), (max_antenna_gain, max_eirp), (dfs_cac_time)
[   31.810000] cfg80211:   (2402000 KHz - 2482000 KHz @ 40000 KHz), (N/A, 2000 mBm), (N/A)
[   31.820000] cfg80211:   (5170000 KHz - 5250000 KHz @ 80000 KHz), (N/A, 2300 mBm), (N/A)
[   31.830000] cfg80211:   (5250000 KHz - 5330000 KHz @ 80000 KHz), (N/A, 2300 mBm), (0 s)
[   31.840000] cfg80211:   (5735000 KHz - 5835000 KHz @ 80000 KHz), (N/A, 3000 mBm), (N/A)
[   31.850000] cfg80211:   (57240000 KHz - 59400000 KHz @ 2160000 KHz), (N/A, 2800 mBm), (N/A)
[   31.850000] cfg80211:   (59400000 KHz - 63720000 KHz @ 2160000 KHz), (N/A, 4400 mBm), (N/A)
[   31.860000] cfg80211:   (63720000 KHz - 65880000 KHz @ 2160000 KHz), (N/A, 2800 mBm), (N/A)
Jul 15 11:28:02 crond[1101]: crond: crond (busybox 1.22.1) started, log level 5
Jul 15 11:28:03 lighttpd[1139]: (log.c.164) server started 
[   34.220000] IPv6: ADDRCONF(NETDEV_UP): wlan0: link is not ready
[   34.240000] device wlan0 entered promiscuous mode
[   41.670000] br-koala: port 3(wlan0) entered forwarding state
[   41.680000] br-koala: port 3(wlan0) entered forwarding state
[   41.680000] IPv6: ADDRCONF(NETDEV_CHANGE): wlan0: link becomes ready
[   41.710000] IPv6: ADDRCONF(NETDEV_CHANGE): br-koala: link becomes ready
[   43.680000] br-koala: port 3(wlan0) entered forwarding state
procd: - init complete -
procd: Instance koala::instance1 s in a crash loop 6 crashes, 3 seconds since last crash



BusyBox v1.22.1 (2015-07-27 10:13:05 AEST) built-in shell (ash)
Enter 'help' for a list of built-in commands.

    ___..._           _...___
   /'--.._ `'-="""=-'` _..--'\
   |   ~. )  _     _  ( .~   |      Koala v1.7272
    \  '~/   a  _  a   \~'  /
     \  `|     / \     |`  /
      `'--\    \_/    /--'`
          .'._  J__.-'.
         / /  '-/_ `-  \
        / -"-'-.  '-.__/
        \__,-.\/     | `\
        /  ;---.  .--'   |
       |     /\'-'      /
       '.___.\   _.--;'`)
              '-'     `"
root@koala:/# 

Enumeration

Checking the usual suspects we find the device has a SSH server on TCP 22, and a web server listening on multiple TCP ports (80,81,82)

Reviewing the HTTP server config and files, there is no immediate vulnerability, digging deeper we find an interesting crontab job…

Before we get to the crontab, it does appear the device may have a static root password which may be useful for targeting SSH, but my limited hashcat attempts did not crack this password.

Running Processes

root@koala:/# ps w
  PID USER       VSZ STAT COMMAND
    1 root      1388 S    /sbin/procd
    2 root         0 SW   [kthreadd]
    3 root         0 SW   [ksoftirqd/0]
    4 root         0 SW   [kworker/0:0]
    5 root         0 SW<  [kworker/0:0H]
    6 root         0 SW   [kworker/u2:0]
    7 root         0 SW<  [khelper]
   59 root         0 SW<  [writeback]
   62 root         0 SW<  [bioset]
   64 root         0 SW<  [kblockd]
   89 root         0 SW   [kworker/0:1]
   94 root         0 SW   [kswapd0]
  139 root         0 SW   [fsnotify_mark]
  153 root         0 SW<  [ath79-spi]
  174 root         0 SW   [kworker/u2:2]
  240 root         0 SW<  [deferwq]
  252 root         0 SW   [khubd]
  321 root         0 SWN  [jffs2_gcd_mtd5]
  378 root       880 S    /sbin/ubusd
  379 root      1360 S    /bin/ash --login
  805 root         0 SW<  [cfg80211]
  938 root      1480 S    /sbin/netifd
  959 root      1152 S    /usr/sbin/odhcpd
 1101 root      1360 S    /usr/sbin/crond -f -c /etc/crontabs -l 5
 1116 root      1152 S    /usr/sbin/dropbear -F -P /var/run/dropbear.1.pid -s -g -p 22 -K 300
 1139 root      3556 S    /usr/sbin/lighttpd -f /etc/lighttpd/lighttpd.conf
 1153 root         0 SW<  [kworker/0:1H]
 1164 root      4888 S    {syslog-ng} supervising syslog-ng
 1165 root      4936 S    /usr/sbin/syslog-ng
 1187 nobody    1628 S    avahi-daemon: running [koala.local]
 1229 root      1572 S    /usr/sbin/hostapd -P /var/run/wifi-phy0.pid -B /var/run/hostapd-phy0.conf
 1248 root      1356 S    udhcpc -p /var/run/udhcpc-br-koala.pid -s /lib/netifd/dhcp.script -f -t 0 -i br-koala -C
 1286 root      1356 S    /usr/sbin/ntpd -n -p 0.openwrt.pool.ntp.org -p 1.openwrt.pool.ntp.org -p 2.openwrt.pool.ntp.org -p 3.open

Listening Ports

root@koala:/# netstat -tunelp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      1139/lighttpd
tcp        0      0 0.0.0.0:81              0.0.0.0:*               LISTEN      1139/lighttpd
tcp        0      0 0.0.0.0:82              0.0.0.0:*               LISTEN      1139/lighttpd
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      1116/dropbear
tcp        0      0 :::22                   :::*                    LISTEN      1116/dropbear
udp        0      0 0.0.0.0:514             0.0.0.0:*                           1165/syslog-ng
udp        0      0 0.0.0.0:5353            0.0.0.0:*                           1187/avahi-daemon: 

File System and Mounts

root@koala:/# ls /
app      dev      init     mnt      proc     root     sys      usr      www
bin      etc      lib      overlay  rom      sbin     tmp      var
root@koala:/# mount
rootfs on / type rootfs (rw)
/dev/root on /rom type squashfs (ro,relatime)
proc on /proc type proc (rw,noatime)
sysfs on /sys type sysfs (rw,noatime)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noatime)
/dev/mtdblock5 on /overlay type jffs2 (rw,noatime)
overlayfs:/overlay on / type overlayfs (rw,noatime,lowerdir=/,upperdir=/overlay)
tmpfs on /dev type tmpfs (rw,relatime,size=512k,mode=755)
devpts on /dev/pts type devpts (rw,relatime,mode=600)
debugfs on /sys/kernel/debug type debugfs (rw,noatime)

Users

root@koala:/# cat /etc/passwd
root:x:0:0:root:/root:/bin/ash
daemon:*:1:1:daemon:/var:/bin/false
ftp:*:55:55:ftp:/home/ftp:/bin/false
network:*:101:101:network:/var:/bin/false
nobody:*:65534:65534:nobody:/var:/bin/false
root@koala:/# cat /etc/shadow
root:$1$UphqF.pl$04rIdfw.DF.BZqqblT6qy1:16605:0:99999:7:::
daemon:*:0:0:99999:7:::
ftp:*:0:0:99999:7:::
network:*:0:0:99999:7:::
nobody:*:0:0:99999:7:::

Cron Jobs

root@koala:/# cat /etc/crontabs/root 
46 1 * * * /lib/functions/phonehome.sh

/etc/lighttpd/lighttpd.conf

# lighttpd configuration file
#
## modules to load
# all other module should only be loaded if really neccesary
# - saves some time
# - saves memory
server.modules = (
        "mod_rewrite",
        "mod_cgi"

)



server.document-root = "/www"

# force use of the "write" backend (closes: #2401)
server.network-backend = "write"

## where to send error-messages to
#server.errorlog = "/var/log/lighttpd/error.log"
server.errorlog-use-syslog = "enable"
#server.breakagelog = "/var/log/lighttpd/breakage.log"
## files to check for if .../ is requested
index-file.names = (  )

## mimetype mapping
mimetype.assign = ( )


##
# which extensions should not be handle via static-file transfer
#
# .php, .pl, .fcgi are most often handled by mod_fastcgi or mod_cgi
static-file.exclude-extensions = ( ".lua" )

######### Options that are good to be but not neccesary to be changed #######

## bind to port (default: 80)
server.port = 80
cgi.assign = (".lua" => "/usr/bin/lua")
## to help the rc.scripts
server.pid-file = "/var/run/lighttpd.pid"


$SERVER["socket"] == ":81" {

  $HTTP["host"] =~ "([^:/]+)" {
        url.rewrite-once = ( "(.*)" => "/redirect.lua?dst=http://%0$1" )
    }
}

$SERVER["socket"] == ":82" {

  $HTTP["host"] =~ "([^:/]+)" {
        url.rewrite-once = ( "(.*)" => "/redirect.lua?dst=http://%0$1" )
    }
}

Web Server Files

root@koala:/# ls -alR /www
/www:
drwxrwxr-x    2 root     root            72 Jul 27  2015 .
drwxrwxr-x    1 1000     1000             0 Jan  1  1970 ..
-rw-rw-r--    1 root     root           120 Jul 27  2015 include.lua
-rw-rw-r--    1 root     root          1854 Jul 27  2015 redirect.lua
-rw-rw-r--    1 root     root           746 Jul 27  2015 status.lua

/www/include.lua

function get_device_id()
    io.input("/sys/class/net/br-koala/address")
    return io.read("*line"):gsub(":","")
end

/www/status.lua

function handle_request(env)

    local uci = require "uci"
    require "include"


    local function get_version()
        io.input("/etc/version")
        return io.read("*line"):gsub(":","")
    end

    local deviceid = get_device_id()
    local clientid = uci.get("koala.@system[0].clientid")
    local version = get_version()
    local format = [[{"deviceid":"%s", "version":"%s", "clientid":%s}]]
    local clientid_string = (clientid and tostring(clientid) or "null")
    local json = string.format(format, deviceid, version, clientid_string)



    io.write("Status: 200 OK\r\n")
    io.write("Access-Controgin: *\r\n")
    io.write("Content-Type: text/plain\r\n\r\n")
    io.write(json .. "\r\n")

end

handle_request({})

/www/redirect.lua

function handle_request(env)

    local uci = require "uci"
    require "include"

    local function find_mac(env)
        requesting_ip = env.REMOTE_ADDR
        for e in io.lines("/proc/net/arp") do
            ip, mac = e:match("^([%d%.]+)%s+%S+%s+%S+%s+([a-fA-F0-9:]+)%s+")
            if ip ==  requesting_ip then
                return mac
            end
        end
    end

    -- character table string
    local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'

    -- encoding base64
    function enc(data)
        return ((data:gsub('.', function(x)
            local r,b='',x:byte()
            for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end
            return r;
        end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
            if (#x < 6) then return '' end
            local c=0
            for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end
            return b:sub(c+1,c+1)
        end)..({ '', '==', '=' })[#data%3+1])
    end
    
    local mac = find_mac(env)
    local device_id = get_device_id()
    local channel = uci.get("koala.@system[0].channel")
    if channel == "master" then
        channel = ""
    else
        channel = channel .. "-"
    end
    local port = env.SERVER_PORT
    local query = "deviceid=" .. device_id .. "&mac=" .. mac
    local url = "https://" .. channel .. "api.koalasafe.com/api/"
    if port == "81" then
        url = url .. "time"
    end
    if port == "82" then
        url = url .. "blocked"
    end

    url = url .. "?q=" .. enc(query) .. "&" .. env.QUERY_STRING

    io.write("Status: 302 OK\r\n")
    io.write("Content-Type: text/plain\r\n")
    io.write("Location: " .. url .. "\r\n\r\n")
end

require "os"
handle_request({QUERY_STRING=os.getenv("QUERY_STRING"), REMOTE_ADDR=os.getenv("REMOTE_ADDR"), SERVER_PORT=os.getenv("SERVER_PORT")})

ET Phone Home?

During the enumeration we found an interesting cronjob calling a script named phonehome.sh which looked very interesting, lets dig into this script and its associated files.

The phonehome.sh script conviently calls the phonehome function from the /lib/functions/koala.sh script, which when we dig into it we find something very interesting.

Whilst we cannot test any theories with the services being long shutdown, it appears this function establishes a SSH tunnel forwarding its local SSH port to a remote port returned by the server.

  1. Obtain port from body of HTTP GET request to https://$(channel_api_prefix)api.koalasafe.com/api/router/$(device_id)/et
  2. Start reverse tunnel /usr/bin/ssh -NT -y -i /etc/dropbear/id_rsa -R ${port}:localhost:22 [email protected]

Of interesting note is it appears to use a local private ssh key to authenticate as the ec2-user on the remote server, suggesting the potential for every device to have SSH access to the server.

Whilst the user may have been restricted to only port forwarding, it seems unlikely as the ec2-user is the standard admin account created when using AWS.

If this account wasnt restricted it would allow a threat actor to easily gain access to a device in a customers home which is also positioned to sniff and MITM all traffic, as well as having access to the LAN before the device. This would pose a serious threat to all customers of KoalaSafe, let alone the infrastructure at KoalaSafe which may also be exposed.

/lib/functions/phonehome.sh

#!/bin/sh

. /lib/functions/koala.sh
phonehome

/lib/functions/koala.sh

#!/bin/sh

. /lib/functions/network.sh

failsafe_koala () {
    echo "Failsafe triggered"
    KOALA_TMP=/tmp/koalafailsafe
    DROPBEAR_DIR=/overlay/etc/dropbear
    KOALA_RESET=/overlay/.koalareset

    mount_root

    # Don't reboot again if we have
    if [ ! -f ${KOALA_RESET} ]; then
        echo "Performing reset"
        mkdir -p ${KOALA_TMP}
        cp ${DROPBEAR_DIR}/* ${KOALA_TMP}
        rm -Rf /overlay/*
        mkdir -p ${DROPBEAR_DIR}
        mv ${KOALA_TMP}/* ${DROPBEAR_DIR}/
        touch ${KOALA_RESET}
        reboot
    fi

}

retry()
{
        local RET=1
        local n=0
        local cmd=$2
        local tries=$1
        until  [ ${RET} -eq 0 -o ${n} -ge ${tries} ];
        do
                echo "Attempt ${n} of cmd: $cmd"
                n=$((${n}+1))
                ${cmd}
                RET=$?
                sleep 1
        done

        if  [ ${RET} -ne 0 ]; then
            logger -p user.error "Failed after ${n} attempts: ${cmd}"
        fi
}

channel_api_prefix(){
    local channel=$(uci get koala.@system[0].channel)
    if [ "${channel}" = "master" ]; then
        echo ""
    else
        echo "${channel}-"
    fi
}


device_id(){
    mac=$(ifconfig br-koala | awk '/HWaddr/ { print $5 }')
    mac=${mac//:/}
    printf "%d\n" 0x${mac}
}

phonehome(){
    local url="https://$(channel_api_prefix)api.koalasafe.com/api/router/$(device_id)/et"
    echo "Phoning home at ${url}"
    local port=0
    port=$(wget -qO- ${url})
    local result=$?
    if [ $result -ne 0 ]; then
        logger -t PhoneHome "Can't phone home Code:${result}"
        echo "Error Retreiving value"
        return $result
    elif [ ! -z "${port}" ] && [ ${port} -gt 0 ]; then
        echo "Starting reverse tunnel on port ${port}"
        /usr/bin/ssh -NT -y -i /etc/dropbear/id_rsa -R ${port}:localhost:22 [email protected]
    else
        echo "Not required to phone home"
    fi
}

Exchange Keys

How did the SSH keys get exchanged to facilitate this phone home? Was it a static credential accross all the devices or was there some sort of exchange?

Digging into files that reference id_rsa we can quickly identify the key is generated after a reset of the device, which means the key needs to be exchanged with the server.

In the /app/koala/Encryption.py file we find all our answers, once again we can only make assumptions since the infrastructure is long gone, but the code would suggest adding a ssh key to the ec2-user is as simple as making a HTTP PUT request to https://api.koalasafe.com/api/router/%(deviceid)s/key with the public key as the body.

With this bit of information a threat actor would never need to own a device to perform this attack, and could also ensure anonymotity in any attack (eg. creating unique keys, from a clean attack box, via a VPN etc)

Additional review of the code base also revealed the config for a device was available via HTTP GET to https://api.koalasafe.com/api/router/%(deviceid)s/config and the SSH key was used for encryption/decryption of this file.

/etc/uci-defaults/generate-keys.sh**

#!/bin/sh
set -o pipefail  # trace ERR through pipes
set -o errexit   ## set -e : exit the script if any statement returns a non-true return value
mkdir -p /etc/dropbear

# We could be in failsafe reset, we need to keep keys that were saved
if [ ! -f  "/etc/dropbear/id_rsa" ]; then
    dropbearkey -t rsa -f /etc/dropbear/id_rsa
    dropbearconvert dropbear openssh /etc/dropbear/id_rsa /etc/dropbear/id_rsa.ssh
fi

/app/koala/Encryption.py

import sys
import Https
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
import base64
import json
import logging
import os
import urllib2
from retrying import retry



class Encryption():

    def __init__(self):
        self.log = logging.getLogger(__name__)
        self._private_key = None
        self._koala_public_key = None


    def initialise(self, api_base_url, keys_path):
        self.keys_path = keys_path
        self.koala_key_path = keys_path + "/koala.pub"
        self.private_key_path = keys_path + "/id_rsa.ssh"
        self._load_keys()
        if not self._have_exchanged_keys():
            self._exchange_keys(api_base_url)

    def _load_keys(self):
        self._private_key = RSA.importKey(self._load_private_key())
        if os.path.exists(self.koala_key_path):
            self._koala_public_key = RSA.importKey(open(self.koala_key_path).read())

    def _load_private_key(self):
        self.log.info("Using private key at %s", self.private_key_path)
        return open(self.private_key_path).read()

    def _exchange_keys(self, exchange_url):
        exchanger = KeyExchanger(exchange_url)
        self._koala_public_key = exchanger.exchange(self._private_key.publickey(), self.koala_key_path)
        with open("/etc/dropbear/authorized_keys", "w") as f:
            f.write(self._koala_public_key.exportKey(format="OpenSSH"))

    def _have_exchanged_keys(self):
        return self._koala_public_key

    def encrypt(self, text):
        cipher = PKCS1_OAEP.new(self._koala_public_key)
        return base64.b64encode(cipher.encrypt(text))

    def decrypt(self, text):
        cipher = PKCS1_OAEP.new(self._private_key)
        return cipher.decrypt(base64.b64decode(text))

class KeyExchanger():

    def __init__(self, api_base_url):
        self.api_base_url = api_base_url
        self.log = logging.getLogger(__name__)

    def exchange(self, public_key, koala_key_path):
        response = self.put(public_key)
        with open(koala_key_path, "w") as f:
            f.write(response["publickey"])

        return RSA.importKey(response["publickey"])

    @retry(wait_fixed=1000)
    def put(self, public_key):
        try:
            url = self.api_base_url + "/key"
            self.log.info("Exchanging keys at %s", url)
            payload = json.dumps({"publickey": public_key.exportKey(format="OpenSSH")})
            request = urllib2.Request(url, data=payload)
            request.get_method = lambda: "PUT"
            return json.loads(Https.opener.open(request).read())
        except Exception, e:
            self.log.warning("Cant exchange keys: %s", e, exc_info=sys.exc_info())
            raise

instance = Encryption()

Conclusion

Whilst we may never know the answers, and an attack could never be performed now with all devices and infrastructure long gone, it is important to remember the damage an IoT device could cause even with the best intentions to keep children safe.