Writer:b1uef0x / Webページ建造途中
チームで参加し、CryptoとWebを解いた。magicは解けたものの分差で時間切れ。悲しみ。1分速く解けるようになればWeb問の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}
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()
このプログラムは次の操作を行うことができる。
self.numbers["12 + 34i"]=[12,34]
といった形式で保存される。self.numbers
が持つ複素数をすべて表示する。self.numbers
を上書きする。self.numbers
をjson.dumpsでJSON形式の文字列データとしたものを、secret.keyを使ってAES 128bit ECBモードで暗号化した出力を表示する。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を取得。
お蕎麦についての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}
NameとFavorite colorを入力してstartするとVILLAGERやKNIGHTのような役がついて表示される。
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になれた。
アクセスすると内部ネットワークしかアクセスできないと弾かれる。
サーバーの構成ファイルが配布されているので、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が表示される。
ctf4b{j50n_is_v4ry_u5efu1_bu7_s0metim3s_it_bi7es_b4ck}
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を表示させることができる。
ctf4b{r4m3n_15_4n_3553n714l_d15h_f0r_h4ck1n6}
ユーザー名とパスワードを登録すると、Memoを記録できるページが表示される。
MagicLinkはhttps://magic.quals.beginners.seccon.jp/magic?token=*********************
のような形式で与えられ、このURLにアクセスするとユーザー名とパスワードの入力を省略してログインすることができる。
また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は特に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, "&")
.replace(/\</g, "<")
.replace(/\>/g, ">")
.replace(/\"/g, """)
.replace(/\'/g, "'");
}
サーバー側の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を取得できた。