I'm Forever Curling Buckets

A look inside how your mates' football apps actually work.

I'm Forever Curling Buckets

The other day, someone in a group chat I'm in mentioned that an IPTV provider was down, which was pretty bad news. Worse still was the screenshot they sent. A Telegram support channel with an admin telling users to enable developer mode on their Android TV boxes and install an APK from a shortened link.

My interest was piqued. So I've spent the last week investigating this IPTV provider, their infrastructure, and the other services the TV piracy industry rests on.

Let's start at the beginning. A simple link to an APK.

hxxps://tiny[.]cc/XSTREAM
→ 303 Redirect →
hxxps://xshsapps[.]xyz/APPS/XSHS/XStreamXCX5[.]apk

SHA256 : 8e089f372cb69a25ab0a9b3559a59a6828fd36d8762a0ad3dba9027a7e8b6ac0
Size   : 110,746,043 bytes

A signed 110 MB APK. Certainly a good place to start.

What's actually in this thing? Who does it talk to? I figured I'd spend an afternoon on it, write up "yep, pirate IPTV, here's the decoded panel URL, cheers", and move on.

I spent most of a week on it instead. I knew going in it would be pirate IPTV. I did not know it would end up in an apartment in Queens.

But first, the APK.


What's actually in the APK

I'm not an expert in Java or reverse engineering but I know enough to get by. Analysis was done with apktool and jadx. The decompiled tree is about what you'd expect, a standard Android application with well under 200 classes outside the third-party libraries.

package     : com.xciptv.xshsxc5
label       : XStream-XCV5
versionName : 7.0
versionCode : 1001
minSdk      : 21 (Android 5.0)
targetSdk   : 33 (Android 13)

The permission list is unremarkable: INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK, CAMERA, FOREGROUND_SERVICE, RECEIVE_BOOT_COMPLETED. And one that's slightly more interesting:

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

REQUEST_INSTALL_PACKAGES screams auto-update. Not a good sign.

No SMS, no contacts, no accessibility service, no overlay, no SYSTEM_ALERT_WINDOW. None of the classic malware tells. It's an IPTV player built from the XCIPTV template that every dodgy-stick on the high street is running some version of.

Bundled into the APK, in the usual lib/arm64-v8a/ and assets/ folders:

libVLC + libvlcjni       VLC media framework, the actual player
libavcodec/libavformat   FFmpeg, via VLC
Google ExoPlayer         secondary player path
Firebase SDK             unconfigured, no google-services.json
AdMob SDK                Google's documented *test* ad IDs, so no real revenue
ZXing barcode scanner    the QR login flow
de.blinkt.openvpn        Arne Schwabe's ics-openvpn

The VPN integration is interesting. It makes sense, but the VPN and IPTV provider should really be two separate parties and responsibilities, not one. Especially given the ongoing concerns about vulnerable Android boxes being hacked and abused for botnets and residential proxies.

There's also an updatecontents/ package, which in this template family is a misnomer. Those classes pull IPTV catalogue refreshes (movie lists, series lists, EPG) from the backend, not APK self-updates. The APK self-update path is elsewhere. We'll find it shortly.

So where does this thing call home?


The first phone home

Code analysis time. Config.SERVER_API is set from a static initialiser:

// com/xciptv/xshsxc5/util/Config.java
public static String SERVER_API = pri();
public static String pri() {
    return PanelUrl._panelUrl;
}

pri() delegates to PanelUrl._panelUrl. The class com.PanelUrl lives at the root com. package. And it holds this:

public class PanelUrl {
    public static String _panelUrl = OTRApp.gtf(
      "a/uSujuHe8kP98xDr08UUSBce0FS1u5cnriE6QOXhim1ezkttyTABYrtAxmpWexRkN/bi1i2/YzkZhPxQirlwA"
    );
}

A base64 blob wrapped in a method called gtf. Let's look at gtf.

public static String gtf(String ciphertext) throws NullPointerException {
    String n7 = get_url("6C6B6A696E62767663786664736A6867")
              + get_url("2423402159545E55526A686746534452");
    SecretKeySpec secretKeySpec = new SecretKeySpec(n7.getBytes(), "AES");
    IvParameterSpec ivParameterSpec = new IvParameterSpec(
        Base64.decode("UjR0Z2hqZ140MjUoQCNHZw", 0)
    );
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(2, secretKeySpec, ivParameterSpec);
    byte[] decryptedtextByte = cipher.doFinal(Base64.decode(ciphertext, 0));
    return new String(decryptedtextByte);
}

get_url is a helper that turns pairs of hex characters into bytes. n7 is the concatenation of two hex-encoded ASCII strings stuck together at runtime. Decoded:

