kaito_tateyama's blog

InterKosenCTF 2019 Writeup

InterKosenCTF2019にmitsuさんと二人で、チーム StarrySky として参加しました。成績はチームで13位/91人が正の得点、個人では12位でした。得点は、4332ptsのうち2944ptsを入れました。
p-1
勝手にライバルだと思っている おたまこうせん, Wani Hackase, 生活習慣崩壊ズ, Contrailには完敗です。今回Contrail強かったですね…
p-2
PwnとWebが課題ですね
p-3
safermとE-Sequel-Injectionが通せたのは嬉しかったです
p-4
うごくペンギンさんかわいいです

Writeup

Welcome

[200pts, 77solved, warmup, welcome]
Slackでflagがアナウンスされました
KosenCTF{g3t_r34dy_f0r_InterKosenCTF_2019}

Kurukuru Shuffle

[200pts, 53solved, easy, crypto]
shuffle.pyを見ると、ランダムに0からL-1の数字を3つ作り、それをもとにしてflagの2つの文字のswapをL回繰り返しています。ランダムが出てきたときは、全探索するか、ランダムな数値をいっぱいとってきて次が予測できないか、結果からのエスパーのどれかを考えるとよさそうなので、全探索を考えます。
L=53secret.pyから分かるので、$O(L^3)$をしても大丈夫です。a, b,kの値を全探索していきましょう。
また、iの値はk->2k->3k->...->(L+1)k(mod L)と変化し、最後の(L+1)kは使われないので、Lkからこれを逆順にたどればよいと分かります。

for k in range(1,L):        
    for a in range(0,L):        
        for b in range(0,L):        
            e = list("1m__s4sk_s3np41m1r_836lly_cut3_34799u14}1osenCTF{5sKm") # secret        
            i = 0        
            for s in range(L):        
                i = ((L-s)*k) % L        
                s = (i + a) % L        
                t = (i + b) % L        
                e[s], e[t] = e[t], e[s] # swap        
        
            # encrypted = "".join(encrypted)        
            print("".join(e))        

実際はこれでたくさんの候補がでてきてしまいます。

