SECCON Beginners 2021 writeup

Writer:b1uef0x / Webページ建造途中

概要

チームで参加し、CryptoとWebを解いた。magicは解けたものの分差で時間切れ。悲しみ。1分速く解けるようになればWeb問のBeginnerを名乗りたいと思う。

目次

simple_RSA (crypto:Beginner)

RSA暗号を実行するプログラムと出力が配布される。

program.py
from Crypto.Util.number import *
from flag import flag

flag = bytes_to_long(flag.encode("utf-8"))

p = getPrime(1024)
q = getPrime(1024)
n = p * q
e = 3

assert 2046 < n.bit_length()
assert 375 == flag.bit_length()

print("n =", n)
print("e =", e)
print("c =", pow(flag, e, n))
output.txt
n = 17686671842400393574730512034200128521336919569735972791676605056286778473230718426958508878942631584704817342304959293060507614074800553670579033399679041334863156902030934895197677543142202110781629494451453351396962137377411477899492555830982701449692561594175162623580987453151328408850116454058162370273736356068319648567105512452893736866939200297071602994288258295231751117991408160569998347640357251625243671483903597718500241970108698224998200840245865354411520826506950733058870602392209113565367230443261205476636664049066621093558272244061778795051583920491406620090704660526753969180791952189324046618283
e = 3
c = 213791751530017111508691084168363024686878057337971319880256924185393737150704342725042841488547315925971960389230453332319371876092968032513149023976287158698990251640298360876589330810813199260879441426084508864252450551111064068694725939412142626401778628362399359107132506177231354040057205570428678822068599327926328920350319336256613

典型的なeが小さい問題。flagがpaddingされておらず、375bitしかない。平文のe乗が公開鍵よりも小さいため、暗号文のe乗根をとれば平文になる。

solve.py
import gmpy2
from Crypto.Util.number import bytes_to_long, long_to_bytes

n = 17686671842400393574730512034200128521336919569735972791676605056286778473230718426958508878942631584704817342304959293060507614074800553670579033399679041334863156902030934895197677543142202110781629494451453351396962137377411477899492555830982701449692561594175162623580987453151328408850116454058162370273736356068319648567105512452893736866939200297071602994288258295231751117991408160569998347640357251625243671483903597718500241970108698224998200840245865354411520826506950733058870602392209113565367230443261205476636664049066621093558272244061778795051583920491406620090704660526753969180791952189324046618283
e = 3
c = 213791751530017111508691084168363024686878057337971319880256924185393737150704342725042841488547315925971960389230453332319371876092968032513149023976287158698990251640298360876589330810813199260879441426084508864252450551111064068694725939412142626401778628362399359107132506177231354040057205570428678822068599327926328920350319336256613

m = gmpy2.iroot(c,e)[0]
p = long_to_bytes(m)
print(p)

ctf4b{0,1,10,11...It's_so_annoying.___I'm_done}

Imaginary (crypto:Medium)

Pythonプログラムが配布され、サーバー上で動作している。

app.py
import json
import os
from socketserver import ThreadingTCPServer, BaseRequestHandler
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from secret import flag, key


