124 lines
5.4 KiB
Python
124 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""
|
||
|
|
HTTP server on port 8765:
|
||
|
|
- serves static files (drm_test.html etc.)
|
||
|
|
- POST /capture → saves challenge, extracts ClientIdentification as client_id.bin
|
||
|
|
"""
|
||
|
|
import http.server, socketserver, os, sys, urllib.request, urllib.error
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
OUT = Path(__file__).parent / 'L3'
|
||
|
|
OUT.mkdir(exist_ok=True)
|
||
|
|
PORT = 8765
|
||
|
|
|
||
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
||
|
|
def do_POST(self):
|
||
|
|
if self.path == '/proxy':
|
||
|
|
# CORS proxy: forward binary body to TVP license server and return response
|
||
|
|
TVP_LICENSE = 'https://vod.tvp.pl/api/products/2993670/drm/widevine/external?platform=BROWSER&type=MOVIE'
|
||
|
|
length = int(self.headers.get('Content-Length', 0))
|
||
|
|
body = self.rfile.read(length)
|
||
|
|
print(f' [proxy] {len(body)}b → {TVP_LICENSE[:60]}')
|
||
|
|
try:
|
||
|
|
req = urllib.request.Request(TVP_LICENSE, data=body,
|
||
|
|
headers={'Content-Type': 'application/octet-stream'}, method='POST')
|
||
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||
|
|
resp_body = resp.read()
|
||
|
|
print(f' [proxy] response: {resp.status} {len(resp_body)}b')
|
||
|
|
self.send_response(resp.status)
|
||
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||
|
|
self.send_header('Content-Type', 'application/octet-stream')
|
||
|
|
self.send_header('Content-Length', str(len(resp_body)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(resp_body)
|
||
|
|
except Exception as e:
|
||
|
|
print(f' [proxy] error: {e}')
|
||
|
|
self.send_response(502)
|
||
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||
|
|
self.end_headers()
|
||
|
|
return
|
||
|
|
|
||
|
|
if self.path == '/capture':
|
||
|
|
length = int(self.headers.get('Content-Length', 0))
|
||
|
|
data = self.rfile.read(length)
|
||
|
|
print(f'\n[+] Received {len(data)} bytes challenge head={data[:16].hex()}')
|
||
|
|
(OUT / 'challenge.bin').write_bytes(data)
|
||
|
|
print(f' Saved → L3/challenge.bin')
|
||
|
|
|
||
|
|
# Parse SignedMessage → LicenseRequest → ClientIdentification
|
||
|
|
try:
|
||
|
|
from pywidevine import license_protocol_pb2 as pb
|
||
|
|
sm = pb.SignedMessage()
|
||
|
|
sm.MergeFromString(data)
|
||
|
|
print(f' SignedMessage.type={sm.type} msg={len(sm.msg)}b sig={len(sm.signature)}b')
|
||
|
|
|
||
|
|
lr = pb.LicenseRequest()
|
||
|
|
lr.MergeFromString(sm.msg)
|
||
|
|
ci = lr.client_id
|
||
|
|
ci_bytes = ci.SerializeToString()
|
||
|
|
print(f' ClientIdentification: type={ci.type} token={len(ci.token)}b total={len(ci_bytes)}b')
|
||
|
|
print(f' CI head: {ci_bytes[:16].hex()}')
|
||
|
|
|
||
|
|
if len(ci_bytes) > 50:
|
||
|
|
(OUT / 'client_id.bin').write_bytes(ci_bytes)
|
||
|
|
print(f'[+] Saved → L3/client_id.bin')
|
||
|
|
# Try pywidevine create-device
|
||
|
|
import subprocess
|
||
|
|
r = subprocess.run(['pywidevine', 'create-device',
|
||
|
|
'-t', 'android', '-l', '3',
|
||
|
|
'-k', str(OUT / 'private_key.der'),
|
||
|
|
'-c', str(OUT / 'client_id.bin'),
|
||
|
|
'-o', str(OUT)], capture_output=True, text=True)
|
||
|
|
if r.returncode == 0:
|
||
|
|
print('\n[+] pywidevine create-device SUCCESS!')
|
||
|
|
print(r.stdout[-600:])
|
||
|
|
else:
|
||
|
|
print('\n[!] pywidevine create-device FAILED:')
|
||
|
|
print(r.stderr[-400:])
|
||
|
|
else:
|
||
|
|
print(f'[!] ClientIdentification too small ({len(ci_bytes)} bytes)')
|
||
|
|
print(f' Full LicenseRequest fields:')
|
||
|
|
for f in lr.DESCRIPTOR.fields:
|
||
|
|
try:
|
||
|
|
val = getattr(lr, f.name)
|
||
|
|
if val:
|
||
|
|
print(f' {f.name}: {str(val)[:80]}')
|
||
|
|
except: pass
|
||
|
|
except Exception as e:
|
||
|
|
import traceback
|
||
|
|
print(f'[!] Parse error: {e}')
|
||
|
|
traceback.print_exc()
|
||
|
|
|
||
|
|
self.send_response(200)
|
||
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||
|
|
self.send_header('Content-Type', 'text/plain')
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(b'OK')
|
||
|
|
else:
|
||
|
|
self.send_response(404)
|
||
|
|
self.end_headers()
|
||
|
|
|
||
|
|
def do_OPTIONS(self):
|
||
|
|
self.send_response(200)
|
||
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||
|
|
self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
|
||
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||
|
|
self.end_headers()
|
||
|
|
|
||
|
|
def log_message(self, fmt, *args):
|
||
|
|
if '/capture' in str(args):
|
||
|
|
pass # already logged above
|
||
|
|
else:
|
||
|
|
super().log_message(fmt, *args)
|
||
|
|
|
||
|
|
os.chdir(Path(__file__).parent)
|
||
|
|
print(f'[*] Serving on port {PORT} (static files + POST /capture)')
|
||
|
|
print(f'[*] In emulator, run:')
|
||
|
|
print(f' adb reverse tcp:{PORT} tcp:{PORT}')
|
||
|
|
print(f' Then open http://localhost:{PORT}/drm_test.html\n')
|
||
|
|
with socketserver.TCPServer(('', PORT), Handler) as httpd:
|
||
|
|
try:
|
||
|
|
httpd.serve_forever()
|
||
|
|
except KeyboardInterrupt:
|
||
|
|
print('\n[!] Stopped.')
|