This post will be a summary of my research on this device and how it led to the discovery of this vulnerability. It has been an introduction for me to IoT and hardware hacking, but also to reverse engineering, so please don’t be hard on me as there may be some mistakes. I also want to thank the cybersec community, since there was always a video, an article or something else that inspired me to learn or try new things whenever I got stuck. Finally, remember that failure is your best friend, although it sometimes makes you take six months for something you could have done in one or two if you would have read correctly the results of a shell command.
Anyway, let’s start with the article. Thanks for reading and I hope you like it.
Once I got the camera out of the box, I proceeded to configure it with my phone and my WiFi. It was fairly easy to do. I started to play with it and I was surprised that a 30€ IP camera has so many functionalities. It can record video and audio to an SD card, it can be moved almost 360º horizontally and like 90º vertically and it can even reproduce audio in real time from the phone app.
My initial plan was just to scan some ports and try to find some vulnerabilities, and also to check how the WiFi configuration worked, since I was very interested in WiFi security at that time. I won’t talk about the WiFi configuration, because after analyzing it with a custom hostapd
+ dnsmasq
access point and Wireshark
, I didn’t’ find anything interesting beyond common functionalities such as creating a wireless access point or connecting to one.
So, let’s talk about the port scanning:
$ nmap -sV -p- 192.168.1.81
Nmap scan report for C200_F81AB2.home (192.168.1.81)
Host is up (0.022s latency).
Not shown: 996 closed ports
PORT STATE SERVICE
443/tcp open https
554/tcp open rtsp
2020/tcp open xinupageserver
8800/tcp open sunwebadmin
As you can see, the device has some interesting open ports. First thing I tested was port 443. Although nmap
clearly indicates that it uses https
, when I initially did the scan, I just overlooked it and spent quite a long time thinking that port 443 was running http
. Because of this, I only tested http://192.168.1.81:443
instead of https://192.168.1.81:443
, so the only thing I got was 400 responses. As I said in the introduction, this journey was full of failures. Regarding the other ports, the services running on them were totally unknown to me and I didn’t find any clear information about them. At this moment, I ran out of known options, so it was time for further investigation.
Before buying the camera, I looked online for previous research on the device and, luckily, I found this Github repository where people were collaborating to reverse engineer it. One of the issues explains how to get a shell through the UART port, which I knew nothing about at the time. So I learned the basics and bought a USB to TTL converter to connect to it:
With the help of the mentioned issue, I was able to open the device with a knife and a screwdriver and quickly locate the UART. After a couple of tries and a lot of patience, I finally managed to solder some wires to the pads:
Then, it was time to test if the soldering was good enough for the data to be transmitted. I connected the wires to the USB adapter, taking into account that Rx of the UART goes to Tx of the adapter and vice versa, and connected the adapter to my computer. Again, thanks to the mentioned issue, I knew the baud rate for the serial connection was 57600, so I executed $ sudo screen /dev/tty.usbserial-0001 57600
, where /dev/tty.usbserial-0001
is the USB port where the adapter is connected to, and powered on the device. I immediately started receiving data, great.
However, I didn’t have a shell yet. What I was receiving was just the booting sequence of the device, which actually was the U-Boot bootloader. It looked something like this:
U-Boot 2014.01-v1.2 (Jul 16 2021 - 18:41:10)
Board: IPCAM RTS3903 CPU: 500M :rx5281 prid=0xdc02
force spi nor mode
DRAM: 64 MiB @ 1066 MHz
Skipping flash_init
Flash: 0 Bytes
flash status is 0, 0, 0
SF: Detected XM25QH64A with page size 256 Bytes, erase size 64 KiB, total 8 MiB
Using default environment
Autobooting in 1 seconds
copying flash to 0x81500000
flash status is 0, 0, 0
SF: Detected XM25QH64A with page size 256 Bytes, erase size 64 KiB, total 8 MiB
SF: 8388608 bytes @ 0x0 Read: OK
[...]
By typing enter, we are asked to input an username and password. Again, thanks to that Github issue, we know the credentials, so we can successfully login with with user root
and password slprealtek
and finally get a shell.
Once I knew the connection worked, I needed to make the soldering less fragile, since it broke twice in the process of actually getting the shell. I applied some hot melt silicone to secure all the wires and closed the device again, disconnecting all motors. Now, my testing unit was ready to go:
Now that we have a shell, let’s explore the device:
root@SLP:~# uname -a
Linux SLP 3.10.27 #1 PREEMPT Wed Nov 11 20:42:05 CST 2020 rlx GNU/Linux
root@SLP:~# cat /etc/openwrt_version
12.09-rc1
As we can see, this is an OpenWRT machine running Linux 3.10.27. Now let’s check active processes and open ports:
root@SLP:~# ps
PID USER VSZ STAT COMMAND
1 root 2328 S init
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 [rcu_preempt]
8 root 0 SW [rcu_bh]
9 root 0 SW [rcu_sched]
10 root 0 SW< [khelper]
11 root 0 SW< [writeback]
12 root 0 SW< [bioset]
13 root 0 SW< [kblockd]
14 root 0 SW [khubd]
15 root 0 SW [kworker/0:1]
16 root 0 SW [kswapd0]
17 root 0 SW [fsnotify_mark]
18 root 0 SW< [crypto]
27 root 0 SW [kworker/u2:1]
46 root 0 SW< [deferwq]
47 root 0 SW< [kworker/0:1H]
247 root 2328 S -ash
262 root 0 SW [irq/27-gpio res]
273 root 0 SW< [cryptodev_queue]
282 root 860 S /sbin/hotplug2 --override --persistent --set-rules-f
304 root 888 S /sbin/ubusd
325 root 8152 S tp_manage
357 root 3416 S /usr/bin/ledd
361 root 3408 S /sbin/msglogd
367 root 3220 S /usr/sbin/netlinkd
370 root 5468 S < /usr/bin/system_state_audio
379 root 10180 S /usr/sbin/wlan-manager
491 root 1636 S /sbin/netifd
492 root 1520 S /usr/sbin/connModed
494 root 11488 S /usr/bin/dsd
496 root 1532 S /usr/sbin/connModed
502 root 7640 S /bin/cloud-service
520 root 4360 S /bin/cloud-brd -c /var/etc/cloud_brd_conf
653 root 15020 S /bin/cloud-client
830 root 2320 S /usr/sbin/telnetd -b 127.0.0.1
861 root 3852 S /usr/sbin/uhttpd -f -h /www -T 180 -A 0 -n 8 -R -r C
870 root 6048 S /usr/bin/relayd
872 root 5948 S /usr/bin/rtspd
879 root 4612 S /usr/bin/p2pd
884 root 11152 S /bin/dn_switch
889 root 4180 S /bin/storage_manager
920 root 40940 S /bin/cet
956 root 32336 S /bin/vda
960 root 3808 S /bin/wtd
970 root 11288 S /bin/nvid
1019 root 2332 S udhcpc -p /var/run/static-dhcpc.pid -s /lib/netifd/s
1037 root 0 SW [RTW_CMD_THREAD]
1059 root 1212 S wpa_supplicant -B -Dwext -iwlan0 -P/tmp/supplicant_p
1089 root 2332 S /usr/sbin/ntpd -n -p time.nist.gov -p 133.100.9.2 -p
1103 root 3840 S /usr/bin/motord
1447 root 2324 R ps
root@SLP:~# netstat -natpu
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:8800 0.0.0.0:* LISTEN 920/cet
tcp 0 0 127.0.0.1:929 0.0.0.0:* LISTEN 875/p2pd
tcp 0 0 0.0.0.0:20002 0.0.0.0:* LISTEN 325/tp_manage
tcp 0 0 0.0.0.0:2020 0.0.0.0:* LISTEN 969/nvid
tcp 0 0 0.0.0.0:554 0.0.0.0:* LISTEN 920/cet
tcp 0 0 127.0.0.1:23 0.0.0.0:* LISTEN 832/telnetd
tcp 0 0 127.0.0.1:921 0.0.0.0:* LISTEN 878/relayd
tcp 0 0 127.0.0.1:922 0.0.0.0:* LISTEN 877/rtspd
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 863/uhttpd
tcp 0 0 192.168.1.80:37380 52.19.66.90:443 ESTABLISHED 507/cloud-brd
udp 0 0 0.0.0.0:20002 0.0.0.0:* 325/tp_manage
udp 0 0 0.0.0.0:38000 0.0.0.0:* 1087/ntpd
udp 0 0 0.0.0.0:3702 0.0.0.0:* 969/nvid
We can see the processes behind those open ports seen in the nmap
scan, such as uhttpd
or cet
. I focused specifically on the uhttpd
process, since it’s the one behind the https
server (which at that point I still thought it was http
) and I was already very familiar with http
protocols.
uhttpd
is a web server made by OpenWRT to be used in embedded devices running this distribution. At this point, I wanted to know if I could get more information about it, something like the source code or, at least, the routes. I went to the OpenWRT wiki and learnt about uhttpd
and OpenWRT in general. In OpenWRT machines, there is a system called Unified Configuration Interface (UCI), which is basically used to easily configure system services. Using this, we can get the uhttpd
configuration:
root@SLP:~# uci show | grep uhttpd
ucitrack.@uhttpd[0]=uhttpd
ucitrack.@uhttpd[0].init=uhttpd
uhttpd.main=uhttpd
uhttpd.main.listen_https=443
uhttpd.main.home=/www
uhttpd.main.rfc1918_filter=1
uhttpd.main.max_requests=8
uhttpd.main.cert=/tmp/uhttpd.crt
uhttpd.main.key=/tmp/uhttpd.key
uhttpd.main.cgi_prefix=/cgi-bin
uhttpd.main.lua_prefix=/luci
uhttpd.main.lua_handler=/usr/lib/lua/luci/sgi/uhttpd.lua
uhttpd.main.script_timeout=180
uhttpd.main.network_timeout=180
uhttpd.main.tcp_keepalive=0
uhttpd.px5g=cert
uhttpd.px5g.days=3600
uhttpd.px5g.bits=1024
uhttpd.px5g.country=CN
uhttpd.px5g.state=China
uhttpd.px5g.location=China
uhttpd.px5g.commonname=TP-Link
upnpc.uhttpd=entry
upnpc.uhttpd.proto=TCP
upnpc.uhttpd.ext_port=80
upnpc.uhttpd.desc=uhttpd
There are some interesting parameters here. First, uhttpd.main.home
points to the server document root, so we may find some web server files. Next, uhttpd.main.lua_handler
points to Lua handler script used to initialize the Lua runtime on server start, since uhttpd
supports Lua scripts, so there may be more interesting files there. However, /www
directory is empty and there is no sgi
directory in /usr/lib/lua/luci
nor uhttpd.lua
file in the system. I kept trying to find anything about how this uhttpd
instance worked but I found nothing, only configuration parameters pointing to nowhere.
At this point, I knew that the solution to this was to directly analyze the uhttpd
binary and reverse engineer it, but before that, I wanted to create a testing environment so I could know what was happening inside the web server when I made the requests, since the way the process was created, there was no output anywhere.
I tried running the command found in the output of the ps
command for process 861: /usr/sbin/uhttpd -f -h /www -T 180 -A 0 -n 8 -R -r C
. However, I got a lot of errors and couldn’t make it work. Since I wasn’t able to create the same uhttpd
process in a different port, I tried to go after the missing output by checking the /proc
entry for the process, in order to try to read them if they existed (as explained in this video from PwnFunction). But there was a big problem:
root@SLP:~#sudo ls -l /proc/864/fd/
lrwx------ 1 root root 64 Nov 10 22:44 0 -> /dev/null
lrwx------ 1 root root 64 Nov 10 22:44 1 -> /dev/null
lrwx------ 1 root root 64 Nov 10 22:44 2 -> /dev/null
lrwx------ 1 root root 64 Nov 10 22:44 3 -> anon_inode:[eventpoll]
lrwx------ 1 root root 64 Nov 10 22:44 4 -> socket:[1830]
All file descriptors for stdin
, stdout
and stderr
were redirected to /dev/null
, which is basically redirecting them to a black hole where they cannot be found. I was stuck and I didn’t know what to do. Since I was already in the /proc
entry, I started looking around as I didn’t remember that /proc
entries had that much information about a process and was curious about it. Thanks to this random curiosity I stumbled across the environ
entry, which contains all environment variables for that process. One of these environment variables was UHTTPD_ARGS=-h /www -T 180 -A 0 -n 8 -R -r C200 -C /tmp/uhttpd.crt -K /tmp/uhttpd.key -s 443
. I immediately realized that the command shown by ps
was not correct and later found that it was because the UART shell didn’t have enough width to display all characters. Yet another fail that made me learn new important things: never trust the output given by a UART port.
Now I could finally create another instance of uhttpd
with the exact same parameters and no pipes to /dev/null
in order to test the binary while reverse engineering it.
This was my first time using Ghidra. I had seen some videos and read some articles about it (thanks stacksmashing and liveoverflow for the amazing and easy-to-digest content), but never really played with it, so this was a very good opportunity to learn.
I opened the uhttpd
binary and, after some trial and error, figured out that the language was MIPS32, little endian, with mips16e. Some function names came by default with the binary, but others didn’t. I also spent some time renaming functions since, apparently, Ghidra is usually confused with external functions and you get strange wrappers for them like:
I looked at the main
function and other important ones to figure out the logic of the binary and how it was structured. I found some interesting ones, already named, among which were do_login
or uh_slp_proto_request
. I will talk more about the last one later.
After this initial contact, I started looking for bugs. Since I’m a huge noob with overflow vulnerabilities, the first thing I did was to search for system
, exec
and popen
calls, in order to check if there was any command injection vuln that I could easily exploit. And oh boy, I was lucky.
Function exec_and_read_json
uses popen
to execute commands:
exec_and_read_json
is used by 2 unnamed functions, which I named set_language
and wifi_connect
. They respectively deal with language and WiFi configuration (obviously). wifi_connect
seems to parse single quotes ('
), however, set_language
doesn’t. This means that if we can control the input for the set_language
function, we can successfully inject our own commands.
Function set_language
is used by uh_slp_proto_request
, the function I mentioned before, which passes as input some parsed data received from the user.
To parse the user data, uh_slp_proto_request
checks if it is a valid JSON object. Then, it gets a string value identified by key "method"
and a dictionary value identified by "params"
(at least that is what I think, since function call could not be resolved by Ghidra but seemed to work this way). Depending on the selected method, uh_slp_proto_request
selects the function which will be called.
So, by sending {"method": "setLanguage", "params":{}}
we successfully call the set_language
function and pass {}
as language_json
parameter. Then, inside set_language
, language_json
object is converted to string and inserted directly into "ubus call system_state_audio set_language \'%s\'"
to be executed.
By submitting {"method": "setLanguage", "params": {"payload": "'; touch poc;'"}}
, ubus call system_state_audio set_language '{"payload": "'; touch poc;'"}'
will be executed, which actually contains 3 commands: ubus call system_state_audio set_language '{"payload": "'
, touch poc
, and '"}'
. The second one gives us full code execution.
Now, uh_slp_proto_request
is used by another unnamed function managing all requests, which I named main_server_function
. If a request is valid (does not exceed maximum length, uses http
or https
depending on the server config, etc.), main_server_function
checks if the URL contains /cgi-bin/luci
or /web-static
. If it doesn’t, uh_slp_proto_request
is called.
By guessing and sending a couple of requests to the camera, we can check that data used by uh_slp_proto_request
is regular POST data. So, if we send a POST request to /
with the previous payload, uh_slp_proto_request
will process this data, call set_language
and our payload will be injected in the command executed by exec_and_get_result
.
As you can see, I didn’t mention anything about authentication, since the setLanguage
method can be called without login. This allows any user to take full control of the camera with just one unauthenticated request.
Now, it’s time to write the exploit. I spent some time figuring out how to get a reverse shell with netcat
. It seemed trivial but I couldn’t get it to work. I found out that the netcat
version installed in BusyBox is quite limited in terms of functionality, so regular reverse shells were not valid. However, I found what I was looking for in
PayloadsAllTheThings repository (as usual), and got the perfect reverse shell. Since uhttpd
is running as root (thanks TP-Link), we get a shell with highest privileges just by sending a malicious POST request. The exploit is available at my Github page:
This summer, I decided to try my luck with bug bounties as I had already gained a decent amount of experience with web application vulnerabilities. I spent some days just looking at some public bug bounty programs, until I found one or two that were affordable for me. They offered interesting functionalities to test for bugs (at least more than a simple online shop), didn’t pay bounties too big so they were not attracting attention of more experienced hackers and hadn’t received a valid report for 9 months. So I opened Burp and started clicking around.
After some hours of figuring out how the applications worked and what was the user workflow, I stumbled upon an interesting feature of one of them. It offered a client and an admin portal, where administrators could manage clients, services provided, etc. One of the functionalities of the administrator panel was to create and edit email templates, so then the admin could use them to send a personalized email to a set of clients all at once. I immediately thought of an old HackerOne report to Uber I once read about, where an SSTI was found on the rendering engine for email templates. SSTI (Server Side Template Injection) is a vulnerability of template engines in which the user can inject native template syntax in the rendered text, which will be executed on the server. Depending on the template engine, the severity can vary, sometimes leading to RCE.
When I opened the email template editor, the first thing I saw was that the default value for the header was {{account.logo}}
, which looked like templating language syntax, so I started to play around. The email template editor had a button to test the template which sent the result directly to the admin email address. This way, any rendered content that produced the template would be directly sent to the admin (me). I started by submitting {{account}}
, which produced an email sent to me with [object Object]
. [object Object]
is the raw representation of a JavaScript object that is not prepared to be represented as a string. I was very excited because this input I submitted produced a value that the application was not prepared to show. This meant that the developers might have missed to check the user input and that SSTI might be possible.
And because of this initial excitement, I was not thinking clearly so I made the mistake of assuming the technology behind the template engine without actually checking it. As I had recently worked with SSTIs in Nunjucks (a common JavaScript template engine), I directly assumed this was the template engine. I didn’t think twice, it seemed so clear to me. I went to an article I had recently read about and started trying to get RCE.
I adapted the payload in the article to my case and submitted {{account.constructor(function(){return 123})()}}
but no email was sent. Also tried {{account.constructor.constructor('return 123')()}}
but again, no email. Then, I started testing a bit more, and found that {{7*7}}
didn’t work either. However, by submitting {{account.constructor}}
and {{account.constructor.constructor}}
I was receiving function Object() { [native code] }
and function Function() { [native code] }
respectively. It didn’t make much sense for me. I could reach objects and functions but I couldn’t really execute them? I couldn’t perform arithmetic operations either? I left it there and took a small break.
After one or two days I suddenly thought that I didn’t actually check if the template engine was Nunjucks or if it was any other NodeJS template engine. I searched for the most common NodeJS template engines that used {{ }}
as tags and started testing. However, I was still very nervous and excited and still not thinking clearly. I didn’t research too much and none of the engines I found were working either. I tried Mustache, Handlebars, Atpl… I was getting very frustrated and knew that I had to take a bigger break since I was not used to bug hunting. I learnt that this can be way much more frustrating than any CTF or wargame I had played out there.
Summer ended and University started, so I knew I would have less free time in a matter of weeks. And it still annoyed me not having been able to actually find the real bug and exploit it. So one day I just started reading my notes about it, recreating all the steps I took. When I got to the part where I “did some research about NodeJS template engines”, I saw that it was very poor research, so I decided to retry again, at least to actually understand what was going on behind that template engine.
This time, I did my research properly by testing each possible option. I finally discarded Nunjuck by testing all syntax it offered and did the same with other common template engines for NodeJS. At the end, it turned out to be LiquidJS, a template engine similar to Liquid used by Shopify or GitHub pages. LiquidJS does not allow real code execution such as other engines. In addition, I couldn’t find any example on the Internet about exploiting SSTI on LiquidJS, so I had to find my own way. Looking at the documentation, I found the deprecated include
tag, which allows to render predefined templates. However, an attacker can also include another file, even if it’s not a real template, giving access to the whole file system of the server (in fact, when writing this article I found this issue explaining this problematic). In the end, I just had to submit {% include '/etc/passwd' %}
in order to receive in my email all the contents of the /etc/passwd
file.
I wasn’t able to get RCE, however, I got Arbitrary File Read and a bounty of $1500, which is pretty good for the first time. Thanks for reading it and I hope you liked it :)
]]>