Key (32 bytes, AES-256) : lkjinbvvcxfdsjhg$#@!YT^URjhgFSDR
IV  (16 bytes)          : R4tghjg^425(@#Gg
Mode                    : AES/CBC/PKCS5Padding

A straight AES-256 decrypt with a hardcoded key. Let's decrypt it.

from base64 import b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

key = bytes.fromhex("6C6B6A696E62767663786664736A6867"
                    "2423402159545E55526A686746534452")
iv  = b64decode("UjR0Z2hqZ140MjUoQCNHZw==")
ct  = b64decode("a/uSujuHe8kP98xDr08UUSBce0FS1u5cnriE6QOXhim1ezkttyTABYrtAxmpWexRkN/bi1i2/YzkZhPxQirlwA==")
print(unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ct), 16).decode())
https://xc.xs22.org/xshs/xciptv/XCX5/XStream/api/

Alongside the panel URL, a second hardcoded constant gets used further downstream. A "builder token" baked in at compile time and used in every license request:

217A6425-3A31-491B-ALFA-38764A2E8GTF

It's shaped like a UUID (8-4-4-4-12 with dashes), but two of the segments contain characters (L, G, T) that aren't valid hex, so it isn't a valid UUID. Functionally it's a pre-shared secret between the compiled APK and the licensing backend.


The licensing handshake

SplashActivity.h() is the method that builds the license-check URL. Reading it as smali gives you this construction:

GET {panel}ApiIPTV.php?tag=licV4
    &l={md5(builder_token)}
    &an={app_name_no_spaces}
    &el={xor_of(builder_token, xor_key)}
    &ea={xor_of(app_name,      xor_key)}
    &eb={xor_of(package_name,  xor_key)}

The XOR step is a repeating-key XOR over Java chars, implemented in a helper called V3.b.a.

public static String a(String plaintext, String key) {
    StringBuilder sb = new StringBuilder();
    int kLen = key.length();
    for (int i = 0; i < plaintext.length(); i++) {
        sb.append((char) (plaintext.charAt(i) ^ key.charAt(i % kLen)));
    }
    return Base64.encodeToString(sb.toString().getBytes(), Base64.NO_WRAP);
}

A formality so the query parameters aren't human-readable if you tcpdump the request. Reconstructing the full URL in Python is 40 lines. The output:

hxxps://xc[.]xs22[.]org/xshs/xciptv/XCX5/XStream/api/ApiIPTV.php
  ?tag=licV4
  &l=c488dc6f02affd1423675118f945da2c
  &an=XStream-XCV5
  &el=UQUPeVJXBFMdASBVV0kFDQNxG3Z5d3AVVQEDA1AgACZgFCA0
  &ea=O2dMSgECW0tocTdT
  &eb=AFtVFhwAXxZERE8eFQxCTFEG

Firing it unauthenticated, the backend hands back a JSON document.

HTTP/2 200
server: BunnyCDN-UK1-1205
content-type: text/html; charset=UTF-8
{
  "success": "1",
  "status":  "ACTIVE",
  "cid":     "540205",
  "which":   "licV4",
  "app":      "eCh5eEVBTQ16KjIXDxQSBwQFU0BFen95...",
  "portal":   "eCh5eEVBTQ16MzdbUFgSCBAXCBsAEQAB...",
  "urls":     "eCh5eEVBTQ16IiZeQEZcEAoVVxoYByg...",
  "button":   "eCh5eEVBTQ16ISJbalhZRFUXWFVWLQod...",
  "settings": "eCh5eEVBTQ16IjFQW0ASCBAXKzYABj0y...",
  ...
}

What's in the response:

  • success: "1", the server accepted our call.
  • status: "ACTIVE", it issued us an active license.
  • cid: "540205", a customer ID. The server minted it for us.

One unauthenticated GET and the operator's licensing backend handed over what looks, to an installed app, like a paid, active, licensed account.

Five encrypted fields starting with the same eight base64 characters eCh5eEVBTQ16, which is the tell for a repeating-key XOR with a shared plaintext prefix.

The code for reversing the fields sits in SplashActivity.m(). Same idea as the request encoding, only on the way in.

C0 = R.string.app_name             // "XStream-XCV5"
D0 = response.getString("cid")     // "540205"
B0 = C0 + D0                       // "XStream-XCV5540205"

for each field in {"app","portal","urls","button","settings"}:
    raw   = response.getString(field)
    step1 = Methods.z(raw)                // new String(base64_decode(raw))
    key   = B0 + <field-specific suffix>
    plain = V3.b.m(step1, key)            // char-level repeating XOR
    json  = new JSONObject(plain)

Base64 decode, XOR against a key made of app_name + cid + <suffix>, parse the plaintext as JSON.

The first decryptor I wrote took the suffix as "the JSON field name" for all five fields, and got three clean: app, portal, urls. The other two came out as garbage. Back to the smali. The fourth field's JSON name is button, but the XOR key suffix used to decrypt its value is "buttons". For the fifth field the JSON name is settings and the suffix is "sett". With those two corrected, all five fields decrypted cleanly.

The interesting stuff in the plaintext:

app, licensing metadata for this install:

{
  "id":             "540205",
  "appname":        "XStream-XCV5",
  "expire":         "LIFETIME",
  "version_code":   "1001",
  "apkautoupdate":  "yes",
  "filter_status":  "No",
  "epg_mode":       "yes",
  "show_expire":    "yes"
}

One unauthenticated GET yields expire: LIFETIME, a permanent customer account.
And apkautoupdate: yes .The app will phone home for APK updates on its own and install whatever it gets. That's where the REQUEST_INSTALL_PACKAGES permission earns its keep.

portal, which IPTV origin to stream from:

{
  "panel":       "xtreamcodes",
  "portal":      "http://xs.notyou.tel:2052",
  "portal_name": "XStream"
}

Port 2052 is one of Cloudflare's allowed proxy ports, so the origin panel can sit behind CF without exposing its real IP. Note the http, not https. User credentials and activity go across in cleartext to anyone in the middle of the request, which is strange because we see HTTPS and certificate usage elsewhere in the app.

urls, some other network sources:

{
  "apkurl":   "https://download.ottrun.com/downloads/",
  "logurl":   "https://xc.xs22.org/xshs/xciptv/XCX5/XStream/api/",
  "ovpn_url": "yes"
}

apkurl points at download[.]ottrun[.]com/downloads/. This is where the self-updater fetches from when apkautoupdate: yes fires. The operator controls this endpoint and it feeds straight into REQUEST_INSTALL_PACKAGES. We'll come back to ottrun.com, it has some really interesting stuff.

ovpn_url: "yes" tells the app's built-in OpenVPN client to fetch per-tenant .ovpn configs from a separate endpoint. We'll come back to that too.


The Xtream Codes panel behind the curtain

I knew someone with credentials for the platform who let me use them to poke around a bit.

The portal URL from the licence response was http://xs.notyou.tel:2052. That's an Xtream Codes panel, a common IPTV backend, cloned from the original Xtream Codes CMS before that project was taken down in an Italian police operation in 2019.

Hit /player_api.php with the credentials and you get back two JSON blocks:

{
  "user_info": {
    "username":              "<REDACTED>",
    "password":              "<REDACTED>",
    "message":               "Welcome",
    "auth":                  1,
    "status":                "Active",
    "exp_date":              "XX",
    "is_trial":              "0",
    "max_connections":       "1",
    "allowed_output_formats": ["m3u8", "ts"],
    "created_at":            "XX"
  },
  "server_info": {
    "url":             "xs.notyou.tel",
    "port":            "2052",
    "https_port":      "25463",
    "rtmp_port":       "25462",
    "server_protocol": "http",
    "timezone":        "Europe/London",
    "time_now":        "XX"
  }
}

Standard stuff. The two other ports (25463 and 25462) were never reachable during the probe, so I can't comment on their purpose.

Enumerate the live catalogue:

  5,188 live TV streams
 29,741 movies (VOD)
182,440 series episodes

Top 15 live categories by stream count:

 485  US | USA AND LOCALS
 284  AR | ARABIC
 218  POL | POLAND
 188  AF | AFRICAN
 173  DEN | DENMARK
 162  ES | SPAIN
 147  ES | SPAIN LOCALS
 145  XXX | ADULT
 115  UK | ENTERTAINMENT
 111  ESPN +
 106  24/7 NEON STORES
 105  NL | NETHERLANDS
 102  SW | SWISS
  87  AU | AUSTRALIA
  85  ES | SPAIN CINEMA

A partial list of the UK crown jewels:

EPL: PREMIER LEAGUE (Live When Games Are On)
EPL PREMIER LEAGUE HUB 1
EPL PREMIER LEAGUE HUB 2
EPL: REPLAYS
UEFA GAMES, UEFA GAMES BACKUPS
UK | SKY SPORTS, UK | SKY SPORTS HEVC, UK | SKY SPORTS +, SKY SPORTS BACK UP
UK | TNT SPORTS, UK | TNT SPORT HEVC, UK | TNT SPORTS BACK UP
UK | SKY CINEMA, UK | SKY CINEMA STORE
24/7 NEON MOVIE STORE, 24/7 NEON KIDS STORE
iFOLLOW CHAMPIONSHIP, IFOLLOW LEAGUE ONE, IFOLLOW LEAGUE TWO
SPFL GAMES, SPFL CLUB CHANNELS
DAZN UK | EVENT TIME ONLY
NOW SPORTS, PEACOCK LIVE TV & EVENTS, AMAZON UK EVENTS, PARAMOUNT PLUS
F1 TV & HELMET CAMS (LIVE RACE TIME ONLY)
RUGBY PASS, FLO RUGBY, GAAGO / BEOSPORTS
HINTS, TIPS & TROUBLESHOOTING

That last one is real. The operator runs a dedicated HINTS, TIPS & TROUBLESHOOTING video channel embedded next to Sky Sports and TNT, to walk subscribers through setup.

get.php?type=m3u&output=ts with the credentials exports the full playlist:

