仮想マシンイメージの脆弱性検知をTrivyに組み込んだ話

はじめに

2022年11月にOSSのコンテナ脆弱性検知ツール Trivy に 仮想マシンイメージ(VMDKやVDIなど)の脆弱性検知機能を追加しました。

今回はこの機能を追加した苦労話や具体的な技術について解説したいと思います。

技術話を書くと、正直クソ長文章になることは明白なので 誰も読まない&書くのが辛い ということは目に見えてるのですが、備忘録も兼ねて書こうと思います。

以下、追加したPull Requestです。

github.com

リリース時の公式ブログ

blog.aquasec.com

このブログでのプログラミング用語は基本的にGo言語で説明されます。

実際に作ったもの

Aqua Security が youtube にしてくれているので貼っておきます。

ここから先が長すぎて面倒だという方向け

僕が所属する会社のグループ(課)では毎週交代制で1時間LT(1時間がライトニングなのかはさておき)をするのですが、それで発表した資料を公開しているので載せておきます。

ブログ長くて面倒だって人は以下の資料でサマってるのでそれで思います雰囲気を理解してもらえればと。

OSSに新機能を追加するまでの苦労話 - Speaker Deck

Analyze Filesystem in Virtual Machine Image - Speaker Deck

なぜVMのスキャンが必要なのか?

なぜ必要なのかは、Aquaの中の人がブログ?に書いてたので引用します。

"With cloud native attacks on the rise, it’s vital that developers are able to automate security scans."

While Trivy is widely popular for scanning container and cloud native workloads, some organisations, and some applications still run on Virtual Machines (VM).

英語が読めないのでDeepLを使いましたが、まだまだVMも世の中には沢山稼働していて、それの検知もしたい!ということらしいです。

全体像

簡単に開発した機能について説明します。 機能についてはTrivyのリリースノートから抜粋します。

v0.35.0 · Discussion #3231 · aquasecurity/trivy · GitHub

Trivy now scans AWS AMI images and EBS snapshots. You just need to specify your AMI ID with the ami: prefix.

はい。実はファーストターゲットは AWS AMI の脆弱性検知をターゲットにしています。理由は世の中に存在する数多あるVMDK等の仮想マシンイメージのスキャンは絶望的だからです。

現時点(Trivy v0.35.0)では安定動作するのは AWS AMIと AWS Instanceを VMDKにExportしたもののみです。 なぜ初期仕様が AWS AMI にフォーカスしているのかも後述で説明しようと思います。

まず始めにTrivyの動作について紹介します。

Trivyの脆弱性検知について

簡単にTrivyの脆弱性検知に対するアプローチを説明すると、Docker Imageやある特定のリポジトリ内に存在する、パッケージ管理システムのファイルを解析することによって脆弱性検知をします。

例えば RedHat系のOSに yumコマンドでインストールされるパッケージの場合、/var/lib/rpm/Packages コマンドを解析することでインストールされているパッケージを取得し、公開されている脆弱性情報とマッチングして検知します。

Rubyのパッケージの場合、gemspecファイルや、Gemfile.lockを解析することで、インストールされているパッケージを取得し、公開されている脆弱性情報とマッチングして検知します。

開発する上での課題

最も大きな課題は、仮想マシンイメージなどを対象とした場合に、解析するにあたっての画一された手法が存在せず、現状仮想マシンイメージを対象としたSCAツールも存在していないことです。 (コピペエンジニアとって、パクる対象がないのは辛いところです)

その他にも以下のような課題がありました。

課題1. 実装が困難

仮想マシンイメージを解析するためには以下のように多層のレイヤーを読み取る必要があります。
Virtual Image → Disk Partition → Filesystem → File

Trivyでは全てのユーザ環境で動作できるよう極力アーキテクチャ依存の実装を避ける方針にあります。 例えば、rpmのパッケージを解析するために rpmdb のパーサをGo言語で書き直す など @knqyf263 の執念を感じる実装が多いです。

