本日も乙

ただの自己満足な備忘録。

AWS LambdaでVulsを使おうと試みた話

[toc]

本記事はVuls Advent Calendar 2016 18日目の投稿記事です。

概要

本記事は、脆弱性検知ツールVulsをサーバレスで実行しようと試みた内容です。 結果としては、AWS Lambda(以下、Lambda)で実行するには様々な制約があって難しいことがわかりましたが、他の方がさらに発展させてくれると信じてここに公開します。

構成

以下のような構成を考えていました。

  • Amazon CloudWatch Events ・・・ スケジューラ(いつ実行するかを決める)
  • Lambda ・・・ 脆弱性情報取得 & スキャン実行
  • S3 ・・・ 脆弱性情報(SQLite)やスキャン結果を保存

VulsはGo言語で書かれていますが、LambdaはNode.js、Java、C# および Pythonでしか動かないため、Vulsをビルドしてバイナリ化することで、Lambda上で実行しようと考えました。

Lambdaで実行するうえでの制約条件

ここで、Lambdaを使ううえでの制約を見ていきましょう。

AWS Lambda の制限

ドキュメントを読むと、問題となるのが一時的に保存できるディスク容量と最大実行時間であることがわかります。

  • 一時ディスク容量 ("/tmp" スペース) ・・・ 512MB
  • 最大実行時間 ・・・ 300秒

一回の実行につき、300秒(=5分間)しか実行できないので、長時間にわたるスキャン実行はできません。また、脆弱性データベースファイル(SQLite)は500MBを超えると取得ができないため、それを超えない範囲で取得しなければなりません。

前準備

さて、ここから設定に入りますが、いくつか準備が必要です。

  • 秘密鍵・公開鍵の作成
  • スキャン用ユーザの作成(vulsユーザ)
  • VPC、サブネット、セキュリティグループの設定
    • Lambdaから各サーバにSSHできるように設定しておく

秘密鍵の作成

パスフレーズなしの秘密鍵を生成します。

$ ssh-keygen -t rsa -b 2048 -C "Vuls scan only"

スキャン用ユーザの作成(vulsユーザ)

スキャン対象サーバにスキャン専用のユーザを作成します。

CentOS

# rootユーザで実行
$ useradd -d /opt/vuls -s /bin/bash vuls

$ su - vuls
$ mkdir .ssh
$ chmod 700 .ssh

# 先ほど作成した公開鍵をもってくる
$ vim .ssh/authorized_keys
$ chmod 600 .ssh/authorized_keys
$ exit

# rootユーザで実行
$ visudo

# 以下を追加
vuls    ALL=(root)      NOPASSWD: /usr/bin/yum, /bin/echo

Ubuntu

# rootユーザで実行
$ useradd -m -s /bin/bash -d /opt/vuls vuls

$ su - vuls
$ mkdir .ssh
$ chmod 700 .ssh

# 先ほど作成した公開鍵をもってくる
$ vim .ssh/authorized_keys
$ chmod 600 .ssh/authorized_keys
$ exit

# rootユーザで実行
$ visudo

# 以下を追加
vuls    ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/bin/apt-cache

Amazon Linux

# rootユーザで実行
$ useradd -d /opt/vuls -s /bin/bash vuls

$ su - vuls
$ mkdir .ssh
$ chmod 700 .ssh

# 先ほど作成した公開鍵をもってくる
$ vim .ssh/authorized_keys
$ chmod 600 .ssh/authorized_keys

IAMロールの作成

