This isn’t news — but it’s worth seeing up close

In June 2026, Include Security (with independent researcher “Buchodi”) published The Smart TV in Your Living Room Is a Node in the AI-Scraping Economy, documenting how Bright Data’s SDK — embedded in free consumer apps — turns always-on devices into exit nodes for its “150M+ / 400M+ residential IP” proxy network. It got wide pickup (The Hacker News, Help Net Security, Lowpass).

Spur then scanned 6,038 apps across LG webOS and Samsung Tizen and found proxy SDKs in 2,058 of them — 42.5% of webOS apps, 26.9% of Tizen, 34.1% across both. Google, Amazon and Roku have since restricted background proxy SDKs and Bright Data dropped those platforms; webOS and Tizen are still supported.

A TV is close to the ideal exit node: plugged in, always on, fast unmetered connection, never rebooted, and — crucially — nobody is watching what it does when the screen is off.

The prior work established that this happens. What I wanted was the full pipeline in my own hands: pull one specific app, get past its DRM, and read the peer engine line by line.

TL;DR

A free LG webOS TV app — “Relic Fisher” (com.brightdata.relicfisher) — ships the Bright Data / “Bright SDK” residential-proxy peer. Once enabled, the TV becomes an exit node: it opens outbound WebSocket control channels to Bright Data infrastructure, and the remote server sends tun commands that relay third-party (mostly AI-scraping) traffic out through your home IP.

This is a hands-on, static-analysis walk-through of getting the app off LG’s servers, peeling the DRM layer, deobfuscating the SDK, and reading exactly what it does — ending with a tiny tool that pulls the SDK’s remote config, and concrete advice on how to block the whole thing at your router.

Everything below is static analysis. No code was executed, no proxy traffic was ever relayed, and every device-specific secret has been redacted.

Step 0 — Getting the IPK

LG apps aren’t distributed as loose files — you have to get the signed .ipk off LG’s infrastructure. There are several ways to do this, some more polite than others. I’m deliberately not going to walk through them in depth: the details sit in a legal and ethical grey area, and publishing a turnkey recipe helps nobody but the people I’d rather not help. The script I used to fetch this particular package will remain private for the same reason.

What I’ll show is only that it worked — a signed .ipk came down, ~2.5 MB, and that’s the starting point for everything below:

❯ python lg_ipk_download.py com.brightdata.relicfisher --out ~/Downloads/
com.brightdata.relicfisher 1.0.20 2580938 bytes [OK] -> com.brightdata.relicfisher_1.0.20.ipk

An .ipk is just an ar/tar archive, so unpacking it is trivial.

~/Downloads/com.brightdata.relicfisher_1.0.20
❯ tree
.
├── control
├── control.tar.gz
├── data
│   └── usr
│       └── palm
│           ├── applications
│           │   └── com.brightdata.relicfisher
│           │       ├── 113561226619612232_38925265_115x115_webos.png
│           │       ├── 113561226647068704_38925265_192x192_webos.png
│           │       ├── 113561226648979057_default_background.png
│           │       ├── 113561226672850244_1920_1080_lg.jpg
│           │       ├── ads.js
│           │       ├── appinfo.json
│           │       ├── brd_api.helper.min.js
│           │       ├── brd_api.mock.js
│           │       ├── brd_api_v1.625.64.js
│           │       ├── i18n.js
│           │       ├── i18n.mock.js
│           │       ├── icon.png
│           │       ├── index.html
│           │       ├── largeIcon.png
│           │       ├── lib
│           │       │   ├── consent.bundle.js
│           │       │   └── consent.bundle.js.map
│           │       ├── logo.png
│           │       ├── main.js
│           │       ├── msg_box.png
│           │       ├── notification.js
│           │       ├── offline.js
│           │       ├── polyfills.js
│           │       ├── qr_brd.png
│           │       ├── release.json
│           │       ├── resources
│           │       │   ├── ar
│           │       │   │   └── appinfo.json
│           │       │   ├── de
│           │       │   │   └── appinfo.json
│           │       │   ├── es
│           │       │   │   └── appinfo.json
│           │       │   ├── fr
│           │       │   │   └── appinfo.json
│           │       │   ├── he
│           │       │   │   └── appinfo.json
│           │       │   ├── id
│           │       │   │   └── appinfo.json
│           │       │   ├── it
│           │       │   │   └── appinfo.json
│           │       │   ├── ja
│           │       │   │   └── appinfo.json
│           │       │   ├── ko
│           │       │   │   └── appinfo.json
│           │       │   ├── ms
│           │       │   │   └── appinfo.json
│           │       │   ├── pt
│           │       │   │   └── appinfo.json
│           │       │   ├── ro
│           │       │   │   └── appinfo.json
│           │       │   ├── ru
│           │       │   │   └── appinfo.json
│           │       │   ├── th
│           │       │   │   └── appinfo.json
│           │       │   ├── tr
│           │       │   │   └── appinfo.json
│           │       │   ├── vi
│           │       │   │   └── appinfo.json
│           │       │   └── zh
│           │       │       └── appinfo.json
│           │       ├── settingsdialog.bundle.js
│           │       ├── settingsdialog.bundle.js.map
│           │       ├── settings.js
│           │       ├── values.consent.js
│           │       ├── values.js
│           │       └── webOSTV.js
│           ├── packages
│           │   └── com.brightdata.relicfisher
│           │       └── packageinfo.json
│           └── services
│               └── com.brightdata.relicfisher.brd_sdk
│                   ├── index.js
│                   ├── package.json
│                   └── services.json
├── data.tar.gz
└── debian-binary

