hls.jsとWASMを用いて、鍵を配信せずに暗号化した動画を配信する (& ctf4b 作問後記)
こんにちは、M1のxryuseixです。今回はSECCON Beginners CTF(ctf4b)で出題した問題「[misc] drmsaw」を通じて、鍵を配信せずに暗号化した動画を配信する手法について説明します。
CTF問題の概要
問題文
DRMSAW Movie Playerは著作権を重視したセキュアな動画再生プラットフォームです。もしあなたが動画をダウンロードできたら、フラグと交換しましょう。
この動画をダウンロードできますか? ダウンロードできたらそのファイルを送信してください:
Webサイト
解説(writeup)
https://qiita.com/xryuseix/items/1c03bcb43eee608dcb59#misc-drmsaw-17solved-221pt
問題文に書いてある通り動画がダウンロードできればフラグが手に入ります。
このページにアクセスすると、動画関係のファイルとして以下のファイルをダウンロードします。
- main.wasm
- hls.js@latest
- video.m3u8
- video0.ts
- video1.ts
- video2.ts
- enc.key
これは、hls.jsを用いて、動画をいくつかのセグメント(*.ts)に分解し、それぞれを暗号化して送信していることがわかります。後述しますが、本来はvideo.m3u8にプレイリストが記載されており、ここに鍵または鍵のURLが記載されています。しかし、ここにはURI="video://hello_where_is_my_key?"
というダミーのURLしか書かれていません。また、enc.keyに鍵が、入っていそうですが、実際にはダミーの鍵しか入っていません。
本問は2つのフェーズで構成されています。
- 暗号化鍵を入手する
- 動画のセグメントをダウンロードし、復元する
また、フロントエンドのJSはこのようになっています。
const keyUrl = "/enc.key";
class CustomLoader extends Hls.DefaultConfig.loader {
constructor(config) {
super(config);
this.context = { url: keyUrl };
const load = this.load.bind(this);
this.load = function (context, config, callbacks) {
if (context.type === "manifest") {
const onSuccess = callbacks.onSuccess;
callbacks.onSuccess = function (response, stats, context) {
response.data = response.data.replace(
/#EXT-X-KEY:METHOD=.*,URI=".*"/,
`#EXT-X-KEY:METHOD=AES-128,URI="${keyUrl}"`
);
onSuccess(response, stats, context);
};
} else {
if (context.url.endsWith(keyUrl)) {
window.gContext = context
hlscotext.load(context);
context = window.gContext;
}
window.globalContext = null;
}
return load(context, config, callbacks);
};
}
}
function mediaPlayer() {
const video = document.getElementById("video");
if (!video) {
return;
}
if (typeof Hls !== "undefined" && Hls.isSupported()) {
const hls = new Hls({ loader: CustomLoader });
const streamUrl = "/public/videos/video.m3u8";
hls.loadSource(streamUrl);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
hls.attachMedia(video);
video.addEventListener("canplay", () => {
console.info("The video can play!");
});
});
} else {
alert("sorry, your browser does not support.");
}
}
const initWasm = async () => {
console.log("wasm loading: start!");
try {
const go = new Go();
const response = await fetch("/main.wasm");
const buffer = await response.arrayBuffer();
const result = await WebAssembly.instantiate(buffer, go.importObject);
go.run(result.instance);
console.log("wasm loading: finished!");
} catch (e) {
alert("sorry, your browser does not support wasm.");
}
};
initWasm().then(() => {
mediaPlayer();
});
暗号化鍵はこの箇所でWASM側から挿入されているため、プロキシやJSオーバーライドを用いてJSを書き換えて、contextから鍵を入手することができます。
if (context.url.endsWith(keyUrl)) {
window.gContext = context
hlscotext.load(context);
context = window.gContext;
}
また、最終的にはこのようなコードで動画をダウンロードできます。
import subprocess
import requests
APP_URL = "http://drmsaw.beginners.seccon.games"
def download():
subprocess.run(["wget", f"{APP_URL}/public/videos/video0.ts"])
subprocess.run(["wget", f"{APP_URL}/public/videos/video1.ts"])
subprocess.run(["wget", f"{APP_URL}/public/videos/video2.ts"])
def make_key():
key = [99, 9, 61, 110, 94, 114, 119, 194, 42, 163, 63, 8, 97, 114, 131, 41]
with open("enc.key", "wb") as f:
f.write(bytes(key))
def make_m3u8():
m3u8 = """#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="file:///app/enc.key",IV=0x00000000000000000000000000000000
#EXTINF:3.040000,
file:///app/video0.ts
#EXTINF:3.040000,
file:///app/video1.ts
#EXTINF:2.280000,
file:///app/video2.ts
#EXT-X-ENDLIST
"""
with open("video.m3u8", "w") as f:
f.write(m3u8)
def combine():
subprocess.run(["ffmpeg", "-allowed_extensions", "ALL", "-i", "./video.m3u8", "-c", "copy", "video.mp4", "-y"])
def upload():
mimetype = "video/mp4"
file = {'video': ('file', open('./video.mp4', 'rb'), mimetype)}
res = requests.post(f"{APP_URL}/flag", files=file).text
print(res)
if __name__ == "__main__":
download()
make_key()
make_m3u8()
combine()
upload()
hls.jsで動画配信機能を作成する
こんな感じのコードを書けば良いです。streamUrlだけ場合によって変えます。
<video id="video" width="90%" controls></video>
<script>
function mediaPlayer() {
const video = document.getElementById("video");
if (!video) {
return;
}
if (typeof Hls !== "undefined" && Hls.isSupported()) {
const hls = new Hls();
const streamUrl = "/public/videos/video.m3u8";
hls.loadSource(streamUrl);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
hls.attachMedia(video);
video.addEventListener("canplay", () => {
console.info("The video can play!");
});
});
} else {
alert("sorry, your browser does not support.");
}
}
mediaPlayer()
</script>
m3u8ファイルや鍵ファイルはこのようなDockerfileで作成できます。
From node:18 AS ts-builder
COPY app/package.json app/tsconfig* app/yarn.lock app/video.mp4 /app/
COPY app/src/ /app/src/
WORKDIR /app
RUN apt-get update && apt-get install -y ffmpeg
RUN mkdir dist
RUN yarn
RUN yarn build
RUN cd ./public/videos && ffmpeg -i ../../video.mp4 -c copy -f hls -hls_time 3 -hls_list_size 0 -muxdelay 0 -hls_flags split_by_time video.m3u8
hls.jsで動画を暗号化したまま配信する
先ほどのDockerfileの最後の行を以下のように変えます。するとm3u8に鍵ファイルのURIが記載され、xhrで取得し、勝手に復号してくれます。
RUN openssl rand -out enc.key 16
RUN echo "/public/videos/enc.key\nenc.key" > ./public/videos/enc.keyinfo
RUN cd ./public/videos && ffmpeg -i ../../video.mp4 -c copy -hls_key_info_file enc.keyinfo -f hls -hls_time 3 -hls_list_size 0 -muxdelay 0 -hls_flags split_by_time video.m3u8
WASMを用いて鍵を配信せずに暗号化した動画を配信する
ここからが本題です。先ほど「CTF問題の概要」で紹介した、Javascriptを用いて、WASM内で鍵ファイルの中身をすり替えます。WASMはgoで書きました。
package main
import (
"strings"
"syscall/js"
)
var keyUrl = "/enc.key"
var cnt = 0
var intervalId js.Value
func Load(this js.Value, args []js.Value) interface{} {
window := js.Global()
key := [16]byte{99, 9, 61, 110, 94, 114, 119, 194, 42, 163, 63, 8, 97, 114, 131, 41}
bytes := window.Get("Uint8Array").New(len(key))
js.CopyBytesToJS(bytes, key[:])
window.Get("gContext").Get("keyInfo").Get("decryptdata").Set("key", bytes)
intervalId = window.Call("setInterval", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if strings.HasSuffix(window.Get("gContext").Get("url").String(), keyUrl) {
window.Get("gContext").Get("keyInfo").Get("decryptdata").Set("key", bytes)
}
cnt += 1
if cnt > 1000 {
window.Call("clearInterval", intervalId)
window.Set("gContext", nil)
}
return nil
}), 10)
return nil
}
func main() {
c := make(chan struct{})
js.Global().Set("hlscotext", js.ValueOf(
map[string]any{
"load": js.FuncOf(Load),
},
))
<-c
}
注目すべき箇所はここです。ここでsyscall/jsを用いてwindow.gContext.keyInfo.decryptdata.keyを書き換えています。window.gContextは私が定義した変数ですが、hls.jsではこのkeyInfoに鍵情報を保持しています。
window.Get("gContext").Get("keyInfo").Get("decryptdata").Set("key", bytes)
最後、Dockerfileでmulti-stage buildをします。
# build wasm
FROM golang:1.18 AS wasm-builder
WORKDIR /app
COPY wasm .
RUN GOOS=js GOARCH=wasm go build -o main.wasm main.go
# build app
From node:18 AS ts-builder
COPY app/package.json app/tsconfig* app/yarn.lock app/video.mp4 /app/
COPY app/src/ /app/src/
COPY app/tools/ /app/tools/
COPY app/public/videos/_enc.key /app/public/videos/enc.key
WORKDIR /app
RUN apt-get update && apt-get install -y ffmpeg
RUN mkdir dist
RUN yarn
RUN yarn build
RUN echo "video://hello_where_is_my_key?\nenc.key" > ./public/videos/enc.keyinfo
RUN cd ./public/videos && ffmpeg -i ../../video.mp4 -c copy -hls_key_info_file enc.keyinfo -f hls -hls_time 3 -hls_list_size 0 -muxdelay 0 -hls_flags split_by_time video.m3u8
RUN cd ./public/videos && rm enc.key enc.keyinfo && echo "This key is a dummy. How can it be played without the key file?" > enc.key
# Run Express
FROM node:18
COPY app/public/index.html app/public/wasm_exec.js /app/public/
COPY --from=wasm-builder /app/main.wasm /app/public/
COPY --from=ts-builder /app/dist/ /app/dist/
COPY --from=ts-builder /app/node_modules/ /app/node_modules/
COPY --from=ts-builder /app/public/videos/ /app/public/videos/
RUN apt-get update && apt-get install -y ffmpeg
WORKDIR /app
CMD ["node", "dist/index.js"]
以上!!簡単にhls.jsとWASMを用いて、鍵を配信せずに暗号化した動画を配信することができました。以前より複製は困難になりましたが、今のままではCTFに出題されるほど脆弱なので、issueも立ってるので公式に対応してもらえたら嬉しいなと思っています。