そのため、各層の解析にはC言語の資産を使いたくないというモチベーションがありました。(C言語の資産を使っても面倒ですが)

課題2. リソース上の課題

仮想マシンイメージをディスク上に解凍すると 数百MB ~ 数GB から 数十GB ~~ のデータ量になるため、Trivyの実行環境として推奨されている CI/CDパイプラインでの利用が困難になります。

そのため、どうにかして圧縮されている仮想マシンイメージから いい感じファイルシステムを解析し必要なファイルを取り出す処理が必要になります。

アーキテクチャ

仮想マシンイメージの脆弱性検知について説明していきます。

ざっくり言うと、仮想マシンイメージの中に存在するファイルシステムをパースして、脆弱性検知に必要なファイルを抽出する機能です。

全体のアーキテクチャはこのような感じです。

仮想マシンを以下の6つの層に分解しています。

  1. Storage層
  2. Virtual Machine Image層(Virtual Diskが厳密かもしれない)
  3. Disk Partition層
  4. Logical Volume層
  5. Filesystem層
  6. File層

全体の方針としては、全てのレイヤーを透過的に io.ReadSeeker インターフェースを通じてアクセスできることにしています。 また、全ての実装においてGo言語で書き直すことにしました。

各層について詳細を説明していきます。

Storage層

Storage層はスキャン対象のファイルがどのインフラ上(バックエンド)に展開されているかによって動作を変えるものです。例えばローカルストレージ上にファイルとして展開されている場合は、 io.ReadSeeker インターフェースを満たす os.File 構造体 を利用します。

その他にも、AWS上のEBSにイメージが配置されている場合などを想定した層になります。 

現時点でサポートしているのは、EBSとFileになりますが、将来的には、vagrant hub(box) や GCP, Azure のイメージレジストリーなども追加していければという目論見があります。

EBS Storage

まず AWS EBS とはなんぞや!という方が多いと思いますので説明します。

aws.amazon.com

Elastic Block Store の略なのですが、EC2からマウントしたり、EC2のスナップショットを保存したり、AMIのデータが保存されたりするストレージです。
そのため、EBSをサポートすることによって、EC2スナップショットの検知や、AMIの検知が可能となっています。

ローカルストレージ上のファイルを io.ReadSeekerインターフェースを満たすことなど造作もないですが、AWS EBSはどうなのだ!というところが重要です。
この機能のために AWS EBS を io.ReadSeekerできるライブラリを開発しました。

GitHub - masahiro331/go-ebs-file

実装は簡単です。 AWS EBS は EBS Direct API を公開しており、 512KB ごとのブロックをbytesデータとして読み書きできます。

例えば 任意のEC2スナップショットに対してブロックのリストを取得するためには awsコマンドで以下のように取得できます。

$ aws ebs list-snapshot-blocks --snapshot-id snap-0f8fe7914a70eca70
{
    "Blocks": [
        {
            "BlockIndex": 0,
            "BlockToken": "**********************"
        },
        {
            "BlockIndex": 1,
            "BlockToken": "**********************"
        },
        {
            "BlockIndex": 2,
            "BlockToken": "**********************"
        },
        ...
    ],
    "ExpiryTime": "2022-12-24T22:26:26.973000+09:00",
    "VolumeSize": 8,
    "BlockSize": 524288
}

EBSのボリュームサイズは1GB毎に確保されるため、 VolumeSize: 8 は 8GBのストレージで 1ブロックあたり 512KBのデータを持っているため 8GB / 512KB から 16384 ブロックが存在することが導出されます。

厳密には Amazon Linux Imageのスナップショットの場合、ブロック内の全てのデータが 0x00 の場合は無視できるようにリストから除外されてたりします。

イメージとしてはこんな感じです。

Goの ioパッケージは io.SectionReader という便利なものを提供してくれており、io.ReaderAtSize() int64 を満たす構造体を io.ReadSeeker を満たすように変換してくれます。