29 directories, 55 files

The interesting part is that the app payload inside is not plaintext — it’s DRM-packaged.

For example index.html from above

                                    ...
<?xml version="1.0"?>
<ncgxmlhdr>
        <content>
                <source></source>
                <sid>[READACTED SID]</sid>
                <cid>[REDACTED CID]</cid>
                <encryption range="-1">1</encryption>
                <packdate>2026-05-25T10:51:34Z</packdate>
        </content>
        <license>
                <url>http://url</url>
        </license>
</ncgxmlhdr>
                                    ...
                        [encrypted index.html content]
                                    ...

Step 1 — The DRM wall (NCG / PallyCon)

Apps that live under /media/cryptofs on webOS are DRM-packaged. This is confirmed conceptually by LG’s own Common Criteria (EAL2) certification report, Application Security Solution V1.0 for LG webOS TV, which states plainly:

  • packaged apps are encrypted at rest,
  • a unique Content Encryption Key (CEK) per app lives on the DRM server, and
  • the TV downloads a Rights Object (RO) that contains that CEK.

So the shape is “CEK-inside-RO, fetched from a server.” The filing discloses the design but none of the internals.

The vendor identity fills in the rest: the encrypted files use the NCG container — “Netsync Content Guard” by INKA Entworks, the company now known as PallyCon / DoveRunner. Public SDK docs describe the API surface only; the wire format and key-agreement are undocumented.

Reconstructing that flow (license URL → key agreement → CEK → bulk-decrypt the NCG files) is, again, something I’m not going to detail. This is the DRM layer that protects the vendor’s code; publishing a working decryptor would be both legally fraught and useful to exactly the wrong audience. The tool I used (store.py) stays private, and I’ve redacted every device-specific and cryptographic value it touches.

What I’ll say is only that the flow the Common Criteria filing describes matches what the app actually does, and that once the per-app CEK is derived it verifies cleanly against a known-plaintext anchor (index.html decrypts to valid HTML) and decrypts the rest of the bundle — 17 NCG files, zero failures. The result is the real, readable app payload (main.js, values.js with appName: 'Relic Fisher', the brd_api_* SDK bundle, the consent dialog, etc.).