class ImaginaryService(BaseRequestHandler):
    def handle(self):
        try:
            self.request.sendall(b'Welcome to Secret IMAGINARY NUMBER Store!\n')
            self.numbers = {}

            while True:
                num = self._menu()
                if num == 1:
                    self._save()
                elif num == 2:
                    self._show()
                elif num == 3:
                    self._import()
                elif num == 4:
                    self._export()
                elif num == 5:
                    self._secret()
                else:
                    break

        except Exception as e:
            try:
                self.request.sendall(f'ERR: {e}\n'.encode())
            except Exception:
                pass

    def _menu(self):
        self.request.sendall(b'1. Save a number\n')
        self.request.sendall(b'2. Show numbers\n')
        self.request.sendall(b'3. Import numbers\n')
        self.request.sendall(b'4. Export numbers\n')
        self.request.sendall(b'0. Exit\n')
        self.request.sendall(b'> ')
        try:
            return int(self.request.recv(128).strip())
        except ValueError:
            return 0

    def _save(self):
        try:
            self.request.sendall(b'Real part> ')
            re = int(self.request.recv(128).strip())

            self.request.sendall(b'Imaginary part> ')
            im = int(self.request.recv(128).strip())

            name = f'{re} + {im}i'
            self.numbers[name] = [re, im]
        except ValueError:
            pass

    def _show(self):
        self.request.sendall(b'-' * 50 + b'\n')
        for name in self.numbers:
            re, im = self.numbers[name]
            self.request.sendall(f'{name}: ({re}, {im})\n'.encode())
        self.request.sendall(b'-' * 50 + b'\n')

    def _import(self):
        self.request.sendall(b'Exported String> ')
        data = self.request.recv(1024).strip().decode()
        enc = bytes.fromhex(data)
        cipher = AES.new(key, AES.MODE_ECB)
        plaintext = unpad(cipher.decrypt(enc), AES.block_size)

        self.numbers = json.loads(plaintext.decode())
        self.request.sendall(b'Imported.\n')
        self._show()

    def _export(self):
        cipher = AES.new(key, AES.MODE_ECB)
        dump = pad(json.dumps(self.numbers).encode(), AES.block_size)
        self.request.sendall(dump + b'\n')
        enc = cipher.encrypt(dump)
        self.request.sendall(b'Exported:\n')
        self.request.sendall(enc.hex().encode() + b'\n')

    def _secret(self):
        if '1337i' in self.numbers:
            self.request.sendall(b'Congratulations!\n')
            self.request.sendall(f'The flag is {flag}\n'.encode())


if __name__ == '__main__':
    host = os.getenv('CTF4B_HOST')
    port = os.getenv('CTF4B_PORT')

    if not host:
        host = 'localhost'

    if not port:
        port = '1337'

    ThreadingTCPServer.allow_reuse_address = True
    server = ThreadingTCPServer((host, int(port)), ImaginaryService)

    print(f'Start server at {host}:{port}')
    server.serve_forever()

このプログラムは次の操作を行うことができる。

1. Save a number
複素数を登録する。RealパートとImaginaryパートを入力するとself.numbers変数に辞書型としてself.numbers["12 + 34i"]=[12,34]といった形式で保存される。
2. Show numbers
self.numbersが持つ複素数をすべて表示する。
3. Import numbers
secret.keyで暗号化した後述のAES暗号文を入力すると、復号化したJSON形式の文字列データをパースしてself.numbersを上書きする。
4. Export numbers
self.numbersをjson.dumpsでJSON形式の文字列データとしたものを、secret.keyを使ってAES 128bit ECBモードで暗号化した出力を表示する。
5. (非表示)
self.numbersのキー("12 + 34i"形式の部分)に1337iがあれば、flagを表示する。

目的はself.numbers["1337i"]=~~~のデータを持つself.numbers変数を作ることになるが、1.の手順ではRealパートが必ず入力されるため、作ることはできない。4.でself.numbersの中身をJSON形式にして暗号化出力するが、暗号利用モードがECBのため、JSONデータは128bit毎に同じキーを使って暗号化されている。よって、複数のself.numbersのデータから作った128bitの暗号ブロックを組み合わせて目的のデータを作成することができる。

1.から次の2つの複素数を入力してJSON出力させる。

{"1000 + 1000i": [1000, 1000], "1 + 1i": [1, 1]}
{"1000 + 1000i": [1000, 1000], "1 + 1i": [1, 1]}に対応する暗号文
2efb1aba487814485f7cc3dd9279211273c572e20dcb878958330253da238bb9450ebe4d6d0ea85378c0212781ca5cdd8db4341b6d2b363abdc9d13de3042f42
{"10000000000 + 1337i": [10000000000, 1337]}
{"10000000000 + 1337i": [10000000000, 1337]}に対応する暗号文e54884837e5c0ff5d78f65863c10af962a49fc4019c3b5747d3b8c655849bcd8d5a6320c80e022d70e7e8c0ce0993f6e

これらの平文と暗号文が対応するブロックは次のようになる。