つまり Direct APIを使って、io.ReaderAtSize() int64 を実装し io.SectionReader を使うことで、インターネット越しに EBS に対して Seek, Read することが可能です。

Virtual Machine Image層

この層はVMDK(VMWare)や VDI(Virtual Box)、 QCOW2(KVM)、VHDX(Hyper visor)など謂わゆる仮想マシンイメージのディスクフォーマットを解析する層になります。各イメージフォーマットに複数のディスクタイプが存在するため正直無限に手が足りません。

現時点でサポートしているのはここら辺です。

Overview - Trivy

基本的なVirtual Diskのアーキテクチャは物理のディスクデータを任意のブロックサイズに分割し、ブロック内のデータが 0x00 で埋まっている場合は除くことで、データを圧縮するようなものになってます。
可逆性を持たせるために、仮想ディスクのブロックが物理ディスク上の何番目のブロックだったかを記録するツリーデータを持たせている実装が多いです。

イメージとしては以下のような感じ

ややこしいのは、実装によっては分割されたブロック毎に Deflateなどを用いてさらに圧縮しているフォーマットも存在します(VMDKのStreamOptimizedフォーマットなど)

実装としては EBSと同様に io.ReaderAtSize() int64 を実装してやって io.SectionReader で包んでやる方針です。

イメージとしては以下のコードです。

var _ VM = &VMDK{}

type VMDK struct {
    f    *os.File // 実体のファイルのポインタ
    size int
}

type VM interface {
    io.ReaderAt
    Size() (n int)
}

func (v *VMDK) ReadAt(p []byte, off int64) (n int, err error) {
    logicalOffset := v.translateOffset(off)
    return v.f.ReadAt(p, logicalOffset)
}

func (v *VMDK) Size() (n int) {
    return v.size
}

func (v *VMDK) translateOffset(physicalOffset int64) (logicalOffset int64) {
    // 圧縮データに対する物理オフセットから解凍した場合に参照されるオフセットを変換する
    // ツリーデータを読み取ってオフセットを変換してあげる
    // Deflate圧縮されていたら部分的に解凍した場合のオフセットを返す
    return logicalOffset 
}

重要なのは translateOffset(physicalOffset int64) int64 関数です。ここで仮想ディスクの物理オフセットを展開した場合の論理オフセットに変換してあげることです。
実際のコード

作ったライブラリ

Disk Partition層

Disk Partition層は Master boot record(MBR)やGUID Partition Table(GPT)を解析する層になります。

例えば、MBRのブートパーティションは無視するなどの処理や、データパーティション1は N byte目から M bytesの領域に配置されているなどの解析がメインの処理です。

イメージとしてはこのようなデータ構造になっています。

必要な実装としては、そのパーティションがどういったタイプなのか?(Swap、Root、Bootなど)と特定パーティションio.ReadSeeker を 返すだけなので、実質 tarみたいなものだなと言う気持ちで、archive/tar パッケージっぽく使えるように実装しました。

作ったライブラリ

GitHub - masahiro331/go-disk

Logical Volume層

Logical Volume層は Logical Volume Manager(LVM)によって作成された、論理ボリュームを解析する層になってます。現時点ではまだサポートしておらず鋭意開発中です。
Vagrant Boxなどで公開されている比較的新しい仮想マシンイメージは大体LVMを使ってるので、これをサポートしないと、野良VMのサポートができましたとは言えません。AMIはLVMを使ってなくて本当に助かりました。

GitHub - masahiro331/go-lvm

なぜLVMやってないの!?という方向けの言い訳を書くんですが、基本的に仕様書通りにバイナリデータをパースしておけば実装できると思っていましたが、LVMのバイナリデータ内に、Textデータとして以下のような独自コンフィグファイルが格納されており、バグらせずにパースできる自信がなかったため、初期リリースからは断念しました。

https://raw.githubusercontent.com/masahiro331/go-lvm/main/testdata/metadata.txt

Filesystem層 & File層

Filesystem層は EXT4やXFS、ZFSといったファイルシステムのバイナリを解析する層です。 公開されている仕様書をもとに実装する簡単なお仕事です。