HTTP/2 200
content-disposition: attachment; filename="tv_channels_<USERNAME>.m3u"
content-length: 23199369

23 MB. 217,369 URLs. Each line is a full stream URL with credentials in the path, over plain HTTP:

http://xs.notyou.tel:2052/<USERNAME>/<PASSWORD>/{stream_id}           # live
http://xs.notyou.tel:2052/movie/<USERNAME>/<PASSWORD>/{id}.{ext}      # VOD
http://xs.notyou.tel:2052/series/<USERNAME>/<PASSWORD>/{id}.{ext}     # series

Plaintext username and password in the URL path, over cleartext HTTP, on every stream fetch. Anyone capturing the user's traffic (the ISP, a machine in the middle on pub Wi-Fi, a compromised home router, a family member reading the local Pi-hole logs) sees both credentials and the exact channel or VOD being streamed.

One more thing about this panel. Probing the root path returns 401, but probing /c/ returns 200:

<!DOCTYPE html>
<html>
<head>
  <title>PORTAL</title>
  <script src="src/version.js"></script>
  <script src="src/global.js"></script>
  <script src="src/JsHttpRequest.js"></script>
  <script src="src/keydown.keycodes.js"></script>
  <script src="src/watchdog.js"></script>
  ...

That's the bootstrap page for Infomir Stalker Portal 5.3.0, confirmed by /c/version.js returning var ver = '5.3.0';. Stalker is the CMS protocol used by MAG-class set-top boxes.


The VPN

Remember the ovpn_url: "yes" flag from the licence response? There's a second endpoint on the same licensing backend that returns .ovpn configs:

GET {panel}ApiIPTV.php?tag=vpnconfigV2&cid=<cid>&aid=<aid>&k=<md5(token)>

The response body is hex-encoded AES-128-CBC/PKCS5 ciphertext. Decryption key: the literal string mysecretkeywsdef. IV: myuniqueivparamu.

I decrypted our test tenant's response and got an empty list. No .ovpn files configured for cid 540205. But the actual .ovpn files for other customers are just sitting in the bucket. Remember apkurl: "https://download[.]ottrun[.]com/downloads/"? The same bucket. I went and looked at /521064/, another customer directory, and found this:

521064/ORPlayer-7.0-v911.apk
521064/XMLTV.xml
521064/bd-dac.ovpn
521064/ipvanish.ovpn
521064/orvpn.ovpn
521064/uk-lon.ovpn

Four OpenVPN configs next to an APK. The remote lines and embedded CAs:

# bd-dac.ovpn
remote bd-dac.prod.surfshark.com 1443
auth-user-pass
<ca>
  CN = Surfshark Root CA, O = Surfshark, C = VG
</ca>

# ipvanish.ovpn
remote lon-a07.ipvanish.com 443
auth-user-pass
verify-x509-name lon-a07.ipvanish.com name
<ca>
  CN = IPVanish CA, O = IPVanish, OU = IPVanish VPN
</ca>

# uk-lon.ovpn
remote uk-lon.prod.surfshark.com 1443
auth-user-pass
# (same Surfshark Root CA)

# orvpn.ovpn
remote nl2.smartvpnexpress.com 1194
auth-user-pass
<ca>
  CN = vpn8460377743.softether.net
</ca>

Three of those files point at real commercial VPN services.

The operator holds live Surfshark and IPVanish account credentials and hands them out to subscribers as part of the app's built-in VPN feature. Neither Surfshark nor IPVanish permit credential redistribution in their TOS.

The fourth file is stranger. orvpn.ovpn, branded in the operator's UI as the operator's own premium "OR VPN" service, points at nl2.smartvpnexpress.com. The CA embedded in the config identifies the server as vpn8460377743.softether.net. VPN Gate is an academic project out of the University of Tsukuba that runs a pool of volunteer relays. Their public policy reserves the right to log packets for research.

Widening the sweep across all customer directories in the bucket, the operator ships configs for at least eleven Surfshark cities, two IPVanish locations, two rebranded SoftEther VPN Gate relays, and a couple of self-hosted OpenVPN servers on Packethub, Core-Backbone, and CyberDock. The self-hosted ones have hand-rolled EasyRSA certs still carrying the default CN=ChangeMe subject.


Pulling on the apkurl thread

https://download[.]ottrun[.]com/downloads/ in a browser. The response is an XML document.

<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name>downloads</Name>
  <IsTruncated>true</IsTruncated>
  <MaxKeys>1000</MaxKeys>
  <Contents>
    <Key>521064/ORPlayer-7.0-v911.apk</Key>
    <LastModified>2024-09-29T20:17:11.074Z</LastModified>
    <Size>98005555</Size>
    <Owner><DisplayName>minio</DisplayName></Owner>
  </Contents>
  ...

A completely public MinIO S3 bucket. We just have to GET /?marker=... to page through the whole inventory.

About a minute later, and I had a list of all the files:

29,264 keys
 2.57 TB total
 3,028 distinct top-level prefixes (= 3,028 customer slots)