$ python3 shuffle.py | grep ^KosenCTF{        
KosenCTF{5s4m1m1_m4rk_s3np41_1s_s38l9y_cut3_34769l1u}        
KosenCTF{5s4m1m1_m4rk_s3np41_1s_s38l9y_cut3_34769l1u}        
KosenCTF{5s4m1m1_m4sk_s3np41_1s_r34l9y_cut3_38769l1u}        
KosenCTF{5s4m1m1_m4sk_s3np41_1s_r34l9y_cut3_38769l1u}        
KosenCTF{us4m1m1_m4sk_s3np41_1s_r34lly_cut3_38769915}        
KosenCTF{us4m1m1_m4sk_s3np41_1s_r34lly_cut3_38769915}        

これらを順にsubmitしていくと、当たりました。
KosenCTF{us4m1m1_m4sk_s3np41_1s_r34lly_cut3_38769915}

uploader

[227pts, 34solved, warmup, web]
ソースコードが与えられているので読んでみると、以下の2点に気づきます

1.SQLiteなので、SQL Injectionができそう。特にsearch
2.if (count($rows) == 1 && $rows[0][0] === $name && $rows[0][1] == $_GET['passcode'])が、passcodeのところで==が使われていて怪しい。

ここで、2番に時間を使ってしまったのですが間違いでした。==の両端がユーザー入力に起因するものであれば話が早いのですが、今回は片方が固定されているので難しいようです。例えば、passcodeが0e123なら0e123==0がTrueになるのですが、今回は違いました。
1.を考えていきます。searchboxに') -- を入れてみるとうまく動いたので、UNIONしてpasscodeも一緒に抜き出せばよさそうです。
') UNION SELECT passcode FROM files --とsearchboxに入力して、色々出てくるものを片っ端から入力していきましょう。今回はユーザーがアップロード可能なのでハズレもたくさんあります。
"the_longer_the_stronger_than_more_complicated"secret_fileのpasscodeになることが分かりました。これでsecret_fileがダウンロードできます。
KosenCTF{y0u_sh0u1d_us3_th3_p1ac3h01d3r}

Temple of Time

[285pts, 25solved, medium, forensics, web]
pcapngファイルが与えられています。Network問ですね!
wiresharkで開くと、パケットの量が多いのでとりあえず一つTCP followsします
すると、どうやら以下のようなpayloadを送っていることが分かります。

/index.php?portal='OR(SELECT(IF(ORD(SUBSTR((SELECT+password+FROM+Users+WHERE+username='admin'),1,1))=57,SLEEP(1),'')))#    

Blind SQL Injectionですね。パスワードを復元しましょう。
…wiresharkの使い方が分からないので以下のようにstring| grepしてtextファイルに出力し、それをpythonで処理しました。

$ strings 40142c592afd88a78682234e2d5cada9.pcapng | grep GET > text.txt    

python code

import urllib.parse    
f = open('./text.txt', 'r')    
string = f.readline()    
import re    
ans = [0]*40    
while string:    
    mod = string.split(" ")[1]    
    tex = urllib.parse.unquote(mod)    
    # pattern = "/index.php?portal='OR(SELECT(IF(ORD(SUBSTR((SELECT+password+FROM+Users+WHERE+username='admin'),(\d+),1))=(\d+),SLEEP(1),'')))"    
    pattern = ".*?(\d+).*?(\d+).*?(\d+).*?(\d+).*"    
    result = re.match(pattern, tex)    
    if result:    
        where = int(result.group(1))    
        charcode = int(result.group(3))    
        print(where, charcode)    
        ans[where] = charcode    
    string = f.readline()    
for i in range(40):    
    print(chr(ans[i]), end="")    

出力

...    
37 92    
37 93    
37 94    
37 95    
37 96    
37 123    
37 124    
37 125    
37 126    
KosenCTF{t1m3_b4s3d_4tt4ck_v31ls_1t}    

flagが得られました。
KosenCTF{t1m3_b4s3d_4tt4ck_v31ls_1t}

lost world

[303pts, 23solved, easy, forensics]
最初にVirtualBoxで入ってみるとrootで入れなかったので、もしかしてと思ってvdi自体をstrings | grepしました

$ strings lost_world.vdi| grep KosenCTF{      
Jul 30 21:54:58 interkosenctf kernel: [  384.221654] KosenCTF{???????????????????????????????}^      
Jul 30 21:56:03 interkosenctf kernel: [    7.089636] KosenCTF{???????????????????????????????}^      
Jul 30 22:02:00 interkosenctf kernel: [    5.650095] KosenCTF{???????????????????????????????}^      
Jul 30 21:54:58 interkosenctf kernel: [  384.221654] KosenCTF{???????????????????????????????}^      
Jul 30 21:56:03 interkosenctf kernel: [    7.089636] KosenCTF{???????????????????????????????}^      
Jul 30 22:02:00 interkosenctf kernel: [    5.650095] KosenCTF{???????????????????????????????}^      
Aug 11 12:26:37 interkosenctf kernel: [    1.134291] KosenCTF{u_c4n_r3s3t_r00t_p4ssw0rd_1n_VM}^      
Aug 11 12:26:37 interkosenctf kernel: [    1.134291] KosenCTF{u_c4n_r3s3t_r00t_p4ssw0rd_1n_VM}^      
MESSAGE=KosenCTF{???????????????????????????????}^      
MESSAGE=KosenCTF{???????????????????????????????}^      
MESSAGE=KosenCTF{u_c4n_r3s3t_r00t_p4ssw0rd_1n_VM}^      

一回起動したおかげで、stringsで解けるようになってしまったみたい。
KosenCTF{u_c4n_r3s3t_r00t_p4ssw0rd_1n_VM}

fastbin tutorial

[250pts, 30solved, easy, pwn]
面白い問題でした。が、適当にmallocとfreeを繰り返しreadすると解けてしまった…偶然ガチャを引いてしまった気分なので復習したいです(手順も覚えてません、すみません…)
KosenCTF{y0ur_n3xt_g0al_is_t0_und3rst4nd_fastbin_corruption_attack_m4yb3}

pascal homomorphicity

[333pts, 20solved, hard, crypto]
まずは手元で与えられたものを動かして感覚を掴みます。よくみると、powのとり方が変なことに気づきます。

c = pow(1 + n, key, n * n)      

今知りたいのはkeyです。二項定理で展開すると、$k=key$として$c = (1+n)^k = 1 + nk \mod n^2 $となります。ここで、c-1はkeyを因数に持つこと、nはとても大きな2つの素数の積であることを考えると、cを問題サーバから2つとってきて、そのgcdを求めてやればよいとわかります(p,qがかぶる可能性がゼロではないので、実際には3つとって3パターン試しました)

enc = 1117592785756945002738587405183374707997236278832876752382077257896599451757365415136677068649202271625728711821443802541774251719291043243174615118338499367764139108608978912484729674259016683702441635510210410592886340310951498663601956453368016849654044425358486660239829076023396143385365334100325762893863056704023424484236267267163330075803211115662510582831949315847282719696491488496688402066546770670414750388491616 # とってきた値      
enc2 = 1426474518666386316952373644886367601067639538435511328875578111313507804333236413595323851672437342906978654624082015584969147016118358685644525710591604212444576378649957985369742169965571282944581336873961524078849506868840212988217246723168750947170868834263585239768592166304488082428998836788690037084234273668740247284858651061564029780768978530663053893783468283981333638372026457086457504452676555823252793501250656 # とってきた値      
def gcd(a, b):      
	while b:      
		a, b = b, a % b      
	return a      
key = gcd(enc-1, enc2-1)      
ans = key.to_bytes(200,'big')      
print(ans)      

得られた結果は以下の通りです。gcdは高速なのでそれほど時間はかかりませんでした。

b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00KosenCTF{Th15_15_t00_we4k_p41ll1er_crypt05y5tem}'      

flagが得られました。
flagから推察するに、Paillier暗号なるものがあるそうです。flagを得る前に、mitsuさんが問題で扱われている暗号に加法準同型性があることを指摘していました。流石mitsuさんだ…
KosenCTF{Th15_15_t00_we4k_p41ll1er_crypt05y5tem}

saferm

[434pts, 13solved, medium, forensics, reversing]
ディスクイメージが与えられるので、調べてみます

$ file disk.img  
disk.img: DOS/MBR boot sector; partition 1 : ID=0x83, start-CHS (0x0,0,2), end-CHS (0x1,70,5), startsector 1, 20479 sectors  

USBデバイスとのことなので、ファイルシステムがFATかな?と思って fatcat を試しましたがうまく行かず。
そこで、7zipで無理やり解凍してみました。

$ 7z x disk.img  
  
7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21  
p7zip Version 16.02 (locale=en_US.UTF-8,Utf16=on,HugeFiles=on,64 bits,12 CPUs Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz (906EA),ASM,AES-NI)  
  
Scanning the drive for archives:  
1 file, 10485760 bytes (10 MiB)  
  
Extracting archive: disk.img  
--  
Path = disk.img  
Type = MBR  
Physical Size = 10485760  
----  
Path = 0.img  
Size = 10485248  
File System = Linux  
Offset = 512  
Primary = +  
Begin CHS = 0-0-2  
End CHS = 1-70-5  
--  
Path = 0.img  
Type = NTFS  
Physical Size = 10485248  
File System = NTFS 3.1  
Cluster Size = 4096  
Sector Size = 512  
Record Size = 1024  
Created = 2019-06-20 21:30:30  
ID = 5966558221663459341  
  
Everything is Ok  
  
Folders: 3  
Files: 14  
Alternate Streams: 2  
Alternate Streams Size: 262428  
Size:       2319696  
Compressed: 10485760  

File System = NTFS 3.1が見えますね。どうやらNTFSのようなので、別のツールを探します。
探してみると、RecuperaBitが見つかりました。このツールを使って、document.zipというファイルが復元できました。
しかし、zipは開けません。7zipで展開した時のsafermというファイルに注目します。ためしに適当にa.txtというファイルを作ってsafermを使ってみます。

$ ltrace ./saferm a.txt  
fopen("a.txt", "rb+")                                      = 0x5653ddbd4260  
fopen("/dev/urandom", "rb")                                = 0x5653ddbd4490  
fread(0x7fff8571ac98, 8, 1, 0x5653ddbd4490)                = 1  
fclose(0x5653ddbd4490)                                     = 0  
fread(0x7fff8571acd8, 1, 8, 0x5653ddbd4260)                = 0  
fclose(0x5653ddbd4260)                                     = 0  
unlink("a.txt")                                            = 0  
+++ exited (status 0) +++  

unlink(削除処理)の前になにかしているようです。どうなっているのか知るために、radare2を用いてunlink部分をffで潰します。safermを実行するとSEGVしますが、unlink以降の流れは無視して良いのでOKです。ltraceを見るに、8byte区切りで何かが行われてそうなので、8文字区切りでわかりやすくなるようa.txtを作り、$ ./saferm a.txtします。

12345678876543219  

が、

����7,)����2) 9  

