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
network.png

これは、hls.jsを用いて、動画をいくつかのセグメント(*.ts)に分解し、それぞれを暗号化して送信していることがわかります。後述しますが、本来はvideo.m3u8にプレイリストが記載されており、ここに鍵または鍵のURLが記載されています。しかし、ここにはURI="video://hello_where_is_my_key?"というダミーのURLしか書かれていません。また、enc.keyに鍵が、入っていそうですが、実際にはダミーの鍵しか入っていません。

本問は2つのフェーズで構成されています。

  1. 暗号化鍵を入手する
  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も立ってるので公式に対応してもらえたら嬉しいなと思っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です