{"1000 + 1000i":
 [1000, 1000], "
1 + 1i": [1, 1]}
2efb1aba487814485f7cc3dd92792112
73c572e20dcb878958330253da238bb9
450ebe4d6d0ea85378c0212781ca5cdd
8db4341b6d2b363abdc9d13de3042f42
{"10000000000 + 
1337i": [1000000
0000, 1337]}
e54884837e5c0ff5d78f65863c10af96
2a49fc4019c3b5747d3b8c655849bcd8
d5a6320c80e022d70e7e8c0ce0993f6e

この2つの暗号文を組み合わせて"1337i"を持つJSONデータの暗号文を作ることができる。

{"1000 + 1000i":
 [1000, 1000], "
1337i": [1000000
0000, 1337]}
2efb1aba487814485f7cc3dd92792112
73c572e20dcb878958330253da238bb9
2a49fc4019c3b5747d3b8c655849bcd8
d5a6320c80e022d70e7e8c0ce0993f6e
{"1000 + 1000i": [1000, 1000], "1337i": [10000000000, 1337]}
{"1000 + 1000i": [1000, 1000], "1337i": [10000000000, 1337]}に対応する暗号文2efb1aba487814485f7cc3dd9279211273c572e20dcb878958330253da238bb92a49fc4019c3b5747d3b8c655849bcd8d5a6320c80e022d70e7e8c0ce0993f6e

作成した暗号を3.でImportして5.でflagを取得。

solve

osoba (web:Beginner)

お蕎麦についてのWebサイトが作成されている。個々のページを開くと、https://osoba.quals.beginners.seccon.jp/?page=public/wip.htmlといった形式でクエリ文字列からページを取得しているので、これを/flagにするだけ。

https://osoba.quals.beginners.seccon.jp/?page=/flagを開くと、ctf4b{omisoshiru_oishi_keredomo_tsukuruno_taihen}

Werewolf (web:Easy)

NameとFavorite colorを入力してstartするとVILLAGERやKNIGHTのような役がついて表示される。

Werewolf入力画面
Werewolf出力画面

app.pyが配布されている。

app.py
import os
import random
from flask import Flask, render_template, request, session

# ====================

app = Flask(__name__)
app.FLAG = os.getenv("CTF4B_FLAG")

# ====================

class Player:
    def __init__(self):
        self.name = None
        self.color = None
        self.__role = random.choice(['VILLAGER', 'FORTUNE_TELLER', 'PSYCHIC', 'KNIGHT', 'MADMAN'])
        # :-)
        # self.__role = random.choice(['VILLAGER', 'FORTUNE_TELLER', 'PSYCHIC', 'KNIGHT', 'MADMAN', 'WEREWOLF'])

    @property
    def role(self):
        return self.__role

    # :-)
    # @role.setter
    # def role(self, role):
    #     self.__role = role


# ====================

@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == 'GET':
        return render_template('index.html')

    if request.method == 'POST':
        player = Player()

        for k, v in request.form.items():
            player.__dict__[k] = v

        return render_template('result.html',
            name=player.name,
            color=player.color,
            role=player.role,
            flag=app.FLAG if player.role == 'WEREWOLF' else ''
        )

# ====================

if __name__ == '__main__':
    app.run(host=os.getenv("CTF4B_HOST"), port=os.getenv("CTF4B_PORT"))

Playerオブジェクトのplayer変数に入力値が入り、player.roleがWEREWOLFのときにflagが表示されるが、ランダムに決定されるroleの中にはWEREWOLFが入っていない。もう少しよく見ると、player.roleを参照するとクラスの中では更にplayer.__roleを読んでおり、これを改竄できればよい。

player変数にフォームのデータを入力する方法としてplayer.__dict__[k]を使っており、これが脆弱性。__dict__はオブジェクト内のすべての変数にアクセスできるため、__roleを変更することもできる。ただし__roleはダブルアンダーバーでネームマングリングされているため、_Player__roleでアクセスする必要がある。

したがってPOSTリクエスト時に_Player__role=WEREWOLFを追加すればWEREWOLFになることができる。方法は色々あるが一例として開発者モードでフォームの部品を追加してあげる。