❯ python store.py /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20
[*] app dir : /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20
[*] app id  : com.brightdata.relicfisher   version: 1.0.20
[*] step 1: <REDACTED - COLLECT AND FORMAT NEEDED VALUES FOR LICENCE NEGOTIATION>
[*] step 2: <REDACTED - PRESENT THE LICENCE REQUEST TO SERVER AND RETRIVE THE RESPONSE>
[+] CEK = <REDACTED>
[*] CEK verified on index.html (printable=1.000). Decrypting 17 files IN PLACE...
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/ads.js  (2889 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'function get_random_video() {\n    var video_number = Math.fl')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/brd_api.helper.min.js  (3696 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'!function(){var n,t,e=!1,o=!1,i="bright_sdk.status",s=localS')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/brd_api.mock.js  (1258 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'(function(){\n    var on_status_change;\n    var consent;\n    ')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/brd_api_v1.625.64.js  (556072 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'!function(){e={626:function(){"use strict";"function"!=typeo')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/i18n.js  (1158 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'function detectLanguage() {\n    return new Promise((resolve,')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/i18n.mock.js  (62 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b"function detectLanguage(){\n    return Promise.resolve('en');")
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/index.html  (4042 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'<!DOCTYPE html>\r\n<html lang="en">\r\n  <head>\r\n    <meta chars')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/main.js  (4916 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'var brd_enabled = false;\nvar mutex = false;\nvar settingsDial')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/notification.js  (1453 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'function showNotification(title, text, autoCloseMs) {\n    //')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/offline.js  (617 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'var offlineNotification = null;\n\n// Listen for network conne')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/polyfills.js  (2567 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'// CustomEvent polyfill\n(function() {\n    if (typeof window.')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/settings.js  (1807 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'var settingsDialog;\n\nfunction initSettings() {\n\n    var sett')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/settingsdialog.bundle.js  (180297 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'!function(A,e){"object"==typeof exports&&"object"==typeof mo')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/values.consent.js  (0 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/values.js  (1111 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b"var values = {\n    appName: 'Relic Fisher',\n    appid: 'com.")
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/webOSTV.js  (15229 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'window.webOS=function(e){var t={};function n(o){if(t[o])retu')
[+] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20/data/usr/palm/applications/com.brightdata.relicfisher/lib/consent.bundle.js  (213705 bytes, cid=<REDACTED> seed=<REDACTED>)  head=bytearray(b'!function(e,t){"object"==typeof exports&&"object"==typeof mo')

[=] /home/blaz/Downloads/com.brightdata.relicfisher_1.0.20: decrypted 17 NCG files, failed 0 (non-NCG files left untouched)

Note on scope: this only decrypts an app I downloaded to my own machine, for the purpose of reading what it does on my own network. It’s not a DRM-content circumvention tool — there’s no media here, just JavaScript.

Step 2 — Deobfuscating the payload

After successful DRM removal we are left with the following package structure:

usr/palm/
├── applications/com.brightdata.relicfisher/   ← the web app (UI shell + game loader)
│   ├── index.html            App entry; creates the game <iframe> and loads scripts
│   ├── main.js               Wires up Bright SDK init + consent dialog callbacks
│   ├── values.js             Config: app name, game URL, script load order
│   ├── ads.js                Plays sample ad videos when the SDK is NOT enabled
│   ├── settings.js           "Enable/disable" settings dialog (key 5 opens it)
│   ├── notification.js        Generic on-screen popup helper
│   ├── offline.js            Online/offline connectivity notifications
│   ├── i18n.js               Detects TV system language via luna settings service
│   ├── brd_api_v1.625.64.js  BrightSDK web API (556 KB, obfuscated/minified)
│   ├── brd_api.helper.min.js Cross-platform wrapper exposing window.BrightSDK
│   ├── brd_api.mock.js       Mock SDK for local testing (not loaded in prod)
│   ├── settingsdialog.bundle.js / lib/consent.bundle.js  React UI bundles (+ .map)
│   ├── webOSTV.js            LG webOS TV JS bridge
│   ├── polyfills.js          Legacy-browser polyfills
│   ├── appinfo.json          webOS app manifest
│   └── resources/<lang>/     Localized app titles (18 languages)
├── packages/com.brightdata.relicfisher/
│   └── packageinfo.json      Declares dependency on the brd_sdk service
└── services/com.brightdata.relicfisher.brd_sdk/
    ├── index.js              The peer service (2.5 MB, heavily obfuscated)
    ├── package.json          Bright SDK v1.625.64 build metadata
    └── services.json         Luna service commands (start, get_status, update_consent, …)

The decrypted files fall into three buckets, and each got a different treatment:

  1. Source maps. consent.bundle.js and settingsdialog.bundle.js shipped their .map files with sourcesContent — so the original source came out verbatim. Best possible fidelity, comments and all.
  2. webcrack on the two javascript-obfuscator-protected files (string-array rotation, control-flow flattening, dead-code injection — ~27.5k transforms on the main bundle). This restores readable structure and inline string literals; the single-letter / _0x… identifiers are gone for good (that information isn’t in the file).
  3. A custom splitter. The SDK is one esbuild-style CommonJS bundle where each module’s original path is its registry key — so I split the bundle back into a browsable file tree (peer_node/rc4.js, peer_node/cloud_conf.js, peer_node/client.js, …).

The remaining app files (main.js, ads.js, values.js, etc.) shipped unobfuscated, so those just read straight.

Step 3 — What it actually is

values.js is honest about the wrapper:

var values = {
  appName: 'Relic Fisher',
  appid: 'com.brightdata.relicfisher',
  ...
};

…but the interesting ~30 hand-written modules under the split tree are the Bright Data peer / proxy engine (SDK ZON_VERSION 1.625.64, Luminati lineage). Stripped down, here’s the behavior I reconstructed purely from the sources.

The control channel (C2)

The peer opens a long-lived outbound wss:// control channel to a random IP from a ~200-entry gateway (“zagent”) pool, port 80 with TLS still negotiated, SNI generated per connection. The remote server drives the device by pushing command frames:

commandeffect
tunnel_initregister peer; device reports ext IP, geo/ASN, arch, release, UUID
tunrelay TCP/UDP to an arbitrary destination through the residential IP (this is the exit-node job)
dnsresolve a name on the device’s resolver
logslist/fetch *.log / *.json files under the SDK’s confdir
tunnel_redirectreconnect the control channel to a server-chosen host/IP/port
update_consentflip consent state

The exit-node relay policy blocks essentially only TCP port 25 and RFC1918/reserved ranges — every other public host:port is relayed for Bright Data’s paying customers. It self-identifies on the wire as User-Agent: Luminati/1.625.64 (sdk; ).

The consent story

There is a genuine opt-in: default OFF, a two-button dialog, and declining stops the peer. But the enforcement is convention, not mechanism:

  • Consent is checked in the orchestration layer (sharing.js), not in client.start() itself.
  • Some telemetry funnel events (init, 05_svc_init, display) fire before/without opt-in, and tunnel_init ships your external IP, geo/ASN regardless.

The crypto (local logs)

Local log encryption uses a hardcoded key, an MD5-based KDF with a null salt (fully deterministic), RC4 as the cipher, and adler32 for integrity — a checksum, not a MAC. Blast radius is limited to local log confidentiality, but it’s textbook weak crypto. On the positive side: I found no eval/remote-code-download-and-execute, TLS validation is never disabled, and there are no hardcoded API keys or private keys (the embedded cert is a public USERTrust CA root).

Step 4 — The closer: the unsigned cloud config

The single finding I want to end on, because it’s both the cleanest to demonstrate and the most consequential: the SDK fetches a remote config every ~15 minutes that can repoint its own infrastructure — and that config is not signed.

The payload is served from a BunnyCDN path and is only lightly encoded, not encrypted: it’s base64 with the last three characters (before any = padding) rotated to the front. The SDK calls this util.decode_base64_shift. There’s no HMAC, no signature — HTTPS is the only integrity control. Decoding it reveals the live gateway IP pools, telemetry sinks, and proxy domains the device will trust.

pull_cloud_config.py is a faithful, read-only reimplementation of that decoder. It sends the SDK’s own User-Agent, does a plain GET, and writes the decoded JSON plus a flat, deduplicated IP list next to itself:

import base64
import json
import os
import sys
import urllib.request

DEFAULT_URL = "https://cdn-cloud.b-cdn.net/static/cloud_config.dat"
# The SDK identifies itself as Luminati on the wire (perr.js User-Agent).
USER_AGENT = "Luminati/1.625.64 (sdk; )"
OUT_DIR = os.path.dirname(os.path.abspath(__file__))


def decode_base64_shift(s: str, enc: str = "utf-8") -> str:
    """Exact reimplementation of util.decode_base64_shift from the SDK."""
    eq = s.find("=")
    n = len(s)
    if eq == -1:
        s = s[n - 3:] + s[:n - 3]
    else:
        s = s[eq - 3:eq] + s[:eq - 3] + s[eq:]
    return base64.b64decode(s).decode(enc)


def get(url: str) -> bytes:
    """HTTPS GET with the SDK's User-Agent (urllib follows redirects)."""
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(req, timeout=20) as resp:
        if resp.status != 200:
            raise RuntimeError("HTTP %s for %s" % (resp.status, url))
        return resp.read()


def main() -> int:
    url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URL
    print("[*] fetching", url, file=sys.stderr)

    raw = get(url)
    raw_str = raw.decode("ascii").strip()
    with open(os.path.join(OUT_DIR, "cloud_config.dat"), "wb") as f:
        f.write(raw)
    print("[*] wrote cloud_config.dat (%d bytes)" % len(raw), file=sys.stderr)

    try:
        decoded = decode_base64_shift(raw_str)
    except Exception as e:  # noqa: BLE001
        print("[!] decode failed:", e, file=sys.stderr)
        return 1

    try:
        obj = json.loads(decoded)
    except json.JSONDecodeError as e:
        # still write what we decoded so it can be inspected
        with open(os.path.join(OUT_DIR, "cloud_config.decoded.txt"), "w") as f:
            f.write(decoded)
        print("[!] decoded payload is not valid JSON:", e, file=sys.stderr)
        print("    wrote raw decoded text to cloud_config.decoded.txt", file=sys.stderr)
        return 1

    pretty = json.dumps(obj, indent=2, ensure_ascii=False)
    with open(os.path.join(OUT_DIR, "cloud_config.decoded.json"), "w") as f:
        f.write(pretty + "\n")
    print("[*] wrote cloud_config.decoded.json (%d chars)" % len(pretty), file=sys.stderr)

    # flat IP list (both non-SSL and SSL gateways), de-duplicated, for IOC use
    seen, ips = set(), []
    for ip in (obj.get("zagent_sdk_ips") or []) + (obj.get("zagent_sdk_ips_ssl") or []):
        if ip not in seen:
            seen.add(ip)
            ips.append(ip)
    if ips:
        with open(os.path.join(OUT_DIR, "cloud_config.ips.txt"), "w") as f:
            f.write("\n".join(ips) + "\n")
        print("[*] wrote cloud_config.ips.txt (%d unique IPs)" % len(ips), file=sys.stderr)

    # brief summary to stdout
    print("--- cloud config summary ---")
    print("updated            :", obj.get("updated"), "(ts %s)" % obj.get("updated_ts"))
    expire = obj.get("expire") or 0
    print("expire             :", expire, "ms =", expire / 86400000, "days")
    print("zagent_sdk_ips     :", len(obj.get("zagent_sdk_ips") or []))
    print("zagent_sdk_ips_ssl :", len(obj.get("zagent_sdk_ips_ssl") or []))
    print("perr_domains       :", json.dumps(obj.get("perr_domains")))
    print("proxyjs_domains_a1 :", json.dumps(obj.get("proxyjs_domains_a1")))
    print("cdn_update_urls    :")
    for u in obj.get("cdn_update_urls") or []:
        print("   ", u)
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as e:  # noqa: BLE001
        print("[!] error:", e, file=sys.stderr)
        sys.exit(1)
❯ python3 pull_cloud_config.py
[*] fetching https://cdn-cloud.b-cdn.net/static/cloud_config.dat
[*] wrote cloud_config.dat (9294 bytes)
[*] wrote cloud_config.decoded.json
[*] wrote cloud_config.ips.txt (372 unique IPs)
--- cloud config summary ---
updated            : ... 
expire             : ...
zagent_sdk_ips     : ~200
zagent_sdk_ips_ssl : ~200
perr_domains       : ["perr.l-agent.me", "perr.bright-sdk.com", "perr.l-err.biz"]
Full response
{
  "zagent_sdk_ips": [
    "45.77.243.25",
    "144.202.50.162",
    "45.76.191.252",
    "104.248.204.65",
    "178.62.192.106",
    "178.62.243.229",
    "45.76.163.213",
    "64.176.223.207",
    "46.101.228.174",
    "188.166.244.73",
    "188.166.95.117",
    "167.71.168.125",
    "134.209.203.211",
    "206.189.35.98",
    "149.28.157.10",
    "149.28.150.62",
    "162.243.35.69",
    "143.198.220.79",
    "165.22.241.130",
    "209.38.106.244",
    "147.182.192.235",
    "139.59.211.128",
    "188.166.186.226",
    "178.128.23.48",
    "138.68.241.182",
    "68.183.211.190",
    "137.184.232.5",
    "64.23.149.27",
    "45.76.228.153",
    "96.30.204.240",
    "104.236.3.102",
    "209.38.47.154",
    "149.28.144.5",
    "45.32.101.40",
    "142.93.17.203",
    "209.222.21.235",
    "159.223.83.48",
    "128.199.181.192",
    "129.212.176.74",
    "80.240.16.114",
    "159.223.190.36",
    "206.189.238.134",
    "206.189.33.193",
    "64.226.95.51",
    "134.122.6.232",
    "136.244.118.191",
    "192.241.157.79",
    "207.148.19.151",
    "159.223.84.127",
    "45.77.111.83",
    "192.241.154.24",
    "165.227.151.220",
    "159.203.82.100",
    "164.92.219.81",
    "95.179.208.202",
    "159.223.34.207",
    "207.148.64.226",
    "162.243.114.20",
    "188.166.41.43",
    "207.246.120.238",
    "162.243.1.158",
    "178.128.235.197",
    "174.138.23.161",
    "178.62.229.194",
    "139.59.113.78",
    "138.197.87.209",
    "178.128.218.76",
    "167.71.215.113",
    "192.241.249.88",
    "188.166.50.245",
    "143.198.9.8",
    "162.243.23.156",
    "64.176.223.220",
    "139.180.157.242",
    "167.71.183.45",
    "209.38.33.219",
    "45.76.230.202",
    "162.243.204.115",
    "167.172.43.72",
    "143.198.139.130",
    "149.28.45.5",
    "178.128.232.37",
    "206.189.106.89",
    "178.128.24.135",
    "143.198.201.22",
    "188.166.215.64",
    "66.135.15.49",
    "178.128.126.90",
    "165.227.73.164",
    "128.199.93.185",
    "107.170.6.127",
    "164.92.157.251",
    "157.230.240.141",
    "128.199.179.117",
    "143.198.47.196",
    "139.180.139.103",
    "207.148.74.167",
    "167.71.108.240",
    "206.189.183.254",
    "66.42.84.250",
    "107.170.39.154",
    "45.32.116.22",
    "192.241.240.30",
    "165.22.248.127",
    "144.202.57.91",
    "165.227.109.129",
    "188.166.26.108",
    "64.176.192.133",
    "209.38.96.121",
    "66.55.159.135",
    "188.166.47.201",
    "174.138.81.100",
    "140.82.54.71",
    "64.23.225.250",
    "159.89.202.9",
    "165.227.115.92",
    "207.148.14.251",
    "104.156.227.33",
    "178.128.83.155",
    "157.245.147.220",
    "165.22.155.65",
    "45.77.39.248",
    "139.180.147.9",
    "165.22.73.35",
    "139.59.232.81",
    "66.42.62.200",
    "178.62.235.25",
    "217.69.11.178",
    "192.241.163.90",
    "64.23.148.210",
    "165.227.234.56",
    "206.189.32.250",
    "45.77.37.206",
    "165.227.72.50",
    "167.71.83.32",
    "134.122.36.12",
    "45.76.57.28",
    "164.92.154.61",
    "45.76.63.0",
    "149.28.114.186",
    "138.68.10.221",
    "209.97.150.201",
    "134.209.81.199",
    "209.38.224.53",
    "162.243.104.127",
    "143.198.143.240",
    "64.23.224.250",
    "143.110.214.4",
    "164.90.226.214",
    "192.241.247.243",
    "66.135.12.24",
    "159.65.178.182",
    "159.89.54.137",
    "149.28.224.23",
    "104.236.127.70",
    "139.180.141.50",
    "45.32.113.77",
    "188.166.107.113",
    "192.81.218.32",
    "138.197.221.213",
    "104.131.234.154",
    "209.38.97.159",
    "152.42.173.246",
    "64.227.10.201",
    "167.71.205.23",
    "45.76.226.239",
    "104.236.78.51",
    "188.166.86.14",
    "207.148.67.247",
    "137.184.209.149",
    "206.189.32.239",
    "95.179.199.203",
    "46.101.96.40",
    "137.220.58.183",
    "134.122.68.252",
    "45.55.247.124",
    "206.189.12.74",
    "164.92.233.217",
    "45.76.25.185",
    "209.38.39.229",
    "152.42.143.155",
    "45.76.16.105",
    "188.166.42.135",
    "64.227.17.152",
    "137.184.136.58",
    "159.89.178.242",
    "207.246.94.204",
    "167.71.50.216",
    "159.203.139.161",
    "139.180.136.250",
    "45.76.20.103",
    "45.77.246.74",
    "142.93.231.95",
    "134.122.55.119",
    "159.89.3.129",
    "159.65.239.4",
    "209.38.99.48",
    "46.101.248.31",
    "146.190.175.123",
    "162.243.2.145"
  ],
  "zagent_sdk_ips_ssl": [
    "144.126.215.223",
    "157.245.195.118",
    "157.230.37.24",
    "167.172.131.25",
    "45.32.124.32",
    "143.198.180.28",
    "64.23.221.123",
    "207.246.94.204",
    "152.42.227.48",
    "165.227.167.78",
    "45.63.10.183",
    "192.241.184.215",
    "178.128.86.63",
    "159.203.88.57",
    "45.77.32.121",
    "162.243.97.228",
    "207.154.246.202",
    "107.170.52.182",
    "178.128.126.90",
    "104.248.208.71",
    "107.170.63.71",
    "159.89.120.5",
    "108.61.95.116",
    "142.93.23.223",
    "167.71.62.88",
    "66.42.117.158",
    "167.71.127.116",
    "64.176.200.49",
    "45.32.180.208",
    "143.198.87.87",
    "140.82.43.24",
    "149.28.141.230",
    "207.148.67.134",
    "139.59.108.76",
    "198.211.114.176",
    "165.22.248.127",
    "178.128.148.155",
    "107.170.6.8",
    "159.89.178.242",
    "45.76.16.105",
    "192.241.255.233",
    "45.63.19.194",
    "165.22.238.229",
    "139.59.232.81",
    "45.76.176.14",
    "66.135.24.112",
    "139.180.130.137",
    "159.89.3.129",
    "209.38.204.235",
    "207.148.123.47",
    "45.77.174.250",
    "137.184.31.143",
    "167.71.215.113",
    "140.82.0.133",
    "165.227.52.221",
    "66.55.159.7",
    "152.42.195.239",
    "167.99.97.237",
    "64.226.71.218",
    "134.209.100.252",
    "68.183.54.253",
    "178.128.83.155",
    "162.243.224.120",
    "178.128.116.236",
    "207.148.21.24",
    "138.68.155.115",
    "144.202.59.203",
    "155.138.196.241",
    "66.42.84.250",
    "138.197.150.58",
    "146.190.111.64",
    "167.172.87.132",
    "45.55.168.79",
    "45.77.34.108",
    "162.243.11.181",
    "207.148.67.32",
    "64.226.95.51",
    "165.22.241.130",
    "192.34.62.60",
    "204.48.27.205",
    "107.191.48.225",
    "107.170.65.183",
    "192.241.241.76",
    "45.77.97.3",
    "206.189.35.98",
    "95.179.211.164",
    "137.184.61.182",
    "134.209.107.219",
    "159.65.178.182",
    "147.182.131.251",
    "66.42.53.171",
    "149.28.61.78",
    "152.42.247.12",
    "139.59.103.45",
    "159.223.198.204",
    "161.35.9.252",
    "137.184.18.187",
    "141.164.51.251",
    "167.71.223.213",
    "207.148.14.251",
    "143.198.37.153",
    "139.59.224.179",
    "206.189.183.254",
    "159.203.162.106",
    "64.227.73.64",
    "46.101.226.239",
    "45.76.163.213",
    "46.101.200.213",
    "167.71.205.23",
    "45.32.121.22",
    "167.99.69.10",
    "78.141.192.115",
    "208.167.241.190",
    "66.42.63.211",
    "165.245.186.79",
    "207.148.20.112",
    "188.166.228.128",
    "159.223.174.229",
    "64.23.206.137",
    "178.128.28.82",
    "149.28.114.186",
    "146.190.104.217",
    "161.35.102.92",
    "167.172.95.143",
    "157.230.87.110",
    "209.38.71.20",
    "95.179.171.187",
    "104.238.150.28",
    "217.69.1.146",
    "138.68.31.113",
    "68.183.87.221",
    "157.245.153.141",
    "139.59.118.247",
    "68.183.62.213",
    "147.182.254.139",
    "64.176.223.36",
    "146.190.252.13",
    "46.101.161.147",
    "207.246.80.178",
    "140.82.43.137",
    "143.244.135.166",
    "149.28.42.206",
    "188.166.186.226",
    "45.77.247.127",
    "107.191.60.248",
    "159.223.162.56",
    "129.212.180.42",
    "139.180.157.99",
    "162.243.106.26",
    "45.32.119.162",
    "45.32.107.60",
    "45.32.102.110",
    "134.199.193.196",
    "142.93.17.203",
    "129.212.176.74",
    "159.203.1.13",
    "149.28.136.221",
    "147.182.147.20",
    "206.189.95.122",
    "45.77.247.102",
    "162.243.79.100",
    "167.71.199.198",
    "178.128.218.76",
    "45.63.19.113",
    "134.122.31.148",
    "46.101.230.182",
    "95.179.199.203",
    "134.209.48.142",
    "139.180.212.161",
    "178.128.235.197",
    "45.63.16.231",
    "143.198.197.107",
    "64.176.84.160",
    "149.28.58.185",
    "207.148.127.89",
    "167.71.7.31",
    "192.241.169.82",
    "45.32.99.212",
    "45.32.106.18",
    "198.199.64.195",
    "104.248.238.59",
    "149.28.150.62",
    "149.28.42.212",
    "149.28.145.253",
    "207.148.79.142",
    "45.77.102.18",
    "144.202.54.155",
    "45.76.160.255",
    "157.245.147.220",
    "207.246.120.93",
    "159.203.63.20",
    "45.77.97.41",
    "104.248.190.87",
    "159.65.11.15",
    "45.76.9.232",
    "167.71.186.176",
    "206.189.210.13",
    "165.22.65.236",
    "64.176.223.207",
    "68.183.57.101"
  ],
  "perr_domains": [
    "perr.l-err.biz",
    "perr.l-agent.me",
    "perr.bright-sdk.com"
  ],
  "proxyjs_domains_a1": [
    "p.l-conn.net"
  ],
  "cdn_update_urls": [
    "https://zagent108.l-cdn.com/admin/rmt/luminati.io/static",
    "https://zagent110.l-cdn.com/admin/rmt/luminati.io/static",
    "https://zagent111.l-cdn.com/admin/rmt/luminati.io/static",
    "https://zagent112.l-cdn.com/admin/rmt/luminati.io/static",
    "https://zagent113.l-cdn.com/admin/rmt/luminati.io/static",
    "https://cdn-bright-sdk.b-cdn.net/static"
  ],
  "expire": 604800000,
  "updated": "2026-05-25T12:57:21.179Z",
  "updated_ts": 1779713841180
}

The decoded config is exactly what you’d expect: a couple hundred gateway IPs (DigitalOcean / Vultr / AWS ranges), the SSL gateway pool, and the telemetry/proxy domain lists — all of which this file can silently overwrite at runtime.

Similary, there is a SDK config endpoint which request pull intervals, hearthbeat period, etc…

curl -s 'https://clientsdk.bright-sdk.com/sdk_config_webos.json?appid=webos_com.brightdata.relicfisher&ver=1.625.64'   -H 'User-Agent: Luminati/1.625.64 (sdk; )'   -H 'content-type: application/json'   -H 'x-uuid: 9c98c877-2b36-45ef-938f-5aca77f365bb'
{"sharing":{"activity_intervals":{"agent":"24h","slave":"3h","master":"10m"},"monitor_heartbeat_interval":600000,"slaves_limit":3,"active_limit":2,"active_limit_testing":10}}

How to block it

You don’t need to root your TV or uninstall anything — you can neuter the peer at the network edge, because all of its infrastructure is a fixed set of hostnames. Pick whichever layer you control:

1. DNS sinkhole (easiest — Pi-hole / AdGuard Home / router DNS). Blackhole the SDK’s control, config and telemetry domains. The peer can’t find a gateway if the names don’t resolve:

# Bright Data / "Bright SDK" peer infrastructure
clientsdk.bright-sdk.com
clientsdk.brdtnet.com
clientsdk.luminati-china.io
cdn-cloud.b-cdn.net          # unsigned cloud-config source - Since its a CDN it might break legit apps too
perr.bright-sdk.com
perr.l-agent.me
perr.l-err.biz
proxyjs.brdtnet.com
proxyjs.bright-sdk.com
p.l-conn.net
l-cdn.com
l-agent.me
brdtest.com

DNS alone isn’t bulletproof — the cloud config can hand the device raw gateway IPs that skip DNS — so pair it with:

2. Firewall egress rules. On a router that supports per-client rules (OpenWrt, pfSense, Firewalla, UniFi), put the TV in its own group and:

  • block outbound TCP to the SDK’s odd ports — 22222 (legacy zagent), 7010 (proxy data), and the SSL gateways that dial port 80 with TLS — and
  • ideally default-deny the TV’s WAN egress and allowlist only what your streaming apps actually need. A TV that only streams doesn’t need to open hundreds of connections to random VPS IPs.

3. Just don’t run it. The surest fix is to not install apps whose business model is “free app, your bandwidth is the product.” Given Spur found proxy SDKs in ~42% of webOS apps, assume any free, obscure app might ship one, and check the settings screen for a “Web Indexing” / “Bright Data” consent toggle — if it’s there, turn it off (and note from the analysis above that “off” is enforced by convention, which is exactly why the network-layer block matters).

Sources / prior research