Each top-level prefix is a numbered customer slot: 521064/, 540205/ (the cid we were assigned), 576881/Tiger-7.0-v2001.apk, 562479/BETFLIX-..., and so on. Each folder contains the APK build history for that customer, sometimes XMLTV files, sometimes OpenVPN configs, sometimes .aab bundle variants, occasionally a sample EPG.

A first-impression tour of the brand names just on the first page of results:

BETFLIX, CuChulainnTV, VizioN, A.K69, Tiger, PMPlus, GreyStream,
PremierPlusMedia, MEDIAVOD, ORPlayer, WorldwideMediaXC, ...

The most recently modified folder when I started writing was touched four hours before I opened the bucket. It's a live system.

A few quick queries against the listing to orient myself. Grep for XSHS, the code the XStream APK uses internally for its own brand, and you get one hit:

522754/XSHS-v4.0.3-555.apk   2020-07-04   84 MB

A 2020 build of what became XStream, sitting in customer slot 522754. XStream is a customer that forked off the central platform sometime between then and now and grew its own licensing backend on the xs22.org subdomain.

I picked one of yesterday's sibling uploads and fetched it.

$ curl -sO https://download[.]ottrun[.]com/downloads/576881/Tiger-7.0-v2001.apk
$ sha256sum Tiger-7.0-v2001.apk
a4d7... Tiger-7.0-v2001.apk
$ aapt dump badging Tiger-7.0-v2001.apk | head -3
package: name='pe.hf.tiger' versionCode='2001' versionName='7.0'
application-label:'Tiger'

Different brand name. Different package. Different builder token. Same template. Same folder structure. Same Config.java, same PanelUrl.java, same SplashActivity.h(), same V3.b.a() XOR helper, same Methods.z() base64-and-string helper. A directory diff of the unpacked APK against XStream returns 95%+ overlap across the Java side of the tree. XStream is one of more than two and a half thousand apps in this bucket. The brand name on the front cover is the only thing that changes.

If they were sloppy enough to leave their customer inventory in a public bucket, I wondered what else they'd left.


The sibling APK and the native-lib evolution

Two years newer. Same template. One new file in the APK tree:

decomp_tiger/apktool/lib/arm64-v8a/libnative-lib.so   286,768 bytes

Tiger ships a libnative-lib.so that XStream doesn't. The Java side has the same class layout (encryption/Encrypt, util/Config, OTRApp), but the key-getter methods are now native:

public static native String ekfj();     // AES key
public static native String ekivfj();   // AES IV
public static native String lkfj();     // builder token
public static native String pri();      // panel URL

The secrets have moved out of Java string constants and into a compiled native library. On paper, that's a hardening pass. Let's see how serious it is.

nm -D libnative-lib.so gives us the JNI symbols unstripped:

Java_pe_hf_tiger_encryption_Encrypt_ekfj    @ 0x1e9d4
Java_pe_hf_tiger_encryption_Encrypt_ekivfj  @ 0x1ea9c
Java_pe_hf_tiger_util_Config_lkfj           @ 0x1e198

Capstone over the disassembly of each short getter. They're all stereotyped:

Java_pe_hf_tiger_encryption_Encrypt_ekfj:
  mov  w9, #0x20                 ; libc++ SSO length byte (16 << 1)
  adrp x8, #0x13000
  add  x8, x8, #0x6cf            ; rodata pointer
  ldr  q0, [x8]                  ; load 16 bytes
  strb w9, [sp]                  ; SSO header byte
  stur q0, [sp, #1]              ; SSO payload
  strb wzr, [sp, #0x11]          ; null terminator
  ; ... NewStringUTF ...

Every getter loads between 16 and 36 bytes out of .rodata, assembles a libc++ std::string using the short-string optimisation, and passes it to NewStringUTF.
I wrote a small Python script that parses the ELF, resolves each adrp + add pair, reads the bytes from the computed rodata offset, and prints them. Side by side with XStream's plain Java strings:

Constant Tiger (native) XStream (Java) Match
Encrypt.ekfj (key) v9y$B&E)H@McQfTj v9y$B&E)H@McQfTj
Encrypt.ekivfj (IV) QfTjWnZr4u7x!A%D QfTjWnZr4u7x!A%D
Encrypt.ekpfj mysecretkeywsdef mysecretkeywsdef
Encrypt.ekivpfj myuniqueivparamu myuniqueivparamu
Config.xkfj A8opr+3e>@y](7wEEM]xp.20 A8opr+3e>@y](7wEEM]xp.20
Config.lkfj (per-build token) D9130663-F2B2-4A6E-A4C4-DAA17EF57658 217A6425-3A31-491B-ALFA-38764A2E8GTF
Panel URL https://api01.ottrun.com/api4/ https://xc.xs22.org/...