Filesystemの解析は Goの 1.16からリリースされている io/fs インターフェースを満たすように感じに実装する方針にしました。

詳細を書こうと思ったのですが、説明することが多すぎたので、いつか個別に記事を書きます。(おそらく書かない)

作ったライブラリ

xfsの実装については仕様書を見てもよくわからない... ということが多かったので、LinuxリポジトリにあるXFSのコードを参考にしつつ実装しました。
参考にしつつと言いますが、C言語が読めないマンのため、正直なにもわからないと思いつつ雰囲気で書いてました。

ここまでの実装を全て連結させることで圧縮されている仮想マシンイメージを透過的にSeekできるようになります。もっと言えば、512KB毎に圧縮されているVMDKに対して fs.WalkDir ができます。すごい!

苦労したこと&学び

正直ここから本題?みたいなところあります。

処理が重すぎる問題

Trivyでは度々メモリを使いすぎてOOMが発生しているぞ!というIssueが立ち上がります。 そのため、今回の実装でもSeekできるように実装し極力メモリを利用しないように実装しました。当初の実装では 数MB程度のメモリ消費量で仮想マシンイメージを透過的にスキャンできていましたが、死ぬほど処理が遅かったです。

原因としては、ReadAt() 関数が実行されるたびに仮想ディスクを部分的に解凍する処理が発生することです。 例えば、 redhat.vmdk の中に存在する Disk partition 1 内の EXT4 内の /etc/os-release を取得する処理では以下のような手続きが発生します。

  1. vmdkのヘッダをパースして仮想ディスクのブロックツリーを作成
  2. 2048 bytes 程度に存在するGPTもしくはMBRをパース(解凍が必要)
  3. MBRのパース結果をもとに Disk Partition 1 の開始Offsetから 2048 bytesを取得(解凍が必要)
  4. EXT4のヘッダ(スーパーブロックと言われるもの)をパース(解凍が必要)
  5. ヘッダに記載の root inode ( / ディレクトリのこと)をパース(解凍が必要)
  6. / ディレクトリの配下にあるファイルもしくはディレクトリの情報を取得 (解凍が必要)
  7. /etc/ ディレクトリのinodeをパース (解凍が必要)
  8. /etc/ ディレクトリの配下にあるファイルもしくはディレクトリの情報を取得 (解凍が必要)
  9. /etc/os-release の inodeをパース(解凍が必要)
  10. /etc/os-release のデータ領域を読み込む(解凍が必要)

はい。めちゃめちゃ解凍させます。これを ルートディレクトリから再帰的に全てのディレクトリを探索した日には処理が終わらないことが目に見えてます。

実際に当時の最初の実装で pprof をとった時のデータがこちらです。

一つの仮想マシンをスキャンするだけで、30分近くかかりました。
複数の改善を費やしたのですが、最も大きな改善となったのはキャッシュ利用です。(当たり前に早くなる)
問題点としては主に2つありました。

  1. 再帰的に探索する際、毎回 root inode から 特定のinodeを辿るため 同じ inodeを頻繁に参照する。しかし毎回解凍している。
  2. io.Reader で読み込ませるため、4096 bytes のデータを読み込む際、512 bytes のバッファで読まれると 同じ inode のデータに対して 複数回の参照が発生するが、毎回解凍している。

これらに対する打ち手として、頻繁に参照されやすいinodeがキャッシュされるように LRUキャッシュ を用いて改善しました。キャッシュのメモリは 64MB程度に収まるよう設計しています。 その結果、以下のような結果になりました。

1916 Sec から 6.66 Sec の圧倒的改善です。正直いろんなキャッシュアルゴリズムを検証しようと思いましたが、圧倒的結果に満足してしまったので試してないです。

仕様書の英語が読めない

今回の機能を開発するために以下の仕様書を読んでました。