開発者モードによる修正

startを押してリクエストするとWEREWOLFになれた。

solve

json (web:Medium)

アクセスすると内部ネットワークしかアクセスできないと弾かれる。

ページの表示

サーバーの構成ファイルが配布されているので、ngnixのコンフィグファイルを見る。

ngnix/default.conf
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location / {
        proxy_pass   http://bff:8080;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

}

X-Forwarded-Forでリバースプロキシを通せる設定となっているので、HTTPリクエストヘッダにX-Forwarded-For: 192.168.111.1を追加すると内部ページにアクセスできるようになる。picoCTF2021のスウェーデン人問題(風評被害)が遂に役に立った。ちなみにヘッダの書き換えにはBurp Suiteを使用している。

内部ページの表示

次にSelect itemを選んでSubmitしていく。Quick brown foxとLorem ipsumは取得に成功するが、flagはIt is forbidden to retrieve Flag from this BFF server.と弾かれる。

配布されている構成ファイルから、bffサーバとapiサーバのmain.goファイルのmain関数を見てみる。

bff/main.goのmain関数
func main() {
        r := gin.Default()
        r.Use(checkLocal())
        r.LoadHTMLGlob("templates/*")

        r.GET("/", func(c *gin.Context) {
                c.HTML(200, "index.html", nil)
        })

        r.POST("/", func(c *gin.Context) {
                // get request body
                body, err := ioutil.ReadAll(c.Request.Body)
                if err != nil {
                        c.JSON(400, gin.H{"error": "Failed to read body."})
                        return
                }

                // parse json
                var info Info
                if err := json.Unmarshal(body, &info); err != nil {
                        c.JSON(400, gin.H{"error": "Invalid parameter."})
                        return
                }

                // validation
                if info.ID < 0 || info.ID > 2 {
                        c.JSON(400, gin.H{"error": "ID must be an integer between 0 and 2."})
                        return
                }

                if info.ID == 2 {
                        c.JSON(400, gin.H{"error": "It is forbidden to retrieve Flag from this BFF server."})
                        return
                }

                // get data from api server
                req, err := http.NewRequest("POST", "http://api:8000", bytes.NewReader(body))
                if err != nil {
                        c.JSON(400, gin.H{"error": "Failed to request API."})
                        return
                }
                req.Header.Set("Content-Type", "application/json")
                client := new(http.Client)
                resp, err := client.Do(req)
                if err != nil {
                        c.JSON(400, gin.H{"error": "Failed to request API."})
                        return
                }
                defer resp.Body.Close()
                result, err := ioutil.ReadAll(resp.Body)
                if err != nil {
                        c.JSON(400, gin.H{"error": "Failed to request API."})
                        return
                }

                c.JSON(200, gin.H{"result": string(result)})
        })

        if err := r.Run(":8080"); err != nil {
                panic("server is not started")
        }
}

内部ページはSubmitを押すとJSONデータを生成してAjaxでPOSTリクエストするようになっており、POSTされたJSONを受け取ったbffサーバはJSONからid変数を取り出してチェックするようになっている。idは0,1,2の3種類で、それぞれQuick brown fox,Lorem ipsum,Flagに対応している。idが2のときエラーを返してくることがわかる。チェックを通るとbffサーバからapiサーバにリクエストボディをそのまま使ってPOSTリクエストを行っている。