Every cryptographic constant in Tiger's native library is byte-identical to the plain Java string constant in XStream. The only differences are the per-build builder token and the panel URL, and those two both vary per-tenant anyway.
The v2001 "hardening" is cosmetic. They moved the same strings from Java into native code. Our existing decryptors work unchanged against every v2001 response we've captured.

But look at Tiger's panel URL: https://api01.ottrun.com/api4/. A centralised, operator-owned, OTT-Run-branded licensing endpoint.

I ran a panel-URL extractor against the other 2,611 APKs in the bucket. The output concentrates on five hostnames:

api01.ottrun.com/api4/
api02.ottrun.com/api4/
api03.ottrun.com/api4/
api04.ottrun.com/api4/
api04.ottrun.com/api3/      (legacy v4.x-era)

Most of the 2,611 customers point at one of those. The vast majority of pirate IPTV apps using the XCIPTV template in the wild are functionally clients of one commercial operator. OTT-Run runs the license servers, the bucket, the reseller control panel, the APK build pipeline, and the VPN credential distribution.

That tells us that XSTREAM is out of the ordinary, as they have built out a section of their own infrastructure.

If I pick any of the 2,611 customers and hit their bucket slot, I'm probably looking at the same backend. Let's see if the backend itself is in the bucket.


The platform's own backend code is in the bucket

Alongside the 3,028 numbered customer directories, the bucket has two directories with names instead of numbers. They aren't customer slots.

oriptvpanel/    2019-era PHP panel
otrcable/       2020-present ASP.NET Core rewrite

These are OTT-Run's own products. Their own deployment bundles, uploaded to their own public bucket and forgotten about.

The 2019 generation (oriptvpanel/)

oriptvpanel/install.sh              4,294 B
oriptvpanel/install_nginx.sh        2,542 B
oriptvpanel/oriptvpanel.conf          705 B
oriptvpanel/streams.conf            1,284 B
oriptvpanel/oriptv_mw.sql          10,650 B
oriptvpanel/api.zip                 4,836 B
oriptvpanel/iptvpanel.zip         ~14 MB

The install scripts provision an Ubuntu 16.04 box. install.sh runs apt-get update, installs Apache, PHP, dotnet-sdk-2.2, and nginx via apt, then fetches configs from the same public download URL, unzips iptvpanel.zip into /var/www/iptv/, installs MySQL, and reboots.
install_nginx.sh is a second, separate script that builds nginx from source with a long list of modules (--with-http_ssl_module, --with-stream, --with-http_v2_module, and so on) for deployments that need the custom build.

A small giveaway near the top of install.sh:

chmod -R 777 /etc/nginx/sites-enabled/
chmod 777 /tmp

And streams.conf leaks an internal IP in an nginx rewrite rule:

proxy_pass http://10.10.10.154:8080;

oriptv_mw.sql is a 10 KB schema-plus-data dump for the panel's MySQL database. It's a textbook Xtream Codes clone: clients, streams, epg, packages, stream_categories, stream_types, settings, users. The users table's default seed row is this:

INSERT INTO users VALUES (1, 'admin', '21232f297a57a5a743894a0e4a801fc3');

That MD5 is the hash of the literal string admin. Default admin/admin on every fresh deployment, no forced password change.

api.zip is a 4.8 KB bundle of four PHP files that together implement player_api.php. The first one I opened, xc_functions.php, contains this:

public function validateUser($username, $password) {
    $sql = "SELECT username, password "
         . "FROM clients "
         . "WHERE username='$username' "
         . "AND password='$password' "
         . "AND enabled=1";
    $query = $this->conn->prepare($sql);
    $query->bindParam(':username', $username, PDO::PARAM_STR);
    $query->bindParam(':password', $password, PDO::PARAM_STR);
    $query->execute();
    ...

The author interpolates $username and $password directly into the query string before calling prepare(). The subsequent bindParam() calls bind to :username and :password placeholders that don't exist in the query, so they're no-ops. Textbook SQL injection.
Any ' character in either field breaks out of the quoted context.

Anyone still running the 2019 PHP panel is one admin' OR 1=1-- away from authenticating as any user.

The 2020 generation (otrcable/)

otrcable/install.sh          4,013 B   ASP.NET Core install
otrcable/otrcable.conf         445 B   nginx config
otrcable/otrcable.sql       29,991 B   current schema + seed data
otrcable/otrcable.zip      ~85 MB      full compiled product
removInactiveApps.sh        16,559 B   operator maintenance cron

The install script for the new generation is shorter and a lot more professional. .NET Core 3.1, systemd unit, nginx reverse proxy on localhost:5000. One commented-out line at the bottom, left in from the operator's own deploy workflow:

# scp iptvpanel.zip root@23.131.224.21:/var/www/html/downloads/oriptvpanel/

That IP 23.131.224.21 is not a placeholder.

otrcable.sql is a 30 KB schema-and-data dump for the current panel's database. A proper commercial product: ASP.NET Core Identity for auth (AspNetUsers, AspNetRoles, AspNetUserRoles, AspNetUserClaims, AspNetUserLogins, AspNetUserTokens), a multi-tenant customer/reseller data model (packages, PackageOptions, resale_price, reseller_settings, ResellerLocations, LineCreditSettings), a full billing pipeline (invoices, InvoiceDetails, orders, OrderDetails, promocodes, transactions), a complete ticketing system (tickets, ticket_details, ticket_attachments), streams, categories, EPG data, and an __EFMigrationsHistory table tracking Entity Framework Core migrations from 2019-10-30.

The real admin row in this schema isn't admin/admin. It's this:

UserName     : otradmin
Email        : otradmin@ottrun.com
UserId       : 8d2ef60f-e535-47d0-93d8-40e4bbed0926
Role         : Admin
Country      : US
PasswordHash : AQAAAAEAACcQAAAAENKoy1zlx0J4DjjoJhsBzfjqlU39…<truncated>

ASP.NET Core Identity v3 PBKDF2-HMAC-SHA256 with 10,000 iterations.

The .zip is 85 MB and contains the full compiled production binary plus its wwwroot/. The important files extract cleanly:

otrcable/
├── appsettings.json
├── web.config
├── OTRPanelCable.dll         (725 KB)
├── OTRPanelCable.pdb
├── wwwroot/
└── (60+ managed dependency DLLs)

appsettings.json is the production config shipped in the production zip in the production bucket. Relevant parts:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=10.10.10.230;Port=3306;Database=otrcable_wip;user=paneldb;password=paneldb;Allow Zero Datetime=True;convert zero datetime=True"
  },
  /* "EmailSettings": {
    "MailServer": "ottrun-com.mail.protection.outlook.com",
    "MailPort":   25,
    "SenderName": "development demo",
    "Sender":     "testdev@ottrun.com",
    "Password":   "",
    "FromEmail":  "test@nathtel.com",
    "Bcc":        "<REDACTED>@nathtel.com"
  }, */
  "Logging": { "LogLevel": { "Default": "Warning" } }
}

Few things worth unpacking in this.

The EmailSettings block is commented out, MailServer is the standard Microsoft 365 MX pattern <domain>.mail.protection.outlook.com, confirming OTT-Run runs a commercial Microsoft 365 tenancy for ottrun.com. FromEmail and Bcc sit on a different domain, nathtel.com. I'll come back to that domain later.

Next the developer email in the BCC, <REDACTED>@nathtel.com, is that developer's own email address, shipped in a production config file uploaded to a public bucket.

The dependency list, dumped from the .deps.json:

MailKit / MimeKit                SMTP (via Microsoft 365)
MySqlConnector                   MySQL driver
Pomelo.EntityFrameworkCore.MySql
PayPal.dll                       PayPal payment processing
TMDbLib                          TMDB integration (movie metadata)
Serilog + sinks                  structured logging
BouncyCastle.Crypto              cryptography primitives

Static-parse of the DLL's metadata via dnfile gives 627 distinct user-string literals from the #US heap. The interesting ones:

  • http://epgguide.net/?epg=21&auth=58b501940947f9c782e9c31732f1b2&.xml. A leaked third-party EPG provider API token, baked into a hardcoded URL in the production binary.
  • support@ottrun.com. The operator's official support mailbox.
  • #EXTM3U OTTRun IPTVMW. The custom M3U export header the panel writes into generated playlists.
  • A block of mail-merge email template fragments: #company#, #firstname#, #invoice#, #duedate#, "This is a notification that your invoice is overdue.", "Your account has been suspended for overdue invoice.", "If you have already paid due inovices, please contact us with Transaction ID."
  • "Ottrun Cron Report:". The email subject line for scheduled-job reports.

And the build path. Roslyn writes the portable PDB path into the assembly under default Release build configuration unless you explicitly suppress it.

C:\Users\nath\Desktop\ORT Panel\OTRCABLE\obj\Release\netcoreapp3.1\OTRPanelCable.pdb

Windows user nath. Built from a Desktop folder. Release mode, .NET Core 3.1, matches the install script.

OK, so the Windows username on the build machine is nath. Let's see who nath is.


Who is Nath?

nathtel.com in a browser. The page is a single paragraph of HTML and nothing else:

<h1>Please visit https://www.hostfav.com !</h1>
<p>Nath Network & Telecom Inc.</p>

nathtel.com redirects visitors to hostfav.com and signs off as Nath Network & Telecom Inc.

RDAP on nathnetwork.com shows its authoritative nameservers are NS1.HOSTFAV.COM, NS2.HOSTFAV.COM, NS3.HOSTFAV.COM. All three of nathtel.com, nathnetwork.com, and hostfav.com resolve via direct A records (no Cloudflare proxying) to 23.131.224.24.

That IP sits inside 23.131.224.0/24, which ARIN's RDAP database has allocated to:

Organisation : Nath Network & Telecom Inc.
NetRange     : 23.131.224.0 - 23.131.224.255
Allocation   : direct, active, 2017
PoC address  : Long Island City / Astoria / NY 11102 / USA
PoC phone    : +1-877-774-1445
PoC email    : ipsupport@nathnetwork.com
PoC validated: 2025-07-06

Remember 23.131.224.21 from the commented scp line in otrcable/install.sh? Same /24. Same owner.

OpenCorporates and the NY State Department of State both list this entity.

Legal name        : NATH NETWORK & TELECOM INC.
Company number    : 3919609
Status            : Active
Type              : Domestic Business Corporation
Jurisdiction      : New York (US)
Registered office : 21-12 27th Road Apt. 1FL, Astoria, NY 11102, USA
Officer of record : ASUK NATH

The registered office is a first-floor apartment in a residential building in Astoria, Queens.

The officer of record is Asuk Nath. The surname matches the company name. A GitHub search for "Asuk Nath" surfaces one candidate:

github.com/asuknath
  display name : Asuk Nath
  location     : Astoria, NY, USA
  joined       : 2016-07-11
  public repos : 5
  last active  : 2026-03-03

The location field matches the registered address on the NY filing. A couple of the public repos belonging to the profile are tools that match the profile of a solo or small team hosting provider.

HostFav is the company's public-facing legitimate business. A commercial VPS, cloud hosting, and SSL-reseller brand, over a decade old, with a cPanel landing page, a Twitter account (@hostfav). As of today, all of the products are out of stock, but the site remains up.

OTT-Run, the IPTV platform serving 2,611 rebranded customer apps, is the newer side of the same business.

There's one more stop on this tour. The operator's own Terms & Conditions.


The Terms & Conditions

While poking at cp.ottrun.com, the reseller control panel, I clicked through to the public sign-up page. ottrun.com/otr_tandc.php. So I had a look.

The page opens with a warning to the operator's own customers:

⚠️ SCAM ALERT: Beware of XCIPTV.COM, XCIP.TV, or any other domains containing "XCIPTV" in their name, we have no affiliation with them. Additionally, we do not own any Smart TV apps for Samsung or LG TVs available in their respective app stores. These apps and websites are unlawfully using our trademarked logo.

A few paragraphs further down, the disclaimer:

OTTRUN is designed to be used with users own created content or added playlists with legal content. The OTTRUN application functions solely as a media player, it displays and plays only the content that the user inputs. OTTRUN does not endorse or support the streaming of copyrighted material without permission or authorization from the copyright owner. OTTRUN does not provide any content or playlists.

What we've seen so far:

  • the unauthenticated licV4 response that returned a lifetime license and a portal field pointing at the Xtream Codes panel
  • the 217,369-URL M3U export from that panel
  • the 2,611 rebranded APKs in the operator's own bucket, each talking to the operator's own backend
  • Sky Sports and TNT Sports in the live catalogue

Further down the page:

OTTRUN's address for Notice is: 2112 27th Rd, Astoria, NY 11102, USA.

Same street, same ZIP as the NY DoS filing for Nath Network & Telecom Inc. And a paragraph later:

The OTTRUN is the property of Nath Network Telecom Inc. We own exclusive Intellectual Property associated with our player including any trademarks, patents, copyrights and proprietary code.

The corporation publicly self-identifies in its own T&C, using the same legal name that's on the NY State filing.

The jurisdiction clause, in three pieces.

Governing Law:

The Agreements (and any non-contractual disputes/claims arising out of or in connection with them) are subject to the laws of Astoria USA, without regard to the choice or conflicts of law principles.

Astoria is a neighbourhood of Queens, New York. It is not a legal jurisdiction.

Arbitration rules:

Any arbitration between you and OTTRUN will take place in accordance with the prevailing laws of India, as modified by this Arbitration Agreement. You and OTTRUN agree that the American Arbitration Association (AAA) rules applies and governs the interpretation and enforcement of this provision (despite the choice of law provision above). Any arbitration hearings will take place in Astoria USA.

Venue:

The parties to this agreement will submit all disputes arising under this agreement to arbitration in Chandigarh, before a single arbitrator.

Chandigarh is a city in northern India. The 2020 otrcable settings table has timezone = "Asia/Calcutta", shifted from America/New_York in the 2019 oriptv schema.

The most honest reading is that Nath Network & Telecom Inc. has a development hub in Chandigarh, in addition to its US registered office in Queens.


So what did we find out

The reality of the IPTV piracy industry, despite the legal pressure and the public attention, is that it's no different from any other SME software business. There are providers, resellers, B2B infrastructure, deployment pipelines, invoicing, and support tickets.

Each of the 2,611 customers in OTT-Run's bucket is a small-time pirate reseller, each building their own retail-facing brand on top of OTT-Run's template and paying the operator for the backend.

Each of those resellers in turn has tens, hundreds, or thousands of end subscribers, each running a rebranded version of the same XCIPTV template on an Android TV box in a living room somewhere.


If you made it this far, thanks for reading. Piracy is cool, but stay safe out there.