自分はTOEIC 245(確率に負ける男)なので、英文法すらわからずDeepLを多用しながら読んだのですが、大事な部分が翻訳で消えていたり、うまく翻訳されないなど無限に苦労をしていました。

途中は英語を読まずにバイナリデータを見ながらエスパーして仕様書で答え合わせをするといったムーブをしていました。

なんとか実装できた理由として、C言語の構造体とバイナリデータをもとに実装を推測することで、わからない英語を補完できたことにあります。英語が苦手な方はとにかくコードを読みながら実装をイメージすることが仕様書を読む秘訣かなと思います。

とにかく人に頼る

これは学びなのですが、仕様書を読んでも実装を読んでもわからないことはあります。 その道のプロっぽい人に頼ることが重要です。
自分の中では困ったら周りの人に頼るというのは、かなり当たり前の感覚だったのですが、ファイルシステム仮想マシンの実装に詳しい人などは周りにいなかったため頼ることもできずに非常に苦労していました。
終わらない実装に限界を迎えた時に、XFSのブログを書いている外国人の方を見かけたのでダメ元でDMしたところ問題が解決しました。

困ったら人に頼るというのは、自分が知っている人だけではなく、視野を広げて世界の誰かに頼るのもいいかもしれません。

巨大なバイナリファイルを読むのが辛い

ファイルシステムのパーサーを書いている時などは野良の仮想マシンイメージを解凍して、生ファイルシステムのデータを見ながら実装していたのですが、60GB のバイナリデータを開くのに苦労していました。

自分は mac ユーザなのですが、いい感じのバイナリビューワーがないなと思いつつ less & xxd などで当初は頑張っていたのですが、無限に辛い思いをしていました。
また、仮想ディスクを自前のパーサで解凍した場合と、7zipなどで解凍した場合に512byteだけサイズがズレちゃう問題など巨大バイナリデータのdiffが取りたい欲がありそれらに対応した即席のバイナリビューワーなどを作りました。

GitHub - masahiro331/gbiew

なぜかソースコードをpushし忘れたまま手元のリポジトリが消失したので、first commitで止まってます。(いつか作り直す)

のちに知ったのですが、 xxdでオフセットが指定できるので xxd + lessだけでも十分なビューワーになります。

感謝の念(一番大事)

このPRを作る上で、めっちゃ頑張った!!と言いたいのですが、Trivyのメンテナーである knqyf263 さんには多大なご支援をいただきました。

いけてない処理をリファクタリングしてくださったり、テストしていただいたり...
また、メイン機能であるAMIスキャンはEBSスキャンの機能を応用すればサクッと開発できる機能だったのですが、自分はそれに気がつくことができず、 knqyf263 さんのアイデアでシュッと実装してくださったりなどがありました。

ちなみに僕はリリース日に北海道へ温泉旅行してました。

最後に

恥ずかしいので、ほとんどの人が読まないであろう一番下にポエムを書きます。

この機能を開発する上で、自分は「何者かになれるのかな?」と考えながら実装していました。
ありがたいことに、自分の周りには社内外問わず本当に優秀な方が多く、見せかけの肩書きや看板ではなく、自らの実力や魅力で世界に存在を証明していて眩しいばかりです。 自分にはそう言ったものがなく、自分は何者でもない凡夫だなと思う毎日です。

この開発はツイートにも書いていただいている通り、おそらく世界で初めてVMイメージを静的解析するスキャナーを作るという自分の実力を超える機能でした。「仏も昔は凡夫なり」と言ったり言わなかったりしますが、この開発を通じて「凡夫」から「何者か」になれるかなという淡い期待があります。
「何者か」になれたか結果が出るまでには、まだまだ時間も努力も必要だと思います。
まだ「リリースしただけ」であってより多くの人に利用してもらえるように一つ一つ課題を解決していこうと思います。

今年でエンジニア歴が7年を迎えるんですが、自分はまだまだ初心者の域をでない凡夫だなと痛感するばかりです。これくらいの機能を修正なしでマージされるぐらいのエンジニアになりたいと思った年末でした。