api/main.goのmain関数
func main() {
        r := gin.Default()

        r.POST("/", func(c *gin.Context) {
                body, err := ioutil.ReadAll(c.Request.Body)
                if err != nil {
                        c.String(400, "Failed to read body")
                        return
                }

                id, err := jsonparser.GetInt(body, "id")
                if err != nil {
                        c.String(400, "Failed to parse json")
                        return
                }

                if id == 0 {
                        c.String(200, "The quick brown fox jumps over the lazy dog.")
                        return
                }
                if id == 1 {
                        c.String(200, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
                        return
                }
                if id == 2 {
                        // Flag!!!
                        flag := os.Getenv("FLAG")
                        c.String(200, flag)
                        return
                }

                c.String(400, "No data")
        })

        if err := r.Run(":8000"); err != nil {
                panic("server is not started")
        }
}

bffサーバからPOSTリクエストを受けたapiサーバでは特にチェックは行われておらず、id==2のときにFLAGを返すようになっているから、上手くここにたどり着きたい。

ミソはbffサーバではJSONの読み取りにjson.Unmarshalを使うのに対して、apiサーバではjsonparser.GetIntを使用している。この2つの挙動の違いを利用する。

送信するJSONデータを次のように改竄して、中身の異なる同じ名前のidを並べたものにする。するとbffサーバのjson.Unmarshalはパースの結果としてid:2が続くid:1で上書きされてid:1を返し、jsonparser.GetIntは最初に見つけたid:2を読み取る。

{"id":2,"id":1}

bffサーバのチェックをパスしてflagが表示される。

solve

ctf4b{j50n_is_v4ry_u5efu1_bu7_s0metim3s_it_bi7es_b4ck}

cant_use_db (web:Medium)

Hack ramenページが表示される。

ページの表示

最初に持っているBalance:$20000を使って、$10000のNoodlesを2個と$20000のSoupを1個購入することができれば、Flagを表示させることができる。

app/app.py
import os
import re
import time
import random
import shutil
import secrets
import datetime
from flask import Flask, render_template, session, redirect

app = Flask(__name__)
app.secret_key = secrets.token_bytes(256)


def init_userdata(user_id):
    try:
        os.makedirs(f"./users/{user_id}", exist_ok=True)
        open(f"./users/{user_id}/balance.txt", "w").write("20000")
        open(f"./users/{user_id}/noodles.txt", "w").write("0")
        open(f"./users/{user_id}/soup.txt", "w").write("0")
        return True
    except:
        return False


def get_userdata(user_id):
    try:
        balance = open(f"./users/{user_id}/balance.txt").read()
        noodles = open(f"./users/{user_id}/noodles.txt").read()
        soup = open(f"./users/{user_id}/soup.txt").read()
        return [int(i) for i in [balance, noodles, soup]]
    except:
        return [0] * 3


@app.route("/")
def top_page():
    user_id = session.get("user")
    if not user_id:
        dirnames = datetime.datetime.now()
        user_id = f"{dirnames.hour}{dirnames.minute}/" + secrets.token_urlsafe(30)
        if not init_userdata(user_id):
            return redirect("/")
        session["user"] = user_id
    userdata = get_userdata(user_id)
    info = {
        "user_id": re.sub("^[0-9]*?/", "", user_id),
        "balance": userdata[0],
        "noodles": userdata[1],
        "soup": userdata[2]
    }
    return render_template("index.html", info = info)


@app.route("/buy_noodles", methods=["POST"])
def buy_noodles():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    if balance >= 10000:
        noodles += 1
        open(f"./users/{user_id}/noodles.txt", "w").write(str(noodles))
        time.sleep(random.uniform(-0.2, 0.2) + 1.0)
        balance -= 10000
        open(f"./users/{user_id}/balance.txt", "w").write(str(balance))
        return "??$10000"
    return "ERROR: INSUFFICIENT FUNDS"


@app.route("/buy_soup", methods=["POST"])
def buy_soup():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    if balance >= 20000:
        soup += 1
        open(f"./users/{user_id}/soup.txt", "w").write(str(soup))
        time.sleep(random.uniform(-0.2, 0.2) + 1.0)
        balance -= 20000
        open(f"./users/{user_id}/balance.txt", "w").write(str(balance))
        return "????$20000"
    return "ERROR: INSUFFICIENT FUNDS"


@app.route("/eat")
def eat():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    shutil.rmtree(f"./users/{user_id}/")
    session["user"] = None
    if (noodles >= 2) and (soup >= 1):
        return os.getenv("CTF4B_FLAG")
    if (noodles >= 2):
        return "The noodles seem to get stuck in my throat."
    if (soup >= 1):
        return "This is soup, not ramen."
    return "Please make ramen."


if __name__ == "__main__":
    app.run()

データベースを使用していないサービスというのがこの問題のキモで、配布されるサーバー構成ファイルのapp.pyを読むと各購入時に呼ばれる関数でそれぞれget_userdata関数を実行してbalance,noodles,soupが記録されたテキストファイルを読み出て計算を行ったあとで書き込む処理をしている。

app/uwsgi.ini
[uwsgi]
wsgi-file = app.py
callable = app
master = true
processes = 4
threads = 4
socket = :23141
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1

構成ファイルの中にuwsgi.iniが存在し、processs及びthreadsが4になっていることから、uWSGIにおいてFlaskは並列動作することがわかる。であれば同時に複数のPOSTリクエストを投げて複数のプロセスでget_userdata関数を実行させれば、balanceが減算される前の数字を読み出せるはずだ。

並列処理でPOSTを投げる方法も考えたが、このページはAjaxで非同期的にPOSTリクエストを投げる仕様になっているから、高速で連打すれば応答が来る前にリクエストをかけることができる。

というわけでマウス連打職人になってNoodlesとSoupのBuyボタンを素早く3連打すると購入することができた。Flagを表示させることができる。

solve

ctf4b{r4m3n_15_4n_3553n714l_d15h_f0r_h4ck1n6}

magic (web:Hard)

ユーザー名とパスワードを登録すると、Memoを記録できるページが表示される。

Magic Memo App

MagicLinkはhttps://magic.quals.beginners.seccon.jp/magic?token=*********************のような形式で与えられ、このURLにアクセスするとユーザー名とパスワードの入力を省略してログインすることができる。

Reportページ

またReportを報告することができ、管理者にhttps://magic.quals.beginners.seccon.jp/********のページを巡回させることができる。

crawl.js
const USERNAME = process.env.USERNAME; // admin username
const PASSWORD = process.env.PASSWORD; // admin password
const APP_URL = process.env.APP_URL;   // https://magic.quals.beginners.seccon.jp/
const FLAG = process.env.FLAG;         // FLAG!!!

const browser = await puppeteer.launch({
  args: [
    "--no-sandbox",
    "--disable-background-networking",
    "--disk-cache-dir=/dev/null",
    "--disable-default-apps",
    "--disable-extensions",
    "--disable-gpu",
    "--disable-sync",
    "--disable-translate",
    "--hide-scrollbars",
    "--metrics-recording-only",
    "--mute-audio",
    "--no-first-run",
    "--safebrowsing-disable-auto-update",
  ],
});
const page = await browser.newPage();

// login admin's page
await page.goto(APP_URL + "login", {
  waitUntil: "networkidle2",
  timeout: 3000,
});
await page.type('input[name="username"]', USERNAME);
await page.type('input[name="password"]', PASSWORD);
await Promise.all([
  page.click('button[type="submit"]'),
  page.waitForNavigation({
    waitUntil: "networkidle2",
    timeout: 3000,
  }),
]);

// type FLAG in memo field
await page.type('input[name="text"]', FLAG);
await page.click("h1");

// Oh, a URL has arrived. Let's check it.
// (If you set `login` as path in Report page, admin accesses `https://magic.quals.beginners.seccon.jp/login` here.)
await page.goto(APP_URL + path, {
  waitUntil: "networkidle2",
  timeout: 3000,
});

await page.close();
await browser.close();

node.jsで実装された構成ファイルが配布されており、管理者が巡回するクローラは上記のファイルになる。

まず/loginを表示して管理者のユーザー名及びパスワードでMagic Memo Appにログインして、FLAGをMemoの入力フォームに入力している(Saveはしていない)。続いてReportで報告されたページを開いて閲覧する。

/index.js
if (localStorage.getItem("memo")) {
  document.getElementById("memoField").value = localStorage.getItem("memo");
}

document.getElementById("memoField").addEventListener("change", (event) => {
  localStorage.setItem("memo", document.getElementById("memoField").value);
});

document.getElementById("saveMemo").addEventListener("click", (event) => {
  localStorage.removeItem("memo");
});

document.getElementById("copyMagicLink").addEventListener("click", (event) => {
  const token = document.getElementById("magicLink").value;
  const link = document.location.origin + "/magic?token=" + token;
  navigator.clipboard.writeText(link);
});

Magic Memo Appのトップページが読み込むindex.jsを読むと、WebブラウザのlocalStorageにMemoに入力された文字列を記録していることが分かる。つまりMemoをSaveせずサーバーに保存されていない状態でも、ブラウザ側が保持するlocalStorageで記録しており、localStorageはMagic Memo Appのログインユーザーに関わらず読み出されることになる。例えば管理者に攻撃者のアカウントのMagicLinkを巡回させれば、管理者のブラウザは攻撃者のトップページを表示させた上でFLAGをMemoの入力フォームに復元するようになる。

Memoの記録

Memoは特にHTMLタグをエスケープしないため、任意のHTMLを埋め込むことができる。では自分のページにスクリプトを埋め込んで閲覧しにきた管理者に実行させるXSSは可能か?

試しに<script>alert(1)</script>をMemoとしてSaveしてみると、以下のように実行が禁止される。

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' ". Either the 'unsafe-inline' keyword, a hash ('sha256-bhHHL3z2vDgxUt0W3dWQOrprscmda2Y5pLsLg4GF+pI='), or a nonce ('nonce-...') is required to enable inline execution.

原因はページ内のスクリプトを禁止するContent-Security-PolicyがHTTPヘッダで設定されているためだ。

Content-Security-Policy: style-src 'self' ; script-src 'self' ; object-src 'none' ; font-src 'none'

同一オリジン(ドメイン)内から読み込まれたスクリプト以外の動作が許可されていないため、スクリプトを埋め込んでも動作させることはできない。XSS対策である。

これを回避する方法を考えたところ、同一オリジン内に任意のスクリプトを書き出す方法が見つかる。

app.js
app.get("/magic", async (req, res, next) => {
  // Parameter check
  if (!req.query.token) {
    return res.send("invalid parameter");
  }
  const token = req.query.token.toString();
  const getConnection = promisify(db.getConnection.bind(db));
  const connection = await getConnection();
  const query = promisify(connection.query.bind(connection));
  try {
    const result = await query(
      "SELECT id, name FROM user WHERE magic_token = ?",
      [token]
    );
    if (result.length !== 1) {
      return res.status(200).send(escapeHTML(token) + " is invalid token.");
    }
    req.session.user_id = result[0].id;
    req.session.username = result[0].name;
    req.session.magic_token = token;
    return res.redirect("/");
  } catch (e) {
    next(e);
  } finally {
    await connection.release();
  }
  return res.redirect("/login");
});

function escapeHTML(string) {
  return string
    .replace(/\&/g, "&amp;")
    .replace(/\</g, "&lt;")
    .replace(/\>/g, "&gt;")
    .replace(/\"/g, "&quot;")
    .replace(/\'/g, "&#x27");
}

サーバー側のapp.jsのうち、MagicLinkを読み込む/magicのページを処理する部分を抜粋する。

magic_tokenが一致した場合はユーザー名を設定して/にリダイレクトするが、一致しない場合はmagic_tokenをescapeHTML関数を通してそのまま表示するようになっている。エスケープされるのはHTMLだけであるため、スクリプトは素通りで表示させることができる。

https://magic.quals.beginners.seccon.jp/magic?token=onload=function(){document.forms[0].submit()}//を表示させると次のように外部スクリプトファイルとして読み込ませた際に動作するページが得られる。

onload=function(){document.forms[0].submit()}// is invalid token.

これを外部スクリプトファイルとして読み込む次のスクリプトタグをMemoに記録させれば、ページを開いた際にContent-Security-Policyの禁止されずに実行することができる。

<script src="magic?token=onload=function(){document.forms[0].submit()}//"></script>

これを管理者に開かせると、index.jsが読み込まれた時点でWebブラウザのlocalStorageに保存されたFLAGがMemoの入力フォームに書き出され、ページの読み込みが完了するとMemoの入力フォームをSubmitしてFLAGを攻撃者のアカウントのMemoに記録することができる(無限ループするが気にしない)。

このスクリプトを仕込み、Reportページで自身のMagicLinkを報告すると、FLAGを取得できた。

solve