SECCON 2019 国内本選 writeup

チーム「yharima」で、 SECCON 2019 の国内本選に出ていた。結果は 7 位。僕自身は、比較的解きやすい問題に手を付けたものの、それでも難易度が高くなかなかに手が出せる箇所が少ない状態が続いていた。解いたものについて write up。

Factor the flag

大きな数が一つ書いてあり、「大きな素数の中にフラグを隠した」と問題文が主張しているので、まずは素因数分解を試みた。13 と 97 で割れたが、その後残った数字の約数はざっくり探した範囲だと見つからなかった。factordb は「Probably Prime Number」という判定になっており、ほとんどの桁が 1 か 9 で例外は最後の 3 桁のみだった。

色々試みたものの、数全体をバイナリと解釈したり 1 と 9 を二進数の二文字とみなしたりしても “SECCON{” に当てはまりそうな解釈の仕方も見当たらなかった。ふと、 9 がたいてい何回か連続していて 9 が妙に少ないことなどが気になったのと、テキストエディタで狭目の幅で開いたりすると微妙にパターンがありそうに見えたので、 1 と 9 は白と黒として解釈することを試そうと思い、幅を変えながら 1 をスペース、9 を # にして表示していくと、途中でフラグが出てきた。

                                                                                                 |
                                       ##                                                 ##     |
 ###  #####  ###   ###   ###  #   #   #   #####  ###    ##   ###   ###  #####       ####    #    |
#   # #     #   # #   # #   # ##  #   #   #     #   #   ##  #   # #   #     #       #   #   #    |
#     #     #     #     #   # # # #   #   #     #   #  # #  #   # #   #    #        #   #   #    |
#     #     #     #     #   # #  ##   #   ####      #  # #      #  # #     #  ####  #   #   #    |
 ###  ####  #     #     #   # #   # ##        #    #  #  #     #    #     #   # # # ####     ##  |
    # #     #     #     #   # #   #   #       #   #   #  #    #    # #    #   # # # #       #    |
    # #     #     #     #   # #   #   #   #   #  #    #####  #    #   #  #    # # # #       #    |
#   # #     #   # #   # #   # #   #   #   #   # #        #  #     #   #  #    # # # #       #    |
 ###  #####  ###   ###   ###  #   #   #    ###  #####    #  #####  ###   #    # # # #       #    |
                                       ##                                                 ##     |
import sys
import math


num=1401111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111222079111111111111111111111111111111111111111111111222079112230890319889030880230880230889200120012002319889030879222080230880230890319887911122318879211992120012999912120013000013000013100892001200119912120012002208920013000011991211991112120012001199211991211991211991211991212001301010000120011991212001209300092001300001199211991111212001200119921199121199121199121199121200130010208012002318879112120929999112120929999121210318889200120011991223089031888919912119912120013000013100791111211992120920919912119921199121200130101111887912220791121299991211991211991212001300001200119911121200120012091992119921299992120013010099991112119911112129999121199121199121200130000120012001200120919922319888919921200120919921301009999111211992120012999912120013000013000013000012001200120012999911112120919912120012091992130100999911121199122308903198890308802308802308892001200119922308903198879212103198890308801199213010099991112119911111111111111111111111111111111111111122207911111111111111111111111111111111111111111111122207911111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111239593

i = 1
while num != 1 and i < 100000:
    i += 1
    while num %  i == 0:
        print i
        num = num / i

cand_ramaining = -1

for i in range(0, 256):
    if (i * ord('}')) % 256 == num % 256:
        cand_remaining = i

#print cand_remaining
print num

s = str(num)

for i in range(2,len(s)):
    print i
    for j in range(1,len(s)):
        if s[j] == '9':
            sys.stdout.write('#')
        else:
            sys.stdout.write(' ')

        if j % i == 0:
            sys.stdout.write('|\n')
    a = raw_input()

num2 = 0
while num > 0:
    d=0
    if num % 10 == 9:
       d=1
    num2 = num2 * 2 + d
    num /= 10

num2 = num2 / 128
a = []
while num2 > 0:
    a.insert(0, num2 % 256)
    num2 = num2 / 256

#for i in range(0, len(a)):
#    sys.stdout.write(chr(a[i]))
exit()

lo = 1
hi = num

while lo < hi:
    mid = (lo + hi ) /2
    if mid * mid == num:
        lo = mid
        hi = mid
        break
    if mid * mid < num:
        lo = mid + 1
    else:
        hi = mid - 1
i = hi + 1

while num != 1:
    while num %  i == 0:
        print i
        num = num / i
    i -= 1

syzbot panic

基本的に syzbot 関係の問題が書いてあり、 Q1 – Q5 の答えをつなぎ合わせたものがフラグ、という問題。Q1 は syzbot 自体の管理アプリの URL (の FQDN)を答える問題で、 Q2-Q5 は syzbot で見つけたバグや syzbot 自体に当てられたパッチのハッシュの先頭 10 文字を答える問題。

Q1 は syzbot で検索すると https://syzkaller.appspot.com/ にあるようだったが、 https://www.syzkaller.appspot.com/ にもヒットした。ただし、 www. がついている方がサーバが見た目としては FQDN っぽさはあるものの、後者は TLS 証明書が正しく認識できず、ドメイン名自体は前者の方が正しいと言えるように思えた。

フラグの形式が SECCON{$answer_for_Q1+$answer_for_Q2+$answer_for_Q3+$answer_for_Q4+$answer_for_Q5}と示されており、これが 73 文字と示されていた。Q2-Q5はそれぞれ git のハッシュの先頭 10 文字なので、数えてみると syzkaller.appspot.com が正解なら “+” は必要、 www.syzkaller.appspot.com が正解なら “+” は結合を示すために書いてあるだけで不要ということになる。+ は直観的には書かないのではないかと思っていたが、わざわざ $ で、文字列に埋め込める変数名っぽい書き方をしているあたりどちらの可能性もあった。結局、二通りならどちらも試せばよいか、ということで提出する時に考えることにした。結論としては syzkaller.appspot.com にして、 + はつける、が正解だった。

Q2 はマルチスレッド環境下でカーネルのメッセージを正しく読めないものに対する、 Tetsuo Handa 氏によるパッチのハッシュを答えよ、というものだった。

最初に 検索すると LKML に LKML: Tetsuo Handa: Re: [PATCH] x86: Avoid pr_cont() in show_opcodes() という投稿が出てきて、これがあてはまりそうだと判断していた。その後 Q4 について調べているときに、セキュリティ・キャンプ全国大会2019 解析トラック E5 The SYZBOT CTF 2019.8.5 という記事に関連する記述があることを見つけ、特に Q4 も Tetsuo Handa 氏が関わっている話題であることがわかったこともあり Q2, Q4 ともこの記事の関係者による作問であることが推測できた。

そのため、 Q2 は x86: Avoid pr_cont() in show_opcodes() · torvalds/linux@8e974b3 のパッチを当初はフラグとして送ろうとしたが、不正解だった。調べなおしたところ、 kernel/git/torvalds/linux.git – Linux kernel source tree に直したら通った。

Q3 についてはなかなかよくわからなかったが、他の Q について調べている間に他のチームメンバーが BUG: workqueue lockup (2)kernel/git/torvalds/linux.git – Linux kernel source tree が関連しそうとのリンクを見つけて Discord に貼ってくれたので、これで提出した。

Q4 については Q2 で挙げた セキュリティ・キャンプ全国大会2019 解析トラック E5 The SYZBOT CTF 2019.8.5 が見つかり、これの 6.1 章のリンク先から Re: INFO: task hung in __sb_start_write – Tetsuo Handaprog: sanitize calls after hints mutation · google/syzkaller@06c33b3 に辿り着き、このコミットで提出した。

Q5 については struct とか syzbot とかのキーワードで色々検索していた結果、 LKML: Dmitry Vyukov: Re: BUG: unable to handle kernel NULL pointer dereference in mem_serial_out というページが見つかり、 Q2 や Q4 と同じく Tetsuo Handa 氏によるメールが出てきたうえに当該の原因の特定はわずか数日前の新鮮なネタであることがわかった。リンク先の https://github.com/google/syzkaller/blob/master/sys/linux/dev_ptmx.txt.warn#L20-L25 自体は該当部分ではなかったものの、ここ一週間とかのことのようなので、この syzkaller リポジトリのコミット履歴を新しいほうから見たところ、 tools/syz-check: add description checking utility · google/syzkaller@64ca0a3 が見つかった。これで提出。

SECCON{syzkaller.appspot.com+15ff2069cb+966031f340+06c33b3af0+64ca0a3711}

壱(1)

Jeopardy 以外の問題は記憶の限りでは名前はなく、番号だけだったが、一度 TCP に繋いだ後 UDP のパケットを指示に従って送り続ける問題。

問題のサーバのチームごとに一位に定まるポート番号(yharima チームは 25000)に接続する案内と、プロセスのバイナリが置いてあり、問題文に「全チームが点数を得られなくなった場合とトラブルを除いて、チームのサービスプロセスがおかしな状態になってもプロセスは再起動しない」旨の警告があった。

実際に 25000/tcp に接続すると、トークンを聞かれ、適当に答えると次につなぐべき(今度は UDP の)ポートを案内された。またこの時点で、 Attack Flag は降ってきた(Attack Flag: SECCON{P13453 +4K3 C4R3})。しかし、一度繋いでポート番号を得るとそのチームは次に 25000/tcp に繋いでも何も出ないようだ。

案内された次に繋ぐべき UDP ポートにトークンを送るとまた次のポートが案内され、しばらく手動で繰り返すと “You Lose!” という案内が来た。

バイナリを読んでいた yuta1024 とこの話をしていたところ、どうやら制限時間があるようだった。

しかし、このプロセス、一度他のポートで待ち受けている状態になると 25000/tcp では繋がりすらせず、さらに言えば You Lose! のあとも(この時点で逆アセンブリされたコードを読んだ yuta1024 は You Lose! のあとは元に戻るはずと言っていたし、後から Hopper で逆アセンブルした僕が見ても元に戻るはずだと判断したが、この時は何が起こっているかわからなかった)一切応答がなくなって何もできなくなった。再起動はしない、の警告の理由がようやくわかったが、要はどうにもならないのか、とこの時は判断した。しかし、しばらく放置するとなぜか接続が復活した(今から考えればプロセスが再起動されていると判断されることが、これに限らず何度かあったが、条件は謎)。ただ、どういう条件で復活するのかは全くわからなかった。幸い、 25000/tcp が割り当てられたチームに対しては 25000-25999 の udp しかこないことだけはわかった。

とはいえ、偶然の復活に期待して進めていたところ、ある程度自動化して20秒(これが繰り返すと縮まることを、この時はまだ知らない)以内に10回返信すれば、最初に指定したトークンが Defense Flag (5分ごとにチームごとの Defense Flag が特定の場所にあるかチェックされ、特定の場所に含まれていると点数が入る。チェック作業が完了するとチームごとに書き込むべき Flag は変更される。これ自体は参加者全員に対し、全チーム分が公開情報。)として追記される。すなわち、解き方自体は、「自分のチームの Defense Flag を取得し、それを認証トークンとして 25000/tcp に投げたあと、指示されるポートに投げては返信されるポートに追随する」を繰り返すことであることがわかった。ただし、一日目はサーバの状態を見失い、できることがなくなった。

二日目の開始時刻である 10:00 になり、一度でもおかしなことをすると二度と解けなくなるリスクを認識していたのでプロセスが不完全な状態でパケットを投げることを恐れてしばらく待ってからここまでの成果を投げたところ、これはうまくいった。しかし、しばらくするとこれも動かなくなっていた。いつの間にか制限時間が20秒から1秒になっていた。結局色々追うと、プロセスが生きている限り一度繋ぐたびに制限時間が1秒ずつ減る(1秒になったらそれ以上は減らない)ようだった。

また、見失う現象についてもわかったことがあった。 25000/tcp で accept して、次のポートを案内した後は、 10 回謎ポートでトークンつきでノックするまで 25000/tcp で次の accept をしないので、変なパケットがサーバ側のキューに残っているとクライアント側でまた新規受付扱いされてしまい、10 回謎ポートをやり直ししないといけなかった。幸い、 25001-25999 であることはわかっていたし、運よく再度つながる状態に戻ったタイミングの繰り返しで培った謎ノウハウでトークンもローカルのファイルに都度保存するようにしたので、 25001-25999 の全ポートにトークンを投げつつ一度でもヒットすればあとは次のポートへの接続をする「復活スクリプト」も温まってきた。偶然失敗したがポート番号だけはわかっている場合用に、もっと速いスクリプトも用意した。少しミスしただけでも 1000 ポートに対する試行、しかも失敗した時点での Defense Flag の候補が複数ある場合はその数だけ試す必要があるが、それでも手も出せない状態と違って自力で復活を試みれた。

ただし 1 秒の制限は結局突破できなかった。2秒になるまでは進んだし、その状態でローカル計測で運が良ければ 0.9 秒までは短縮できたものの、実際にそれで 1 秒制限を通すことはできなかったようで、制限が 1 秒になってからは一度も突破できなかった。

CTF オンサイト会場は東京の秋葉原にあり、チームに割り当てられた IP アドレスはオンサイト会場に設置されたチーム用のルーターから接続する必要があった。一方でこの LAN からインターネットに出た場合のグローバル IPv4 アドレスは whois でも SAKURA-ISHIKARI という netname がついており、 CTF のサーバもこの部屋のネットワークの経路も何もかも北海道石狩市にあることが容易に想像できた。この問題のサーバへの RTT も 20ms を超えており、加えて前のポートへの接続で次のポート番号を経てから 59ms 程度 sleep しないと次のポートへの接続が失敗することがある(次のポート番号を送ってから、実際に bind するまでに時間がかかる。UDP のためクライアント側もそれに気づかない。短めでも毎回は起こらないが、なんとか一度も起こさず通せると判断したラインがローカルでは 59ms だった)ことも試行でわかり、それだけで 10 往復で 0.9 秒以上かかるのでかなり絶望的だった。何度かローカル計測で 0.95 秒未満までは達成した気がするが、結局のところ、ラップトップの省電力を解除したり、仮想マシンのネットワークを NAT からブリッジに切り替えたりしても、ついに 1 秒制限で実際に通すことはできなかった。シェルで書いていたかつ失敗の復旧に時間がとられていて C 言語への書き換えなどの作業をするどころではなかったが、何らかの書き換えを行い、投機的にパケットを何度か投げて全部のパケットを後から回収すれば 1 秒制限でも可能性はあったかもしれない。そしてそうこうしているうちにうちのチームのサーバがおかしな状態になり、どの defense flag を token としているうちにおかしくなったのかもよくわからなくなり色々試しているうちにコンテストが終わってしまった。

最終的に使った(2秒制限までは通ったが、1秒制限は通らなかった)コードは以下の通り。

#!/bin/bash -x

sync

# PHPSESSID は今となっては何の価値もないセッション ID のはずではあるが、コンテストの説明で何度もスコアページの権限を厳重に管理するよう何度も二か国語で説明があったので、コンテストの趣旨に則り伏せた。
defense_key=$(curl -b 'PHPSESSID=CENSORED' http://score-dom.seccon/flagwords/ | grep yharima | grep -v 'Team Name' | sed -e 's/<th>yharima<\/th><td>//' -e 's/<\/th><\/tr>//')

echo $defense_key
echo $defense_key >> defense_keys.txt

echo $defense_key | timeout 2 nc 10.1.1.1 25000 > output.txt
cat output.txt
cp output.txt output2.txt
cat output.txt >> output_all.txt

port=$(cat output.txt | grep 'first port is' | sed -e 's/^.*first port is //' -e 's/, time .*$//')
echo $port

start_time=$(date +%H:%M:%S.%N)

while true
do
  flag=F
  for i in 1 2 3
  do
    portstr=$(echo $defense_key | timeout 0.11 nc -u 10.1.1.1 $port | dd bs=18 count=1)
    #echo $portstr >> /dev/shm/output_all.txt
    if [ "a$portstr" != "a" ]
    then
      break
    fi
    echo -e '\e[31mRETRY\e[m'
  done
  port=$(echo -n $portstr | tail --bytes +14)
  echo $port
  if [ "x$portstr" = "x" ]
  then
    echo -e '\e[31mSURVEY NEEDED\e[m'
    exit 1
  fi
  if [ "$portstr" = "You Won!" ]
  then
    echo $start_time
    date +%H:%M:%S.%N
    echo -e '\e[32mSUCCEEDED\e[m'
    exit 0
  fi
  if [ "$portstr" = "You Lose!" ]
  then
    echo $start_time
    date +%H:%M:%S.%N
    echo -e '\e[31mYOU LOSE\e[m'
    exit 1
  fi
  sleep 0.059
done

なお、懇親会中に、このコンテストに関わった Network Operation Center の方々の紹介の時間があり、その際に改めて思い出したが、このコンテストのネットワーク環境は極めて安定しており、快適だったと感じる。インターネット接続でストレスを感じることがなかったし、コンテスト関係のシステムに繋ぎに行くのにもストレスを感じることもなかった。しかし、改めて思えば、東京都から北海道石狩市は通常でも 20ms 以上の RTT が必要な距離なのに、 UDP の問題を純粋に解くコードも回復スクリプト等も timeout 0.12 nc -u 10.1.1.1 $port_num のような感じで「東京秋葉原から UDP パケットを投げ、 120ms 以内に石狩市のサーバから返事が返ってこないと失敗扱い(しかも、失敗が正しいと見做して次に進むので、あとになって結果が間違いとわかる)」という処理を乱発しても、気付く範囲での問題が発生しない程度の安定したネットワークが保たれていた。コンテスト中はぎりぎりまで詰めることしか頭になかったが、冷静に考えたら 120ms で何もかも捨てるスクリプトは色々おかしいし、これが動いたのはネットワークが相当安定していたからだと思う。このコンテストの関係者の方は全員に感謝したいが、中でもこのネットワーク管理(しかも今回からクラウド移行したという)の関係の方には賛辞を送りたいと思う。

(追記)チームメンバーの write up

SECCON CTF 2019 国内決勝 writeup – yuta1024’s diary
SECCON 2019 国内決勝 writeup – liniku’s blog