# SKSD - JUSTCTF ## Just TV | Category | Points | Difficulty | | -------- | -------- | ------------ | | RE, Misc | 326 | Medium | We were given an `.asn` file. After some quick googling (before hint released) we can run theses file with [MhegPlus Player](https://sourceforge.net/projects/mhegplus) To run it use this command ``` $ java -Dmheg-source-root=src/ -Ddfs-root-dir=src/ -Dfile-mapping.//a=src/a -Dmheg.profile=uk.dtt -jar MhegPlus.MhegPlayer-1.0.1a.jar ``` Then click `populate carousel from disk`. It has 3 modules which are clock, weather and extras. Choosing extras will prompt us a flag checker, and the main logic for flag checker are lies on extras.asn After some reversing we manage to reconstruct the logic to a python code. ```py correct = "11011100010101001000100011001000110010000110100011101010011110110110001001001111001000010110000101110011101011011101011001001011110100011000111100101110000100101001111001111011110111101001110100101100110101111101101110101111001000111100111000000100101001101000001101010111101010010100000101010001010010010100101111011101111100110010101000100000001101000011101001011111101001100110111001000000110110101010111100100101111110111010011110001000011011010010110000000011111100001100" actual = "11010011010000101111101110101001011001101100101000111101101110101101010000010111101110000110100001000111101100000001110010010000000001011111001101111110011110111100111000111111101000110110010111100111110001111010110100110111000001001111010001100110111000101010010001000110010001100100001101000111010100111101101100010010011110010000101100001011100111010110111010110010010111101000110001111001011100001001010011110011110111101111010011101001011001101011111011011101011110010001" list_bin = "0000000000000100000100000011000010000001010000110000011100010000001001000101000010110001100000110100011100001111001000000100010010010001001100101000010101001011000101110011000001100100110100011011001110000111010011110001111101000000100001010001001000110100100010010101001100100111010100001010010101010010101101011000101101010111001011110110000011000101100100110011011010001101010110110011011101110000111001011101001110110111100011110101111100111111100000010000011000010100001110001001000101100011010001111001000100100110010101001011" list_char = "1234567890qwertyuiopasdfghjkl{zxcvbnm_!@#$%^&*+=3DQWERTYUIOPASDFGHJKL}ZXCVBNM-" inp = "j" dict = {} for i in range(len(list_char)): tmp = list_char[i] index = list_char.index(tmp) index += 1 print(list_char[i], index) val1 = ((index - 1) * 7) + 1 val2 = index * 7 dict[list_bin[val1-1:val2]] = list_char[i] print(dict) assert len(correct) == len(actual) nice = "" for i in range(len(correct)): if actual[i] == "0": nice += correct[i] else: if correct[i] == "1": nice += "0" else: nice += "1" print(len(dict)) print(nice) for i in range(0, len(nice), 7): target = nice[i:i+7] try: print(dict[target]) except Exception as e: print("unknown", target) ``` But its still not correct, after some debugging we found that var65 are rotated based on the length of the input that entered. Looking back into the code we knew that it xor the input with var65. Now we just need to bruteforce the correct flag len. ```py b = '00011001101110001010100100010001100100011001000011010001110101001111011011000100100111100100001011000010111001110101101110101100100101111010001100011110010111000010010100111100111101111011110100111010010110011010111110110111010111100100011110011100000010010100110100000110101011110101001010000010101000101001001010010111101110111110011001010100010000000110100001110100101111110100110011011100100000011011010101011110010010111111011101001111000100001101101001011000000001111110' c = '11010011010000101111101110101001011001101100101000111101101110101101010000010111101110000110100001000111101100000001110010010000000001011111001101111110011110111100111000111111101000110110010111100111110001111010110100110111000001001111010001100110111000101010010001000110010001100100001101000111010100111101101100010010011110010000101100001011100111010110111010110010010111101000110001111001011100001001010011110011110111101111010011101001011001101011111011011101011110010001' chars = '1234567890qwertyuiopasdfghjkl{zxcvbnm_!@#$%^&*+=3DQWERTYUIOPASDFGHJKL}ZXCVBNM-' def split(s, n): return [s[i:i+n] for i in range(0, len(s), n)] def rotate(l, n): return l[n:] + l[:n] brr = [int(x, 2) for x in split(b, 7)] crr = [int(x, 2) for x in split(c, 7)] for i in range(len(brr)): rotated = rotate(brr, i) xrr = [i^j for i, j in zip(rotated, crr)] print(i, xrr) ``` The correct flag len is 34, now just transform it to with the chars index. ```py idx = [26, 16, 21, 14, 70, 52, 61, 29, 9, 28, 22, 37, 52, 71, 37, 32, 3, 35, 37, 34, 2, 37, 55, 35, 52, 12, 51, 3, 32, 14, 55, 33, 2, 67, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] for i in idx: if i > 78 / 2: i += 2 print(chars[i], end='') ``` > justCTF{0ld_TV_c4n_b3_InTeR4ctIv3} ## Interlock | Category | Points | Difficulty | | -------- | -------- | ------------ | | Crypto, Pwn | 355 | Medium | We were given a python program which will execute an executable binary called `timer`. The given python program is a simulation of man-in-the-middle attack, where we can intercept encrypted communication between Alice and Bob. ```python #!/usr/bin/env python from subprocess import PIPE, Popen from time import sleep import threading from datetime import datetime from queue import Queue, Empty import json from sys import stderr import hpke from cryptography.hazmat.primitives.serialization import PublicFormat, Encoding from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import hashes import os from binascii import hexlify, unhexlify FLAG = os.environ['FLAG'] if 'FLAG' in os.environ else 'justCTF{temporary-interlock-flag}' K = 4 suite = hpke.Suite__DHKEM_P256_HKDF_SHA256__HKDF_SHA256__ChaCha20Poly1305 timer = Popen(["./timer"], stdin=PIPE, stdout=PIPE, bufsize=1, encoding="ascii") timer_lock = threading.Lock() alice_x1, alice_x2, bob_x1, bob_x2 = None, None, None, None def get_time(): timer_lock.acquire() try: timer.stdin.write("gettimeofday\n") t = timer.stdout.readline().strip()[:-3] return datetime.strptime(t, "%Y-%m-%d %H:%M:%S.%f") finally: timer_lock.release() def fmt(data): return hexlify(data).decode() def ufmt(data): return unhexlify(data.encode()) def alice(qr, qw, ev): try: alice_w(qr, qw) except Exception as e: ev.set() qr.put("ERROR") def alice_w(qr, qw): global alice_x1, alice_x2 msg = "" while msg != "start": msg = qw.get() ska = suite.KEM.generate_private_key() pka = ska.public_key().public_bytes( encoding=Encoding.X962, format=PublicFormat.UncompressedPoint ) x1 = os.urandom(128) n1 = os.urandom(64) alice_x1 = x1 m1 = json.dumps({"x1": fmt(x1), "n1": fmt(n1), "pka": fmt(pka)}) c1_d = hashes.Hash(hashes.SHA3_256()) c1_d.update(m1.encode()) c1 = c1_d.finalize() qr.put(fmt(c1)) sleep(K) s1 = ska.sign(m1.encode(), ec.ECDSA(hashes.SHA3_256())) m1_sig = json.dumps({"m1": m1, "s1": fmt(s1)}) qr.put(m1_sig) start_time = get_time() m2_enc = json.loads(qw.get()) stop_time = get_time() if (stop_time - start_time).total_seconds() >= K: raise Exception("too late") encap, ct, pkb = ufmt(m2_enc["encap"]), ufmt(m2_enc["ct"]), ufmt(m2_enc["pkb"]) pkb_k = ec.EllipticCurvePublicKey.from_encoded_point(suite.KEM.CURVE, pkb) m2 = suite.open_auth( encap, ska, pkb_k, info=b"interlock", aad=pkb, ciphertext=ct, ) m2 = json.loads(m2) if ufmt(m2["pka"]) != pka: raise Exception("wrong data") if m2["m1"] != m1: raise Exception("wrong data") x2 = ufmt(m2["x2"]) alice_x2 = x2 def bob(qr, qw, ev): try: bob_w(qr, qw) except Exception as e: ev.set() qr.put("ERROR") def bob_w(qr, qw): global bob_x1, bob_x2 msg = "" while msg != "start": msg = qw.get() skb = suite.KEM.generate_private_key() pkb = skb.public_key().public_bytes( encoding=Encoding.X962, format=PublicFormat.UncompressedPoint ) c1 = ufmt(qw.get()) sleep(K) m1_sig = json.loads(qw.get()) m1 = json.loads(m1_sig["m1"]) s1 = ufmt(m1_sig["s1"]) x1, n1, pka = ufmt(m1["x1"]), ufmt(m1["n1"]), ufmt(m1["pka"]) bob_x1 = x1 m1 = json.dumps({"x1": fmt(x1), "n1": fmt(n1), "pka": fmt(pka)}) c1_d = hashes.Hash(hashes.SHA3_256()) c1_d.update(m1.encode()) if c1 != c1_d.finalize(): raise Exception("wrong hash") pka_k = ec.EllipticCurvePublicKey.from_encoded_point(suite.KEM.CURVE, pka) pka_k.verify(s1, m1.encode(), ec.ECDSA(hashes.SHA3_256())) x2 = os.urandom(128) n2 = os.urandom(64) bob_x2 = x2 m2 = json.dumps( {"x2": fmt(x2), "pka": fmt(pka), "m1": m1, "n2": fmt(n2)} ) encap, ct = suite.seal_auth( pka_k, skb, info=b"interlock", aad=pkb, message=m2.encode() ) m2_enc = json.dumps({"encap": fmt(encap), "ct": fmt(ct), "pkb": fmt(pkb)}) qr.put(m2_enc) def router(targets, aliceE, bobE): while True: if aliceE.is_set() or bobE.is_set(): raise Exception("Communication error") data = input() data = json.loads(data) if not isinstance(data, dict): raise Exception("Communication error") if data.get("type") not in targets: raise Exception("Communication error") if data["type"] == "quit": return else: if data.get("target") not in targets[data["type"]]: raise Exception("Communication error") if data["type"] == "write": if "msg" not in data: raise Exception("Communication error") targets[data["type"]][data["target"]].put(data["msg"]) elif data["type"] == "read": try: msg = targets[data["type"]][data["target"]].get(True, 1) print(msg) except Empty: print("none") def main(): aliceQW, bobQW = Queue(), Queue() aliceQR, bobQR = Queue(), Queue() aliceE, bobE = threading.Event(), threading.Event() aliceT, bobT = threading.Thread( target=alice, args=(aliceQR, aliceQW, aliceE) ), threading.Thread(target=bob, args=(bobQR, bobQW, bobE)) targets = { "read": {"alice": aliceQR, "bob": bobQR}, "write": {"alice": aliceQW, "bob": bobQW}, "quit": None, } aliceT.start(), bobT.start() print(f"Welcome in {get_time()} at World Chess Championship!") try: router(targets, aliceE, bobE) except: print("Error") os._exit(1) aliceT.join(), bobT.join() timer.stdin.write("q\n") timer.communicate() if aliceE.is_set() or bobE.is_set(): print("NOPE") return print("Communication established, check if MITM was successful") try: x1 = unhexlify(input("Give me x1: ").strip()) x2 = unhexlify(input("Give me x2: ").strip()) except: print("Error") return if x1 == alice_x1 == bob_x1: if x2 == alice_x2 == bob_x2: print(FLAG) return print("NOPE") if __name__ == "__main__": main() ``` The goal is to make `x1 == alice_x1 == bob_x1` and `x2 == alice_x2 == bob_x2`. `alice_x1` and `bob_x2` are random, meanwhile `bob_x1` and `alice_x2` are given from the received payload on each function. Notice that `alice_x1` goes unencrypted, so we can know the value. But, `bob_x2` is encrypted, and we can't know Alice's private key to decrypt it. So, we need to send another key to Bob so we can get the value of `bob_x2` and send it to Alice. Thanks to the author, the skeleton of the solver was given so we just need to complete it. There was one more problem to solve, the `timer`. Notice that we just can get `alice_x1` just before the timer starts in `alice_w`. And we can't send anything to Bob just yet because we need to send `m1_sig` before we send forged `m1`. We also need to wait `K` seconds to get `bob_x2`, so the time difference checking will always failed. Because the calculation uses the binary `timer`, and the tag of the challenge is `pwn`, but we can't influence the binary. We tried to run the binary and noticed that no matter what the inputs are, the `timer` binary will just outputting the time at the end of the year 1990. After some trial and error, we noticed that there is a bug that shows inaccurate time. ![image](https://hackmd.io/_uploads/HkDRVZwLC.png) After some testing, we concluded that we can start the timer from `K` seconds before new year to make the time differences below `K`. ```python #!/usr/bin/env python import sys from datetime import datetime import threading from subprocess import PIPE, Popen from time import sleep timer = Popen(["./timer"], stdin=PIPE, stdout=PIPE, bufsize=1, encoding="ascii") timer_lock = threading.Lock() def get_time(): timer_lock.acquire() try: timer.stdin.write("gettimeofday\n") t = timer.stdout.readline().strip()[:-3] return datetime.strptime(t, "%Y-%m-%d %H:%M:%S.%f") finally: timer_lock.release() K = 4 t = get_time() while not (t.minute == int(sys.argv[1]) and t.second == int(sys.argv[2])): print(f'{t = }') t = get_time() sleep(1) start = get_time() sleep(K) end = get_time() print((end - start).total_seconds()) ``` ![Screenshot from 2024-06-16 00-21-49](https://hackmd.io/_uploads/S1X10-P8C.png) Solver: ```python import json, os import hpke from cryptography.hazmat.primitives.serialization import PublicFormat, Encoding from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import hashes from binascii import hexlify, unhexlify from pwn import * from time import sleep from datetime import datetime suite = hpke.Suite__DHKEM_P256_HKDF_SHA256__HKDF_SHA256__ChaCha20Poly1305 eve_x1, eve_x2 = None, None ske = suite.KEM.generate_private_key() pke = ske.public_key().public_bytes( encoding=Encoding.X962, format=PublicFormat.UncompressedPoint ) def send(conn, t, msg): conn.sendline(json.dumps({"type": "write", "target": t, "msg": msg}).encode()) def send_alice(conn, msg): send(conn, "alice", msg) def send_bob(conn, msg): send(conn, "bob", msg) def recv(conn, t): conn.sendline(json.dumps({"type": "read", "target": t}).encode()) msg = conn.recvline(keepends=False) if msg == b"none": return None return msg def recv_blocking(conn, t): msg = None while msg is None: msg = recv(conn, t) return msg def recv_alice(conn): return recv_blocking(conn, "alice") def recv_bob(conn): return recv_blocking(conn, "bob") def fmt(data): return hexlify(data).decode() def ufmt(data): return unhexlify(data.encode()) def eve_to_bob(m1_sig): global eve_x1 m1_sig = json.loads(m1_sig) m1 = json.loads(m1_sig["m1"]) x1 = ufmt(m1["x1"]) n1 = ufmt(m1["n1"]) eve_x1 = x1 m1 = json.dumps({"x1": fmt(x1), "n1": fmt(n1), "pka": fmt(pke)}) c1_d = hashes.Hash(hashes.SHA3_256()) c1_d.update(m1.encode()) c1 = c1_d.finalize() s1 = ske.sign(m1.encode(), ec.ECDSA(hashes.SHA3_256())) m1_sig = {"m1": m1, "s1": fmt(s1)} return fmt(c1), json.dumps(m1_sig) def eve_to_alice(m1_sig, m2_enc): global eve_x2 m1_sig = json.loads(m1_sig) m2_enc = json.loads(m2_enc) encap, ct, pkb = ufmt(m2_enc["encap"]), ufmt(m2_enc["ct"]), ufmt(m2_enc["pkb"]) pkb_k = ec.EllipticCurvePublicKey.from_encoded_point(suite.KEM.CURVE, pkb) m2 = suite.open_auth( encap, ske, pkb_k, info=b"interlock", aad=pkb, ciphertext=ct, ) m2 = json.loads(m2) x2 = ufmt(m2["x2"]) n2 = ufmt(m2["n2"]) eve_x2 = x2 m1 = json.loads(m1_sig["m1"]) s1 = ufmt(m1_sig["s1"]) x1, n1, pka = ufmt(m1["x1"]), ufmt(m1["n1"]), ufmt(m1["pka"]) m1 = json.dumps({"x1": fmt(x1), "n1": fmt(n1), "pka": fmt(pka)}) c1_d = hashes.Hash(hashes.SHA3_256()) c1_d.update(m1.encode()) pka_k = ec.EllipticCurvePublicKey.from_encoded_point(suite.KEM.CURVE, pka) pka_k.verify(s1, m1.encode(), ec.ECDSA(hashes.SHA3_256())) m2 = json.dumps({"x2": fmt(x2), "pka": fmt(pka), "m1": m1, "n2": fmt(n2)}) encap, ct = suite.seal_auth( pka_k, ske, info=b"interlock", aad=pke, message=m2.encode() ) m2_enc = {"encap": fmt(encap), "ct": fmt(ct), "pkb": fmt(pke)} return json.dumps(m2_enc) def main(): conn = remote("interlock.nc.jctf.pro", 7331) welcome = conn.recvline(keepends=False).decode() current_datetime = datetime.strptime(welcome[len('Welcome in '):-(len(' at World Chess Championship!') + 3)], "%Y-%m-%d %H:%M:%S.%f") print(current_datetime) expected_datetime = datetime.strptime("1990-12-31 23:59:52.500", "%Y-%m-%d %H:%M:%S.%f") sleep((expected_datetime - current_datetime).total_seconds()) send_bob(conn, "start") send_alice(conn, "start") c1 = recv_alice(conn).decode() m1_sig = recv_alice(conn).decode() original_m1_sig = m1_sig c1, m1_sig = eve_to_bob(m1_sig) print("sending c1 to bob: ", c1) send_bob(conn, c1) print("sending m1_sig to bob: ", m1_sig) send_bob(conn, m1_sig) m2_enc = recv_bob(conn).decode() print("received m2_enc: ", m2_enc) m2_enc = eve_to_alice(original_m1_sig, m2_enc) send_alice(conn, m2_enc) print("sending m2_enc to alice") conn.sendline(json.dumps({"type": "quit"}).encode()) print(conn.recvline_startswith(b"Communication")) conn.recvuntil(b"Give me x1: ") conn.sendline(fmt(eve_x1)) conn.recvuntil(b"Give me x2: ") conn.sendline(fmt(eve_x2)) err = conn.recvline().strip() print(err) conn.interactive() conn.close() ``` ![Screenshot from 2024-06-16 00-21-19](https://hackmd.io/_uploads/ryaOVbwUA.png) > justCTF{p3rf3c71y_un6r34k4b13_1f_n0t_71m3_7r4v31s} ## Budget SoC | Category | Points | Difficulty | | -------- | -------- | ------------ | | Fore, Misc | 363 | Medium | Given flashdump.bin, parse the executable using https://github.com/tenable/esp32_image_parser. There will be issue if we use the newer version of esptool library, use this patch to solve the issue https://github.com/tenable/esp32_image_parser/issues/14#issuecomment-2041247535. ![image](https://hackmd.io/_uploads/BkOMByFUC.png) ![image](https://hackmd.io/_uploads/BJdVH1FL0.png) Open 32-bit Tensilica Xtensa file using ghidra. Looking at string "flag" we will found reference to the function that will produce flag like in previous SOC challenge. ![image](https://hackmd.io/_uploads/Bk23ByYUC.png) ![image](https://hackmd.io/_uploads/SJRyIkKL0.png) Rename some variable and function to make it easier to analyze. ```c ---snippet--- // FUN_400d29b memw(); memw(); iStack_24 = _DAT_3ffc4120; FUN_400d88c4(0x3ffc3eec,&DAT_3f400120); memcpy_(auStack_94,s__<!DOCTYPE_html>_<html>_<head>_<_3f400125); if (DAT_3ffc3ca8 != '\0') { FUN_400d88c4(0x3ffc3eec,s_here2_3f4002d0); if (0x83 < _DAT_3ffc3e3c) { allocation_(ciphertext,_DAT_3ffc3e38 + 100,0x20); } FUN_400d296c(ciphertext,decrypted,0x20); memcpy_(auStack_84,decrypted); memcpy_(auStack_74,s_<h2>Flag:_3f4002d6); uVar2 = FUN_400d8fa8(auStack_74,auStack_84); uVar2 = FUN_400d8fd8(uVar2,s_</h2>_3f4002e1); FUN_400d8ec4(auStack_94,uVar2); FUN_400d8a98(auStack_74); FUN_400d88ac(0x3ffc3eec,auStack_84); FUN_400d8a98(auStack_84); } ---snippet--- ``` So the ciphertext are processed on function FUN_400d296c, next take a look on function FUN_400d296c. There are some constant in the function so it nice to search it on github. ![image](https://hackmd.io/_uploads/BkilwJF80.png) Search for the constant in 4 bytes format, https://github.com/search?q=0x52096ad5&type=code and found https://github.com/defanator/mcespi/blob/800d492838ca56dde29e6c56df28249131fda3d4/mcespi.c#L339. From above code we can see that the constant is actually from aes decrypt process. Looking at another function looks like it is same like in the app0.elf function. So the last step is basically finding the key and the ciphertext used by the function in app0.elf. Rename some variable and function to make it easier to trace. ```c undefined4 FUN_400d82e4(int instance,undefined4 ciphertext,int param_3,undefined4 param_4,undefined4 key, undefined2 length,undefined4 param_7) { undefined4 uVar1; int iVar2; *(instance + 0xfc) = param_3; aes_key_expand(instance,key,length); iVar2 = param_3 + 0xf; if (-1 < param_3) { iVar2 = param_3; } aes_(instance,ciphertext,param_4,iVar2 >> 4,param_7); uVar1 = FUN_40173bc4(instance,param_4,param_3); return uVar1; } ``` From the caller function we get the key, which is on the fifth argument (DAT_3ffbdb68). The ciphertext is on second argument and it allocated from function allocation_ that we assume the data is from `_DAT_3ffc3e38`. ```c FUN_400d831c(auStack_134,ciphertext,param_3,output,&DAT_3ffbdb68,0x10,uVar1); ``` Looking at ELF file, we know that `_DAT_3ffc3e38` is not stored on it. ![image](https://hackmd.io/_uploads/B1pItyKI0.png) So we assume that the data is maybe on runtime memory. Because we have the flashdump.bin we try to directly find the ciphertext by bruteforcing all 32 bytes value in the flashdump.bin. Below is our script to do bruteforce. ```python from Crypto.Cipher import AES f = open("flashdump.bin", "rb").read() key = [0x33,0xBD,0xFB,0x72,0x4C,0x22,0x87,0x33,0x62,0xFF,0x75,0x41,0xD5,0x14,0xF6,0xFD] bytes_key = bytes(key) for i in range(0, len(f) - 32): cipher = AES.new(bytes_key, AES.MODE_ECB) tmp = f[i:i+32] res = cipher.decrypt(tmp) if b"just" in res: print(i, res) ``` ![image](https://hackmd.io/_uploads/B1rVq1K80.png) Looks like we got partial flag, so the mode should be not ECB. The next step we do is trying to use AES CBC with iv null bytes, because the first block is already correct plaintext. ```python from Crypto.Cipher import AES f = open("flashdump.bin", "rb").read() key = [0x33,0xBD,0xFB,0x72,0x4C,0x22,0x87,0x33,0x62,0xFF,0x75,0x41,0xD5,0x14,0xF6,0xFD] bytes_key = bytes(key) iv = b"\x00"*16 i = 42308 cipher = AES.new(bytes_key, AES.MODE_CBC, iv) tmp = f[i:i+32] print(cipher.decrypt(tmp)) ``` ![image](https://hackmd.io/_uploads/rkCi5kYUA.png) > justCTF{dUmp3d_r3v3rs3d_h4ck3d} ## Leaving soon | Category | Points | Difficulty | | -------- | -------- | ------------ | | Misc | 406 | Medium | >My friend wanted to rewatch this cool miniseries on Catflix but it looks like they removed it. Can you help him recover all episodes from the network capture? We were given a network packet capture named catflix.pcapng, consisting of various protocols, as follows: ```sh » tshark -r catflix.pcapng -qz =================================================================== Protocol Hierarchy Statistics Filter: frame frames:31331 bytes:3780884 eth frames:31331 bytes:3780884 ip frames:31117 bytes:3779777 udp frames:943 bytes:323491 data frames:183 bytes:36741 dns frames:36 bytes:3582 quic frames:608 bytes:269396 quic frames:14 bytes:10731 nbns frames:28 bytes:2576 mdns frames:56 bytes:4816 ssdp frames:28 bytes:6020 ntp frames:4 bytes:360 tcp frames:30153 bytes:3747302 tls frames:2834 bytes:4532961 tcp.segments frames:602 bytes:1244373 tls frames:590 bytes:1230237 http frames:899 bytes:1281707 json frames:35 bytes:13045 tcp.segments frames:35 bytes:86964 mp4 frames:350 bytes:936313 tcp.segments frames:280 bytes:906773 media frames:13 bytes:37289 tcp.segments frames:13 bytes:37289 data frames:13 bytes:18247 tcp.segments frames:13 bytes:18247 tcp.segments frames:14 bytes:23030 data frames:40 bytes:58640 igmp frames:21 bytes:1260 arp frames:214 bytes:11076 =================================================================== ``` Based on the description, we've observed that most of the traffic came from a video-streaming service, which seems to have been taken down. Furthermore, let's see how many episodes need to be recovered. ```sh » tshark -r catflix.pcapng -Y http.request -Tfields -e http.request.uri / /generate_204 /api/episodes/0 /media/episode_0.mpd /media/episodes%2Fepisode_0_video.mp4 /media/episodes%2Fepisode_0_audio.mp4 /media/episodes%2Fepisode_0_audio.mp4 /media/episodes%2Fepisode_0_video.mp4 [..snip..] /api/episodes/34 /media/episode_34.mpd /media/episodes%2Fepisode_34_video.mp4 /media/episodes%2Fepisode_34_audio.mp4 /media/episodes%2Fepisode_34_audio.mp4 /media/episodes%2Fepisode_34_video.mp4 » tshark -r catflix.pcapng -Y 'mp4' -Tfields -e http.content_range bytes 1276-1343/148728 bytes 1126-1193/257048 bytes 0-1275/148728 bytes 0-1125/257048 bytes 1344-84498/148728 bytes 1194-105575/257048 bytes 84499-128764/148728 bytes 105576-212744/257048 bytes 212745-257047/257048 bytes 128765-148727/148728 bytes 1276-1343/148490 bytes 1126-1193/257048 bytes 0-1125/257048 bytes 0-1275/148490 [..snip..] ``` As we can see, the traffic starts with an MPD file being fetched before the video segments are downloaded and played. Each of the video segments was partially requested using the `HTTP Range: bytes` header. As expected, this behavior was influenced by the MPD manifest file. ```xml <Representation id="1" bandwidth="77715" codecs="avc1.640015" mimeType="video/mp4" sar="1:1"> <BaseURL>episodes%2Fepisode_0_video.mp4</BaseURL> <SegmentBase indexRange="1276-1343" timescale="12800" presentationTimeOffset="294"> <Initialization range="0-1275"/> </SegmentBase> </Representation> ``` Before diving deeper, let's try to reconstruct **episode_0_video.mp4** by sorting both **http.content_range** and **http.file_data**. ```sh » tshark -r catflix.pcapng -Y 'mp4 and http.response_for.uri matches "_0_video.mp4"' -Tfields -e http.content_range > range » tshark -r catflix.pcapng -Y 'mp4 and http.response_for.uri matches "_0_video.mp4"' -Tfields -e http.file_data > data » paste range data | sort -k2 -n | awk '{print $3}' | xxd -r -p > episode_0_video.mp4 ``` Unfortunately, we got no video playback from the MP4 file. Thus, we tried to force FFMPEG to ignore the error by doing something like this: ```sh » ffmpeg -err_detect ignore_err -i episode_0_video.mp4 -c copy fixed.mp4 ``` Eventually, we managed to see a cat video for the first 7 seconds. After that, the video became more distorted and unplayable. After a while, we decided to check the video properties. ```sh » mediainfo episode_0_video.mp4 General Complete name : episode_0_video.mp4 Format : MPEG-4 Format profile : Base Media / Version 1 Codec ID : mp41 (iso8/isom/mp41/dash/avc1/cmfc) File size : 145 KiB Duration : 18 s 600 ms Overall bit rate : 64.0 kb/s Encoded date : UTC 2024-06-13 20:43:31 Tagged date : UTC 2024-06-13 20:43:31 Video ID : 1 Format : AVC Format/Info : Advanced Video Codec Format profile : High@L2.1 Format settings : CABAC / 4 Ref Frames Format settings, CABAC : Yes Format settings, Reference frames : 4 frames Codec ID : encv / avc1 Codec ID/Info : Advanced Video Coding Duration : 18 s 600 ms Bit rate : 59.1 kb/s Width : 480 pixels Height : 360 pixels Display aspect ratio : 4:3 Frame rate mode : Constant Frame rate : 25.000 FPS Color space : YUV Chroma subsampling : 4:2:0 Bit depth : 8 bits Scan type : Progressive Bits/(Pixel*Frame) : 0.014 Stream size : 134 KiB (92%) Encoded date : UTC 2024-06-13 20:43:31 Tagged date : UTC 2024-06-13 20:43:31 Encryption : Encrypted Color range : Limited Color primaries : BT.709 Transfer characteristics : sRGB/sYCC Codec configuration box : avcC ``` It turned out that the MP4 video was encrypted from the beginning, specifically DRM-protected. DRM (Digital Rights Management) refers to technology designed to control how digital content can be accessed, used, and distributed. DRM is used by content creators and distributors to protect their intellectual property rights and prevent unauthorized copying, sharing, and piracy. Therefore, the video content is often encrypted, making it unreadable without the proper decryption keys. This ensures that only authorized users can view the content. Just in case, let's verify whether the manifest file is related to DRM or not. ```xml » tshark -r catflix.pcapng --export-objects http,files » head files/episode_0.mpd <?xml version="1.0" encoding="UTF-8"?> <!--Generated with https://github.com/shaka-project/shaka-packager version v3.2.0-53b8668-release--> <MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd" xmlns:cenc="urn:mpeg:cenc:2013" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" minBufferTime="PT2S" type="static" mediaPresentationDuration="PT18.6S"> <Period id="0"> <AdaptationSet id="0" contentType="audio" subsegmentStartsWithSAP="1" subsegmentAlignment="true"> <ContentProtection value="cenc" schemeIdUri="urn:mpeg:dash:mp4protection:2011" cenc:default_KID="e027ea1b-5f08-54ca-9bc7-8c1b6bd27245"/> <ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"> <cenc:pssh>AAAAN3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABciD1vVaGBtAS9fZJX/djReK0jj3JWbBg==</cenc:pssh> </ContentProtection> <ContentProtection schemeIdUri="urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b"> <?xml version="1.0" encoding="UTF-8"?> <!--Generated with https://github.com/shaka-project/shaka-packager version v3.2.0-53b8668-release--> <MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd" xmlns:cenc="urn:mpeg:cenc:2013" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" minBufferTime="PT2S" type="static" mediaPresentationDuration="PT18.6S"> <Period id="0"> <AdaptationSet id="0" contentType="audio" subsegmentStartsWithSAP="1" subsegmentAlignment="true"> <ContentProtection value="cenc" schemeIdUri="urn:mpeg:dash:mp4protection:2011" cenc:default_KID="e027ea1b-5f08-54ca-9bc7-8c1b6bd27245"/> <ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"> <cenc:pssh>AAAAN3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABciD1vVaGBtAS9fZJX/djReK0jj3JWbBg==</cenc:pssh> </ContentProtection> <ContentProtection schemeIdUri="urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b"> ``` Based on these findings, we know that each episode needs to be decrypted with a decryption key in the form of KID:DRM-KEY. The KID can be found inside the MPD manifest file, while the DRM key is supposed to be confidential. Fortunately, there are a few license servers that can fetch these DRM keys. In this case, we used [CDRM Project](https://cdrm-project.com/api), which is specifically designed for fetching Shaka DRM keys. Here's our full implementation code: ```py from binascii import unhexlify from pyshark import FileCapture import os import re import requests keys = list() paths = dict() packets = FileCapture( 'catflix.pcapng', use_json=True, include_raw=True ) def get_decryption_key(pssh, keyid): body = { 'PSSH': pssh.decode(), 'License URL': 'https://cwip-shaka-proxy.appspot.com/no_auth', 'Headers': "{'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0'}", 'JSON': "{}", "Cookies": "{}", 'Data': "{}", 'Proxy': "" } resp = requests.post('https://cdrm-project.com/', json=body) drm_keys = resp.json()['Message'] keyid = keyid.replace(b'-', b'').decode() for key in drm_keys.split('\n'): if keyid in key: return key for pkt in packets: try: http = pkt.http if 'video.mp4' in http.response_for.uri: target = http.uri.split('/')[-1][11:] brange = http.content_range.split('-')[0][6:] filedata = unhexlify(''.join(http.file_data_raw[0])) print(target) value = paths.get(target, {}) if not value: paths[target] = value value[int(brange)] = filedata elif '.mpd' in http.response_for.uri: filedata = unhexlify(''.join(http.file_data_raw[0])) pssh = re.findall(rb'pssh>(.+?)</cenc', filedata)[0] keyid = re.findall(rb'default_KID="([\w\-]+)', filedata)[-1] keys.append(get_decryption_key(pssh, keyid)) except: pass for key, item in zip(keys, paths.items()): filename, data = item with open(filename, 'wb') as f: content = b''.join(dict(sorted(data.items())).values()) f.write(content) # https://www.bento4.com/documentation/mp4decrypt/ os.system(f'mp4decrypt --key {key} {filename} d_{filename}') os.system(f'ffmpeg -i d_{filename} -ss 00:00:16 -to 00:00:17 -vf "fps=1" -q:v 2 {filename[:-4]}.png') ``` ![alt](https://i.imgur.com/XeWsyXn.png) ``` justCTF{Y0u_w0uldnt_d0wnl04d_a_C4T} ```