I patched an Android app — decompiled it, injected code, recompiled — to pull its encrypted HLS video streams out as plain .mp4 files. This is how that worked.

Note: This article is intended solely for educational and reading purposes. I will not disclose the app’s name or any information that could identify or impact the original application.

I still remember, back in 2015 or 16, i used Sandroproxy Sandroproxy android app to intercept traffic of android apps to basically download resources/video/stream links. It was so cool!

I have not had any such passion or such for a long time since SSL and the MITM became harder and harder in non-rooted devices. But intercepting network traffic is something i still really like. Like how its so cool to intercept ARP requests to find hosts in a network? zANTI app was so good too! Nostalgic memories from 2014. I got my hands on and it seemed like next level.

Background

Android APK basics

APK file format: An APK is essentially a ZIP archive containing compiled code (.dex files), resources, manifests, etc. Modders often unpack it, modify contents, and repack/sign it.

DEX files and classes.dex*: Android bytecode lives in .dex files. The main one is usually classes.dex.

Smali: Human-readable disassembly of DEX bytecode (like assembly for Android’s Dalvik/ART runtime). Tools like Apktool decompile APKs into smali + resources. Patching smali means editing this low-level code to hook or modify behavior (e.g., calling new code at specific points). The text avoids heavy smali rewriting by injecting a new class instead.

HLS (HTTP Live Streaming)

Adaptive bitrate streaming protocol using .m3u8 playlist files (text-based manifests). A master playlist points to variant playlists (different resolutions/bitrates). Each variant lists many small .ts (MPEG-TS) segments for video/audio.

How HLS streaming works

When you press play on a video, you probably imagine one big file travelling from a server to your screen. That’s not what happens.

Modern streaming uses HLS — HTTP Live Streaming. The video is split into small chunks, typically 6–10 seconds each. A plain text file called a manifest (.m3u8) acts as a table of contents.

A manifest looks like this:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0

#EXTINF:9.009,
https://cdn.example.com/seg_000.ts
#EXTINF:9.009,
https://cdn.example.com/seg_001.ts
#EXTINF:9.009,
https://cdn.example.com/seg_002.ts

The player fetches the manifest, then downloads chunks one by one, stitching them into continuous playback. If your connection drops, it switches to a lower-quality manifest — smaller chunks, same idea.

  sequenceDiagram
    Player->>Server: GET master.m3u8
    Server-->>Player: playlist with segment URLs
    Player->>CDN: GET seg_000.ts
    CDN-->>Player: 9 seconds of video
    Player->>CDN: GET seg_001.ts
    CDN-->>Player: next 9 seconds
    Note over Player: plays seamlessly

To protect content, platforms encrypt each .ts chunk with AES-128-CBC.

The decryption key is fetched from a URL declared right in the manifest. The key is passed to authenticated requests. There’s no client-side secret involved.

#EXT-X-KEY:METHOD=AES-128,URI="https://cdn.example.com/keys/key.bin",IV=0x00000000000000000000000000000001

#EXTINF:9.009,
https://cdn.example.com/seg_000.ts
  • URI= -> fetch this URL -> you get 16 raw bytes — that’s the AES key
  • IV= -> the initialisation vector for CBC mode. If absent, it’s derived from the segment sequence number
  • Every .ts after this line is encrypted with that key+IV pair

So the full extraction chain is:

  flowchart TD
    A[Fetch .m3u8 manifest] --> B[Find EXT-X-KEY]
    B --> C[GET key URL -> 16 raw bytes]
    A --> D[List all segment URLs in order]
    D --> E[GET each .ts chunk]
    C --> F[AES-128-CBC decrypt each chunk]
    E --> F
    F --> G[Concatenate all chunks]
    G --> H[Remux -> .mp4]

This is exactly how yt-dlp, ffmpeg, and every HLS downloader works. ffmpeg -i playlist.m3u8 out.mp4 runs this entire chain in one command.


Reverse engineering the app

That’s the theory. The app made it harder.

The app had an offline download feature. You’d tap download, wait, and the video would be “available offline”. Except the files on disk were useless – dozens of .exo fragments scattered across numbered folders. It is unplayable outside the app. Our aim at the end is to get .mp4 out of it.

Now the interesting part: the .m3u8 URL is buried inside a locked Android app. How do you get to it?

Using apktool, we decompile the APK into smali, modify it, and recompile back into a working APK.

Part 1: Extract the m3u8 URL from the app

The key is on the server — only authenticated requests get it. No client-side secret to find.

At the point where it starts the download, it’s an authenticated request to the server to get the .m3u8 and store it locally on disk.

I looked into it and found an OfflineVideoDownloadService inside the decompiled smali — the class managing offline downloads (I presumed).

Why not re-initiate the same request made to the server? Without handling the key extraction separately, we just re-trigger the download process. At the end, all I need is the .mp4 file.

So the plan: hook into this download process so that I get the full request (including the key) by re-triggering the same download URL.

It implements a listener with one method:

onDownloadChanged(DownloadManager, Download, Exception)

This fires on every download state change. State 3 = completed. And the Download object sitting in that callback carries exactly what we need:

Download.request.uri  -> the m3u8 URL
Download.request.data -> the video title

We patched the smali to call our own class the moment state hits 3. A few lines of smali is all it takes to redirect execution.

For the exporter logic itself — rather than writing it in smali (error-prone, painful) — we wrote it in Java, compiled it, ran it through d8 to get a classes.dex, and dropped it into the APK zip as classes12.dex. Android’s classloader picks up all classes*.dex files automatically. The patched smali just calls into our class; our class does all the work.

Our Downloader class is HlsMp4Exporter.

  flowchart TD
    A[User taps Download] --> B[ExoPlayer fetches m3u8 and downloads segments]
    B --> C[segments stored as .exo files on disk]
    C --> D[onDownloadChanged fires — state = 3]
    D --> E[our injected hook]
    E --> F[grab m3u8 URL from Download.request.uri]
    F --> G[HlsMp4Exporter runs on background thread]

Part 2: Getting the MP4

HlsMp4Exporter runs inside the app process – which means it inherits the user’s authenticated session. When it requests the AES key from the CDN, the server sees a valid logged-in request and hands it over.

The steps:

  1. Fetch the master playlist -> pick the highest-bandwidth stream
  2. Fetch the media playlist -> parse #EXT-X-KEY -> GET the 16-byte AES key
  3. For each segment: GET .ts -> AES-128-CBC decrypt
  4. Concatenate all chunks into a single .ts file
  5. Pass through Android’s MediaExtractor + MediaMuxer –> .mp4
  6. Save to external storage via getExternalFilesDir() — no special permissions needed

The output: a clean .mp4 in external storage, playable anywhere.


Why this worked at all

The app used HLS + AES-128. Standard, widely supported, easy to implement. The alternative — Widevine DRM — requires hardware attestation, license server infrastructure, and costs real money to integrate properly. Many platforms skip it. It’s what’s used in Amazon prime videos, Hotstar and more. But I know there are still ways to download them (have not looked into it).

That gap is the entire attack surface here. There was no client-side secret to find, no algorithm to break. The key was on the server; the server handed it over because the request was authenticated. We just asked for it at the right time.


One thing I tried: skipping the re-download

I thought: “Can I skip re-downloading? The .exo files are already on disk. Maybe I can just read them and make an .mp4?”

The .exo files use ExoPlayer’s own secret format. They are not raw or decrypted video segments. Without ExoPlayer’s internal code, they are unreadable.

The re-download wasn’t lazy – it was the only practical choice.