Lambda Functionに付与するIAMロールを作成します。 権限は以下のとおり。lambda_vuls_executionという名前で作成します。 VPC内で実行するためENI関係の権限が必要なので付与しています。 また、後述しますが、AWS Key Management Service (KMS)で秘密鍵を復号するのでその権限も必要です。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmtxxxxxxxxxxxxx",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Sid": "Stmtxxxxxxxxxxxxx",
            "Effect": "Allow",
            "Action": [
                "ec2:CreateNetworkInterface",
                "ec2:DeleteNetworkInterface",
                "ec2:DescribeNetworkInterfaces"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Sid": "Stmtxxxxxxxxxxxxx",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Sid": "Stmtxxxxxxxxxxxxx",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt",
                "kms:DescribeKey",
                "kms:GetKeyPolicy"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

S3バケットの作成

脆弱性情報ファイルやスキャン結果を格納するS3バケットを作成します。 各々セキュリティ要件や環境に応じてアクセス制限をかけてください。

秘密鍵をKMSに登録

秘密鍵をLambda Function内に仕込むのは危険なので、KMSで暗号化して、実行時のみ復号するようにします。 KMSへの暗号化手順は以下の記事を参考にしました。 KMSで認証情報を暗号化しLambda実行時に復号化する | Developers.IO

$ aws kms encrypt --key-id alias/vuls --plaintext fileb://id_rsa --region ap-northeast-1
{
    "KeyId": "arn:aws:kms:ap-northeast-1:xxxxxxxxxxxx:key/xxxxxxxxxxxxxxxxxxxxxxxx",
    "CiphertextBlob": "xxxxxxxxxxxxxxxxxxxxxx"
}

CiphertextBlobをメモっておいてください。

Lambdaの実行環境構築

Vulsをバイナリ化したり、Lambda Functionのパッケージ化をするサーバを作ります。 Lambda 実行環境と利用できるライブラリにAMI名(amzn-ami-hvm-2016.03.3.x86_64-gp2)が記載されているのでそれを使います。インスタンスタイプはt2.microで十分です。

$ ssh -i .ssh/<キーペア>.pem ec2-user@xxx.xxx.xxx.xxx


       __|  __|_  )
       _|  (     /   Amazon Linux AMI
      ___|\___|___|

https://aws.amazon.com/amazon-linux-ami/2016.03-release-notes/

lambda-uploaderのインストール

Lambdaで実行するデプロイパッケージを作成するために今回はlambda-uploaderを使いました。シンプルに使えるのがとても良いです。

$ sudo pip install lambda-uploader
$ git clone https://github.com/rackerlabs/lambda-uploader
$ cd lambda-uploader
$ python setup.py install
$ lambda-uploader --version
1.0.3

Go言語のインストール

今日時点(2016/12/18)で最新版の 1.7.4 をインストールします。

$ wget https://storage.googleapis.com/golang/go1.7.4.linux-amd64.tar.gz
$ sudo tar -C /usr/local -xzf go1.7.4.linux-amd64.tar.gz
$ echo 'export GOROOT=/usr/local/go' >> ~/.bashrc
$ echo 'export GOPATH=$HOME/go' >> ~/.bashrc
$ echo 'export PATH=$PATH:$GOROOT/bin:$GOPATH/bin' >> ~/.bashrc
$ source ~/.bashrc
$ go version
go version go1.7.4 linux/amd64

go-cve-dictionaryのインストール、バイナリ化

Deploy go-cve-dictionaryを見ながらインストールします。

# sqliteはすでに入っているので不要
$ sudo yum -y install git gcc

$ mkdir -p $HOME/go
$ sudo mkdir -p /var/log/vuls
$ sudo chown ec2-user /var/log/vuls
$ sudo chmod 700 /var/log/vuls
$ mkdir -p $GOPATH/src/github.com/kotakanbe
$ cd $GOPATH/src/github.com/kotakanbe
$ git clone https://github.com/kotakanbe/go-cve-dictionary.git
$ cd go-cve-dictionary
$ make install

ビルド

$ go build -o $HOME/go-cve-dictionary
$ cd
$ ./go-cve-dictionary -v
go-cve-dictionary 0.1.1

Vulsのインストール、バイナリ化

v0.1.7をダウンロードし、ビルドします。

$ mkdir -p $GOPATH/src/github.com/future-architect
$ cd $GOPATH/src/github.com/future-architect
$ wget https://github.com/future-architect/vuls/archive/v0.1.7.tar.gz
$ tar zxf v0.1.7.tar.gz
$ rm v0.1.7.tar.gz
$ mv vuls-0.1.7 vuls
$ cd vuls
$ make install
$ go build -o $HOME/vuls
$ cd
$ ./vuls -v
vuls 0.1.7

設定ファイル

# config.toml
[slack]
hookURL     = "https://hooks.slack.com/services/xxxxxxxxxx/xxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxxx"
channel     = "#vuls"
iconEmoji   = ":ghost:"
authUser    = "Vulnerability report"

[default]
port        = "22"
user        = "vuls"
keyPath     = "/tmp/id_rsa"

[servers]

[servers.web001]
host = "xxx.xxx.xxx.xxx"

NVD、JVDから脆弱性情報を取得する

500MBを超えない範囲で取得します。

$ cd
$ ./go-cve-dictionary fetchnvd -last2y
$ ./go-cve-dictionary fetchjvn -last2y

Zipに圧縮してS3にアップロードします。

$ zip cve.sqlite3.zip cve.sqlite3
$ aws s3 cp ./cve.sqlite3.zip s3://xxxxxxxxxxx/cve/ --region ap-northeast-1

デプロイパッケージの作成

$ mkdir lambda_vuls
$ cd lambda_vuls

# 先ほど作成したgo-cve-dictionary, vulsをもってくる
$ cp ~/go-cve-dictionary ./
$ cp ~/vuls ./

ファイル構成

./lambda_vuls
├── config.toml
├── go-cve-dictionary
├── lambda.json      # 設定ファイル
├── vuls
└── vuls.py          # 実行ファイル

lambda.json(設定ファイル)

メモリが足りないとプロセスがKillされるため、設定値MAX(1,536MB)にしました。

{
  "name": "Vuls",
  "description": "Vuls scan",
  "region": "ap-northeast-1",
  "handler": "vuls.lambda_handler",
  "role": "arn:aws:iam::xxxxxxxxxxx:role/lambda_vuls_execution",
  "timeout": 300,
  "memory": 1536,
  "vpc": {
    "subnets": [
      "subnet-xxxxxxxx",
      "subnet-xxxxxxxx"
    ],
    "security_groups": [
      "sg-xxxxxxxx"
    ]
  }
}

vuls.py(実行ファイル)

S3_BUCKETは作成したS3バケット名を、ENCRYPTED_KEYはメモったCiphertextBlobの値に置き換えてください。 レポート結果をSlackに投げるようにしました。

import boto3, commands, zipfile, os
from base64 import b64decode
from os.path import basename

bucket = "S3_BUCKET"
key = "cve/cve.sqlite3.zip"
cve_path = "/tmp/cve.sqlite3"
new_cve_path = "/tmp/new_cve.sqlite3"
id_rsa_path = "/tmp/id_rsa"

encrypted_key = "ENCRYPTED_KEY"

def lambda_handler(event, context):
    s3 = boto3.resource("s3", region_name="ap-northeast-1")

    # download cve.sqlite3
    s3.meta.client.download_file(Bucket=bucket, Key=key, Filename=cve_path + ".zip")

    with zipfile.ZipFile(cve_path + ".zip", "r") as zf:
        zf.extractall(path="/tmp/")

    # fetch
    check = commands.getoutput("./go-cve-dictionary fetchnvd -last2y -dbpath=%s" % cve_path)
    print check
    check = commands.getoutput("./go-cve-dictionary fetchjvn -latest -dbpath=%s" % cve_path)
    print check

    # create zip file
    with zipfile.ZipFile(new_cve_path + ".zip", "w", zipfile.ZIP_DEFLATED) as zf:
        zf.write(cve_path, basename(cve_path))

    # upload cve.sqlite3
    s3.meta.client.upload_file(new_cve_path + ".zip", bucket, key)

    # create id_rsa
    kms = boto3.client("kms", region_name="ap-northeast-1")
    id_rsa = kms.decrypt(CiphertextBlob=b64decode(encrypted_key))['Plaintext']
    f = open(id_rsa_path, "w")
    f.write(id_rsa)
    f.close()

    # prepare
    check = commands.getoutput("./vuls prepare")
    print check

    # scan
    check = commands.getoutput("./vuls scan -config=./config.toml -results-dir=/tmp/results -cve-dictionary-dbpath=%s -report-slack -report-json web001" % cve_path)
    print check

    # delete files
    os.remove(id_rsa_path)
    os.remove(cve_path)
    os.remove(cve_path + ".zip")
    os.remove(new_cve_path + ".zip")

    return True

アップロード

$ cd lambda_vuls
$ lambda-uploader
λ Building Package
λ Uploading Package
λ Fin

課題

LambdaでVulsを使って見ましたが、幾つか課題が見つかりました。

ログ

  • /var/log/vulsはLambdaの特性上、作成できません。ディレクトリが作成できないことで落ちることはありませんが、実行に失敗した場合はエラーログではなく、CloudWatch Logsから見ることになります

実行時間

  • Lambdaは5分間しか実行できません。今回は1台のみのスキャンだったので、実行時間が短かかったのですが、十数台〜数百台になると到底5分で終わらないと思います
  • 最近、リリースされたAWS Step Functionsを使うことで、go-cve-dictionaryの実行やスキャンの連続実行などを工夫してできるようになるのではないかと期待しています

ファイルサイズ

  • 制約条件でも挙げていましたが、生成できるファイルサイズが500MBまでなので、脆弱性情報(SQLite)やスキャン結果を保存する際は容量に注意しなければなりません
  • SQLiteではなく、MySQLに置き換える手もあります。RDS(MySQL)にすれば、運用する手間が大分省けますが、かなり安く使ったとしても $20/月 程度かかってしまうので予算との相談になります(t2.micro, マグネティック5GBで使った場合)。

セキュリティ的懸念

  • 秘密鍵をKMSで暗号化・復号しているのですが、これってセキュリティ的にどうなんですかね・・・?生ファイルをパッケージに入れておくよりかは安全だと思いますが、他の方ってどうしているのでしょうか
  • また、標準化したディレクトリ構成を無視しているので、本記事を試す方は自己責任でお願いします(投げやり)

スキャンに失敗する

  • vuls scanコマンドを実行する際、ホスト名を指定しないとFailed to read stdin: read /dev/stdin: resource temporarily unavailableというエラーが出て、スキャンが失敗します。

ソースコードを見ると、以下の箇所が該当します。

https://github.com/future-architect/vuls/blob/master/commands/scan.go#L297

    var servernames []string
    if 0 < len(f.Args()) {
        servernames = f.Args()
    } else {
        stat, _ := os.Stdin.Stat()
        if (stat.Mode() & os.ModeCharDevice) == 0 {
            bytes, err := ioutil.ReadAll(os.Stdin)
            if err != nil {
                logrus.Errorf("Failed to read stdin: %s", err)
                return subcommands.ExitFailure
            }
            fields := strings.Fields(string(bytes))
            if 0 < len(fields) {
                servernames = fields
            }
        }
    }

本来であれば、パイプ(|)を使ってホスト名を渡さないと通らないはずなのですが、ここで落ちてしまいます。 きちんと調べるべきだと思いますが、今回は時間が無かったため、ホスト名を指定することでこの問題を回避しました。

ちなみに、誰かが調べてくれるかもしれないので、FileModeをデバッグした結果を貼っておきます。

# Lambdaで実行
stat.Mode() ->                     Srwxrwxrwx
os.ModeCharDevice ->               c---------
stat.Mode() & os.ModeCharDevice -> ----------

# EC2で実行
stat.Mode() ->                    Dcrw--w----
os.ModeCharDevice ->               c---------
stat.Mode() & os.ModeCharDevice -> c---------

最後に

LambdaでVulsを実行してスキャンできましたが、いくつか課題があったので、当面はサーバレスの夢を追いかけながら、実行サーバ(EC2)で運用することにします。