Jack O'Sullivan
March 22 2021
After some research prior to flying out to Vegas, we found the SOHOpelesslyBroken 0-Day contest as well as the SOHOpelesslyBroken CTF contest, both of which were run by the IoT Village. We decided to focus our efforts on the 0-Day contest. The IoT Village made their list of target devices and firmware versions public prior to the 0-day contest, so we had some time to do some initial bug hunting before the main event.
By the end of the event, our team had identified 8x “Leet” level vulnerabilities, most of which were unauthenticated remote code execution flaws. We are currently following our responsible disclosure policy and waiting for patches to be released by the vendors prior to disclosing the issues publicly. Unfortunately, that means that we can’t talk about most of them yet! With that in mind, this post will cover our general approach that we took, with some examples that we can talk about in the Zyxel NBG6716 (Firmware version V1.00(AAKG.9)C0).
METHODOLOGY
Choose The Device
To score high points, the vulnerability needed to achieve one of the following:
- Full device control - 5000 points
- Unprivileged/partial control - 4000 points
- Corruption/compromise internal network - 4000 points
Essentially these boil down to remote code execution either authenticated or unauthenticated, via the Internet or internal network. With these factors in mind, we chose devices with:
- A large attack surface (i.e. a lot of functionality)
- Network-connected via Ethernet or Wifi
- Likely to call out to the shell (Higher chance of command injection flaws)
Due to the goals above, we focused primarily on home routers and Network Attached Storage (NAS) devices.
Access Firmware
As with any application test, rather than assessing live devices to find vulnerabilities, a more efficient approach is to analyse the source code. Commonly with IoT devices, this involves getting access to the firmware and then extracting source code from there.
Gaining access to a device’s firmware can either be problematic or very easy depending on the device. Often when people think of extracting firmware, they think of taking apart a device and using JTAG, Serial, or similar to perform the extraction. This presents a barrier to entry to a lot of people as they feel that they need specialist hardware or some kind of deep hardware knowledge before they can even start.
Often this simply is not the case. Vendors commonly publish the firmware on their support website which is then freely available for download. We took this approach with each of the devices that we assessed, including the Zyxel router.
We downloaded the NBG6716 firmware version V1.00(AAKG.9)C0 from here: http://www.zyxel.com/uk/en/support/download_library.shtml
We then extracted any files we could find within the firmware. To begin with, we unzipped the .zip file using the unzip tool. We then attempted to extract files using binwalk and encountered the following error:
1
2
3
4
5
6
7
8
9
10
11
12 |
V1.00\(AAKG.9\)C0.zip
Archive: NBG6716_V1.00(AAKG.9)C0.zip
inflating: NBG6716_V1.00(AAKG.9)C0-foss.pdf
inflating: V1.00(AAKG.9)C0.bin
inflating: NBG6716_V1.00(AAKG.9)C0_release_note.pdf
root@kali:~ /zyxel # binwalk -e V1.00\(AAKG.11\)C0.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
WARNING: Extractor.execute failed to run external extractor 'jefferson -d ' %%jffs2-root%% ' ' %e '' : [Errno 2] No such file or directory: 'jefferson'
|
This can be solved by installing the jefferson tool:
1
2
3
4
5 |
sudo pip install cstruct
sudo apt-get install python-lzma
git clone https: //github .com /sviehb/jefferson .git
cd jefferson/
python setup.py install
|
After this, we could successfully extract the JFFS2 filesystem:
1
2
3
4
5 |
root@kali:~ /zyxel # binwalk -e V1.00\(AAKG.9\)C0.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
131072 0x20000 JFFS2 filesystem, big endian
|
This extracted the filesystem into the following directory:
1
2
3 |
root@kali:~ /zyxel/_V1 .00(AAKG.11)C0.bin-0.extracted /jffs2-root/fs_1 # ls
bin dev lib overlay rom sbin tmp var
boot etc mnt proc root sys usr www
|
Grep for Command Injection
The next step is to search for interesting CGIs or any server-side code such as PHP. In this case, we found an interesting LUA script within /www/cgi-bin/ozkerz:
The most trivially exploitable RCE, in this case, would likely be a Command Injection or File Upload bug. When performing a code review, it can be useful to think in terms of "Untrusted Sources" and "Dangerous Sinks". For example, in the case of a Cross-Site Scripting (XSS) vulnerability, a Dangerous Sink might be a function that writes data to an HTML page. An Untrusted Source might be a URL parameter. Each of these on their own are innocuous, but if data from an Untrusted Source is fed into a Dangerous Sink without the appropriate escaping or sanitisation, then a vulnerability is likely to occur. In the case of the above example, XSS.
We started by searching for a common Dangerous Sink in LUA for command injection vulnerabilities "popen". We can see line 13 is returned within /www/cgi-bin/ozkerz:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 |
#!/usr/bin/lua#!/usr/bin/lua
local protocol = require "luci.http.protocol"
local uci = require( "luci.model.uci" ).cursor()
print ( "Content-type:application:json\n" )
-- print ( "hihi" )
params = protocol.urldecode_params(os.getenv( "QUERY_STRING" ) or "" )
if params[ "eventFlows" ] then
-- print (params[ "beginTime" ])
-- print (params[ "endTime" ])
-- local handle = io.popen( "dump_events_by_time_json " .. params[ "beginTime" ] .. " " .. params[ "endTime" ])
local handle = io.popen( "dump_flow_events_index_json " .. params[ "beginIndex" ] .. " " .. params[ "endIndex" ])
local result = handle: read ( "*a" )
handle:close()
print (result)
[...]
|
In this case, the io.popen() function is called with the hard-coded binary "dump_flow_events_index_json" concatenated with two variables: params["beginIndex"] and params["endIndex"] respectively.
The next step is to trace back to where those variables come from and see whether they can be controlled by the user in any way (an Untrusted Source). Further up in the file, the following line can be found:
1 |
params = protocol.urldecode_params(os.getenv( "QUERY_STRING" ) or "" )
|
Essentially params is an array which is created based on the QUERY_STRING environment variable - certainly controllable by the user! There also did not appear to be any further sanitisation or escaping conducted on the params["beginIndex"] or params["endIndex"] variables.
As a side note, it is normally more efficient to trace back from a dangerous sink, as we did above, rather than starting at every untrusted source and tracing forward.
Exploit
It is worth noting that the io.popen() function above only gets executed if the following if statement returns true:
1
2
3 |
if params[ "eventFlows" ] then
...
handle = io.popen(...
|
With this in mind, we will need to set a URL parameter named eventFlows to 1 to ensure the if statement returns true and then goes on to execute the io.popen() function. We also need to set either beginIndex or endIndex to a value which breaks out of the data context of io.popen() and into the execution context. At this point, we can specify whatever Linux command we like! In this case, we used the Linux pipe character (|) in order to break out of the data context and then executed the ls command.
http://<IPADDR>/cgi-bin/ozkerz?eventFlows=1&beginIndex=|ls&endIndex=
This vulnerability could be exploited by an unauthenticated attacker situated on the internal LAN. It could also be exploited remotely via a Cross-Site Request Forgery attack such as:
<img src="http://<IPADDR>/cgi-bin/ozkerz?eventFlows=1&beginIndex=|ls&endIndex=" />
RESPONSIBLE DISCLOSURE
As per Secarma's responsible disclosure policy as well as the rules of the 0-Day contest, we reported the issue to Zyxel. We then reported the issue to the contest organisers and demonstrated our exploit in order to be eligible for points.
We're happy to announce that Zyxel has successfully resolved the issue in version V1.00(AAKG.11)C0 of the firmware (Released August 22nd 2017). The method they chose to fix the issue was fairly clean. The following additional if statement (Line 13) was added:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 |
#!/usr/bin/lua#!/usr/bin/lua
local protocol = require "luci.http.protocol"
local uci = require( "luci.model.uci" ).cursor()
print ( "Content-type:application:json\n" )
params = protocol.urldecode_params(os.getenv( "QUERY_STRING" ) or "" )
local invalidCommand = false
if params[ "eventFlows" ] then
if tonumber(params[ "beginIndex" ]) ~= nil and tonumber(params[ "endIndex" ]) ~= nil then
local handle = io.popen( "dump_flow_events_index_json " .. params[ "beginIndex" ] .. " " .. params[ "endIndex" ])
local result = handle: read ( "*a" )
handle:close()
print (result)
else
invalidCommand = true
end
[...]
|
Essentially they cast the two variables to a float using the tonumber() function and then check they are both not nil before going on to call the io.popen() function. Barring any vulnerabilities in the tonumber() function that we are not aware of, this appears to be a working fix.
Timeline
- July 29, 2017 - Contacted vendor requesting GPG key
- July 31, 2017 - GPG key provided
- July 31, 2017 - Advisory provided to vendor
- August 22, 2017 - Firmware Version V1.00(AAKG.11)C0 released
- October 6, 2017 - Public disclosure
SUMMARY
So in summary, our approach was to:
- Select devices with a large attack surface
- Download firmware from vendor websites
- Hunt through the code for simple code execution vulnerabilities
- Generate working exploit PoC's
- Report to Vendor
- Report to public upon patch release
We found that applying this approach resulted in a number of Critical new vulnerabilities discovered in a host of consumer IoT devices which ultimately led to our team winning the 0-Day contest.