のように変化しました。
Ghidraで見てやると、以下のような関数safermを発見します。

​  
void saferm(EVP_PKEY_CTX *pEParm1)  
​  
{  
  int iVar1;  
  undefined4 extraout_var;  
  EVP_PKEY *pkey;  
  EVP_PKEY_CTX *ctx;  
  long in_FS_OFFSET;  
  ulong local_30;  
  FILE *local_28;  
  ulong local_20;  
  size_t local_18;  
  long local_10;  
    
  local_10 = *(long *)(in_FS_OFFSET + 0x28);  
  pkey = (EVP_PKEY *)&DAT_00100af4;  
  ctx = pEParm1;  
  local_28 = fopen((char *)pEParm1,"rb+");  
  if (local_28 == (FILE *)0x0) {  
    perror((char *)pEParm1);  
  }  
  else {  
    iVar1 = keygen(ctx,pkey);  
    local_20 = CONCAT44(extraout_var,iVar1);  
    while( true ) {  
      local_18 = fread(&local_30,1,8,local_28);  
      if (local_18 != 8) break;  
      local_30 = local_30 ^ local_20;  
      fseek(local_28,-8,1);  
      fwrite(&local_30,8,1,local_28);  
    }  
    fclose(local_28);  
    iVar1 = unlink((char *)pEParm1);  
    if (iVar1 != 0) {  
      perror((char *)pEParm1);  
    }  
  }  
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {  
                    /* WARNING: Subroutine does not return */  
    __stack_chk_fail();  
  }  
  return;  
}  

