Introduction
I wanted to understand one specific thing about Axis TV on my Android TV box. During setup, the app only asks for a 16 digit activation code. It does not ask for a server URL or account credentials. I wanted to see how it resolves the backend from that one input.
I tested this only against my own subscription and a couple of expired codes shared by friends. This writeup is about reverse engineering and security analysis, not bypassing paid services.
I started with three codes:
1.
4666961643252940 from 2022, expired
2.
4652818338928241 from 2021, expired
3.
An active code, not published here
The APK used is axistv.apk , which is at about 84 MB.
Some context
Before diving any deeper into this article, I’ll add some more context. When proof-reading this post, I noticed I rushed it a bit. Axis TV is a subscription-based IPTV app. You can buy access on their website for 1, 6 or 12 months. They then provide you with a licence / activation code that you input in their app to access the channels. They also provide you with a .m3u file that you can use in any app like VLC.
They also provide you with a “Host URL” and a “Portal URL”. In this case, the host URL is http://1gotv.org:8080 and the portal URL is http://1gotv.org:8080/c.
It’s not clear why they provide these URLs, since they give no explication on what to do with them. The standard user will only need the licence code.
Decompiling the APK
I extracted the app with apktool:
apktool d axistv.apk
Bash
복사
That gave me SMALI, resources, and the manifest. The package name is com.nathnetwork.axistvn.
I first searched for obvious endpoints:
grep -r "http://" axistv/
grep -r "https://" axistv/
grep -r "rtmp://" axistv/
Bash
복사
In axistv/res/values/strings.xml, I found the API hosts:
<string name="api_primary">https://api01.ottrun.com/api4/</string>
<string name="api_backup">https://api04.ottrun.com/api4/</string>
XML
복사
This told me that Axis runs on the OTTRun service. This seems to be a “legal”, white-label IPTV provider. Of course, I’m sure there is a gap between their official business and what services they really provide.
The native library
The Java layer loads a native library:
System.loadLibrary("native-lib")
Java
복사
The file is axistv/lib/arm64-v8a/libnative-lib.so.
Running strings showed a few interesting values:
BAF46667-FCA6-40BC-990F-81E8D7835DB2
mysecretkeywsdef
myuniqueivparamu
Plain Text
복사
At the time, I did not know what the UUID (BAF…) was supposed to represent. I just noted it as something interesting: it looks like a real identifier, it sits in the native library, and it is not one of the common "all zeros" style placeholders you often see in templates. Later, when I traced the startup path, I found the app loads a value from a native method (Config.lkfj()) into Config.a, and that value is what the licV4 request is built around.
At this stage I wrongly assumed the last two strings were active AES keys.
SMALI flow for license validation
The main logic is in SplashActivity.smali around the license check. The client builds request fields like this:
1.
Compute MD5 of the license input.
2.
Append axistv to that MD5 to build a key string.
3.
XOR license, app name, and package name against that key.
4.
Base64 encode the XOR outputs.
5.
Send the result to https://api01.ottrun.com/api4/ApiIPTV.php with tag=licV4.
The resulting request shape:
tag=licV4
l={MD5(license)}
an=axistv
el={Base64(XOR(license, md5+axistv))}
ea={Base64(XOR("axis tv", md5+axistv))}
eb={Base64(XOR("com.nathnetwork.axistvn", md5+axistv))}
Plain Text
복사
I reproduced that behavior in Python.
import hashlib
import base64
def md5_hash(text):
return hashlib.md5(text.encode()).hexdigest()
def xor_strings(s1, s2):
s1_bytes = s1.encode()
s2_bytes = s2.encode()
result_chars = []
s2_index = 0
for byte1 in s1_bytes:
if s2_index == len(s2_bytes):
s2_index = 0
result_chars.append(chr(byte1 ^ s2_bytes[s2_index]))
s2_index += 1
return ''.join(result_chars)
def validate_license(license_key):
license_md5 = md5_hash(license_key)
combined_key = license_md5 + "axistv"
return {
"tag": "licV4",
"l": license_md5,
"an": "axistv",
"el": base64.b64encode(xor_strings(license_key, combined_key).encode('latin-1')).decode(),
"ea": base64.b64encode(xor_strings("axis tv", combined_key).encode('latin-1')).decode(),
"eb": base64.b64encode(xor_strings("com.nathnetwork.axistvn", combined_key).encode('latin-1')).decode(),
}
Python
복사
First failure
When I sent requests generated above with real activation codes, I always got Invalid License.
import requests
params = validate_license("my_actual_code_here")
headers = {
"User-Agent": "Dalvik/2.1.0 (Linux; Android 11; LEAP-S1)"
}
response = requests.get(
"https://api01.ottrun.com/api4/ApiIPTV.php",
params=params,
headers=headers,
timeout=15
)
print(response.text)
Python
복사
I tested multiple encodings and user agents and got the same result.
Breakthrough in the native path
The key line in SMALI was this read from Config.a:
Config.a is set by a native method lkfj():
And the symbol exists in the .so:
nm -D libnative-lib.so | grep lkfj
Bash
복사
000000000001046c T Java_com_nathnetwork_axistvn_util_Config_lkfj
Plain Text
복사
I then used the UUID from strings instead of an activation code:
HARDCODED_UUID = "BAF46667-FCA6-40BC-990F-81E8D7835DB2"
params = validate_license(HARDCODED_UUID)
Python
복사
HARDCODED_UUID hashes to 268e0de4a9d923e218cd41433e4d6b6a, which matched the successful request path.
That worked. So up until now, I did not need any unique / personal activation code.
API Response and encrypted fields
When using the aforementioned UUID, the API returned a valid JSON response with success: 1, plus encrypted portal and urls fields beginning with Glo....
This answers my first question: the hardcoded UUID in the app is used to get the actual portal and IPTV servers, alongside a few other keys.
Some fields such as hostname or maintenance metadata can vary.
{
"success": "1",
"status": "ACTIVE",
"hostname": "heco1api04",
"portal": "GloZEhoTWRAOF09H...",
"urls": "GloIAx8DR14WDxVb...",
"cid": "524573",
"maintenance": {
"mnt_message": "welcome",
"mnt_status": "INACTIVE",
"mnt_expire": "2023-06-25 23:59:00"
}
}
JSON
복사
I already know the values of the encrypted fields, since these values were provided when purchasing a licence. Still, it’s interesting that these values are somewhat considered “sensitive” since they took the time to encrypt them.
Decrypting portal and urls
I first assumed AES because of the two key-like strings in the binary. That path did not validate with output lengths and mode tests.
I switched to a known plaintext approach with expected IPTV URL patterns, then derived repeating XOR keys.
The practical approach was:
1.
Remove the Glo prefix.
2.
Base64 decode the remainder.
3.
Use known URL fragments to recover a repeating XOR key.
4.
Apply the key over the full payload.
import base64
import re
def decode_payload(value):
blob = value[3:] # strip Glo
blob += "=" * ((4 - len(blob) % 4) % 4)
return base64.b64decode(blob)
def derive_repeating_key(ciphertext, known_plaintext, key_len):
plain = known_plaintext.encode("utf-8")
key = bytearray(key_len)
for i in range(min(len(plain), len(ciphertext))):
key[i % key_len] = ciphertext[i] ^ plain[i]
return bytes(key)
def xor_decrypt(ciphertext, key):
out = bytearray(len(ciphertext))
for i, b in enumerate(ciphertext):
out[i] = b ^ key[i % len(key)]
return bytes(out)
def extract_urls(decoded_text):
return re.findall(r"https?://[a-zA-Z0-9.-]+(?::\d+)?", decoded_text)
Python
복사
For this sample, the repeating periods were 21 bytes for portal and 45 bytes for urls.
Portal key:
0c3c1c3d5e6f176c5a737c5e620b7a6b2e4c597d51
Plain Text
복사
URLs key:
4878087d275777056c0b6b68026e43326a3b563d5d415d7124001c685a2b7f687b2f181a47361f6e5f44587410
Plain Text
복사
Decrypted output resolved to:
These findings are interesting, but useless. Nonetheless, it answers my initial question, that is “how does the app knows the values of the streaming server URLs”.
What about the activation / licence code then?
Once portal decrypts to http://1gotv.org:8080, the rest of the architecture starts to look like a standard Xtream setup. That is important because it changes how to interpret the 16 digit "activation code".
Axis TV only asks for a single value during onboarding. In my testing, that same value is used as both the Xtream Codes username and password when the app talks to the panel (so username == password). I verified this by querying the panel account endpoint and checking that it returns an active status and an expiry date for my own subscription.
The point is the mapping: OTTRun bootstrap returns the backend, and the user facing activation code is then reused as the panel credentials.
What the app is actually doing
Based on this trace, Axis TV stores the user activation code locally but authenticates the OTTRun API call with a UUID returned by native code. The API then returns endpoint data encrypted with field specific repeating XOR keys.
That design has obvious weaknesses:
1.
Secrets in native binaries are still extractable.
2.
Repeating XOR is weak against known plaintext.
3.
If a credential is reused broadly, the API surface becomes much easier to probe.
Do this weaknesses matter? No, I don’t think so. The server URLs are public, and a quick sniff with Wireshark would have revealed them instantly. But still, it’s quite funny to see this half-assed attempt at encrypting the URLs.
The bucket
From the APK URL structure, I checked https://download.ottrun.com/downloads/ and found directory listing enabled.
Example entry:
<Contents>
<Key>521064/ORPlayer-7.0-v911.apk</Key>
<LastModified>2024-09-29T20:17:11.074Z</LastModified>
<Size>98005555</Size>
</Contents>
XML
복사
I paginated listings and collected these aggregate numbers:
1.
16,181 APK files
2.
2,560 unique client IDs
3.
3,487 unique app names
4.
1.387 TB total size
For client 524573, I observed three brands sharing the same backend family:
1.
Axis TV
2.
SMARTGOTV
3.
OHCASTTV
Sampling
I sampled 15 apps from different providers in my latest run with an automated flow:
1.
Download APK
2.
Extract with apktool
3.
Locate UUID candidates in native libs
4.
Build API request
5.
Attempt XOR key recovery and URL decoding
Core automation loop:
from concurrent.futures import ThreadPoolExecutor, as_completed
def analyze_apk(apk_url):
apk_path = download_apk(apk_url)
extracted = unpack_with_apktool(apk_path)
metadata = extract_metadata(extracted) # app names, package, API endpoints
uuids = find_uuid_candidates(extracted)
uuids = filter_noise_uuids(uuids)
for uuid in uuids:
success, payload, detail = test_uuid_with_api(
uuid=uuid,
app_name_candidates=metadata["app_name_candidates"],
package_name=metadata["package_name"],
api_endpoints=metadata["api_endpoints"],
)
if not success:
continue
portal_text, portal_key = decrypt_server_field(payload.get("portal", ""), "portal")
urls_text, urls_key = decrypt_server_field(payload.get("urls", ""), "urls")
return {
"apk": apk_url,
"uuid": uuid,
"api_endpoint": detail["api_endpoint"],
"portal": portal_text,
"urls": urls_text,
"portal_key": portal_key,
"urls_key": urls_key,
}
return None
with ThreadPoolExecutor(max_workers=4) as pool:
futures = [pool.submit(analyze_apk, url) for url in recent_apk_urls]
results = [f.result() for f in as_completed(futures)]
Python
복사
Result: 10 of 15 apps produced valid responses in this run (66.67%).
Vulnerable app names and UUIDs:
1.
TruXstreams: 6FC42D12-9381-4F15-B1E5-C82740A29459
2.
TVapp: 7E7AC79A-6F2D-4015-BD91-A88164F46A88
3.
2HTV v3: 2CDCC93D-C69B-4162-A102-5A25D15D6BD3
4.
NOVA1TV: FC7D313B-40E2-4FE9-B88E-F6684AE476E9
5.
AVapp: 7306E3AF-A064-4AA9-A63B-B7812A16F4E5
6.
Team Orion: 75C912FE-63CE-452B-BD6D-6E0635EEB79C
7.
ORBEPLAY: 6BBC426F-75B0-4698-B557-C0783C7648D2
8.
S C XCIPTV: 10006402-D478-4EA1-BFA9-F76CDF997588
9.
Aaron Johnstone: 948F8B56-2CEB-4CE7-9143-68501EE33899
10.
BULLDOG TV: D6677C71-640B-42D0-848C-6DE30E1038DD
Security takeaways
What did we learn here? Honestly, not much. The most valuable thing I discovered must be the bucket that allows directory listing since it allows to have a full list of services that use OTTRun services. I’m sure with the current crackdown on IPTV, such a list would be an interesting find for some government agencies: taking OTTRun down could disrupt more that 2500 IPTV services.
I haven’t discovered any vulnerability that would allow streaming for free, but it wasn’t my goal. It’s worth noting that the Xtream authentication endpoint are not protected at all and are very fragile, they are vulnerable to bruteforce attacks. Worse than that, it’s possible to render the authentication service unresponsive (for all users using the service) when running an agressive bruteforce attack (a.k.a DOS attack) on the authentication endpoint.
Python clients
To keep the behavior easy to recheck, I put the "minimum viable" clients in this repo:
1.
ottrun_api_client.py reproduces the licV4 request to ApiIPTV.php, then decodes the portal and urls fields using the XOR keys recovered from known plaintext.
2.
axis_client.py is a small client for panel style APIs. For this writeup, it is mainly useful to confirm the decoded portal is an Xtream Codes panel and that a subscription is active using the player_api.php JSON response.
Both scripts default to the same Android style User Agent as the app and use conservative timeouts.
Example: Checking the panel With axis_client.py
This is what a quick check looks like with a valide licence.
./axis_client.py --type XC --server http://<portal>:8080 --username <activation_code> --password <activation_code> --list-channels
Bash
복사
Example output (truncated):
axis_client
client_type=XC
server=http://<portal>:8080
timeout_seconds=10
user_agent=Dalvik/2.1.0 (Linux; U; Android 11; LEAP-S1 Build/RP1A.201005.006)
connection_test=xc
fetch=account_info
account=<activation_code>
status=Active
expires=1791559213
======================================================================
CHANNELS
======================================================================
fetch=live_streams
live_stream_count=7101
1. |FR| TF1 HD
ID: 350816
Category: 115
URL: [redacted]
... and 7081 more channels
status=ok
Plain Text
복사
