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.
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.
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.
- Obtain port from body of HTTP GET request to
https://$(channel_api_prefix)api.koalasafe.com/api/router/$(device_id)/et
- 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.