Talos Vulnerability Report

TALOS-2025-2281

Hangzhou Hikvision Digital Technology Co., Ltd. Face Recognition Modules SADP XML parsing stack-based buffer overflow vulnerability

March 18, 2026
CVE Number

CVE-2025-66176

SUMMARY

A stack-based buffer overflow vulnerability exists in the SADP XML parsing functionality of Hangzhou Hikvision Digital Technology Co., Ltd. Ultra Face Recognition Terminal 3.7.60_250613 and Face Recognition Terminal for Turnstyle 3.7.0_240524 (under emulation). A specially crafted network packet can lead to remote code execution. An attacker can send a malicious packet to trigger this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

Hangzhou Hikvision Digital Technology Co., Ltd. Ultra Face Recognition Terminal 3.7.0_240524 (under emulation)
Hangzhou Hikvision Digital Technology Co., Ltd. Ultra Face Recognition Terminal 3.7.60_250613 (under emulation)

PRODUCT URLS

Ultra Face Recognition Terminal - https://www.hikvision.com/en/products/Access-Control-Products/Face-Recognition-Terminals/Ultra-Series/ds-k1t671tmfw/

CVSSv3 SCORE

8.8 - CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

CWE

CWE-121 - Stack-based Buffer Overflow

DETAILS

The DS-K1T671TMFW and the DS-K5671-3XF/ZU are access control terminals that extend authentication beyond standard badge-based functionality to include facial recognition, mask detection, and body temperature detection for authentication and authorization. They share an ancestry of firmware that is based on embedded Linux and they can connect to a network via ethernet or WiFi.

The core functionality of the device is implemented in a monolithic binary named hicore, and the vulnerability this report will discuss occurs due to an implementation flaw within the parsing of XML data encapsulated inside the Hikvision Search Active Device Protocol (SADP), a multicast protocol used to identify Hikvision devices on a network. SADP is an XML based protocol and this vulnerability lies within the seemingly custom XML parser implemented for interpreting SADP payloads. The thread responsible for receiving, parsing and handling inbound SADP packets is titled multicast_thr_sadp_capture. The function executed by this thread is located at offset 0x174560 of the hicore binary contained in firmware version 3.7.60_250613 and at offset 0x171d80 of the hicore binary contained in firmware version 3.7.0_240524.

We will showcase the path from receiving a packet off the network to stack overflow, starting with a partial decompilation of multicast_thr_sadp_capture. It is included to show the path between recvfrom and the function we call parse_xml has limited impact on the received payload prior to parsing. It does add one barrier, which is that any malicious payload may contain only one null-byte, and it must be the final byte, otherwise the strlen(xml_payload) parameter will truncate the payload prematurely.

