Building Your Own DRM: A Case Study in Why You Shouldn't

I recently looked at the content protection of a European audiobook streaming platform. They decided to build their own DRM instead of using Widevine or FairPlay. It went about as well as you'd expect.
Names and identifiers are changed but the architecture and patterns are real.
The Setup
The platform lets subscribers download audiobooks for offline playback. Downloaded files are encrypted and stored on-device. So far, so normal. The interesting part is how they implemented it.
Their key exchange uses JWE with an ECDH key agreement scheme. The negotiated content encryption key protects each audiobook file with AES-128-CTR. Keys are stored in Android Keystore after exchange.
Sounds fine on paper. Three things make it fall apart.
No Auth on the CDN
The encrypted audiobook files sit on a CDN behind a predictable URL structure:
https://cdn.redacted.net/api/aes/download/mp4/{asset_id}.mp4
asset_id is a UUID you get from the license API. The problem: the CDN endpoint requires zero authentication.
$ curl -o audiobook.mp4 "https://cdn.redacted.net/api/aes/download/mp4/a1b2c3d4e5f6.mp4"
% Total % Received
100 282M 100 282M 0 0 45.2M 0 0:00:06 0:00:06 --:--:-- 51.3M
No cookies. No bearer token. No signed URL. Just a naked GET and you've got 282MB of encrypted audiobook.
"But it's encrypted!" — sure. And your front door is locked. But you left all your windows open, and we'll get to the lock in a second.
The fix here is trivial: signed URLs with short TTLs. Every major CDN supports this natively. There is genuinely no reason to serve content — encrypted or not — without authentication.
Decryption Lives in Java
The entire crypto pipeline runs in the Java layer. After key exchange, the content key arrives as a raw byte[] in a class constructor:
// ContentCipher.java
public class ContentCipher {
private final byte[] key;
private final byte[] nonce;
public ContentCipher(byte[] key, long contentIdLsb) {
this.key = key;
this.nonce = buildNonce(contentIdLsb);
}
private byte[] buildNonce(long lsb) {
byte[] iv = new byte[16];
ByteBuffer.wrap(iv).putLong(lsb); // bytes 0-7: content UUID lower bits
// bytes 8-15: counter, starts at 0
return iv;
}
}
This is the entire security boundary. A single Java class. The key exists as a plaintext byte array in application memory, passed through a hookable constructor.
Anyone with Frida or a similar instrumentation framework can intercept this in about 10 lines:
// hook.js
Java.perform(() => {
var cipher = Java.use('com.example.drm.ContentCipher');
cipher.$init.overload('[B', 'long').implementation = function(key, lsb) {
console.log('[*] Key: ' + toHex(key));
console.log('[*] LSB: ' + lsb);
return this.$init(key, lsb);
};
});
That's it. Download an audiobook through the app, key shows up in your terminal.
Compare this to Widevine L1: the key never leaves the TEE (Trusted Execution Environment). It goes from the license server → secure enclave → hardware decoder. The app process literally never sees the key. That's what actual content protection looks like.
Deterministic IV
AES-CTR needs a unique nonce per encryption. They derive it from the content UUID:
# iv.py
import struct, uuid
def build_iv(asset_id: str) -> bytes:
u = uuid.UUID(asset_id)
lsb = u.int & 0xFFFFFFFFFFFFFFFF
return struct.pack('>Q', lsb) + struct.pack('>Q', 0) # 16 bytes
The IV is fully determined by a value the client already has. Not random, not derived from a KDF with user-specific salt — just the lower 64 bits of the content UUID, big-endian, padded with zeros.
For CTR mode this isn't catastrophic by itself (each content UUID is unique, so nonce reuse doesn't happen). But it means the IV is not a secret. The only secret in the entire scheme is the 16-byte AES key. Once that's extracted, decryption is trivial:
# decrypt.py
from Crypto.Cipher import AES
def decrypt(asset_id: str, key_hex: str, data: bytes) -> bytes:
key = bytes.fromhex(key_hex)
iv = build_iv(asset_id)
cipher = AES.new(key, AES.MODE_CTR, nonce=b'', initial_value=iv)
return cipher.decrypt(data)
The Chain
None of these are earth-shattering individually. Together they're a complete content extraction pipeline:
- Get
asset_idfrom license API (legit subscriber) curlthe CDN (no auth needed)- Hook the constructor (Frida, 10 lines)
- Derive IV from
asset_id(deterministic) - AES-CTR decrypt
ffplay decrypted.mp4→ 6+ hours of audiobook
| Issue | Alone | In Chain |
|---|---|---|
| Unauthenticated CDN | Medium | Critical |
| Key in Java layer | Medium | Critical |
| Deterministic IV | Low | Amplifier |
The real kicker: keys are per-content, not per-user. A single subscriber can extract keys for every audiobook they download, publish a {isbn, asset_id, key} database, and anyone can download and decrypt without ever having a subscription.
Why Custom DRM Fails
This isn't a story about one company making a mistake. It's a pattern.
Widevine, FairPlay, and PlayReady exist because content protection is one of those problems that looks simple ("just encrypt it") but requires hardware-level integration to actually work. The moment your key touches the Java/Kotlin/Swift layer as a byte array, you've lost. The moment your CDN serves content without auth, you've lost. The moment your IV is deterministic, you've made the attacker's job easier.
If you're building a media platform and you're thinking about rolling your own DRM: don't. Widevine L1 licensing is free for Android. FairPlay is built into iOS. Use them.
If you have to build custom (and I genuinely can't think of why), at minimum:
- Signed CDN URLs with per-session tokens and short TTLs
- Native (C++) decryption with integrity checks, not Java
- Per-user key derivation so leaked keys have limited blast radius
- Random IVs stored alongside the encrypted content
- Download anomaly detection — flag accounts pulling 500 audiobooks in a day
But really, just use Widevine.