本質的には

while( true ) {  
      local_18 = fread(&local_30,1,8,local_28);  
      if (local_18 != 8) break;  
      local_30 = local_30 ^ local_20;  
      fseek(local_28,-8,1);  
      fwrite(&local_30,8,1,local_28);  
    }  

ここが大事で、要はある定数local_20(=Cとおく)と、消したいファイルから8byteとってきたものをXORして元のファイルを書き換えているという処理がされています。
この定数がわかれば、X xor C xor C = Xより、元のzipが復元できそうです。
定数はランダムなので全探索を考えましたが、計算量は256^8と厳しいので、推測できないか考えます。
いくつかの種類のファイルでzipを作り前の8byteを見たところ、

50 4b 03 04 14 00 08 00  

となっていたので、これになるような定数を探すことにしました。前の8byteだけ見ればよくて、定数C

C = [46, 87, 173, 46, 255, 200, 194, 73]  

となります。これを用いて8byteずつ読み込んでXORして新しいファイルに書き込むスクリプトを書きましたが、それでもうまくいきません。hexdump -C edit.zipとみてやると、locumentなどの文字が見えたのでこれがdocumentとなるようにCを修正しました。(以下のスクリプトのxor_vecにあたります)

xor_vec = [46, 87, 173, 46, 255, 200, 202, 73]  
import struct  
f = open("document.zip", "rb")  
x = list(f.read())  
outdata = bytearray([])  
print(x)  
for i in range(len(x)//8):  
    part = x[8*i:8*i+8]  
    xo = [a^b for (a,b) in zip(xor_vec,part)]  
    print([chr(a) for a in xo])  
    outdata.extend(xo)  
outdata.extend(x[8*i+8:])  
f.close()  
outfile = open("outfile.zip", "wb")  
outfile.write(outdata)  
outfile.close()  

zipを解凍すると、ねこちゃんがいるpdfが出てきました。
KosenCTF{p00r_shr3dd3r}

Survey

[212pts, 37solved, warmup, survey]
Surveyが時間によらないとアナウンスされていたので、ゆっくり埋めることができました。終わった後だと埋める気にならないので、競技中にアンケートをフェアに行うのはとても良いシステムだと思います。
KosenCTF{th4nk_y0u_f0r_pl4y1ng_InterKosenCTF_2019}

E-Sequel-Injection

[500pts, 10solved, hard, web]
個人的に好きな分野です。いくつかの単語が使用禁止されているときに、それをBypassしてください。というjail escape系と解釈しています。(自分の中では)
まず、方針を考えると、adminとしてログインするのが最終目標なので、
1.adminのpasswordのleak->usernameでリークさせられないだろうか?
2.adminとして、後ろでなんとかする
の2つが思い浮かびました。
次に、与えられたソースコードをよく眺めます。

$pattern = '/(\s|UNION|OR|=|TRUE|FALSE|>|<|IS|LIKE|BETWEEN|REGEXP|--|#|;|\/|\*|\|)/i';  

ここで禁止される単語がわかりますね。ORが潰されているのでpassw"or"dもダメです。というわけで、1.の方針はやばそうです。
passwordだけでなんとかしてみます。SQLiについての記事を見ると、比較系は以下のようになっています

= (comparison), <=>, >=, >, <=, <, <>, !=, IS, LIKE, REGEXP, IN  
BETWEEN, CASE, WHEN, THEN, ELSE  
NOT  
AND, &&  
XOR  
OR, ||  
= (assignment), :=  

IN, NOTあたりが使えそうですね。
実際にいろいろ打ち込んでいきます。

' or 1=1 # 定番。スペースとシャープとイコールとORで引っかかります。
'or(1=1)# スペースなしで出来ました
'IN('Tokyo')# #だけでできるようになりました
'IN('Tokyo')order by 'Tokyo' orが入っています
'IN('Tokyo')and!'0' これで通りました。 -> SELECT username from users where username='admin' and password=''IN('Tokyo')and!'0'
流れとしては、まずは禁止文字を使って、徐々に禁止文字の種類を減らすように頑張っていくとよさそうです。
KosenCTF{Smash_the_holy_barrier_and_follow_me_in_the_covenant_of_blood_and_blood}

最後に

もっともっと精進して、一つでも多くのflagを速く通せるようにがんばりたいです。悔しいです。
作問者はすごい人々だなあと思いました。ツールゲーではなく、頭をひねるものが多くてとても楽しかったです。ありがとうございました。