謎's キッチン

謎のひとりごと。Amazon欲しい物リストはこちら: https://www.amazon.co.jp/hz/wishlist/ls/CCPOV7C6JTD2

AbemaTVの仕様とHLSの暗号化の弱さ

AbemaTVの仕様について気になったので調べてみた (研究目的です念の為)。
AbemaTVはPCへの動画配信において、配信プロトコルにHLSを使用しているようだ。HLSはMPEG-DASHと異なりDRMが使えず (厳密にはMac環境のFairplayなどの例外もあるが) 、AbemaTVでは鍵の生成に若干工夫を行ってるのみのようだ。


まず、APIを使ってチャンネル一覧をダウンロード。

$ curl https://api.abema.io/v1/channels
{"channels":[{"id":"abema-news","name":"AbemaNewsチャンネル","playback":{"hls":"https://linear-abematv.akamaized.net/channel/abema-news/playlist.m3u8"}},{"id":"abema-special","name":"AbemaSPECIALチャンネル","playback":{"hls":"https://linear-abematv.akamaized.net/channel/abema-special/playlist.m3u8"}},(後略)

次に画質一覧をダウンロード。

$ curl https://linear-abematv.akamaized.net/channel/abema-news/playlist.m3u8
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=184000
180/playlist.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=300000
240/playlist.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=900000
360/playlist.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1400000
480/playlist.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2200000
720/playlist.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=4200000
1080/playlist.m3u8

映像のフラグメント一覧をダウンロード。

$ curl https://linear-abematv.akamaized.net/channel/abema-news/1080/playlist.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:951004
#EXT-X-DISCONTINUITY-SEQUENCE:16994
#EXT-X-KEY:METHOD=AES-128,URI="abematv-license://XXXXXXXXXXXXXXXXXXXXXX",IV=0x000000000000000000000000000000
#EXTINF:5.005000,
https://linear-abematv.akamaized.net/tsnews/abema-news/h264/1080/xxxxxxxxxxxxxxxxxxxxxx.ts
#EXTINF:5.005000,
https://linear-abematv.akamaized.net/tsnews/abema-news/h264/1080/xxxxxxxxxxxxxxxxxxxxxx.ts
#EXTINF:5.005000,
https://linear-abematv.akamaized.net/tsnews/abema-news/h264/1080/xxxxxxxxxxxxxxxxxxxxxx.ts
#EXTINF:5.005000,
https://linear-abematv.akamaized.net/tsnews/abema-news/h264/1080/xxxxxxxxxxxxxxxxxxxxxx.ts

さて、映像はAES-128方式で暗号化されているようだ。暗号の鍵には初期化ベクトル(IV)とURIが指定されているが、URIに使われているabematv-licenseスキーマとは何だろう。仕組みは良く分からないが、Chromeの通信ログを見ると、スキーマの後ろの部分 (XXXXXXXXXXXXXXXXXXXXXX) と何処かにあるトークンを使って、とあるURLにアクセスしているようだ。

トークンはローカルストレージにあるものと同じようなので、Chromeのコンソールからwindow.localStorage["abm_mediaToken"]と打つと手に入る。このトークンとスキーマの後ろの部分を使って、ライセンスキーの種を手に入れる。

$ curl https://license.abema.io/abematv-hls?t=トークン --data '{"lt":"スキーマの後ろの部分","kv":"wd","kg":166}'
{"cid":"abema-news","k":"XXXXXXXXXXXXXXXXXXXXXXX"}

さて、どうやってライセンスキーの種 (k) をキーに変換するのだろう? 調べた所、遅延ロードされた以下のJavascriptがこの変換を処理しているようだ。
https://abema.tv/xhrp.js

若干難読化されているけれども、肝心の部分はそのままだし、コードインジェクションもし放題なので割と何とかなる。
キー計算の表面部分のロジックはこんな感じ。

function _0x569113(cid, uid, k){
  var _k = k.substring(0,k.length-1);
  var c = k.charAt(k.length-1);
  return c=='5'?_0x1e2ccc(cid, uid, _k):
         c=='4'?_0xa25b8f(cid, uid, _k):
                _0x2782e2(cid, uid, _k);
}
var _0x5ee3af=_0x569113(cid, window.localStorage["abm_userId"], k)

キー計算の中心部分は解読していないけれど、alert(_0x5ee3af);をインジェクションしてコードを実行するだけでキーが手に入る。
手に入ったキーは、バイナリ化してkey.binとして保存しておく。あとはそのキーを使って再生するだけ。

$ wget -N https://linear-abematv.akamaized.net/channel/abema-news/1080/playlist.m3u8 \
&& sed -i 's/URI=.*\,/URI=\"key.bin\",/g' playlist.m3u8 \
&& ffplay playlist.m3u8 -protocol_whitelist file,http,https,tcp,tls,crypto

フラグメント毎にしか再生できないので実用性には欠けるけれども、何にせよHLSが弱いことは証明できたので良いかな。
今後、AbemaTVでも強固なDRM付きのMPEG-DASHが導入されていくらしいので期待。



追記。何となく「簡単さ」が伝わってないようで残念なので、Python + Selenium WebDriverで自動化した方法を書いておきます。といっても大したものでもないですが。

from selenium import webdriver
from time import sleep
import requests
import re
import os

if __name__ == '__main__':
    browser = webdriver.Chrome(executable_path = "/usr/lib/chromium-browser/chromedriver")
    browser.get("https://abema.tv/now-on-air/abema-news")
    sleep(1)
    js = requests.get("https://abema.tv/xhrp.js").text
    mod_js = re.sub("(_0x31a687=.*?);", "\\1;window.key=_0x31a687;", js)
    browser.execute_script(mod_js)
    sleep(1)
    key = browser.execute_script("return window.key;")
    print(key)
    browser.close()

    f = open("key.bin", "wb")
    f.write(bytearray(key))
    f.close()

    pl = requests.get("https://linear-abematv.akamaized.net/channel/abema-news/1080/playlist.m3u8").text
    mod_pl = re.sub('URI=.*?\,', 'URI=\"key.bin\",', pl)

    f = open("playlist.m3u8", "w")
    f.write(mod_pl)
    f.close()

    os.system("ffplay playlist.m3u8 -protocol_whitelist file,http,https,tcp,tls,crypto")

なお、これはHLSの弱さを伝えるための単なる技術デモであり、フラグメント毎にしか再生できないため実用的ではなく、研究目的以外での利用は想定していません。

再追記。普通に独自スキーマXMLHttpRequestするだけで良いと聞いたのでテストコード。

from selenium import webdriver
from time import sleep
import requests
import re
import os

if __name__ == '__main__':
    browser = webdriver.Chrome(executable_path = "/usr/lib/chromium-browser/chromedriver")
    browser.get("https://abema.tv/now-on-air/abema-news")
    sleep(2)

    pl = requests.get("https://linear-abematv.akamaized.net/channel/abema-news/1080/playlist.m3u8").text

    key_url = re.search(u'URI=\"(.*?)\"\,',pl).group(1)

    browser.execute_script('''
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200)
        window.key = new Uint8Array(xhr.response)
}
xhr.open("GET", "%s");
xhr.send();
'''%key_url)

    sleep(1)
    key = browser.execute_script("return window.key;")
    browser.close()

    f = open("key.bin", "wb")
    f.write(bytearray(key))
    f.close()

    mod_pl = re.sub('URI=.*?\,', 'URI=\"key.bin\",', pl)

    f = open("playlist.m3u8", "w")
    f.write(mod_pl)
    f.close()

    os.system("ffplay playlist.m3u8 -protocol_whitelist file,http,https,tcp,tls,crypto")

ありゃ、本当だ。色々難しく考えすぎてたようです。Javascriptへのcode injectionは不要だし、他のサイトにも使えそう。

ついでに複数フラグメントについても調べてみたら、単にプレーヤー側がリロードを繰り返すだけとのこと。ちょっと信じられないので、とりあえずPython3でプロキシを書いてみた。

from http.server import HTTPServer, SimpleHTTPRequestHandler
import requests
import re

class MyHandler(SimpleHTTPRequestHandler):

    def do_GET(self):
        if self.path == "/key.bin":
            f = open("key.bin", "rb")
            body = f.read()
            f.close()
        else:
            pl = requests.get("https://linear-abematv.akamaized.net/channel/abema-news/1080/playlist.m3u8").content
            body = re.sub(b'URI=.*?\,', b'URI=\"key.bin\",', pl)
        self.send_response(200)
        self.send_header('Content-type', 'application/x-mpegURL')
        self.send_header('Content-length', len(body))
        self.end_headers()
        self.wfile.write(body)

httpd = HTTPServer(('localhost', 8000), MyHandler)
httpd.serve_forever()

…本当ですね。思った以上にザルだった。MPEG-DASHへの一本化が待たれます。

なお、これらコードはエラーチェックが適当ですし、何故かたまに途切れりします。研究目的以外での利用は想定していません。また、暗号化においてHLSを使用することも推奨しません。