int32_t multicast_thr_sadp_capture() {
    char* xml_payload = nullptr;
    int ifArrIdx = 0;
    fprintf(stderr, "%s: \n-------------\n fd1[%d], fd2[%d]\n-------\n",
            "multicast_thr_sadp_capture", SADP_IFAR[0].sockfd, SADP_IFAR[1].sockfd);
    if (prctl(0xf, "mu_sadp_cap") == -1) {
        printf("%s:%d, set thread_name: mu_sadp_cap err!   errno = %d\n",
               "multicast_thr_sadp_capture", 0x144c, *__erno_location());
    }

    while (1) {
        if (gSADP_DEACTIVATE) {
            break;
        } 

        // Eliding readfds setup

        struct sockaddr s;
        memset(&s, 0, sizeof(sockaddr));
        memset_s(interface->buffer, interface->buffer_sz, 0, interface->buffer_sz);

        int32_t num_bytes = recvfrom(interface->sockfd, interface->buff, interface->buff_sz, 0, &s, sizeof(sockaddr));
        if (num_bytes > 0) {
            xml_payload = interface->buffer;
        } else {
            xml_payload = NULL;
        }

        if (xml_payload) {
            struct XMLTree_t tree;
            init_xml_tree(&tree);
            // Any null-byte in the payload will truncate the buffer being passed into parse_xml
            if (parse_xml(&tree, xml_payload, strlen(xml_payload)) >= 0){
                ...
                // Implement SADP functionality based on parsed XML payload
                ...
            } else {
                // Error handling
      ...
}

As seen in the decompilation, the data is received into the specific interface’s SADP_IFAR buffer field. The SADP_IFAR global variable is an array initialized in a function titled sadp_init_sock located at offset 0x1702d0 or 0x16daf0. The two relevant pieces of information derived from this function are that the sockets are bound to the multicast address 239.255.255.250 on UDP port 37020, and that the value of interface->buffer_sz (used as the len parameter in the recvfrom call) is 0x800 bytes. With an understanding of how data flows into the function and confirmation that the only limitation is that the data can be no longer than 0x800 bytes, we now look at the implementation of parse_xml.

int parse_xml(struct XMLTree_t* tree, char* xml_buffer, int buffer_len) {
    if (xml_buffer == NULL || tree == NULL) {
        return -1;
    }

    int remaining_bytes = buffer_len;
    char* buffer_tail = &xml_buffer[remaining_bytes - 1];

    for (; remaining_bytes >= 0; remaining_bytes--) {
        if (*buffer_tail == '>') {
            int n = validate_tag(tree, NULL, xml_buffer, remaining_bytes);
        }

        if (tree->root != 0) {
            return n;
        }

        break;
    }

    return -1;
}

This function is rather straightforward, and adds very few constraints to what a valid SADP payload must conform to for it to be parsed by validate_tag. It identifies the location of the trailing-most > character (presumed to be the final closing character of the XML payload) and then uses that to derive the total length of the XML payload to be parsed. validate_tag takes a pointer to the XML tree structure being populated, an optional pointer to a parent XML tag (NULL in this case, as this is the entry point for parsing the root node), a pointer to the head of the buffer to be parsed, and the calculated length of the payload.

The relevant structure for the validate_tag function is one we refer to as ‘XMLTag’, representing a parsed XML element. The below definition incorporates a mix of field names recovered directly from strings in the code and names we derived from context.

struct XMLTag {
    char      magic[4];     // "TAGT"
    int32_t   tag_id;       // Incrementing unique value
    int32_t   empty;        // Indicates if an element was an XML empty tag, e.g. <empty />
    char*     name;         // Finalized tag name, after validation
    char*     value;        // Finalized tag value, after parsing of content
    char*     attrs;        // Finalized attributes string, after validation, or NULL
    char*     start_name;   // Pointer to opening '<' in original payload
    char*     content;      // Pointer immediately after opening tags '>' in original payload
    char*     close_name;   // Pointer to closing '<' in original payload
    int32_t   start_len;    // Size of opening tag from before '<' to after '>'
    int32_t   content_len;  // Size of content from opening tag's '>' to closing tag's '<'
    int32_t   close_len;    // Size of closing tag, from before '<' to after '/>'
    int32_t   attrs_len;    // Size of attributes, from first whitespace to right before '>'
    XMLTag_t* children;     // Pointer to first child tag, or NULL
    XMLTag_t* siblings;     // Pointer to next sibling tag, or NULL
    XMLTag_t* parent;       // Pointer to parent tag, or NULL
} XMLTag_t;

Annotated portions of the decompilation of validate_tag are provided below to help set the stage for how the vulnerable condition is reached.

int32_t validate_tag(struct XMLTree_t* tree, struct XMLTag_t* parent, char* xml_payload, int32_t tag_len) {
    int curr_idx = 0;
    char curr_chr;
    char next_chr;
    char prev_chr;

    while (curr_idx < tag_len) {
        curr_chr = xml_payload[curr_idx];
        next_chr = xml_payload[curr_idx + 1];
        prev_chr = (curr_idx > 0) ? xml_payload[curr_idx - 1] : '\0';

        switch (xml_state) {
            ...

The function begins by entering the core parsing loop, where the previous, current, and next character are extracted from the payload for parsing. For each increment of curr_idx these characters are retrieved and parsing is then dispatched into the state machine based on the value of xml_state. The states that are relevant to this vulnerability are the states that parse start (state #1, which we refer to as START_TAG) and close (state #3, a.k.a CLOSE_TAG) tags, and the state responsible for validating and finalizing the parsed XML structure (a.k.a FINALIZE).

It is simpler to begin with a review of the CLOSE_TAG handler, as it does not need to deal with the same complexities of the START_TAG handler and therefore the issue is more readily apparent.

case CLOSE_TAG: // Parse close tag, not always parsed if a tag was self closing (e.g. "<empty />")
    if (!tag->close_name) {
        tag->close_name = &xml_payload[curr_idx];
    }

    // [1] For every character parsed by the CLOSE_TAG state, increment the associated length field
    tag->close_len++;

    if (curr_chr == '>') {
        xml_state = FINALIZE_TAG;
    }
    
    break;

For each iteration of the loop where the parser remains in the CLOSE_TAG state, the close_len field is incremented, without any upper bound. The only way to exit this state is to either consume all characters in the buffer or to reach the final closing ‘>’ character. The implementation of START_TAG includes some additional complexities related to parsing specific types of tags (comments, processing instructions, and self-closing), as well as tag attributes. However, the same issue presents for the calculation of the start_len field, at [2]. Note that it also applies to attr_len but that is not relevant to this particular vulnerability. Finally, we note at [3] that in the event of a self-closing tag the state machine can skip directly to FINALIZE_TAG without entering the CONTENT or CLOSE_TAG states.

case START_TAG: // Parse start/open tag (and any attributes, since they're a subset of the start tag)

    // If we enter case 1 and have not already initialized the start_name pointer, do so now
    if (tag->start_name == NULL) {
        char* head = &xml_payload[curr_idx];
        if (strncmp(head, "<!--", 4) == 0) {
            // Implementation just skips XML comments, irrelevant to this
            ...
        }

        // Strip prefixing whitespace
        while (isspace(xml_payload[curr_idx++])) { }

        // tag begins at first non-whitespace character
        tag->start_name = &xml_payload[curr_idx];
    }

    // [2] For every character parsed by the START_TAG state, increment the associated length field
    tag->start_len++;

    if (tag->attr_start == NULL) {

        // The attribute portion of an opening tag begins after the first space
        int begin_attribute = (curr_chr == ' ');
        if (next_chr == '/') {
            // Unless the tag is a self-closing one (e.g. "<empty />"")
            begin_attribute = 0;
        }
        if (begin_attribute) {
            tag->attr_start = &xml_payload[curr_idx+1];
        }

    } else if (curr_chr != '>') {
        // If we've already begun tracking the attributes, just increment the size (unless we've reached the end of the tag)
        // then exit the switch statement early
        tag->attr_len++;
        break;
    } else if (prev_chr == '?') { // Stacking on the previous, this conditional only triggers if the two bytes are '?>'
        // Handle "processing instruction" tags
        ...
    }

    // If we're here, we've reached the end of a "valid", non-processing instruction, opening tag

    if (tree->root == NULL) {
        tree->root = tag;
    }

    // [3] If the tag ended with "/>" then it's a self-closing tag and we should jump straight to finalizing
    if (prev_chr == '/') {
        tag->empty = 1;
        xml_state = FINALIZE;
        continue;
    }  else {
        // otherwise, begin to parse content
        xml_state = CONTENT;
    }

    break;

The vulnerabilities present within the FINALIZE state, when the final clean up and validation occurs. This state is responsible for stripping the prefixing and suffixing ‘<’, ‘>’, and ‘/>’ characters from the names and updating the name, value, and attrs pointers to point to clean copies of these strings. It is also responsible for validating that the opening and closing tags share matching names.

CVE-2025-66176 - Close Tag Buffer Overflow

The remainder of the FINALIZE state is responsible for cleaning up the close tag name, and also confirming that the start and close names match.

if (!tag->empty) {
    // Non empty / non self-closing tags are validated to determine whether their opening and closing tags have matching names
    // Similarly, a closing tag must be at least 4 bytes long (e.g. </a>)
    if (tag->close_len <= 3) {
        __log__(__file__, __line__, __func__, "tag->start_name %s len %d\n", tag->close_name, tag->close_len);  // Not a typo, does say tag->start_name
        error_code = -4;
        xml_state = ERROR;
        continue;
    } 

    // [5] Take a copy of the close name for validation against the start name
    //     This is also a stack-local copy that is only defined to be 0x80 bytes long
    char local_close_name[0x80];
    n = tag->close_len - strlen('</>');
    strncpy(&local_close_name, tag->close_name[2], n);
    &local_close_name[n] = '\0';

    // Closing tags must start with '</', otherwise this is actually the start tag of a child element and
    // the parser needs to recursively parse the child
    if (tag->close_name[1] == '/') {

        // Remove trailing whitespace
        while (isspace(local_close_name[--n])) {
            local_close_name[n] = '\0';
        }

        // The (first) reason why the local copies were necessary, is the validation that the opening and closing tags share a name
        // A simple example that would fail this validation is `<a></b>`
        if (strcmp(&local_start_name, &local_close_name) != 0) {
            __log__(__file__, __line__, __func__, "tags don't match [%s] : [%s]\n", &local_start_name, &local_close_name);
            error_code = -5;
            xml_state = ERROR;
            continue;
        }

        ...
    }
} else { 
    // Empty tags do not need to validate that the open and close tags have matching names
    if (local_start_name[n-1] == '/') {
        local_start_name[n-1] = '\0';
    }
}
...

At [5] a second, very similar, strncpy call is made with a different stack-local destination and an incorrectly bounded n parameter. The purpose of this is two-fold: to validate that the tags match in name, and to have a clean copy from which to strdup a long-term copy that will live in the tag->name field. As above, a sufficiently long close tag name will overflow this buffer and can corrupt the stack frame, granting attacker control of the program counter.

CVE-2025-66176 - Start Tag Buffer Overflow

The first of the two vulnerabilities presents during the finalization of the tag->start_name field.

case FINALIZE:
        // Every start tag must be at minimum 3 characters long (e.g. <a>), must start with '<' and end with '>', otherwise error
        if (tag->start_len <= 2 || (tag->start_name && tag->start_name[0] != '<') || (tag->start_name[tag->start_len - 1] != '>')) {
            __log__(__file__, __line__, __func__, "tag->start_name %s len %d\n", tag->start_name, tag->start_len);
            error_code = -1;
            xml_state = ERROR;
            continue;
        }

        // Calculate the "clean" length of the tag's name, after accounting for attributes and extraneous characters
        int n = tag->start_len - strlen("<>");
        if (tag->attributes_len) {
            n -= tag->attributes_len + strlen(" ");
        }

        // [4] Take a copy of the start name for validation against the close name
        //     This is a stack-local copy that is only defined to be 0x80 bytes long
        char local_start_name[0x80];
        strncpy(&local_start_name, &tag->start_name[1], n);
        local_start_name[n] = '\0';

        // Remove trailing whitespace
        while (isspace(local_start_name[--n])) {
            local_start_name[n] = '\0';
        }

It first confirms that the structure of the start tag conforms to the expected XML format, and then it extracts just the tag name from the tag->start_name, excluding the attribute portions and any opening and closing characters. At [4] it takes a stack-local copy of that name into a statically sized 0x80 byte long buffer using strncpy. The n parameter of this call is the unbounded value from tag->start_len minus the length of any attributes attached to the string. A sufficiently long start name for a tag will overflow this buffer and can overwrite the stack frame, granting control of the program counter.

VENDOR RESPONSE

Vendor Advisory URL: https://www.hikvision.com/cn/support/CybersecurityCenter/SecurityNotices/2026-01-12/

TIMELINE

2025-10-21 - Vendor Disclosure
2025-10-21 - Initial Vendor Contact
2026-01-12 - Vendor Patch Release
2026-03-18 - Public Release

Credit

Discovered by a member of Cisco Talos.